Compare commits

..

5 Commits

Author SHA1 Message Date
f27a0dcfb5 fix(cordis-host-runner): make inspect provider registration idempotent on identical manifest
Some checks failed
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / windows node 24 / wine blocking (push) Has been skipped
CI / wine apt cache (push) Successful in 6s
CI / serial / linux (push) Has been skipped
Release (vendor) / Pack npm tarballs (push) Failing after 1m59s
Release (dsh) / Pack npm tarballs (push) Failing after 1m57s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m59s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m23s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m21s
The Host cordisInspect registry threw on any duplicate provider id, but
tool-cordis is mounted per-preset (standing scope), so two presets that
both include it (e.g. `maximum` and a user copy such as `design`) collide.

`register()` now replaces the stored entry when the incoming manifest is
structurally identical (same id, description, and methods); a provider
with the same id but a different manifest still throws as before.

Added `jsonEqual` and `sameManifest` module-private helpers; new spec
covers first register, identical re-register, different-manifest reject,
empty-id validation, and method-array mismatch.
2026-08-27 14:03:40 +07:00
8719fa84f0 feat: add economy/maximum presets, tool-lab and subagent-cursor extensions
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
- new economy and maximum agent presets with three-role pipeline skill
- new packages/extensions/tool-lab (home-lab ComfyUI/Docling/Whishper tools)
- new packages/subagent/subagent-cursor provider
- openrouter balance UI with on-demand refresh
- session projection context-seed boundary fold
- regenerate docs catalogs; keep local searxng benchmark scripts
2026-08-23 22:12:30 +07:00
e818e37f0a chore: regenerate cordis/client service catalogs for openRouterUsage
Some checks failed
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / windows node 24 / wine blocking (push) Has been skipped
CI / wine apt cache (push) Successful in 1m11s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m34s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 7s
Release (vendor) / Pack npm tarballs (push) Failing after 2m16s
Release (dsh) / Pack npm tarballs (push) Failing after 4m59s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Successful in 2m52s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
The prior openrouter feature commit added the ctx.openRouterUsage Remote
service without a SERVICE_PAGE partition entry or regenerated artifacts, so
the committed api-catalog, slot-catalog, and llm-streaming subsystems docs
were stale and the catalog byte-for-byte gate failed. Map the service to its
documentation page, regenerate all three artifacts, and re-record the
bilingual sidecar.
2026-08-20 22:16:17 +07:00
99b63abde6 feat: show per-step OpenRouter cost on the Trajectory tab
Extend the openRouterCost projection with a per-step priced-cost map
keyed by turn:step so surfaces can render spend without re-pricing, and
have the Trajectory view read it through the framework useProjection seat:
each priced assistant step shows its USD cost on the request boundary chip
and in the inspector summary, while unpriced steps stay blank. Also fix a
pre-existing exactOptionalPropertyTypes error in extractCacheRates that the
host typecheck surfaced.
2026-08-20 22:14:09 +07:00
d2ad46b8aa fix: keep client session projection stores across removal and revival
The per-session ProjectionValueStore was deleted on host/session-removed
even though the Session instance survives removal (open() is idempotent and
drop() only removes the map entry). A re-added or re-listed session resumed
on the same instance with an empty projection face, so projection-backed UI
(such as the OpenRouter cost readout) reverted to blank until a fresh host
baseline landed.

Retain the store on removal and lift the removed tombstone from the
surviving instance on revival (host/session-added and list-refresh
re-listing), so chat switching keeps cost readouts populated.
2026-08-20 22:10:43 +07:00
141 changed files with 6361 additions and 202 deletions

View File

@@ -0,0 +1,52 @@
# Agent Note: Projection units fold against a header-derived context
Status: implemented
## Problem
A forked subagent's session log opens with a verbatim copy of its parent's log. `SessionHeader.seedLength` records that inherited prefix, and consumers that must attribute work to the session that actually did it already slice past it — `dsh-agent`'s inbox and `dsh-schedule`'s domain both do.
Projection units could not. `ProjectionDefinition.apply(state, event)` received only the event stream, so every unit folded the inherited prefix as if the child had produced it. For `openRouterCost` that made a child's value report its parent's spend: measured on a real fork with `seedLength` 109342, the child folded to $2.0401 over 575 priced steps while its own work was $0.1073 over 25. Summing a parent with its subagents — what the composer dock does to show delegated spend — therefore counted the parent once per forked child, inflating an $8.59 session to $12.79.
The log alone cannot supply the boundary. `session/end-seed` marks a constructor seed, but a resumed ordinary session appends one too, so the marker cannot distinguish a fork boundary from a resume boundary: the parent session in the measurement above carries four of them. Only the durable header separates the two.
## Decision
`ProjectionDefinition.apply` takes a third parameter, `ProjectionFoldContext` — per-session facts derived from the session's durable header, currently just `seedLength`. The registry supplies it on every call, so a unit reads session facts without ever touching a `Session`:
- `snapshot`, the eager drive, and the lazy cell build derive it from `session.header` through the exported `foldContextOf`.
- `restore` takes it as a required fourth parameter, because a detached fold has no Session to derive it from. Its four callers each already hold the stored header: the projection cache's cold read (`tail.meta`, `whole.meta`), api-proxy's detached history baseline, and the subagent catalog's identity probe.
The parameter is required in the interface and unused by 13 of the 14 shipped units: a two-parameter implementation satisfies a three-parameter signature, so no other unit changed.
`openRouterCost` skips events below the boundary and bumps `stateVersion` to 4. It still reads `request/context` from the inherited prefix — those records carry no cost, and the route they establish is what attributes the child's first chunk-only step, which would otherwise fold unpriced.
`CostDock` sums the session plus its subagent subtree over the session list's `parentId`/`origin` rows. That sum is only sound because each value now describes its own session's work.
## What the context does not decide
`ProjectionFoldContext` is not a general escape hatch to session state. It carries header facts a pure fold legitimately needs and nothing derived from mutable session state, which would break the synchronous-fold and plain-JSON-state guarantees the persisted cache depends on. A unit describing the whole conversation rather than the session's own work — context pressure, the visible transcript — correctly ignores `seedLength` and folds the inherited prefix like any other event.
## Alternatives considered
**Reset the accumulated state on `session/end-seed`.** No framework change, and the marker is already durable. Rejected because a resumed ordinary session appends the same marker: resetting on it would zero a session's spend on every reopen. The marker cannot name which boundary it is.
**Track a second accumulator for "spend since the last marker" and let the client pick per `origin`.** Contained entirely in this plugin. Rejected because it is wrong for a resumed continuable subagent, whose own earlier segments fall before the last marker and would silently vanish from its total — trading a 20× over-count for a quiet under-count.
**Deduplicate by the `steps` map's `${turn}:${step}` keys across the lineage.** The inherited steps carry the parent's own keys, so a union would drop them. Rejected because sibling forks both continue from the same fork point and mint colliding keys for their own first steps, and a child seeded with nothing numbers from 0 exactly like its parent.
**Have the client stop adding forked children entirely.** Never inflated. Rejected because delegated spend is the figure the dock exists to show; hiding it answers the wrong question.
**Give `apply` the whole `Session`.** Simpler signature change. Rejected because it hands every unit a mutable, non-JSON object and an appendable log, inviting folds that read state outside the event they were given — the exact discipline the unit contract exists to enforce.
## Consequences
Every unit's value now means "this session's own work" or "the whole conversation" by explicit choice rather than by accident. The cost is one more parameter on the seam's central function and a required argument on `restore`, which is what makes the omission impossible to reintroduce silently: a caller with no header cannot compile.
The `stateVersion` bump discards persisted `openRouterCost` rows. Sessions whose rows are dropped and which are never reopened read as absent rather than refolded, because the cold read serves the cache and never folds a cold log — so historical subagent spend stays missing until something opens those sessions. `dsh-client-ui-openrouter-usage` records that gap under its known limitations; a cold-fold aggregate is a separate decision.
`tokenUsage` and `sessionStats` still fold a forked child's inherited prefix. That is now a deliberate, visible choice rather than an invisible one: whether those figures should describe own work or inherited history is a question for their owners, and the context they need is already in hand.
## Testing
`packages/session/session-projection/tests/registry.spec.ts` proves the context reaches `apply` on all three drive paths — eager drive, lazy cell build after events flowed, and `restore` with a caller-supplied context — through a unit that counts only its own events. `packages/llm/openrouter-usage/tests/projection.spec.ts` builds a real fork through `ctx.sessions.create(undefined, { seed, meta: { seedLength } })` and asserts the child excludes the inherited prefix while the parent's figure is untouched, plus that a chunk-only child step still prices from a route recorded in that prefix. `packages/client/ui-openrouter-usage/tests/cost-dock.client.spec.tsx` covers the subtree walk: nested chains, ordinary forks excluded, a descendant with no projection value skipped, and `parentId` cycle termination.

View File

@@ -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/bug-fix/2026-08-20-web-session-projection-store-survives-revival.md
2026-08-20-web-session-projection-store-survives-revival.md: 27340e87e9fd2dfbfd4bf1868f4aacc2d4af5fdb
2026-08-20-web-session-projection-store-survives-revival.zh.md: 63799da02266e1b2a724bb00659ea0fb5717d93d

View File

@@ -0,0 +1,52 @@
# Agent Note: Web session projection stores survive removal and revival
Status: implemented
English | [中文](2026-08-20-web-session-projection-store-survives-revival.zh.md)
## Problem
The client runtime's `ProjectionValueStore` — the per-session store backing
`useProjection('openRouterCost')`, `useProjection('title')`, and every other
projection face — was deleted on every `host/session-removed`. The session
instance itself is resident: `open()` is idempotent, `drop()` only removes
the `sessions` map entry, and removal is modeled as a `removed` flag on the
surviving instance. Deleting the projection store disagreed with that model:
when the session was re-added (`host/session-added`) or re-listed by a
`session.list` refresh, the same instance resumed without its projection
store, so projection-backed UI (notably the OpenRouter cost readout in the
composer dock) reverted to blank until the host re-served a projection
commit.
## Decision
The projection store follows the resident instance: `SessionManager` no
longer deletes it on `host/session-removed`. The `removed` tombstone is
lifted by a new `Session.handleRevived()` — called from the `host/session-added`
path and from the list-refresh summary push — which clears the flag and
records the store as dirty so subscribers re-read the retained values. A
re-added session therefore resumes its previous projection values through the
same instance; the host re-serves the projection rows on the next `session.list`
from its cached row set, and the store needs no re-seed.
## Alternatives considered
**Delete the store on removal and re-seed on re-add.** Rejected: the session
instance is not re-seeded by `open()` (idempotent), so a deletion would leave
the resumed session projection-blank until a fresh projection frame arrives.
Retention matches the resident-instance contract already in place.
**Drop the store only when the session is dropped from the list.** The removal
frame is the only cleanup signal the manager receives; there is no separate
lifecycle step distinguishing "tombstoned for revival" from "gone", so keeping
the store until `drop()` is the model-consistent rule.
## Consequences
A removed-but-resident session keeps its projection values for as long as the
instance exists, and re-listing restores them without a host re-seed. The
store retains memory for removed sessions until the manager drops the map
entry, which is the same tenure the session instance already held. `test:gui`
pins both revival paths — `host/session-added` and list-refresh re-listing —
plus the plain-switch retention case, and the projection-store spec pins that
a removed session's store keeps its folded values.

View File

@@ -0,0 +1,23 @@
# Agent Note: Web 会话投影存储在移除与复活后继续存活
Status: implemented
[English](2026-08-20-web-session-projection-store-survives-revival.md) | 中文
## 问题
客户端运行时的 `ProjectionValueStore` —— 支撑 `useProjection('openRouterCost')`、`useProjection('title')` 以及其他所有投影面(face)的按会话存储 —— 会在每次 `host/session-removed` 时被删除。会话实例本身是常驻的:`open()` 幂等,`drop()` 只删除 `sessions` 映射项,移除被建模为幸存实例上的 `removed` 标志。删除投影存储与该模型不一致:当会话被重新添加(`host/session-added`)或由 `session.list` 刷新重新列出时,同一实例继续运行却没有其投影存储,于是投影驱动的 UI(尤其是 composer dock 中的 OpenRouter 费用读数)会回退为空白,直到宿主重新下发投影提交。
## 决策
投影存储随常驻实例存活:`SessionManager` 不再在 `host/session-removed` 时删除它。`removed` 墓碑由新增的 `Session.handleRevived()` 解除——它从 `host/session-added` 路径与列表刷新摘要推送两处调用——清除标志并将存储标记为脏,使订阅者重新读取保留的值。因此被重新添加的会话通过同一个实例恢复之前的投影值;宿主在下一次 `session.list` 时从缓存的投影行集合重新下发这些行,存储无需重新播种。
## 考虑过的替代方案
**移除时删除存储,重新添加时重新播种。** 否决:`open()` 幂等,不会为会话重新播种,因此删除会让恢复的会话在收到新投影帧之前一直投影空白。保留与既有的常驻实例契约一致。
**仅当会话从列表移除时才丢弃存储。** 移除帧是管理器收到的唯一清理信号;没有单独的「为复活而墓碑化」与「真正消失」生命周期步骤,因此在 `drop()` 之前保留存储是模型一致的规则。
## 结论
被移除但常驻的会话在其实例存活期间保留投影值,重新列出时无需宿主重新播种即可恢复。在管理器删除映射项之前,存储会为已移除会话保留内存——这与会话实例本身已有的任期相同。`test:gui` 钉定了两条复活路径(`host/session-added` 与列表刷新重新列出),外加普通切换保留场景;投影存储规范钉定被移除会话的存储会保留其折叠后的值。

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 .agents/notes/implemented/feature/2026-08-20-openrouter-cost-balance-readouts.md
2026-08-20-openrouter-cost-balance-readouts.md: 339acc4140eb6c34d1c3910a93babbeda9a28cac
2026-08-20-openrouter-cost-balance-readouts.zh.md: 16606ae58b346a1eeb10f703eae748665f64ea83
2026-08-20-openrouter-cost-balance-readouts.md: 471ca8f3d6552a0fdba680580908b3d7b268b115
2026-08-20-openrouter-cost-balance-readouts.zh.md: 5a3a09453bb26ebcc82887bda4209c281f8476bd

View File

@@ -42,7 +42,14 @@ into `conversation.composer.dock` (the shipped stats line's dock), reading the
balance badge registers into `sidebar.footer.action`, polling the balance
Remote on a 60s client-side interval. Both hide themselves when their data is
absent: cost hides before any priced step, balance renders an empty dashes
marker when the snapshot has no value yet.
marker when the snapshot has no value yet. The projection's `steps` view field
also maps every priced step to its USD cost under `${turn}:${step}` keys, and
`dsh-client-ui-trajectory` reads it from the resident projection store to show
each priced assistant step's cost on the Trajectory request boundary and in
its inspector summary. The projection store follows the session instance's
residency ([retention note](../bug-fix/2026-08-20-web-session-projection-store-survives-revival.md)),
so the readouts survive switching between chats instead of reverting to
blank.
**The API key is a credential reference, seeded identically to routing.**
`apiKeyEnv` (default `OPENROUTER_API_KEY`) resolves through the credentials

View File

@@ -14,7 +14,7 @@ Status: implemented
**费用是面向客户端的只读模型投影,绝不是被记录或模型可见的事实。** 网关拉取 OpenRouter 的模型定价表(`GET /models`,`pricing.prompt`/`completion` USD 每 token、flat 的 `request` 费用、披露的缓存费率),并在 `openRouterCost` 的 `SessionProjectionMap` 条目中按各会话已记录的用量折叠它。余额来自 OpenRouter 权威余额端点 `GET /credits`(可用余额 = `total_credits` 减去已花费的 `total_usage`,即仪表盘展示的数字),并与 `GET /auth/key` 合并以获取 key 标签与月度 token 预算(`usage`/`limit`);标准 `sk-or-v1` key 在 `/auth/key` 上**不**返回 `credits` 字段,因此仅靠该端点总会读到空余额。两个请求都使用 `redirect: 'error'` 的 `global.fetch` —— 携带凭证的出站调用的主机标准。
**Web 表面是两个 slot 条目。**每会话费用读数注册进 `conversation.composer.dock`(随附统计行的 dock),通过 `useProjection` 框架座位读取 `openRouterCost` 投影;实时余额徽章注册进 `sidebar.footer.action`,以客户端 60s 间隔轮询余额 Remote。两者在数据缺失时自我隐藏:费用在任何已计价步骤前隐藏,余额在快照尚无值时渲染空的破折号标记。
**Web 表面是两个 slot 条目。**每会话费用读数注册进 `conversation.composer.dock`(随附统计行的 dock),通过 `useProjection` 框架座位读取 `openRouterCost` 投影;实时余额徽章注册进 `sidebar.footer.action`,以客户端 60s 间隔轮询余额 Remote。两者在数据缺失时自我隐藏:费用在任何已计价步骤前隐藏,余额在快照尚无值时渲染空的破折号标记。投影的 `steps` 视图字段还以 `${turn}:${step}` 为键映射每个已计价 step 的美元成本,`dsh-client-ui-trajectory` 从常驻投影存储读取它,在 Trajectory 请求边界与其检查器摘要中显示每个已计价助手 step 的费用。投影存储跟随会话实例的常驻性([保留笔记](../bug-fix/2026-08-20-web-session-projection-store-survives-revival.md)),因此读数在切换聊天时持续存在,不会回退为空白。
**API key 是凭证引用,与路由的初始化方式一致。**`apiKeyEnv`(默认 `OPENROUTER_API_KEY`)与 `dsh-web-search-deepseek` 完全一样,通过凭证接缝加环境回退解析,按刷新在 thunk 中捕获,使凭证或设置变更无需重建即可在下一周期生效。

View File

@@ -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-22-cursor-external-agent-provider.md
2026-08-22-cursor-external-agent-provider.md: 1126ee883fecf56081ac44b00cb70a54f7a8cbe0
2026-08-22-cursor-external-agent-provider.zh.md: 0565f29236c4319857444081d6e6f132f93064ee

View File

@@ -0,0 +1,57 @@
# Agent Note: Cursor joins the external-agent providers, and all three become visible
Status: implemented
English | [中文](2026-08-22-cursor-external-agent-provider.zh.md)
## Problem
The subagent seam already had two external product providers — `codex` over the Codex app-server protocol and `claude-code` over the official Claude Agent SDK — but Cursor had none, and neither existing provider was reachable by a model in a shipped composition: every Agent Preset carried its tool row with `disabled: true`, and the committed base bundle mounted neither provider. The capability existed and nothing could use it.
Cursor is also the first of the three whose only headless prompt channel is a command-line argument. `cursor-agent --print` takes the task as a POSITIONAL argument, documents no `--` end-of-options separator, and reads no prompt from stdin. Codex sidesteps this entirely — its task travels inside JSON-RPC — and the Claude Code provider hands its prompt to an SDK. A Cursor provider has to decide what happens when model-authored text becomes argv.
## Decision
`@deepseek-ai/dsh-subagent-cursor` registers the fixed `cursor` provider over `cursor-agent --print --output-format stream-json`. It reuses the seam's out-of-process vocabulary (`NO_START_CAPABILITIES`, `resolveChildCwd`, `settleRunResult`, `subprocessRunHandle`) and owns only three product-specific things: the print-mode event decoder, the publication gate, and the argv admission rules.
**Publication gates on `system`/`init`.** Print mode has no handshake, so there is no "remote session exists" moment to wait for. The CLI's first event announces its own chat id after it has resolved credentials and model, which is the earliest point that proves a usable child exists — the structural equivalent of Codex returning an ephemeral thread. A successful `result` arriving with no prior `init` fails startup rather than resolving: a run the caller was never handed has nowhere to put an answer.
**Argv admission fails loud twice.** A task whose first character is `-` is rejected at admission, because the CLI would parse it as an option and nothing in the seam can escape it. A resolved Windows `.cmd` or `.bat` shim is rejected too: only `cmd.exe` can run one, and its command tail reparses — model-authored task text would become shell syntax. PATHEXT resolution prefers the `cursor-agent.exe` the native Windows installer provides, so the rejection names a fixable installation rather than blocking the platform.
**Cancellation settles the result, the seam stops the process.** There is no reply channel, so no protocol interrupt exists. The run's abort signal goes to the spawn spec, where the subprocess seam owns the termination escalation, and the same abort is raced into both the startup and result awaits so a cancelled run settles as `aborted` immediately instead of waiting out the grace period. Stdin is closed right after spawn, so a prompt the CLI still tries to read fails fast rather than stalling an unattended child forever.
**Only `result` completes a run.** The provider accepts `subtype: "success"` with `is_error: false` and a nonblank `result`. Print mode carries no machine-readable failure taxonomy, so every other ending is `error` and the provider produces neither `max-tokens` nor `refusal`. A malformed stdout line is a protocol failure, not something to skip — skipping it would hide a CLI version whose stream this contract cannot read. Unknown event kinds are ignored, because a newer CLI adding events must not break a contract that needs only three of them.
**All three providers move onto the shipped host plane, and their tool rows turn on.** The base bundle mounts `codex`, `claude-code`, and `cursor` once each, so a Profile enables one by removing `disabled` from its preset tool row instead of mounting a duplicate provider. The `code`, `cordis`, and `standard` presets carry enabled rows. The `economy` preset keeps all three `disabled: true`: its stated purpose is not to reach for external paid agents by default, and that reason survives this change.
Loading a provider starts no product process, so a deployment without a given CLI keeps an inert tool row whose call fails at call time. That is deliberate: the alternative — gating the row on a PATH probe at load — makes the model's roster depend on host state it cannot see, and turns a missing CLI into a silent absence instead of an answerable error.
## Alternatives considered
**Drive Cursor through the existing ACP provider.** `cursor-agent acp` speaks the Agent Client Protocol natively, so `dsh-subagent-acp` can drive it with configuration alone and no new package. Rejected as the primary path because it produces no `subagent_cursor` tool row, no product-specific config, and no product-specific stop-reason or failure mapping — Cursor would be reachable only by a deployment that hand-wrote an ACP row, which is the same invisibility this change exists to remove. The ACP path remains valid and is documented in the package README for a deployment that wants ACP's permission auto-answer policy or a longer-lived remote session.
**Pass the task through `cmd.exe` on Windows, as the Claude Code provider does for its `.cmd` shim.** That provider quotes the resolved executable into an environment variable that cmd expands once, and its remaining arguments are fixed SDK flags with no cmd metacharacters. Rejected here because the trick does not generalize to the payload: a quoted value containing `"` breaks out of its quoting, and an executable path cannot contain `"` while a model-authored task certainly can.
**Default `force` to true so a delegated child can actually edit.** Rejected: Cursor's own print-mode default only proposes changes, and the sibling providers are unattended-safe by the same logic — the Codex provider declines approvals and the Claude Code provider disables `AskUserQuestion`. A deployment that wants edits sets `force` explicitly, which is also where the decision is auditable.
**Pass `CURSOR_API_KEY` as `--api-key`.** The CLI accepts it. Rejected because argv is world-readable in a process listing; the credential goes through the provider's `env` config, which the subprocess seam already treats as a deliberate opt-in past its credential scrub.
**Add a `--stream-partial-output` delta path.** Rejected as unused surface: without it each `assistant` event is one complete message, which is exactly the seam's "last non-empty assistant message" selection rule with no accumulation state to own.
**Probe the CLI at load and skip the tool row when absent.** Rejected as above — it makes the model-visible roster a function of invisible host state, and a missing CLI is better reported as a failed call than as a tool that was never there.
## Consequences
Three external product agents are now visible to the model in three of the four shipped presets, which is the point and also the cost: each is a tool row the model can choose, and a deployment without the corresponding CLI pays one failed call to learn that. The `economy` preset is unchanged in behavior.
The committed base-bundle guard inverted. `packages/bundle/base/tests/base.spec.ts` previously asserted that the base layer mounts none of these providers and depends on none of them; it now asserts each is mounted exactly once and declared as a dependency. The old assertion encoded the opt-in-only policy this change replaces.
Cursor delegation has no continuation. The CLI supports resuming a chat by id, and the provider deliberately does not: the seam's one-shot contract is one process, one run, one result, and a resumable Cursor chat would need durable descriptor fields no consumer asks for yet.
## Testing
`packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts` drives the real event stream through a fake subprocess handle: the init gate, chunk-split and blank-line framing, last-non-empty-message selection with non-text blocks dropped, every unusable terminal result, a result without an announced session, malformed stdout, stream error, end of stream, and close. Its lifecycle cases cover the fixed argv with and without `force`/`trust`, stdin closure, immediate cancellation settling as `aborted` with partial output, post-publication exit and protocol failures flattened through the diagnostic sink, pre-spawn abort, the four startup rollback paths, an aggregate when rollback itself fails, run isolation, and the registered plugin's config, resolved executable, and warning text. The argv admission rules are tested per platform so the suite pins both outcomes on every host.
`packages/subagent/subagent-cursor/tests/loader-composition.e2e.ts` boots the public opt-in composition with an empty `PATH` and asserts the provider, the `subagent_cursor` schema, and the generic Job controls register while no run starts — loading must not probe or launch a CLI.
`packages/bundle/base/tests/base.spec.ts` pins the inverted policy: each of the three providers is mounted once and declared once.

View File

@@ -0,0 +1,57 @@
# Agent Note:Cursor 加入外部 agent 提供方,并让三者同时可见
Status: implemented
[English](2026-08-22-cursor-external-agent-provider.md) | 中文
## 问题
subagent seam 早已拥有两个外部产品提供方——基于 Codex app-server 协议的 `codex`,以及基于官方 Claude Agent SDK 的 `claude-code`——但 Cursor 一个都没有,而且这两个既有提供方在任何已发布的组装中都无法被模型触及:每个 Agent Preset 都把其工具行设为 `disabled: true`,已提交的 base bundle 也没有挂载任何一个。能力存在,却没有任何东西能用它。
Cursor 还是三者中唯一一个仅以命令行参数作为无头提示词通道的产品。`cursor-agent --print` 按**位置**接收任务,未记载 `--` 选项终止符,也不从标准输入读取提示词。Codex 完全绕开了这个问题——它的任务在 JSON-RPC 内部传输;Claude Code 提供方则把提示词交给 SDK。而 Cursor 提供方必须决定:当模型撰写的文本变成 argv 时会发生什么。
## 决策
`@deepseek-ai/dsh-subagent-cursor` 基于 `cursor-agent --print --output-format stream-json` 注册固定的 `cursor` 提供方。它复用 seam 的进程外词汇(`NO_START_CAPABILITIES`、`resolveChildCwd`、`settleRunResult`、`subprocessRunHandle`),只拥有三件与产品相关的事:print 模式事件解码器、发布闸门,以及 argv 准入规则。
**发布以 `system`/`init` 为闸门。** print 模式没有握手,因此不存在可等待的“远端会话已就绪”时刻。该 CLI 的首个事件会在它解析出凭证与模型之后公布其自有会话 ID,这是能证明存在可用子级的最早时点——在结构上等同于 Codex 返回临时线程。若成功的 `result` 在没有先行 `init` 的情况下到达,启动会失败而非返回结果:调用方从未拿到的运行没有地方安放答案。
**argv 准入两处显式失败。** 首字符为 `-` 的任务在准入阶段被拒绝,因为该 CLI 会将其解析为选项,而 seam 中没有任何手段可以转义它。解析到 Windows `.cmd` 或 `.bat` 包装脚本同样被拒绝:只有 `cmd.exe` 能运行它,而其命令尾部会被重新解析——模型撰写的任务文本会变成 shell 语法。PATHEXT 解析会优先选择原生 Windows 安装程序提供的 `cursor-agent.exe`,因此这项拒绝指向的是一个可修复的安装问题,而不是封禁该平台。
**取消负责结束结果,seam 负责停止进程。** 由于没有回复通道,也就不存在协议层中断。本次运行的中止信号被交给 spawn 规格,由子进程 seam 拥有逐级终止机制;同一个中止信号同时被并入启动等待与结果等待的竞态,因此被取消的运行会立即判为 `aborted`,而不必等完宽限期。标准输入在 spawn 后立即关闭,因此该 CLI 若仍尝试读取提示词,会快速失败,而不是让无人值守的子级永远停滞。
**只有 `result` 能让运行完成。** 提供方只接受 `subtype: "success"` 且 `is_error: false` 并带非空白 `result` 的事件。print 模式不携带可供程序判读的失败分类,因此其他任何结束都是 `error`,且该提供方既不产生 `max-tokens` 也不产生 `refusal`。格式错误的标准输出行属于协议失败,而不是可以跳过的东西——跳过它会掩盖某个本约定无法读取其事件流的 CLI 版本。未知事件类别会被忽略,因为更新版 CLI 新增事件不应破坏一个只需要其中三种事件的约定。
**三个提供方一并进入已发布的宿主平面,其工具行随之开启。** base bundle 各挂载 `codex`、`claude-code`、`cursor` 一次,因此 Profile 启用其中之一的方式是从其 preset 工具行中删除 `disabled`,而不是挂载重复的提供方。`code`、`cordis` 与 `standard` preset 携带已启用的行。`economy` preset 三者均保持 `disabled: true`:它被明确设定为默认不动用外部付费 agent,而这条理由在本次变更后依然成立。
加载提供方不会启动任何产品进程,因此缺少某个 CLI 的部署会保留一个惰性工具行,其调用会在调用时失败。这是有意为之:另一种做法——在加载时以 PATH 探测为工具行设闸——会让模型的工具清单取决于它看不见的宿主状态,并把“缺少 CLI”从一个可回答的错误变成一次无声的缺席。
## 考虑过的替代方案
**通过既有 ACP 提供方驱动 Cursor。** `cursor-agent acp` 原生讲 Agent Client Protocol,因此 `dsh-subagent-acp` 仅凭配置即可驱动它,无需新包。作为主路径被否决,因为它不产生 `subagent_cursor` 工具行、没有产品专属配置,也没有产品专属的停止原因与失败映射——Cursor 将只能被手写 ACP 行的部署触及,而这正是本次变更要消除的那种不可见性。ACP 路径依然有效,并已在包 README 中记录,供需要 ACP 权限自动应答策略或更长生命周期远端会话的部署使用。
**在 Windows 上让任务穿过 `cmd.exe`,如 Claude Code 提供方处理其 `.cmd` 包装脚本那样。** 那个提供方把解析出的可执行文件加引号放进环境变量,由 cmd 展开一次,其余参数是不含 cmd 元字符的固定 SDK 标志。此处被否决,因为该技巧无法推广到载荷本身:含 `"` 的加引号值会突破引号,而可执行文件路径不可能含 `"`,模型撰写的任务却完全可能含有它。
**把 `force` 默认设为 true,好让被委托的子级真能编辑。** 被否决:Cursor 自身的 print 模式默认只提出改动,而同族提供方按同一逻辑保持无人值守安全——Codex 提供方拒绝审批,Claude Code 提供方禁用 `AskUserQuestion`。需要编辑的部署显式设置 `force`,那也正是该决策可被审计的位置。
**把 `CURSOR_API_KEY` 作为 `--api-key` 传入。** 该 CLI 接受它。被否决,因为 argv 在进程列表中对所有人可读;凭证走提供方的 `env` 配置,子进程 seam 已把该配置视为越过其凭证清除的一次有意选择。
**新增 `--stream-partial-output` 增量路径。** 作为无人使用的surface被否决:不启用它时,每个 `assistant` 事件就是一条完整消息,这恰好就是 seam 的“最后一条非空助手消息”选择规则,且无需拥有任何累积状态。
**在加载时探测 CLI,缺失则跳过工具行。** 同上被否决——那会让模型可见的工具清单成为不可见宿主状态的函数,而缺少 CLI 更适合报告为一次失败的调用,而非一个从未存在过的工具。
## 后果
三个外部产品 agent 现在在四个已发布 preset 中的三个里对模型可见,这既是目的也是代价:每一个都是模型可以选择的工具行,而缺少对应 CLI 的部署要用一次失败调用来得知这一点。`economy` preset 的行为未变。
已提交的 base bundle 闸门发生反转。`packages/bundle/base/tests/base.spec.ts` 此前断言 base 层不挂载这些提供方、也不依赖它们;现在它断言每一个都被挂载恰好一次并被声明为依赖。旧断言编码的正是本次变更所替换的“仅可选启用”政策。
Cursor 委托没有续接。该 CLI 支持按 ID 恢复对话,而提供方有意不做:seam 的 one-shot 约定是一个进程、一次运行、一个结果,可恢复的 Cursor 对话需要目前没有任何消费方要求的持久化描述符字段。
## 测试
`packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts` 通过伪造的子进程句柄驱动真实事件流:init 闸门、分块与空行的帧解析、丢弃非文本块的最后一条非空消息选择、每一种不可用的终止结果、没有公布会话的结果、格式错误的标准输出、流失败、流结束以及关闭。其生命周期用例覆盖带与不带 `force`/`trust` 的固定 argv、标准输入关闭、立即取消并携带部分输出判为 `aborted`、发布后退出与协议失败经诊断出口摊平、spawn 前中止、四条启动回滚路径、回滚自身失败时的聚合错误、运行间隔离,以及已注册插件的配置、解析出的可执行文件与告警文本。argv 准入规则按平台分别测试,因此该套件在任何宿主上都能钉住两种结果。
`packages/subagent/subagent-cursor/tests/loader-composition.e2e.ts` 以空 `PATH` 启动公开的可选组装,断言提供方、`subagent_cursor` schema 与通用 Job 控制工具均已注册且没有任何运行启动——加载不得探测或启动 CLI。
`packages/bundle/base/tests/base.spec.ts` 钉住反转后的政策:三个提供方各被挂载一次、各被声明一次。

View File

@@ -0,0 +1,35 @@
# Agent Note: OpenRouter balance refreshes on click
Status: implemented
## Problem
The account balance badge polled `openRouterUsage.snapshot()` on a 60s interval and rendered whatever the host had last fetched. A user who had just topped up, or who had just watched a long run spend credits, had no way to ask for the current figure: the badge looked like a button, was not one, and the only way to move the number was to wait out the interval.
Widening the poll interval trades staleness for request volume in both directions and never answers "what is it right now".
## Decision
The gateway exposes a second Remote, `refresh()`, which fetches the account immediately and resolves with the resulting snapshot. Concurrent callers share one in-flight fetch, so repeated clicks cost one request, and the shared promise is released once it settles so a later click fetches again. A failed fetch resolves with the last-known snapshot rather than rejecting — the caller compares `updatedAt` if it wants to know whether the figure moved.
`refresh()` re-reads only the account, not the model pricing table. Pricing is a six-hour table whose staleness the badge does not express, and folding it in would make a click cost a second request for a figure the click is not about.
`BalanceBadge` calls `refresh` on click, marks itself `aria-busy` for the duration, and ignores further clicks until it settles. The poll continues underneath, so the badge stays current without clicks and a failed refresh is retried by the next tick.
## Alternatives considered
**Shorten the poll interval.** No new surface. Rejected because it multiplies requests for every user to serve the moment one user cares, and still cannot answer "right now".
**Have the click re-read the cached snapshot.** A one-line client change. Rejected because the cached value is exactly what the badge already shows; the click would appear to do something and do nothing.
**Push balance changes as a forwarded Remote event.** Instant and click-free. Rejected because the host learns of a change only by polling OpenRouter itself, so the event would carry the same staleness with more machinery. The badge's known limitations record that the figure is polled rather than pushed.
**Refresh pricing alongside the balance.** Rejected as above: a click about the balance should not pay for a table the user cannot see.
## Consequences
The badge is now honestly interactive — it looks like a button and behaves like one — at the cost of one more method on the gateway's Remote surface and a user-triggerable outbound request. The in-flight share bounds that: a held-down click is one fetch, not a stream of them.
## Testing
`packages/llm/openrouter-usage/tests/loader-composition.spec.ts` moves the mocked `/credits` figures between calls and asserts `refresh()` serves the moved balance, that two concurrent refreshes issue one fetch, and that a later refresh fetches again. `packages/client/ui-openrouter-usage/tests/balance-badge.client.spec.tsx` covers the click path: the fetched figure renders, a click while outstanding is ignored, and a failed refresh keeps the last-known figure and clears the busy state.

View File

@@ -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-23-maximum-preset-three-role-pipeline.md
2026-08-23-maximum-preset-three-role-pipeline.md: 1ce18e6c9dc68cea0d6bd0f60a5f579466ffc4a2
2026-08-23-maximum-preset-three-role-pipeline.zh.md: 8aa375c60a104b5184dbafe4fc330edb793b6a6c

View File

@@ -0,0 +1,65 @@
# Agent Note: the `maximum` agent preset and its three-role delivery pipeline
Status: implemented
English | [中文](2026-08-23-maximum-preset-three-role-pipeline.zh.md)
## Problem
The shipped preset roster covered a capability RANGE — `minimal` two tools, `standard` the usual coding agent, `code` the same catalog as one TypeScript program, `cordis` plus self-modification, `economy` the same catalog spent carefully — and its upper end was still short of what the deployment can compose. Persistent terminals, language-server queries, session history, durable time and tmux context, scheduled reminders, MCP servers, and the standalone editor each ship as a plugin that no shipped preset mounts, so the only way to get them was to author a preset by hand and rediscover which plane each row belongs to.
The roster also had no opinion about HOW a change gets made. Every preset offers `subagent` and `subagent_fork` as unshaped delegation: the model decides per call whether to delegate, what to say, and whether anyone checks the result. Specify, build, and verify are three different jobs with three different failure modes, and one agent doing all three in one context reviews its own work with its own assumptions in scope.
## Decision
`apps/cli/config/agent-presets/maximum/` is a sixth shipped preset (`order: 6`) that mounts every model-facing row this deployment can compose for one agent, and structures delegation as a fixed three-role pipeline.
Its catalog is `standard`'s plus: the six `terminal_*` tools over an entry-local PTY realm, `lsp` over an entry-local `lsp` realm, the five `session_*` read tools, `schedule_*`, `str_replace_editor`, the seven `cordis_*` tools, and `run_code` beside the native schemas (`tool-presentation` at `mode: both`). `time-context` and `tmux-context` inject per-step durable context. The three external product agents stay enabled, as they now are in `standard`.
The pipeline is three `tool-subagent` instances over the one `spawn` provider, distinguished only by `toolName` and the child `persona` the provider installs as a scoped section shadowing `deployment:persona`:
| Tool | Role | Reply sections |
|---|---|---|
| `subagent_architect` | reads the repository and specifies the change | GOAL / CONTEXT / PLAN / ACCEPTANCE / RISKS |
| `subagent_implementer` | makes the repository satisfy that specification | CHANGES / VERIFICATION / DEVIATIONS |
| `subagent_reviewer` | verifies the result against it and returns a verdict | VERDICT / EVIDENCE / DEFECTS |
All three are `backgroundMode: one-shot` — a stage's reply is the next stage's input, so the default must be the foreground call that returns it — and `maxDepth: 1`, which makes the roles leaves and keeps one pass's agent count equal to the stages run. The preset persona owns sequencing: architect, implementer, reviewer, with a FAIL verdict returning to the implementer and a third failed review going to the user instead of a fourth round. A `three-role-delivery-pipeline` skill ships in the preset's own `skills/` directory with the handoff rules, since a role child cannot see the parent's conversation and everything it needs must be pasted into its prompt.
The preset's `skill-filesystem` scans two custom roots: its own `skills/`, and the `cordis` preset's, so composition authoring is documented for the `cordis_*` tools it also carries. A copy landing beside no `cordis` directory scans a missing path, which is valid empty state for that provider.
### Rows that stay off, and why
Two rows ship `disabled` because what they need is a machine fact, not a deployment fact, and each carries the worked configuration a copy fills in; a third is absent because its name is already taken:
- **`lsp-stdio`** resolves every configured executable AT LOAD and rolls back every provider when one is missing, so enabling it in a shipped preset would fail the mount on any machine without that language server. The `lsp` service and `tool-lsp` are unconditional instead; without a provider the tool stays in the catalog and answers the structured `LSP_UNAVAILABLE`.
- **`mcp-client`** binds one instance to one server — a command to spawn or a URL to reach.
- **`tool-bash-persistent`** is absent rather than disabled: it registers under the name `bash`, which this preset's `tool-bash` already owns. `tool-terminal` supplies long-lived sessions under names that do not collide.
`web_fetch` stays off because the shipped host mounts no fetch provider; that provider defers SSRF protection and the model would choose the request target. Full-text session search stays off because the host mounts `session-query-sqlite` with `openAt: never` — `session_search` and `session_event_search` answer `SESSION_QUERY_SEARCH_DISABLED` while the three read and trace tools work, and changing that is a host patch, not a preset row.
## Alternatives considered
**Enforce the roles with `toolFilter` instead of personas.** A read-only architect and reviewer are exactly what `toolFilter` is for, and a persona cannot enforce anything. It is not usable from a shipped preset: the filter names GLOBAL tool names and `tools.restrict()` throws on an unknown one, while the shell tool is `bash` off Windows and `pwsh` on it — one list would be a startup failure on one platform. A deny list over the write tools would also be theater while the shell remains, since a child can write through it. The preset states the restriction in each role persona and the composition comment names the filter as the enforcement a deployment adds for its own platform.
**Drive the pipeline from a fixed `workflow` script.** `tool-workflow` runs deterministic orchestration, which is what a three-stage pipeline is. Its script comes from the model per call, not from the composition, and its own prompt guidance reserves it for explicit user requests for orchestration; `tool-ralph` takes a build-time script but fans out fresh identical rounds rather than distinct roles. Encoding the order in the persona keeps the stages in the transcript as ordinary tool calls the user can watch, interrupt, and read.
**One role tool with a `role` argument.** Child policy is fixed per `tool-subagent` instance — another persona means another instance — so a single tool could only pass the role in its prompt, leaving the role's rules as text the parent must remember to repeat rather than a section the provider installs.
**Add the capabilities to `standard` instead of a sixth preset.** `standard` is the default every new session gets, and this catalog is 53 tools plus a generated Code Mode SDK on every request. Keeping the maximal point separate leaves the default affordable and gives the roster an explicit capability-versus-cost axis, with `economy` at the other end.
**Mount `schedule` on the host plane, as the Web overlay does.** Its `agent/created` listener is scope-filtered, so a preset-mounted instance installs only on root agents joined to that preset — the property that makes it a legal preset row. Host mounting would hand the tools to every session including `minimal`, which is what the preset boundary exists to prevent.
## Consequences
The roster now spans from two tools to every tool, and the preset file is the readable inventory of what this deployment can compose for one agent — including the three rows that need machine-local configuration, each with the worked example a copy edits.
The cost is real and deliberate: every request carries the full native catalog and the Code Mode SDK, and one pipeline pass is three child agents with their own contexts. The composition header says so and names `standard` and `economy` as the cheaper points.
The three roles are advisory, not enforced. A reviewer child can write files; only its persona tells it not to. Enforcement requires the platform-specific `toolFilter` a deployment adds to its own copy.
`apps/cli` gains `dsh-tool-terminal` and `dsh-tool-session-query`: a preset's bare specifiers resolve from the host install's dependency surface, so a row no shipped composition mounted before needs the dependency added there.
## Testing
`apps/cli/tests/web-agent-presets.e2e.ts` mounts the preset on the real shipped Web composition and asserts the EXACT tool catalog — the assertion that catches a row registering into the wrong layer, which otherwise mounts cleanly and contributes nothing — plus that each role tool registers under its own name with foreground-by-default semantics. The Web authoring and selection goldens carry the new roster row.

View File

@@ -0,0 +1,65 @@
# Agent Note:`maximum` agent preset 及其三角色交付流水线
Status: implemented
[English](2026-08-23-maximum-preset-three-role-pipeline.md) | 中文
## 问题
随附的 preset 名册已经覆盖了一条能力**区间**——`minimal` 两个工具、`standard` 常规编码 agent、`code` 把同一份目录变成一个 TypeScript 程序、`cordis` 再加自我修改、`economy` 精打细算地花同一份目录——但它的上限仍然够不到本部署真正能组合出的东西。持久终端、语言服务器查询、会话历史、durable 时间与 tmux 上下文、定时提醒、MCP 服务器、独立编辑器,各自都以插件形式随附,却没有任何随附 preset 挂载它们;要用上就只能手写一个 preset,并重新弄清每一行属于哪个平面。
名册对**改动如何发生**也没有主张。每个 preset 都提供 `subagent` 和 `subagent_fork` 作为无形状的委派:是否委派、说什么、有没有人复核结果,都由模型逐次决定。规格、实现、验证是三件不同的工作,各有不同的失败方式;一个 agent 在同一个上下文里全做完,就是带着自己的假设复核自己的产出。
## 决定
`apps/cli/config/agent-presets/maximum/` 是第六个随附 preset(`order: 6`):它为一个 agent 挂载本部署能组合的全部面向模型的行,并把委派固定成三角色流水线。
它的目录是 `standard` 再加上:entry 本地 PTY realm 上的六个 `terminal_*` 工具、entry 本地 `lsp` realm 上的 `lsp`、五个 `session_*` 读取工具、`schedule_*`、`str_replace_editor`、七个 `cordis_*` 工具,以及与原生 schema 并存的 `run_code`(`tool-presentation` 取 `mode: both`)。`time-context` 与 `tmux-context` 逐步注入 durable 上下文。三个外部产品 agent 保持启用,与 `standard` 现在的状态一致。
流水线是同一个 `spawn` 提供方上的三个 `tool-subagent` 实例,彼此只由 `toolName` 和子 `persona` 区分——提供方会把该 persona 作为遮蔽 `deployment:persona` 的 scoped 段落安装到子 agent 上:
| 工具 | 角色 | 回复段落 |
|---|---|---|
| `subagent_architect` | 阅读仓库并写出改动规格 | GOAL / CONTEXT / PLAN / ACCEPTANCE / RISKS |
| `subagent_implementer` | 让仓库满足该规格 | CHANGES / VERIFICATION / DEVIATIONS |
| `subagent_reviewer` | 对照规格验证结果并给出结论 | VERDICT / EVIDENCE / DEFECTS |
三者都是 `backgroundMode: one-shot`——上一阶段的回复就是下一阶段的输入,所以默认必须是能把结果带回来的前台调用——并且 `maxDepth: 1`,这让角色成为叶子,一次流水线的 agent 数量恰好等于跑过的阶段数。排序由 preset persona 负责:架构师、实现者、审查者;FAIL 结论退回实现者,第三次审查失败则交给用户,而不是开始第四轮。preset 自带的 `skills/` 目录里随附一个 `three-role-delivery-pipeline` skill 说明交接规则,因为角色子 agent 看不到父会话的对话,它需要的一切都必须粘贴进它的提示词。
preset 的 `skill-filesystem` 扫描两个自定义根目录:它自己的 `skills/`,以及 `cordis` preset 的——这样它同时携带的 `cordis_*` 工具就有了组合编写文档。若某份拷贝落在没有 `cordis` 目录的位置,扫描到的就是一个不存在的路径,对该提供方而言是合法的空状态。
### 哪些行保持关闭,以及为什么
有两行以 `disabled` 随附,因为它们需要的是机器事实而非部署事实,并且各自带着拷贝时填写的示例配置;还有一行则因为名字已被占用而根本没有出现:
- **`lsp-stdio`** 会在**加载时**解析每个已配置的可执行文件,缺一个就回滚全部提供方;因此在随附 preset 里启用它,会让任何没装该语言服务器的机器挂载失败。作为替代,`lsp` 服务与 `tool-lsp` 无条件挂载;没有提供方时工具仍在目录中,并返回结构化的 `LSP_UNAVAILABLE`。
- **`mcp-client`** 一个实例绑定一台服务器——要么是待启动的命令,要么是待访问的 URL。
- **`tool-bash-persistent`** 是缺席而非禁用:它以 `bash` 之名注册,而这个名字已被本 preset 的 `tool-bash` 占用。长期存活的会话由 `tool-terminal` 以不冲突的名字提供。
`web_fetch` 保持关闭,因为随附宿主没有挂载 fetch 提供方:该提供方把 SSRF 防护留给别人,而请求目标将由模型选择。会话全文检索保持关闭,因为宿主挂载 `session-query-sqlite` 时取 `openAt: never`——`session_search` 与 `session_event_search` 会返回 `SESSION_QUERY_SEARCH_DISABLED`,三个读取与追踪工具照常可用;改变这一点是宿主 patch,不是 preset 里的一行。
## 备选方案
**用 `toolFilter` 而不是 persona 来强制角色。** 只读的架构师与审查者正是 `toolFilter` 的用途,而 persona 强制不了任何事。它在随附 preset 里不可用:过滤器写的是**全局**工具名,`tools.restrict()` 遇到未知名字会抛错,而 shell 工具在非 Windows 上是 `bash`、在 Windows 上是 `pwsh`——同一份名单必然在某个平台上启动失败。而且只要 shell 还在,针对写工具的 deny 名单也只是摆设,子 agent 照样能通过 shell 写入。preset 把限制写进各角色 persona,并在组合注释里指明:过滤器是部署方按自己平台补上的强制手段。
**用固定的 `workflow` 脚本驱动流水线。** `tool-workflow` 运行确定性编排,而三阶段流水线正是编排。但它的脚本由模型逐次撰写,而非来自组合;它自己的提示词指引也把它限定在用户明确要求编排时使用。`tool-ralph` 接受构建期固定脚本,但它扇出的是一轮轮相同的全新 agent,而不是彼此不同的角色。把顺序写进 persona,则让各阶段以普通工具调用的形式留在 transcript 里,用户可以看、可以打断、可以读。
**一个带 `role` 参数的角色工具。** 子 agent 策略是每个 `tool-subagent` 实例固定的——换一个 persona 就得换一个实例——所以单一工具只能把角色写在提示词里,角色规则于是退化成父 agent 必须记得每次重复的文本,而不是提供方安装的段落。
**把这些能力加进 `standard`,而不是新增第六个 preset。** `standard` 是每个新会话拿到的默认值,而这份目录是 53 个工具外加每次请求都要带上的 Code Mode SDK。把最大点单独放置,既让默认值保持可负担,也给名册一条明确的能力—成本轴,`economy` 在另一端。
**像 Web overlay 那样把 `schedule` 挂在宿主平面。** 它的 `agent/created` 监听是按 scope 过滤的,所以 preset 挂载的实例只会安装到加入该 preset 的根 agent 上——正是这一性质让它成为合法的 preset 行。挂在宿主上则会把这些工具发给包括 `minimal` 在内的每个会话,而这恰恰是 preset 边界要阻止的事。
## 影响
名册现在从两个工具一直延伸到全部工具,而这个 preset 文件本身就是一份可读的清单:本部署能为一个 agent 组合出什么,包括那三行需要机器本地配置的行,每行都带着拷贝后可直接编辑的示例。
代价真实且有意为之:每次请求都携带完整原生目录与 Code Mode SDK,一次流水线是三个各有上下文的子 agent。组合文件的头部注释说明了这一点,并指出 `standard` 与 `economy` 是更便宜的选择。
三个角色是约定而非强制。审查者子 agent 能写文件,只有它的 persona 让它别写。要强制,就需要部署方在自己的拷贝里加上与平台相符的 `toolFilter`。
`apps/cli` 新增了 `dsh-tool-terminal` 与 `dsh-tool-session-query` 依赖:preset 的裸 specifier 从宿主安装的依赖面解析,因此此前没有任何随附组合挂载过的行,需要在那里补上依赖。
## 测试
`apps/cli/tests/web-agent-presets.e2e.ts` 在真实的随附 Web 组合上挂载该 preset,断言**精确**的工具目录——这正是能抓住「某一行注册到了错误层级」的断言,否则它会干净地挂载却什么也不贡献——并断言每个角色工具以自己的名字注册、默认前台执行。Web 的编写与选择 golden 也带上了新的名册行。

84
.bench-searxng-50.mjs Normal file
View File

@@ -0,0 +1,84 @@
// 50 live searches directly through SearXngSearchProvider (bypassing the tool).
// Companion to .tool-searxng.mjs: that one goes through ctx.tools.execute(); this
// one calls provider.search() directly so the two surfaces can be compared.
import { SearXngSearchProvider } from '@deepseek-ai/dsh-web-search-searxng'
const BASE = process.env.SEARXNG_BASE ?? 'http://192.168.31.240:8066'
// Same 50 queries as the web_search exercise, in the same order.
const QUERIES = [
'DeepSeek R1 reasoning model',
'latest SpaceX starship launch news',
'производительность видеокарт RTX 5090 обзор',
'Python async programming best practices 2025',
'Olympic games 2028 Los Angeles schedule',
'how does search engine ranking algorithm work',
'текущая погода в Москве сегодня',
'Node.js v24 release notes',
'best programming languages to learn 2026',
'A.I. regulation EU AI Act summary',
'Elon Musk Tesla news today',
'заголовок новости главные события недели',
'Docker compose tutorial beginners',
'climate change global warming impact report',
'как приготовить борщ рецепт пошагово',
'SQL injection prevention cheat sheet OWASP',
'самый быстрый способ выучить английский язык',
'React 19 features new hooks',
'gold price today per ounce forecast',
'рецепт шарлотки с яблоками в духовке',
'nuclear fusion breakthrough energy company',
'как похудеть без диет советы диетолога',
'TypeScript 5 generics tutorial',
'sustainable fashion eco brands list',
'история создания интернета кратко кто придумал',
'machine learning vs deep learning difference explained',
'лучшие фильмы 2025 года рейтинг топ',
'home solar panels cost savings 2026',
'как настроить VPN на Windows 11 инструкция',
'Quantum computing stocks investment 2026',
'how to write a resume for software engineer tips',
'лучшие книги по программированию читать',
'electric car battery recycling process technology',
'что такое криптовалюта простыми словами',
'web development full stack roadmap 2026',
'space tourism first commercial passengers mission',
'польза и вред кофе для здоровья исследования',
'Rust vs Go backend performance comparison',
'как выбрать ноутбук для работы критерии',
'artificial intelligence in healthcare applications examples',
'pluto dwarf planet facts discovery',
'банановые оладьи рецепт просто и быстро',
'GitHub copilot pricing plans comparison',
'великие открытия российской науки список',
'open source LLM models to run locally 2026',
'microservices architecture patterns distributed systems',
'как экономить электроэнергию дома советы',
'sleep quality improvement tips science backed',
'космос последние новости запуски ракет',
'postgresql vs mysql performance use cases',
]
const provider = new SearXngSearchProvider({ baseURL: BASE })
let ok = 0
let errors = 0
let totalSources = 0
const out = []
for (let i = 0; i < QUERIES.length; i++) {
const q = QUERIES[i]
try {
const res = await provider.search({ query: q, maxResults: 10 })
const n = res.sources.length
totalSources += n
ok++
out.push({ i: i + 1, q, ok: true, sources: n, firstUrl: res.sources[0]?.url ?? null })
} catch (e) {
errors++
const code = e?.info?.code ?? null
const msg = String(e?.message ?? e)
out.push({ i: i + 1, q, ok: false, errorCode: code, error: msg.slice(0, 120) })
}
}
console.log(JSON.stringify({ ok, errors, totalSources, queries: QUERIES.length, results: out }, null, 2))

45
.tool-searxng.mjs Normal file
View File

@@ -0,0 +1,45 @@
// Native web_search tool run over the real SearXNG provider.
// Mirrors packages/web/tool-web/tests/integration.spec.ts: nothing bypasses
// ctx.tools.execute(). Boots the real seam (web-search-searxng), the model
// tool (tool-web), and the tool registry from the BUILT bundles.
import { Context } from '@deepseek-ai/cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import WebRuntime from '@deepseek-ai/dsh-web'
import * as SearXng from '@deepseek-ai/dsh-web-search-searxng'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
const BASE = process.env.SEARXNG_BASE ?? 'http://192.168.31.240:8066'
const QUERY = process.env.SEARCH_QUERY ?? 'deepseek harness'
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(WebRuntime, { searchProvider: SearXng.SEARXNG_PROVIDER_ID })
await ctx.plugin(SearXng, { baseURL: BASE })
const fiber = await ctx.plugin(ToolWeb, { search: true, fetch: false })
const byName = new Map(ctx.tools.schemas().map((s) => [s.name, s]))
if (!byName.has('web_search')) {
console.error('web_search tool NOT registered')
process.exit(1)
}
console.error(`tool web_search registered; params=${JSON.stringify(byName.get('web_search').parameters)}`)
const out = await ctx.tools.execute({
callId: CallId('searxng-native-1'),
name: 'web_search',
arguments: { query: QUERY },
signal: new AbortController().signal,
})
const text = out.content.map((b) => b.type === 'text' ? b.text : '').join('')
console.log(JSON.stringify({
isError: out.isError,
errorCode: out.error?.info?.code ?? null,
contentLength: text.length,
preview: text.slice(0, 400),
}, null, 2))
await fiber.dispose()

View File

@@ -198,12 +198,14 @@
toolName: subagent_fork
backgroundMode: continuable
# Production dsh does not install these optional providers. An opting-in
# Profile mounts each provider once on the host plane; copy this preset,
# then remove `disabled` from the matching tool row.
# External product agents. The base host plane mounts each provider, and
# each run needs that product's own CLI on PATH plus its own account:
# `codex`, `claude`, and `cursor-agent`. A missing CLI fails the call it is
# asked for rather than the composition, so a deployment without one keeps
# an inert tool row; copy this preset and add `disabled: true` to drop it
# from the model's roster entirely.
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
disabled: true
config:
provider: codex
toolName: subagent_codex
@@ -212,13 +214,20 @@
- id: tool-subagent-claude-code
name: '@deepseek-ai/dsh-tool-subagent'
disabled: true
config:
provider: claude-code
toolName: subagent_claude_code
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed
- id: workflow-worker-thread
name: '@deepseek-ai/dsh-workflow-worker-thread'
config:

View File

@@ -185,12 +185,14 @@
toolName: subagent_fork
backgroundMode: continuable
# Production dsh does not install these optional providers. An opting-in
# Profile mounts each provider once on the host plane; copy this preset,
# then remove `disabled` from the matching tool row.
# External product agents. The base host plane mounts each provider, and
# each run needs that product's own CLI on PATH plus its own account:
# `codex`, `claude`, and `cursor-agent`. A missing CLI fails the call it is
# asked for rather than the composition, so a deployment without one keeps
# an inert tool row; copy this preset and add `disabled: true` to drop it
# from the model's roster entirely.
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
disabled: true
config:
provider: codex
toolName: subagent_codex
@@ -199,13 +201,20 @@
- id: tool-subagent-claude-code
name: '@deepseek-ai/dsh-tool-subagent'
disabled: true
config:
provider: claude-code
toolName: subagent_claude_code
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed
- id: workflow-worker-thread
name: '@deepseek-ai/dsh-workflow-worker-thread'
config:

View File

@@ -9,7 +9,7 @@ Every capability in this harness is a plugin row in a `cordis.yml`. There is no
## Off-limits
**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.
**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, whose ids the roster reports. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.
To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.

View File

@@ -0,0 +1,211 @@
# The `economy` agent preset: token-efficient agent with cost-aware delegation.
# ── identity ────────────────────────────────────────────────────────────────
- id: persona
name: '@deepseek-ai/dsh-persona'
config:
text: >-
You are an economy-focused coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
TOKEN & COST EFFICIENCY PRINCIPLES (MANDATORY OPERATING RULES):
1. Do cheap work directly in the main thread:
- A direct read/grep/glob call is almost always cheaper and faster than a subagent, which pays its own system prompt and tool catalog. Use read/grep/glob/pwsh directly for lookups, small edits, and tasks with a small working set.
2. Delegate only when delegation clearly saves tokens:
- Delegate large, self-contained subtasks that would otherwise add many tool-call rounds to the main context: whole-codebase exploration, running and analyzing a test suite, drafting an isolated implementation, independent research.
- Use `subagent` for self-contained work; use `subagent_fork` only when the child must build on this conversation.
- Group related questions into one delegation rather than one subagent per small question.
3. Bound the delegation cost:
- Prefer one subagent per work item; never spawn a subagent for a lookup a main-thread tool can answer.
- Children are leaves: a delegated child works directly with its own tools and reports back; it must not delegate further.
- Ignore or cancel a subagent whose result no longer matters; collect only the results you need.
4. Context protection:
- Keep the main conversation lean: paste conclusions, not raw file dumps.
5. Communication:
- Keep assistant responses concise, structured, and actionable.
- id: agent-instructions
name: '@deepseek-ai/dsh-agent-instructions'
config:
maxBytes: 65536
# ── shell ───────────────────────────────────────────────────────────────────
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform === 'win32'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: !!js process.platform !== 'win32'
# ── filesystem ──────────────────────────────────────────────────────────────
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
config:
sampleOverCapGlobResults: false
# ── background jobs ────────────────────────────────────────────────────────
- id: tool-jobs
name: '@deepseek-ai/dsh-tool-jobs'
# ── skills ──────────────────────────────────────────────────────────────────
- id: skill-filesystem
name: '@deepseek-ai/dsh-skill-filesystem'
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'
# ── goals ───────────────────────────────────────────────────────────────────
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# ── plan mode ───────────────────────────────────────────────────────────────
- id: planning
name: cordis:group
group: true
isolate:
planMode: true
config:
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
# ── compaction ──────────────────────────────────────────────────────────────
- id: compaction
name: cordis:group
group: true
isolate:
compaction: true
toolResultPruner: true
config:
- id: compaction-basic
name: '@deepseek-ai/dsh-compaction-basic'
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
- id: tool-result-pruner
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
config:
thresholdChars: 4096
headChars: 2048
tailChars: 512
# ── delegation and workflows ────────────────────────────────────────────────
- id: delegation
name: cordis:group
group: true
isolate:
workflowEngine: true
config:
- id: tool-subagent-control
name: '@deepseek-ai/dsh-tool-subagent-control'
- id: tool-subagent-list-agents
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent
backgroundMode: one-shot
# Children are leaves: the top-level agent may delegate, a child may
# not, so the number of spawned agents is bounded by the top-level
# delegations instead of cascading.
maxDepth: 1
- id: tool-subagent-fork
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
backgroundMode: one-shot
maxDepth: 1
# An economy mode should not reach for external paid agents by default, so
# these rows stay off even though the base host plane mounts each provider.
# A deployment that wants them copies this preset and removes `disabled`
# from the matching row; each run then needs that product's own CLI on PATH
# (`codex`, `claude`, `cursor-agent`) plus its own account.
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
disabled: true
config:
provider: codex
toolName: subagent_codex
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-claude-code
name: '@deepseek-ai/dsh-tool-subagent'
disabled: true
config:
provider: claude-code
toolName: subagent_claude_code
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
disabled: true
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed
- id: workflow-worker-thread
name: '@deepseek-ai/dsh-workflow-worker-thread'
config:
provider: spawn
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
config:
# Fresh-agent rounds are the most expensive delegation pattern: every
# round is a new child with no conversation seed. Keep the budget tight.
subagentProvider: spawn
maxRounds: 8
# ── remaining model-facing rows ─────────────────────────────────────────────
- id: tool-ask-user
name: '@deepseek-ai/dsh-tool-ask-user'
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
config:
allowParallelInProgress: true
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
fetch: false
searchTimeoutMs: 60000

View File

@@ -0,0 +1,3 @@
name: Экономный режим
description: Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются.
order: 5

View File

@@ -0,0 +1,539 @@
# The `maximum` agent preset: every model-facing capability this deployment
# can compose, driven by a three-role delivery pipeline.
#
# Two things distinguish it from `standard`. It adds the capabilities the other
# shipped presets leave out — persistent terminals, language-server queries,
# session history, durable time and tmux context, reminders, self-modification,
# the extra editor, and Code Mode beside the native schemas. And it adds three
# ROLE delegation tools over the same `spawn` backend, each carrying its own
# child persona: `subagent_architect` writes the specification,
# `subagent_implementer` builds it, `subagent_reviewer` verifies it. The
# orchestrating persona below owns the order they run in; the child personas
# own what each role must produce.
#
# COST: this preset is deliberately the expensive one. Every row here adds tool
# schemas and prompt sections to every request, `mode: both` sends the native
# catalog AND a generated Code Mode SDK, and one pipeline pass is three child
# agents with their own contexts. Copy it and delete rows to trade capability
# for tokens; `standard` and `economy` are the smaller shipped points.
#
# TRUST: `cordis_mount` evaluates model-written JavaScript against the live
# runtime, and the external product agents below run whatever their own CLI and
# account allow. Treat a session on this preset as shell access.
#
# This file is an AGENT-PLANE composition. The roster mounts it ONCE under a
# standing scope; every session naming it joins by scope parentage, so the tools
# and prompt sections registered here cover each joined agent while a session's
# own state stays keyed per Session/Agent inside the plugins. The host
# composition (`base.cordis.yml` + `web.cordis.yml`) keeps everything a preset
# must not own: the registries themselves, the sandbox and approval stack,
# persistence, and the model route.
#
# A service row here MUST sit inside a group carrying an `isolate` realm.
# Without one it publishes into the root realm, where it is process-global —
# another preset publishing the same name collides, and a host reader would
# resolve one preset's instance for every session; `dsh-agent-presets` rejects
# that at mount. `true` means an entry-local realm: this standing mount's own
# private instance, apart from every other preset's.
# ── identity ────────────────────────────────────────────────────────────────
# The preset's own persona, shadowing the deployment default for this agent.
# `{{model}}` and `{{cwd}}` resolve from the agent's own route and workspace.
# It states the pipeline the three role tools below exist for; each role's own
# instructions live on that role's tool row, not here, because a child never
# reads this section — the delegation replaces it with the role persona.
- id: persona
name: '@deepseek-ai/dsh-persona'
config:
text: |-
You are the lead agent of a three-role delivery pipeline, powered by the {{model}} model. Your working directory is {{cwd}}.
Every change to the repository passes through three delegated roles, in this order:
1. `subagent_architect` turns the request into a specification: the goal, the files and subsystems involved, the ordered change plan, the acceptance checks, and the risks. It reads and never writes.
2. `subagent_implementer` receives that specification and makes the repository satisfy it, running the checks the specification names.
3. `subagent_reviewer` receives the specification and the implementer's report, inspects the working tree itself, and returns PASS with its evidence or FAIL with a numbered defect list.
On FAIL, hand the defect list plus the original specification back to `subagent_implementer` and review again. Stop after the third failed review and bring the disagreement to the user rather than starting a fourth round.
Each role runs as a fresh agent that cannot see this conversation or the other roles' sessions. Everything a role needs goes into its prompt as complete text — paste the specification and the verdict, never "as discussed above" or a session id.
Skip the pipeline for work that is not a change: questions, read-only investigation, running a command the user asked for, or a fix the user dictated line by line. Use it for anything that changes behavior, and say which stage you are in as you go.
Do your own reading, searching, and answering. Delegate the three roles, not your judgment.
- id: agent-instructions
name: '@deepseek-ai/dsh-agent-instructions'
config:
maxBytes: 65536
# ── shell ───────────────────────────────────────────────────────────────────
# `shell-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to
# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is
# the criterion for host-plane ownership — injection resolves before any session
# exists, so there is no agent to key by. Both shell tools consume the host
# registry from here; their executors (`bash-sandbox`/`pwsh-sandbox`) are
# host-plane too.
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform === 'win32'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: !!js process.platform !== 'win32'
# ── persistent terminals ────────────────────────────────────────────────────
# The PTY registry is an agent-owned service, so it lives in an entry-local
# realm; the backend still consumes the host sandbox policy and subprocess
# implementation. `tool-terminal` rather than `tool-bash-persistent` is what
# joins the one-shot shell above: the persistent-bash tool registers under the
# name `bash`, which this preset's `tool-bash` already owns, while the six
# `terminal_*` tools add long-lived sessions under their own names.
#
# The backend starts an interactive `bash`. A machine without one fails
# `terminal_open`, not the composition, exactly like the external agents below.
- id: persistent-terminals
name: cordis:group
group: true
isolate:
terminals: true
config:
- id: pty
name: '@deepseek-ai/dsh-terminal'
- id: terminal-bash
name: '@deepseek-ai/dsh-terminal-bash'
config:
timeoutMs: 300000
- id: tool-terminal
name: '@deepseek-ai/dsh-tool-terminal'
config:
enableRunInBackground: true
maxResultBytes: 262144
# ── filesystem ──────────────────────────────────────────────────────────────
# All three register into the host `tools` registry and provide nothing, so
# they need no realm. The `fs` service and its policy stay in the host.
# `str_replace_editor` is the standalone view/create/replace/insert editor; it
# overlaps `edit` deliberately, since this preset's contract is that every
# shipped model-facing tool is present.
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
config:
sampleOverCapGlobResults: false
- id: tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
config:
maxOutputChars: 16000
# ── language servers ────────────────────────────────────────────────────────
# The `lsp` registry has no consumer outside an agent, so it lives in an
# entry-local realm with the tool that reads it. Without a provider the tool
# stays in the catalog and every query answers the structured `LSP_UNAVAILABLE`
# error, which is why the tool row is unconditional and the provider row is not.
#
# `lsp-stdio` resolves every configured executable AT LOAD and fails the mount
# when one is missing, so a shipped preset cannot enable it: the language server
# is a machine-local install, not a deployment fact. The row below is the
# worked example — copy this preset, drop `disabled`, and name the servers this
# machine actually has.
- id: language-servers
name: cordis:group
group: true
isolate:
lsp: true
config:
- id: lsp
name: '@deepseek-ai/dsh-lsp'
- id: lsp-stdio
name: '@deepseek-ai/dsh-lsp-stdio'
disabled: true
config:
servers:
typescript:
command: typescript-language-server
args: ['--stdio']
extensionToLanguage:
.ts: typescript
.tsx: typescriptreact
.mts: typescript
.cts: typescript
.js: javascript
.jsx: javascriptreact
- id: tool-lsp
name: '@deepseek-ai/dsh-tool-lsp'
# ── background jobs ────────────────────────────────────────────────────────
# Only the model-facing controls. The task REGISTRY stays on the host plane:
# its producers sit outside any realm this file could put it in — `tool-bash`
# and `tool-terminal` above resolve it with `ctx.get`, and an entry-local realm
# here is invisible to every sibling row, so `run_in_background` would answer
# "background jobs unavailable" while these controls sat in the catalog. The
# registry is keyed by owning agent anyway, so one host instance serves every
# session.
- id: tool-jobs
name: '@deepseek-ai/dsh-tool-jobs'
# ── skills ──────────────────────────────────────────────────────────────────
# The skill REGISTRY lives in the host composition and is layered per scope:
# these rows register into THIS preset's layer of it, so they need no realm.
# The merged catalog carries the project and user roots this provider always
# scans, whatever the deployment registered globally, and the two custom roots
# below.
#
# The first custom root is this preset's own `skills/`, which travels with it.
# The second is the `cordis` preset's, so composition authoring is documented
# for the `cordis_*` tools this preset also carries; a copy of this preset that
# lands beside no `cordis` directory simply scans a missing path, which is
# valid empty state for this provider.
- id: skill-filesystem
name: '@deepseek-ai/dsh-skill-filesystem'
config:
customSkillDirs:
- !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('skills/', baseUrl))"
- !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('../cordis/skills/', baseUrl))"
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'
# ── goals ───────────────────────────────────────────────────────────────────
# Only the model-facing tool. The goal SERVICE, its session driver, and the
# `/goal` command stay on the host plane: the Gateway serves the goal domain as
# Remote endpoints whose receiver comes from a generated descriptor, so it
# resolves `goals` on the host and an entry-local realm here would hide it.
- id: tool-goal
name: '@deepseek-ai/dsh-tool-goal'
# ── session history ─────────────────────────────────────────────────────────
# Five read-only tools over the host `sessionQuery` service, each result
# authorized from the calling agent's own session. The shipped host mounts that
# service with `openAt: never`, so `session_search` and `session_event_search`
# answer `SESSION_QUERY_SEARCH_DISABLED` while the three read and trace tools
# work; full-text search is a host decision (`openAt: first-search` in a
# profile patch), not one a preset can make.
- id: tool-session-query
name: '@deepseek-ai/dsh-tool-session-query'
# ── durable per-step context ────────────────────────────────────────────────
# Both inject their snapshot at `agent/pre-step`, which is scope-filtered, so
# they reach only agents joined to this preset. Time context lets the model
# read relative dates in the request's own zone; tmux context names the pane
# this process runs in, and reads as "not in tmux" everywhere else.
- id: time-context
name: '@deepseek-ai/dsh-time-context'
config:
refreshIntervalMs: 60000
- id: tmux-context
name: '@deepseek-ai/dsh-tmux-context'
config:
refreshIntervalMs: 60000
# ── scheduled reminders ─────────────────────────────────────────────────────
# Session-scoped durable reminders (`schedule_create`/`_list`/`_delete`). The
# plugin installs on root agents created after it loads and takes its state
# from the session log through the host persistence barrier, so one instance
# per preset mount serves every session that joins — `agent/created` is
# scope-filtered, and an agent on another preset never reaches this one.
- id: schedule
name: '@deepseek-ai/dsh-schedule'
# ── plan mode ───────────────────────────────────────────────────────────────
# Plan state is per-agent by nature, so an entry-local realm is not a
# workaround here — it is the correct lifetime.
- id: planning
name: cordis:group
group: true
isolate:
planMode: true
config:
- id: plan-mode
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. Delegation does not lift this: a delegated role may read and report, and none of them may write while plan mode is active.
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
# ── compaction ──────────────────────────────────────────────────────────────
# `compaction-basic` reads `toolResultPrune` through `ctx.get`, so the pruner
# must share this realm rather than sit outside it.
#
# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST
# plane, and the rows here resolve that one instance. It keys every fold by
# Session and owns the context-meter projection units the browser reads for
# every session — behind a realm those units would come and go with whichever
# presets happen to be mounted.
- id: compaction
name: cordis:group
group: true
isolate:
compaction: true
toolResultPruner: true
config:
- id: compaction-basic
name: '@deepseek-ai/dsh-compaction-basic'
- id: command-compact
name: '@deepseek-ai/dsh-command-compact'
- id: tool-result-pruner
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
config:
thresholdChars: 8192
headChars: 4096
tailChars: 1024
# ── delegation, roles, and workflows ────────────────────────────────────────
# The `subagents` registry and its spawn/fork backends live in the HOST
# composition: the registry is a process singleton whose cross-session queries
# the api-proxy serves to the browser, and a provider name may only be
# registered once. This preset contributes the delegation TOOLS, which resolve
# that host registry.
#
# `workflows` is different — nothing outside an agent reads it — so every row
# that reaches it shares one entry-local realm here, and a consumer left
# outside would resolve a host registry this preset does not populate.
- id: delegation
name: cordis:group
group: true
isolate:
workflowEngine: true
config:
- id: tool-subagent-control
name: '@deepseek-ai/dsh-tool-subagent-control'
- id: tool-subagent-list-agents
name: '@deepseek-ai/dsh-tool-subagent-control/list-agents'
# The unshaped delegations, kept beside the roles: `subagent` for work that
# is not a pipeline stage, `subagent_fork` for work that needs this
# conversation's history.
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent
backgroundMode: continuable
- id: tool-subagent-fork
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
backgroundMode: continuable
# ── the three pipeline roles ────────────────────────────────────────────
#
# One `spawn` instance per role, distinguished only by its `toolName` and
# its child `persona` — the provider applies that persona as a scoped
# section shadowing `deployment:persona`, so a role child never reads the
# orchestrating persona at the top of this file.
#
# All three are `one-shot`: a stage's result is the next stage's input, so
# the default must be the foreground call that returns it. `maxDepth: 1`
# keeps the pipeline flat — the roles are leaves and the lead agent owns
# sequencing, so the agent count of one pass is exactly the stages run.
#
# A role's restrictions are stated in its persona rather than as a
# `toolFilter`: the filter names GLOBAL tool names and fails the start when
# one is unknown, and the shell tool's name differs by platform, so a
# read-only filter here would be a Windows-versus-POSIX startup failure. A
# persona cannot enforce a restriction; deployments that need enforcement
# copy this preset and add the filter their platform actually registers.
- id: tool-subagent-architect
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent_architect
backgroundMode: one-shot
maxDepth: 1
persona: |-
You are the ARCHITECT of a three-role delivery pipeline. You produce one specification and nothing else. An implementer who cannot see this session receives your reply verbatim and builds from it.
Ground the specification in the repository as it actually is. Read the files the change touches, the tests around them, and the existing patterns it must follow; prefer an existing function, module, or convention over new machinery. Resolve by inspection anything you could otherwise guess at.
Change nothing. No edits, no writes, no commits, no formatters, no code generation, no dependency installs. Running read-only commands and tests to learn how the code behaves today is expected.
Reply with exactly these sections:
GOAL — one paragraph: what will be true when this is done, and what is explicitly out of scope.
CONTEXT — the files, symbols, and patterns the change must fit, each with a path, and what each one contributes.
PLAN — ordered steps, each naming the file it changes and the change it makes, in enough detail that the implementer makes no design decisions.
ACCEPTANCE — the checks that decide done: exact commands to run, and the observable behavior to confirm.
RISKS — what could break elsewhere, the edge cases to cover, and every assumption you could not verify.
When the request is ambiguous in a way that changes the design, state the interpretation you chose and why in GOAL rather than asking; you cannot see the user.
- id: tool-subagent-implementer
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent_implementer
backgroundMode: one-shot
maxDepth: 1
persona: |-
You are the IMPLEMENTER of a three-role delivery pipeline. You receive a specification and make the repository satisfy it. A reviewer who cannot see this session checks your work against that same specification.
Follow the plan you were given. Match the surrounding code: its naming, its idioms, its comment density, its error handling. Change what the specification calls for and leave the rest alone.
Run the checks the specification names, plus whatever narrower test covers the code you touched. A check you did not run is not a check you may report.
When a step is wrong or impossible, do every step that is not blocked, then say exactly what you skipped and why. Never substitute a different design in silence, and never widen the scope past the specification.
Reply with exactly these sections:
CHANGES — one line per file: the path and what changed in it.
VERIFICATION — each command you ran and its outcome, quoting the failing output where it failed.
DEVIATIONS — everything you did differently from the plan, or did not do, each with its reason. Write "none" when there is nothing.
- id: tool-subagent-reviewer
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent_reviewer
backgroundMode: one-shot
maxDepth: 1
persona: |-
You are the REVIEWER of a three-role delivery pipeline. You receive a specification and an implementer's report, and you return a verdict on the work as it exists in the repository.
Verify, do not trust. Read the changed files yourself, re-run the acceptance checks the specification names, and confirm each claim in the report against what you observe. An unrunnable or skipped check is a defect, not a pass.
Judge the implementation against the specification: unmet acceptance criteria, missed edge cases and failure modes, behavior changed outside the plan, tests that assert nothing the change could break, and anything that contradicts the conventions of the surrounding code.
Fix nothing. Do not edit files, do not commit, do not run formatters or code generation. Reporting the defect is your whole job.
Reply with exactly these sections:
VERDICT — the first line, either PASS or FAIL and nothing else.
EVIDENCE — the commands you ran and the files you read, each with its outcome.
DEFECTS — numbered, each naming the file, what the specification requires, what the code does instead, and a severity of blocking or minor. PASS requires this list to be empty; a blocking defect requires FAIL.
# External product agents. The base host plane mounts each provider, and
# each run needs that product's own CLI on PATH plus its own account:
# `codex`, `claude`, and `cursor-agent`. A missing CLI fails the call it is
# asked for rather than the composition, so a deployment without one keeps
# an inert tool row; copy this preset and add `disabled: true` to drop it
# from the model's roster entirely.
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: codex
toolName: subagent_codex
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-claude-code
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: claude-code
toolName: subagent_claude_code
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed
- id: workflow-worker-thread
name: '@deepseek-ai/dsh-workflow-worker-thread'
config:
provider: spawn
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
- id: tool-ralph
name: '@deepseek-ai/dsh-tool-ralph'
config:
subagentProvider: spawn
maxRounds: 64
# ── MCP servers ─────────────────────────────────────────────────────────────
# One row per external MCP server, each registering its tools on the host
# registry as `mcp__<serverName>__<rawName>`. Which servers exist is a machine
# fact — every one names a command to spawn or a URL to reach — so this preset
# ships the worked example disabled. Copy the preset, drop `disabled`, and add
# one row per server.
- id: mcp-example
name: '@deepseek-ai/dsh-mcp-client'
disabled: true
config:
serverName: example
transport: stdio
command: npx
args: ['-y', '@modelcontextprotocol/server-everything']
# ── remaining model-facing rows ─────────────────────────────────────────────
- id: tool-ask-user
name: '@deepseek-ai/dsh-tool-ask-user'
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
config:
allowParallelInProgress: true
# The `web` service and its search provider stay in the host composition; only
# the model-facing tool is per-session. `fetch` stays off because the shipped
# host mounts no fetch provider: that provider defers SSRF protection and the
# model would choose the request target.
- id: tool-web
name: '@deepseek-ai/dsh-tool-web'
config:
fetch: false
searchTimeoutMs: 60000
# ── self-modification ───────────────────────────────────────────────────────
# Read the live runtime, mount a temporary plugin, unmount it. The toolset is a
# trust boundary, not a sandbox — see this file's header. The composition-
# authoring skill reaches this agent through the second custom skill root above.
- id: tool-cordis
name: '@deepseek-ai/dsh-tool-cordis'
# ── presentation ────────────────────────────────────────────────────────────
# `both` sends the native tool schemas AND the generated Code Mode SDK, so the
# model may call a tool directly or write one TypeScript program that combines
# several. It waits for the host's `codeRuntime` rather than assuming it: a
# deployment composing no TypeScript runtime fails this preset at mount, naming
# this id, instead of at the first request.
- id: tool-presentation
name: '@deepseek-ai/dsh-agent-tool-presentation'
config:
mode: both

View File

@@ -0,0 +1,3 @@
name: 全能模式
description: 组合全部可用插件、技能与外部 Agent,并以「架构师 → 实现者 → 审查者」三角色流水线规划、实现并验证每一次改动。
order: 6

View File

@@ -0,0 +1,48 @@
---
name: three-role-delivery-pipeline
description: Use when running a change through this preset's architect → implementer → reviewer roles — writing the prompt for subagent_architect, subagent_implementer, or subagent_reviewer, deciding whether a task needs the pipeline at all, handing a FAIL verdict back for a second round, or splitting work that is too large for one pass.
---
# The three-role delivery pipeline
This preset carries three delegation tools that are one tool each, distinguished by the persona their children run under:
| Tool | Role | Produces |
|---|---|---|
| `subagent_architect` | specify | GOAL / CONTEXT / PLAN / ACCEPTANCE / RISKS |
| `subagent_implementer` | build | CHANGES / VERIFICATION / DEVIATIONS |
| `subagent_reviewer` | verify | VERDICT / EVIDENCE / DEFECTS |
All three are foreground calls that return their reply as the tool result, and none of them can delegate further. You are the only agent that sees the whole pipeline.
## When it applies
Run the pipeline for anything that changes behavior: a feature, a bug fix, a refactor, a migration, a configuration change that alters what the system does.
Skip it for a question, a read-only investigation, a command the user asked you to run, or an edit the user dictated exactly. Answer those yourself. A pipeline pass costs three child agents; using it to rename one variable is waste, and the user notices.
Split before you delegate when the request holds several independent changes. One pipeline pass carries one specification; two unrelated changes in one specification produce a review that cannot say PASS or FAIL about either.
## The handoff is the whole design
Each child is a fresh agent. It cannot see this conversation, the user's message, the earlier stages, or the other children's sessions. Whatever you leave out of the prompt does not exist for that role.
**To the architect**, pass the user's request in full, the constraints the user stated, and anything you already learned that narrows the work — a file you found, a decision the user made mid-conversation, a check that already fails.
**To the implementer**, pass the architect's reply verbatim. Add nothing and remove nothing; if you disagree with the plan, say so to the user or re-run the architect, but do not edit a specification into the implementer's prompt.
**To the reviewer**, pass the same specification verbatim plus the implementer's complete report. The reviewer compares two texts against the repository; withholding either leaves it guessing.
Never write "as described above", "the plan from the previous step", or a session id in a role prompt. There is no above.
## The FAIL loop
A FAIL verdict comes back as a numbered defect list. Send the implementer the original specification, its own previous report, and that defect list, and say that this round fixes the listed defects and nothing else. Then review again, with the same specification and the new report.
Stop after the third failed review. Three rounds against one specification means the specification and the implementation disagree about something the reviewer cannot resolve — take it to the user with the specification, the last report, and the surviving defects, rather than starting a fourth round.
A `minor` defect does not have to block. When the reviewer returns PASS with minor defects, or FAIL where every defect is minor and unrelated to the acceptance criteria, say so to the user and let them decide whether to spend another round.
## Reporting
Say which stage you are in as you go: the user is waiting through three model calls and a silent gap reads as a hang. When the pipeline finishes, report what changed, what the reviewer verified, and any deviation or surviving defect. Never report the pipeline as complete when the reviewer never ran or returned FAIL.

View File

@@ -197,12 +197,14 @@
toolName: subagent_fork
backgroundMode: continuable
# Production dsh does not install these optional providers. An opting-in
# Profile mounts each provider once on the host plane; copy this preset,
# then remove `disabled` from the matching tool row.
# External product agents. The base host plane mounts each provider, and
# each run needs that product's own CLI on PATH plus its own account:
# `codex`, `claude`, and `cursor-agent`. A missing CLI fails the call it is
# asked for rather than the composition, so a deployment without one keeps
# an inert tool row; copy this preset and add `disabled: true` to drop it
# from the model's roster entirely.
- id: tool-subagent-codex
name: '@deepseek-ai/dsh-tool-subagent'
disabled: true
config:
provider: codex
toolName: subagent_codex
@@ -211,13 +213,20 @@
- id: tool-subagent-claude-code
name: '@deepseek-ai/dsh-tool-subagent'
disabled: true
config:
provider: claude-code
toolName: subagent_claude_code
backgroundMode: one-shot
maxDepth: provider-managed
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed
- id: workflow-worker-thread
name: '@deepseek-ai/dsh-workflow-worker-thread'
config:

View File

@@ -38,6 +38,8 @@
"@deepseek-ai/dsh-goal-round-driver": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-lsp-stdio": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-headless": "workspace:^",
"@deepseek-ai/dsh-mcp-client": "workspace:^",
@@ -56,6 +58,9 @@
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-tmux-context": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-subagent-claude-code": "workspace:^",
"@deepseek-ai/dsh-subagent-codex": "workspace:^",
"@deepseek-ai/dsh-subagent-cursor": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
@@ -70,7 +75,10 @@
"@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
"@deepseek-ai/dsh-tool-terminal": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tool-lsp": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",

View File

@@ -150,6 +150,21 @@ function enablePresetTool(composition: string, id: string): string {
return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length)
}
function disablePresetTool(composition: string, id: string): string {
const row = ` - id: ${id}\n`
const start = composition.indexOf(row)
if (start < 0) throw new Error(`missing preset row ${id}`)
const end = composition.indexOf('\n - id:', start + row.length)
const disabled = composition.indexOf(' disabled: true\n', start)
if (disabled >= 0 && (end < 0 || disabled < end)) {
throw new Error(`preset row ${id} is already disabled`)
}
// `disabled` is entry metadata, so it goes beside `name` rather than inside
// the row's `config`.
const nameLine = composition.indexOf('\n', start + row.length) + 1
return `${composition.slice(0, nameLine)} disabled: true\n${composition.slice(nameLine)}`
}
let ctx: Context
beforeAll(async () => {
const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml')
@@ -197,7 +212,7 @@ describe('the shipped Web composition', () => {
it('supplies both shipped presets, and only those, from the system root', async () => {
const listed = await ctx.agentPresets.list()
expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard'])
expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'economy', 'maximum', 'minimal', 'standard'])
expect(listed.every(preset => preset.trust === 'system')).toBe(true)
expect(ctx.agentPresets.defaultId).toBe('standard')
})
@@ -216,7 +231,8 @@ describe('the shipped Web composition', () => {
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_search',
'subagent', 'subagent_claude_code', 'subagent_codex', 'subagent_cursor',
'subagent_fork', 'todo_write', 'update_goal', 'web_search',
'workflow', 'write',
])
} finally {
@@ -224,6 +240,75 @@ describe('the shipped Web composition', () => {
}
})
it('composes the economy agent without external subagent providers', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-economy'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'economy').then(() => undefined),
})
try {
const toolList = toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')
// The shell row is platform-gated like `standard`'s: bash off Windows,
// pwsh on it. Everything else is the standard catalog.
const shell = process.platform === 'win32' ? 'pwsh' : 'bash'
// `economy` keeps every standard tool except the external product
// agents, which it disables to avoid paid delegations by default.
expect(toolList).toEqual(expect.arrayContaining([
'ask_user_question', shell, 'create_goal', 'edit', 'exit_plan_mode',
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_search',
'workflow', 'write',
]))
expect(toolList).not.toEqual(expect.arrayContaining([
'subagent_codex', 'subagent_claude_code', 'subagent_cursor',
]))
// The delegation tools default to foreground: one-shot, not the
// continuable background scheduling `standard` uses.
const subagent = ctx.tools.schemas(handle.agent).find(schema => schema.name === 'subagent')
expect(subagent?.description).toContain('This call waits for the result by default.')
} finally {
await handle.dispose()
}
})
it('composes every shipped capability and the three pipeline roles from `maximum`', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-maximum'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'maximum').then(() => undefined),
})
try {
// The shell row is platform-gated like `standard`'s: bash off Windows,
// pwsh on it. The registry answers in name order, so the gated name is
// sorted in rather than written at a fixed position.
const shell = process.platform === 'win32' ? 'pwsh' : 'bash'
// The EXACT catalog, for the reason `standard`'s assertion states: a row
// that registers into the wrong layer mounts cleanly and contributes
// nothing, and this preset exists to carry every row at once.
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
'ask_user_question', shell, 'cordis_define', 'cordis_inspect_list', 'cordis_inspect_query',
'cordis_inspect_self', 'cordis_run', 'cordis_stop', 'cordis_undefine', 'create_goal', 'edit',
'exit_plan_mode', 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output',
'list_agents', 'lsp', 'ralph', 'read', 'read_image', 'run_code', 'schedule_create',
'schedule_delete', 'schedule_list', 'send_message', 'session_event_read',
'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill',
'str_replace_editor', 'subagent', 'subagent_architect', 'subagent_claude_code',
'subagent_codex', 'subagent_cursor', 'subagent_fork', 'subagent_implementer',
'subagent_reviewer', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read',
'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_search', 'workflow',
'write',
].sort())
// Each role is one `spawn` instance carrying its own child persona, so
// the roles are distinguishable only by what their descriptions and
// personas say — the wire schemas are otherwise identical.
const schemas = ctx.tools.schemas(handle.agent)
for (const role of ['subagent_architect', 'subagent_implementer', 'subagent_reviewer']) {
expect(schemas.find(schema => schema.name === role)?.description)
.toContain('This call waits for the result by default.')
}
} finally {
await handle.dispose()
}
})
it('composes the exact RL prompt and two tools from `minimal`', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-minimal'),
@@ -436,7 +521,7 @@ describe('the shipped Web composition', () => {
describe('product subagent rows in user presets', () => {
let productCtx: Context
const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const
const ids = ['products-none', 'products-codex', 'products-claude', 'products-all'] as const
beforeAll(async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-'))
@@ -444,23 +529,26 @@ describe('product subagent rows in user presets', () => {
const settingsFile = join(root, 'settings.yaml')
const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8')
await writeFile(settingsFile, '{}\n')
// `standard` ships every product row enabled, so each variant is built by
// REMOVING the rows it must not carry.
for (const id of ids) {
let composition = standard
if (id === 'products-codex' || id === 'products-both') {
composition = enablePresetTool(composition, 'tool-subagent-codex')
if (id !== 'products-all' && id !== 'products-codex') {
composition = disablePresetTool(composition, 'tool-subagent-codex')
}
if (id === 'products-claude' || id === 'products-both') {
composition = enablePresetTool(composition, 'tool-subagent-claude-code')
if (id !== 'products-all' && id !== 'products-claude') {
composition = disablePresetTool(composition, 'tool-subagent-claude-code')
}
if (id !== 'products-all') {
composition = disablePresetTool(composition, 'tool-subagent-cursor')
}
const directory = join(userRoot, id)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'agent.cordis.yml'), composition)
}
// The base patch already mounts every product provider on the host plane,
// so this boot adds only the preset roster.
productCtx = await bootWeb(settingsFile, [
{ insert: [
{ id: 'subagent-codex', name: '@deepseek-ai/dsh-subagent-codex' },
{ id: 'subagent-claude-code', name: '@deepseek-ai/dsh-subagent-claude-code' },
] },
{
id: 'agent-presets',
config: {
@@ -479,15 +567,15 @@ describe('product subagent rows in user presets', () => {
await productCtx.fiber.dispose()
})
it('composes none, either product, or both without changing the shared host registry', async () => {
it('composes none, one product, or every product without changing the shared host registry', async () => {
const expected = new Map<string, string[]>([
['products-none', []],
['products-codex', ['subagent_codex']],
['products-claude', ['subagent_claude_code']],
['products-both', ['subagent_claude_code', 'subagent_codex']],
['products-all', ['subagent_claude_code', 'subagent_codex', 'subagent_cursor']],
])
expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([
'spawn', 'fork', 'codex', 'claude-code',
'spawn', 'fork', 'codex', 'claude-code', 'cursor',
]))
for (const [id, productTools] of expected) {
@@ -497,7 +585,7 @@ describe('product subagent rows in user presets', () => {
})
try {
const tools = toolNames(productCtx, handle.agent)
expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code'))
expect(tools.filter(name => name.startsWith('subagent_') && name !== 'subagent_fork'))
.toEqual(productTools)
expect(tools).toEqual(expect.arrayContaining(['job_kill', 'job_list', 'job_output']))
for (const productTool of productTools) {

View File

@@ -61,6 +61,26 @@
- 'button "复制: 创造模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: Экономный режим"':
- text: Экономный режим 内置 Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются.
- code: economy
- 'button "查看: Экономный режим"':
- img
- text: 查看
- 'button "复制: Экономный режим"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 全能模式"':
- text: 全能模式 内置 组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。
- code: maximum
- 'button "查看: 全能模式"':
- img
- text: 查看
- 'button "复制: 全能模式"':
- img
- text: 复制
- heading "自定义" [level=3]
- list:
- listitem:

View File

@@ -61,6 +61,26 @@
- 'button "复制: 创造模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: Экономный режим"':
- text: Экономный режим 内置 Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются.
- code: economy
- 'button "查看: Экономный режим"':
- img
- text: 查看
- 'button "复制: Экономный режим"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 全能模式"':
- text: 全能模式 内置 组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。
- code: maximum
- 'button "查看: 全能模式"':
- img
- text: 查看
- 'button "复制: 全能模式"':
- img
- text: 复制
- heading "自定义" [level=3]
- list:
- listitem:

View File

@@ -61,6 +61,26 @@
- 'button "复制: 创造模式"':
- img
- text: 复制
- listitem:
- 'button "设为默认: Экономный режим"':
- text: Экономный режим 内置 Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются.
- code: economy
- 'button "查看: Экономный режим"':
- img
- text: 查看
- 'button "复制: Экономный режим"':
- img
- text: 复制
- listitem:
- 'button "设为默认: 全能模式"':
- text: 全能模式 内置 组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。
- code: maximum
- 'button "查看: 全能模式"':
- img
- text: 查看
- 'button "复制: 全能模式"':
- img
- text: 复制
- heading "自定义" [level=3]
- button "用「创造模式」创作自定义预设":
- img

View File

@@ -5,3 +5,5 @@
- menuitem "PTC mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program."
- menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor."
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance."
- menuitem "Экономный режим Оптимизированный режим для экономии токенов и затрат на API: дешёвые операции выполняются в основном контексте, делегирование субагентам — только когда оно окупается, результаты инструментов активно сжимаются."
- menuitem "Maximum mode Every capability this deployment can compose — terminals, language servers, session history, MCP, external agents, and Code Mode beside the native tools — delivering each change through an architect, an implementer, and a reviewer."

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: 040deb80fe232dc61bba1b2041a541d33516973f
config-catalog.zh.md: e0166072b66d4a940c5242f23974a602a5658f1a
config-catalog.md: 2c450518537d9cb318efa449fabbfba82eeda1cd
config-catalog.zh.md: 365c443a9f36a45309e0196e821ecb8e5bea974d

View File

@@ -1671,7 +1671,7 @@ export interface Config {
}
```
Source: [`packages/session/session-projection-cache/src/index.ts:42`](../packages/session/session-projection-cache/src/index.ts)
Source: [`packages/session/session-projection-cache/src/index.ts:43`](../packages/session/session-projection-cache/src/index.ts)
<a id="deepseek-aidsh-session-query-sqlite"></a>
@@ -2138,6 +2138,39 @@ export interface Config {
Source: [`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts)
<a id="deepseek-aidsh-subagent-cursor"></a>
## `@deepseek-ai/dsh-subagent-cursor`
Requires: `subagents` · `subprocess`
```ts config-catalog
/** Deployment-owned environment, permissions, and process-release bound. */
export interface Config {
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment. `CURSOR_API_KEY` belongs here
* rather than on the command line, where a process listing would expose it.
*/
env?: Record<string, string>
/** Grace in milliseconds for `cursor-agent` process-tree termination. */
disposeGraceMs?: number
/**
* Whether the child may apply file changes and run commands (`--force`).
* Cursor's own print-mode default only PROPOSES changes, so a delegation
* expected to edit the workspace needs this on.
*/
force?: boolean
/**
* Whether the child may act in the workspace without Cursor's interactive
* trust prompt (`--trust`). An unattended child cannot answer that prompt.
*/
trust?: boolean
}
```
Source: [`packages/subagent/subagent-cursor/src/index.ts:35`](../packages/subagent/subagent-cursor/src/index.ts)
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>
## `@deepseek-ai/dsh-subagent-dsh-sdk`

View File

@@ -2140,6 +2140,39 @@ export interface Config {
来源:[`packages/subagent/subagent-codex/src/index.ts:30`](../packages/subagent/subagent-codex/src/index.ts)
<a id="deepseek-aidsh-subagent-cursor"></a>
## `@deepseek-ai/dsh-subagent-cursor`
需要:`subagents` · `subprocess`
```ts config-catalog
/** Deployment-owned environment, permissions, and process-release bound. */
export interface Config {
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment. `CURSOR_API_KEY` belongs here
* rather than on the command line, where a process listing would expose it.
*/
env?: Record<string, string>
/** Grace in milliseconds for `cursor-agent` process-tree termination. */
disposeGraceMs?: number
/**
* Whether the child may apply file changes and run commands (`--force`).
* Cursor's own print-mode default only PROPOSES changes, so a delegation
* expected to edit the workspace needs this on.
*/
force?: boolean
/**
* Whether the child may act in the workspace without Cursor's interactive
* trust prompt (`--trust`). An unattended child cannot answer that prompt.
*/
trust?: boolean
}
```
来源:[`packages/subagent/subagent-cursor/src/index.ts:35`](../packages/subagent/subagent-cursor/src/index.ts)
<a id="deepseek-aidsh-subagent-dsh-sdk"></a>
## `@deepseek-ai/dsh-subagent-dsh-sdk`

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: 1a09c6c39dd0710b490ec23ae76b928cd6edf8d6
module-graph.zh.md: 811ee0899068ce475df8b6b168513f60a492d56f
module-graph.md: 690c680fd3dc64f3313274c27bbb622fe4ab4fa6
module-graph.zh.md: fbb3363d753c1c1419ad120f053c77c94cc64cc1

View File

@@ -60,6 +60,7 @@ flowchart TD
pkg_subagent_acp["subagent-acp"]
pkg_subagent_claude_code["subagent-claude-code"]
pkg_subagent_codex["subagent-codex"]
pkg_subagent_cursor["subagent-cursor"]
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
pkg_subagent_fork_in_process["subagent-fork-in-process"]
pkg_subagent_in_process_driver["subagent-in-process-driver"]
@@ -978,6 +979,12 @@ flowchart TD
pkg_subagent_claude_code --> pkg_subagent
pkg_subagent_claude_code --> pkg_subprocess
pkg_subagent_claude_code --> pkg_timeout
pkg_subagent_cursor --> pkg_invariants
pkg_subagent_cursor --> pkg_llm
pkg_subagent_cursor --> pkg_session
pkg_subagent_cursor --> pkg_subagent
pkg_subagent_cursor --> pkg_subprocess
pkg_subagent_cursor --> pkg_timeout
pkg_subagent_in_process_driver --> pkg_agent
pkg_subagent_in_process_driver --> pkg_invariants
pkg_subagent_in_process_driver --> pkg_llm
@@ -1232,6 +1239,7 @@ flowchart TD
pkg_client_ui_trajectory --> pkg_client_ui_primitives
pkg_client_ui_trajectory --> pkg_compaction
pkg_client_ui_trajectory --> pkg_invariants
pkg_client_ui_trajectory --> pkg_openrouter_usage
pkg_client_ui_trajectory --> pkg_tools
pkg_client_ui_user_questions --> pkg_api_remotes
pkg_client_ui_user_questions --> pkg_client_locale
@@ -1606,6 +1614,7 @@ flowchart TD
| [`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) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-cursor`](../packages/subagent/subagent-cursor) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
@@ -1640,7 +1649,7 @@ flowchart TD
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`openrouter-usage`](../packages/llm/openrouter-usage), [`tools`](../packages/core/tools) |
| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |

View File

@@ -62,6 +62,7 @@ flowchart TD
pkg_subagent_acp["subagent-acp"]
pkg_subagent_claude_code["subagent-claude-code"]
pkg_subagent_codex["subagent-codex"]
pkg_subagent_cursor["subagent-cursor"]
pkg_subagent_dsh_sdk["subagent-dsh-sdk"]
pkg_subagent_fork_in_process["subagent-fork-in-process"]
pkg_subagent_in_process_driver["subagent-in-process-driver"]
@@ -980,6 +981,12 @@ flowchart TD
pkg_subagent_claude_code --> pkg_subagent
pkg_subagent_claude_code --> pkg_subprocess
pkg_subagent_claude_code --> pkg_timeout
pkg_subagent_cursor --> pkg_invariants
pkg_subagent_cursor --> pkg_llm
pkg_subagent_cursor --> pkg_session
pkg_subagent_cursor --> pkg_subagent
pkg_subagent_cursor --> pkg_subprocess
pkg_subagent_cursor --> pkg_timeout
pkg_subagent_in_process_driver --> pkg_agent
pkg_subagent_in_process_driver --> pkg_invariants
pkg_subagent_in_process_driver --> pkg_llm
@@ -1234,6 +1241,7 @@ flowchart TD
pkg_client_ui_trajectory --> pkg_client_ui_primitives
pkg_client_ui_trajectory --> pkg_compaction
pkg_client_ui_trajectory --> pkg_invariants
pkg_client_ui_trajectory --> pkg_openrouter_usage
pkg_client_ui_trajectory --> pkg_tools
pkg_client_ui_user_questions --> pkg_api_remotes
pkg_client_ui_user_questions --> pkg_client_locale
@@ -1608,6 +1616,7 @@ flowchart TD
| [`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) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-cursor`](../packages/subagent/subagent-cursor) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
@@ -1642,7 +1651,7 @@ flowchart TD
| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`openrouter-usage`](../packages/llm/openrouter-usage), [`tools`](../packages/core/tools) |
| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |

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/llm-streaming.md
llm-streaming.md: 7c0e0865f8dcc0e7722bb2205d0129d9e0ca3086
llm-streaming.zh.md: 5c31909ee79137c6c5eef101235b43a2419b1339
llm-streaming.md: 6adaf86016a6bcab757c86d47c10518f41539fe7
llm-streaming.zh.md: 20349f426b4976904941b4069dfe51e988a8b142

View File

@@ -866,6 +866,22 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts)
<a id="ctxopenrouterusage--openrouterusagegateway"></a>
### `ctx.openRouterUsage` — `OpenRouterUsageGateway`
OpenRouter usage + balance gateway (`ctx.openRouterUsage`).
```ts cordis-catalog
/**
* The latest known account snapshot.
* @returns a fresh copy of the cached balance.
*/
@Remote('snapshot') snapshot(): OpenRouterBalance
```
Source: [`packages/llm/openrouter-usage/src/index.ts:91`](../../packages/llm/openrouter-usage/src/index.ts)
<a id="llm-events"></a>
### `llm/*` events

View File

@@ -872,6 +872,22 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
Source: [`packages/llm/llm/src/index.ts:284`](../../packages/llm/llm/src/index.ts)
<a id="ctxopenrouterusage--openrouterusagegateway"></a>
### `ctx.openRouterUsage` — `OpenRouterUsageGateway`
OpenRouter usage + balance gateway (`ctx.openRouterUsage`).
```ts cordis-catalog
/**
* The latest known account snapshot.
* @returns a fresh copy of the cached balance.
*/
@Remote('snapshot') snapshot(): OpenRouterBalance
```
Source: [`packages/llm/openrouter-usage/src/index.ts:91`](../../packages/llm/openrouter-usage/src/index.ts)
<a id="llm-events"></a>
### `llm/*` events

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/subagent.md
subagent.md: a683a679e6017351540ee4b73adc74375ef0a1d6
subagent.zh.md: 61391cd297c0eb14f4c0d8eac4539b551cb60bda
subagent.md: 2e504fbeccb917328a7675e13c205fca4c54f3d0
subagent.zh.md: e4ed14ce53f22824740c3457e5f84045dfc92050

View File

@@ -4,7 +4,7 @@ English | [中文](subagent.zh.md)
The subagent seam lets an agent delegate work to a child agent. Like [bash](shell.md), it is **one optional capability**, not part of the agent loop, so its types live here rather than in [core.md](core.md). It differs from the other capability seams because **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), while bash allows only one executor. Its registry follows the [LLM adapter registry](llm-streaming.md), not the single-service bash executor.
Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service Providers are sibling packages (`dsh-subagent-spawn-in-process`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only child and descendant discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service Providers are sibling packages (`dsh-subagent-spawn-in-process`, `-fork`, `-acp`, `-codex`, `-claude-code`, `-cursor`, `-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation), [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls), and [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report) (the optional child-scoped `report` return channel). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only child and descendant discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) and [the Cursor Agent Note](../../.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), [the report-tool Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md), [the durable catalog Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), [the list-identity-projection Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md), and [the merged-service Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md).
Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)

View File

@@ -4,7 +4,7 @@
subagent seam 让一个 agent(智能体)将工作委派给子 agent。与 [bash](shell.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环),因此其类型定义在此而非 [core.md](core.md) 中。它不同于其他能力 seam,因为**同一上下文中可共存多个提供方实现**,并按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。该注册表遵循 [LLM(大语言模型)适配器注册表](llm-streaming.md),而非单服务的 bash 执行器。
Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service Provider 是六个兄弟包:`dsh-subagent-spawn-in-process`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接基于会话存储和可选的会话持久化提供只读的 child 与后代发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service Provider 是七个兄弟包:`dsh-subagent-spawn-in-process`、`-fork`、`-acp`、`-codex`、`-claude-code`、`-cursor`、`-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)、[dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)和 [dsh-tool-subagent-report](../../packages/subagent/tool-subagent-report)(可选的 child 作用域 `report` 返回通道)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接基于会话存储和可选的会话持久化提供只读的 child 与后代发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md) 与 [Cursor Agent Note](../../.agents/notes/implemented/feature/2026-08-22-cursor-external-agent-provider.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)、[report 工具 Agent Note](../../.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md)、[持久化目录 Agent Note](../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[列表身份投影 Agent Note](../../.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md)和[服务合并 Agent Note](../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。
源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)

View File

@@ -0,0 +1,29 @@
# Keyless twin of product-subagent-cursor.cordis.yml: keep the same product
# provider/tool composition and replace only the external model adapter.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- insert:
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
providers:
- id: deepseek-official
name: DeepSeek
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
- id: subagent-cursor
name: '@deepseek-ai/dsh-subagent-cursor'
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed

View File

@@ -0,0 +1,18 @@
# Add the native Cursor product provider and its preset-shaped one-shot tool to
# the real ACP composition. The model is told not to call it; the scenario pins
# the assembled request schema without starting cursor-agent.
- id: base
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./cordis.yml
patches:
- insert:
- id: subagent-cursor
name: '@deepseek-ai/dsh-subagent-cursor'
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed

View File

@@ -65,6 +65,7 @@ const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath(
new URL('../background-job-admission.cordis.yml', import.meta.url),
)
const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url))
const PRODUCT_SUBAGENT_CURSOR_CONFIG = fileURLToPath(new URL('../product-subagent-cursor.cordis.yml', import.meta.url))
const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url))
const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url))
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
@@ -149,6 +150,15 @@ const SCENARIOS: Scenario[] = [
headerClass: 'product-subagent-codex',
configPath: PRODUCT_SUBAGENT_CODEX_CONFIG,
},
{
name: 'product-subagent-cursor',
hasModelTurn: true,
recorded: false,
pinsHeader: true,
headerClass: 'product-subagent-cursor',
systemPromptSource: 'product-subagent-codex',
configPath: PRODUCT_SUBAGENT_CURSOR_CONFIG,
},
{
name: 'product-subagent-both',
hasModelTurn: true,

View File

@@ -0,0 +1,40 @@
# Test-only composition of the public opt-in provider and one-shot task tool.
# The owning e2e boots this tree but never invokes the model or Cursor.
- id: fixture
name: './fixture.ts'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
- id: subagent-cursor
name: '@deepseek-ai/dsh-subagent-cursor'
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: 'provider-managed'
- id: agent-spine
name: '@deepseek-ai/dsh-agent-spine-demo'
config:
agents:
- id: main
provider: mock
model: mock-delegate
cwd: !!js process.cwd()
persona: 'This composition test must not start a model turn.'
workspaceContext: false
- id: persistence
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: './.sessions'
- id: checkpoint-policy
name: '@deepseek-ai/dsh-session-checkpoint-policy'

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env node
/** Inspect the public Cursor provider composition without invoking the product. */
import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-subagent'
import type {} from '@deepseek-ai/dsh-tools'
const configPath = process.argv[2]
if (configPath === undefined) {
throw new Error('subagent-cursor Loader composition driver requires a config path')
}
let starts = 0
const ctx = await boot(
'subagent-cursor-loader-composition',
resolveConfigPath(configPath, undefined),
undefined,
(hostCtx) => {
hostCtx.on('subagent/start', () => {
starts += 1
})
},
)
try {
const provider = ctx.subagents.getProvider('cursor')
if (provider === undefined) throw new Error('Cursor provider was not registered')
const tool = ctx.tools.schemas().find(schema => schema.name === 'subagent_cursor')
if (tool === undefined) throw new Error('subagent_cursor tool was not registered')
const properties = tool.parameters.properties
if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) {
throw new Error('subagent_cursor tool has invalid parameter properties')
}
const jobTools = ctx.tools.schemas()
.map(schema => schema.name)
.filter(name => name === 'job_kill' || name === 'job_list' || name === 'job_output')
.sort()
process.stdout.write(`${JSON.stringify({
providers: ctx.subagents.list(),
provider: {
name: provider.name,
capabilities: provider.capabilities,
inheritsParentContext: provider.inheritsParentContext,
},
tool: {
name: tool.name,
parameterNames: Object.keys(properties).sort(),
required: tool.parameters.required,
},
jobTools,
starts,
})}\n`)
} finally {
await ctx.fiber.dispose()
}

View File

@@ -0,0 +1,22 @@
/** Parent adapter that fails if the composition-only Loader test starts a turn. */
import type { Context } from '@deepseek-ai/cordis'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
class CompositionOnlyAdapter extends LlmAdapter {
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
throw new Error('subagent-cursor Loader composition must not invoke a model')
}
}
export const name = 'cursor-loader-composition-fixture'
export const inject = ['llm']
/**
* Register a parent adapter solely so the host composition is complete.
* @param ctx - Loader context supplying the LLM seam.
*/
export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['mock'], new CompositionOnlyAdapter())
}

View File

@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." }
]
}

View File

@@ -0,0 +1,22 @@
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}}
{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"}
{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}}
{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}}
{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"c883cf16-01fe-4afc-b37c-d255bb450d21"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"}
{"type":"step/end","seq":38,"time":1785730415298,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -0,0 +1,548 @@
{
"initial": [
{
"name": "bash",
"description": "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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute."
},
"description": {
"type": "string",
"description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
},
"workdir": {
"type": "string",
"description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
},
"run_in_background": {
"type": "boolean",
"description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."
},
"sandbox_permissions": {
"type": "string",
"description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.",
"enum": [
"workspace-write",
"danger-full-access"
]
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."
}
},
"required": [
"command",
"description"
]
}
},
{
"name": "create_goal",
"description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.",
"parameters": {
"type": "object",
"properties": {
"objective": {
"type": "string",
"description": "The concrete completion objective inferred from the direct human request."
},
"max_goal_rounds": {
"type": "number",
"description": "Optional positive safe-integer limit on automatic continuation rounds."
}
},
"required": [
"objective"
]
}
},
{
"name": "edit",
"description": "Edit an existing UTF-8 text file by replacing literal text.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to edit, resolved by the filesystem backend."
},
"old_string": {
"type": "string",
"description": "Literal text to replace. Must match exactly."
},
"new_string": {
"type": "string",
"description": "Literal replacement text. Use an empty string to delete the match."
},
"replace_all": {
"type": "boolean",
"description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once."
},
"sandbox_permissions": {
"type": "string",
"description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.",
"enum": [
"workspace-write",
"danger-full-access"
]
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
}
},
"required": [
"file_path",
"old_string",
"new_string"
]
}
},
{
"name": "get_goal",
"description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.",
"parameters": {
"type": "object",
"properties": {}
}
},
{
"name": "interrupt_agent",
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
"parameters": {
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "The agent id of the running agent to interrupt."
}
},
"required": [
"agent_id"
]
}
},
{
"name": "job_kill",
"description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.",
"parameters": {
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "Job id returned by the tool that started the background work."
},
"reason": {
"type": "string",
"description": "Optional short reason, recorded in the log and forwarded to the job."
}
},
"required": [
"job_id"
]
}
},
{
"name": "job_list",
"description": "List your background jobs (running and finished) with their ids, kinds, and statuses.",
"parameters": {
"type": "object",
"properties": {}
}
},
{
"name": "job_output",
"description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.",
"parameters": {
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "Job id returned by the tool that started the background work."
},
"wait": {
"type": "boolean",
"description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."
},
"timeout_ms": {
"type": "number",
"description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
}
},
"required": [
"job_id"
]
}
},
{
"name": "list_agents",
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
"parameters": {
"type": "object",
"properties": {
"scope": {
"type": "string",
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
"enum": [
"children",
"descendants"
]
}
}
}
},
{
"name": "ralph",
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
"parameters": {
"type": "object",
"properties": {
"objective": {
"type": "string",
"description": "The immutable completion objective for every fresh Ralph round."
},
"maxRounds": {
"type": "number",
"description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
}
},
"required": [
"objective"
]
}
},
{
"name": "read",
"description": "Read a UTF-8 text file and return line-numbered content.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to read, resolved by the filesystem backend."
},
"offset": {
"type": "number",
"description": "1-based first line to return. Defaults to 1."
},
"limit": {
"type": "number",
"description": "Maximum number of lines to return. Defaults to 2000."
}
},
"required": [
"file_path"
]
}
},
{
"name": "send_message",
"description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.",
"parameters": {
"type": "object",
"properties": {
"subagent_id": {
"type": "string",
"description": "The subagent id returned when the background subagent was started."
},
"message": {
"type": "string",
"description": "The message to deliver to the subagent."
}
},
"required": [
"subagent_id",
"message"
]
}
},
{
"name": "skill",
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The exact skill name from the available skills list."
}
},
"required": [
"name"
]
}
},
{
"name": "subagent",
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short (3-5 word) description of the delegated task, for display."
},
"prompt": {
"type": "string",
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
},
"run_in_background": {
"type": "boolean",
"description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."
}
},
"required": [
"description",
"prompt"
]
}
},
{
"name": "subagent_cursor",
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short (3-5 word) description of the delegated task, for display."
},
"prompt": {
"type": "string",
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
},
"run_in_background": {
"type": "boolean",
"description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
}
},
"required": [
"description",
"prompt"
]
}
},
{
"name": "subagent_fork",
"description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This call waits for the subagent and returns its result.",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "A short (3-5 word) description of the delegated task, for display."
},
"prompt": {
"type": "string",
"description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."
}
},
"required": [
"description",
"prompt"
]
}
},
{
"name": "todo_write",
"description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).",
"parameters": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"description": "The COMPLETE task list, replacing any previous list.",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"content": {
"type": "string",
"description": "What the task is — a short imperative line."
},
"status": {
"type": "string",
"description": "pending (not started) | in_progress (now) | completed (done).",
"enum": [
"pending",
"in_progress",
"completed"
]
}
},
"required": [
"content",
"status"
]
}
}
},
"required": [
"todos"
]
}
},
{
"name": "update_goal",
"description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.",
"parameters": {
"type": "object",
"properties": {
"goal_id": {
"type": "string",
"description": "Exact id returned by get_goal."
},
"revision": {
"type": "number",
"description": "Exact positive revision returned by get_goal."
},
"action": {
"type": "string",
"description": "edit | pause | resume | complete | blocked",
"enum": [
"edit",
"pause",
"resume",
"complete",
"blocked"
]
},
"objective": {
"type": "string",
"description": "Replacement objective; valid only with action edit."
},
"max_goal_rounds": {
"type": "number",
"description": "Replacement cap; valid only with action edit."
},
"blocked_reason": {
"type": "string",
"description": "Concrete blocking condition; required only with action blocked."
}
},
"required": [
"goal_id",
"revision",
"action"
]
}
},
{
"name": "workflow",
"description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: 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.",
"parameters": {
"type": "object",
"properties": {
"script": {
"type": "string",
"description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
},
"meta": {
"type": "object",
"description": "The workflow identity block (plain JSON — never code).",
"additionalProperties": true,
"properties": {
"name": {
"type": "string",
"description": "Short kebab-case workflow name."
},
"description": {
"type": "string",
"description": "One-line description of what the workflow does."
},
"whenToUse": {
"type": "string",
"description": "Optional guidance on when this workflow applies."
},
"phases": {
"type": "array",
"description": "Optional phase declarations matched by phase() calls.",
"items": {
"type": "object",
"additionalProperties": true,
"properties": {
"title": {
"type": "string",
"description": "The phase title phase() calls match by exact string."
},
"detail": {
"type": "string",
"description": "Optional one-line description of the phase."
},
"provider": {
"type": "string",
"description": "Optional provider override this phase is expected to use."
},
"model": {
"type": "string",
"description": "Optional model override this phase is expected to use."
}
},
"required": [
"title"
]
}
}
},
"required": [
"name",
"description"
]
},
"args": {
"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
}
},
"required": [
"script",
"meta"
]
}
},
{
"name": "write",
"description": "Create or fully replace a UTF-8 text file.",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to write, resolved by the filesystem backend."
},
"content": {
"type": "string",
"description": "Full UTF-8 text content to write."
},
"sandbox_permissions": {
"type": "string",
"description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.",
"enum": [
"workspace-write",
"danger-full-access"
]
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
}
},
"required": [
"file_path",
"content"
]
}
}
],
"changes": []
}

View File

@@ -75,6 +75,7 @@
"@deepseek-ai/dsh-subagent-acp": "workspace:*",
"@deepseek-ai/dsh-subagent-claude-code": "workspace:*",
"@deepseek-ai/dsh-subagent-codex": "workspace:*",
"@deepseek-ai/dsh-subagent-cursor": "workspace:*",
"@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*",
"@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*",
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*",

View File

@@ -61,6 +61,8 @@
"acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts",
"acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts",
"acp-agent/tests/fixtures/subagent/subagent-codex/driver.ts",
"acp-agent/tests/fixtures/subagent/subagent-cursor/fixture.ts",
"acp-agent/tests/fixtures/subagent/subagent-cursor/driver.ts",
"jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts",
"jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts",
"jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts",
@@ -768,6 +770,10 @@
"tests/**/*.ts"
]
},
"packages/subagent/subagent-cursor": {
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
"project": ["src/**/*.ts", "tests/**/*.ts"]
},
"packages/subagent/subagent-dsh-sdk": {
"entry": [
"tests/**/*.spec.ts",

View File

@@ -302,6 +302,15 @@
config:
providerName: fork
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
- id: subagent-claude-code
name: '@deepseek-ai/dsh-subagent-claude-code'
- id: subagent-cursor
name: '@deepseek-ai/dsh-subagent-cursor'
# Continuable background children are selected per delegation tool. The
# separately loaded follow-up tool registers the one global `send_message`.
- id: tool-subagent-control

View File

@@ -64,6 +64,8 @@
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-lsp-stdio": "workspace:^",
"@deepseek-ai/dsh-permission-presets": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
@@ -85,6 +87,9 @@
"@deepseek-ai/dsh-spill-local": "workspace:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-claude-code": "workspace:^",
"@deepseek-ai/dsh-subagent-codex": "workspace:^",
"@deepseek-ai/dsh-subagent-cursor": "workspace:^",
"@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
@@ -104,6 +109,7 @@
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tool-lsp": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",

View File

@@ -35,10 +35,18 @@ describe('dsh-base bundle', () => {
expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({
__jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
})
expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0)
expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0)
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex')
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code')
// The base layer mounts each external product agent exactly once, so a
// Profile enables one by removing `disabled` from its Agent Preset tool
// row rather than mounting a duplicate provider. Loading a provider starts
// no product process; a run still needs that product's own CLI on PATH.
for (const [id, dependency] of [
['subagent-codex', '@deepseek-ai/dsh-subagent-codex'],
['subagent-claude-code', '@deepseek-ai/dsh-subagent-claude-code'],
['subagent-cursor', '@deepseek-ai/dsh-subagent-cursor'],
] as const) {
expect(rows.filter(row => row.id === id)).toHaveLength(1)
expect(manifest.dependencies).toHaveProperty(dependency)
}
})
it('gates each shell stack by platform with a symmetric disabled expression', () => {

View File

@@ -476,6 +476,11 @@ export class SessionManager {
if (session === undefined) continue
session.handleBlank(s.blank)
session.handleRunning(s.running)
// A durable session the host disposed earlier being re-listed is
// alive again: lift the removed tombstone so the surviving
// instance renders normally (mutation replay already excluded a
// genuinely-removed session from this.summaries).
session.handleRevived()
}
// Seed each row's projection baseline into the per-session value
// store (cold titles surface without opening the session). Per-key
@@ -804,6 +809,10 @@ export class SessionManager {
...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}),
})
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
// A re-emitted id (host re-engaging a previously disposed session)
// revives the surviving instance: the removed tombstone must not keep
// the conversation (and its projection readouts) in the removed state.
this.sessions.get(frame.sessionId)?.handleRevived()
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
this.markCatalogParentExpandable(frame.parentSessionId)
}
@@ -834,7 +843,12 @@ export class SessionManager {
// no relative order. Clearing here makes a detached Activation's rows
// disappear whichever arrives first.
this.jobsBySession.delete(frame.sessionId)
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
// The projection store is retained: the Session instance survives
// removal (resident-instance rule — handleRemoved above only flags the
// snapshot), and a session the host re-lists resumes its projections
// through the same instance. Deleting the store here would leave that
// surviving instance with an empty projection face until a fresh
// baseline lands — and open() is idempotent, so no re-open re-pulls it.
// A pull already in flight was requested before this removal and can
// carry the pre-removal parentAvailable:true, which would resurrect
// the writable editor this invalidation just closed. Replay false over

View File

@@ -577,6 +577,18 @@ export class Session implements SessionFace {
this.notifier.markDirty()
}
/**
* host/session-added or host re-listing relay: the session is alive again,
* so lift the removed tombstone. The instance survives removal, and a
* removed-then-re-listed session must render its conversation again (the
* composer and its projection readouts) rather than the removed state.
*/
handleRevived(): void {
if (!this.removed) return
this.removed = false
this.notifier.markDirty()
}
/**
* host/agent-error relay: the only outlet for live failures with no turn position.
* @param message - the stringified error.

View File

@@ -190,7 +190,7 @@ describe('list lifecycle', () => {
expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2])
})
it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => {
it('retains title projections before list arrival, keeps last-wins by seq, and survives removal', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote())
const titleFrame = (rpcId: string, title: string, seq: number) => {
@@ -212,9 +212,13 @@ describe('list lifecycle', () => {
expect(titled.items[0]?.title).toBe('Newest')
expect(titled.items[1]?.title).toBeUndefined()
// A removed-then-re-added session is the same durable lifecycle under the
// resident-instance rule: its projection face (here the title) survives
// the round-trip, so the row resurfacing shows the retained value rather
// than re-seeding from an empty store.
manager.handleHostEnvelope({ rpcId: 'removed' as never, payload: { type: 'host/session-removed', sessionId: S1 } })
manager.handleHostEnvelope({ rpcId: 'readded' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } })
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBe('Newest')
})
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {

View File

@@ -209,7 +209,7 @@ describe('manager frame routing', () => {
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
})
it('drops the projection store with the removed session', async () => {
it('retains the projection store with the removed session (the instance survives removal)', async () => {
const api = new FakeApiClient()
const manager = new SessionManager(api, fakeRemote())
api.onList = () => Promise.resolve(ok({
@@ -224,6 +224,9 @@ describe('manager frame routing', () => {
rpcId: 'rm' as never,
payload: { type: 'host/session-removed', sessionId: sid('s1') } as never,
})
expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
// The Session instance survives removal (resident-instance rule), so its
// projection face survives with it: a re-listed session resumes its
// values instead of re-seeding from an empty face.
expect(manager.get(sid('s1')).projections.get('title')).toBe('Doomed')
})
})

View File

@@ -0,0 +1,147 @@
/**
* Repro + behavioral pin for the chat-switch projection retention contract:
* a session's projection store (openRouterCost is the canonical example) must
* survive a selection change and remain readable through the same value face
* when the session becomes current again.
*
* Server frames exercised:
* - session.list rows carrying a projections block (the cold seed path)
* - session/projection push frames to a live session
* - host/session-removed (transient host teardown) + host/session-added
* - session/subscribed (durable-replay baseline) + a re-pulled history tail
*
* The manager's per-session ProjectionValueStore is retained across select();
* this suite pins the retention contract so a regression in the
* retention/seeding discipline shows up as a red test here.
*/
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
const S1 = 'fk-s1' as SessionId
const S2 = 'fk-s2' as SessionId
const COST = { totalUsd: 1.23, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' as const }
function summary(sessionId: SessionId, projections?: { asOfSeq: number; values: Record<string, unknown> }) {
return {
sessionId,
updatedAt: 100,
running: false,
blank: false,
...(projections === undefined ? {} : { projections }),
} as never
}
describe('projection retention across chat switches (repro)', () => {
it('retains the projection store for a session that was opened, seeded, and switched away from and back', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2)] }))
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
// A live push frame lands the cost in S1's resident store.
manager.handleMuxEnvelope({
rpcId: 'seed' as never,
payload: { type: 'session/projection', sessionId: S1, key: 'openRouterCost', value: COST, seq: 5 } as never,
})
expect(manager.getListSnapshot().items[0]?.projectionValues?.openRouterCost).toEqual(COST)
// The conversation switches to another chat and back; nothing removes S1.
manager.select(S2)
manager.select(S1)
expect(manager.getListSnapshot().items[0]?.projectionValues?.openRouterCost).toEqual(COST)
})
it('a host/session-removed disposal followed by a re-add re-seeds the cost from the list row block', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({
items: [
summary(S1, { asOfSeq: 6, values: { openRouterCost: COST } }),
summary(S2),
],
}))
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.projectionValues?.openRouterCost)
.toEqual(COST)
// Transient host teardown: the chat leaves the list; its projection store
// is retained (the Session instance survives removal), and the durable
// chat still exists host-side, so a list refresh re-adds it.
manager.handleHostEnvelope({
rpcId: 'rm' as never,
payload: { type: 'host/session-removed', sessionId: S1 } as never,
})
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)).toBeUndefined()
await manager.refreshList()
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.projectionValues?.openRouterCost)
.toEqual(COST)
})
it('lifts the removed tombstone when a disposed session is re-listed by the host', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2)] }))
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
const session = manager.get(S1)
manager.handleHostEnvelope({
rpcId: 'rm' as never,
payload: { type: 'host/session-removed', sessionId: S1 } as never,
})
expect(session.getSnapshot().removed).toBe(true)
// The same instance survives (resident-instance rule).
expect(manager.get(S1)).toBe(session)
// The durable chat comes back: the re-listing revives the surviving
// instance rather than leaving the conversation tombstoned forever.
manager.handleHostEnvelope({
rpcId: 'add' as never,
payload: { type: 'host/session-added', blank: false, sessionId: S1 } as never,
})
expect(session.getSnapshot().removed).toBe(false)
})
it('revives through a list refresh when the host re-lists a previously disposed session', async () => {
const api = new FakeApiClient()
const listed = [summary(S1), summary(S2)]
api.onList = () => Promise.resolve(ok({ items: listed }))
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
const session = manager.get(S1)
manager.handleHostEnvelope({
rpcId: 'rm' as never,
payload: { type: 'host/session-removed', sessionId: S1 } as never,
})
expect(session.getSnapshot().removed).toBe(true)
// The host never re-emits host/session-added for an existing id; the
// durable session simply appears again on the next list pull.
await manager.refreshList()
expect(session.getSnapshot().removed).toBe(false)
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)).toBeDefined()
})
it('survives a subscribed durable-replay baseline when the value is at or before the baseline', async () => {
const api = new FakeApiClient()
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] }))
const manager = new SessionManager(api, fakeRemote())
await manager.refreshList()
manager.handleMuxEnvelope({
rpcId: 'push' as never,
payload: { type: 'session/projection', sessionId: S1, key: 'openRouterCost', value: COST, seq: 3 } as never,
})
// The durable baseline is at seq 3, so the row is not phantom: it survives.
manager.handleMuxEnvelope({
rpcId: 'sub' as never,
payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 } as never,
})
expect(manager.getListSnapshot().items[0]?.projectionValues?.openRouterCost).toEqual(COST)
})
})

View File

@@ -8,6 +8,7 @@ export type AgentPresetSettingsKey =
| 'presetCodeName' | 'presetCodeDescription'
| 'presetMinimalName' | 'presetMinimalDescription'
| 'presetCordisName' | 'presetCordisDescription'
| 'presetMaximumName' | 'presetMaximumDescription'
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
| 'displayName' | 'displayNamePlaceholder'
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
@@ -46,6 +47,9 @@ export const en: Record<AgentPresetSettingsKey, string> = {
presetCordisName: 'Creator mode',
presetCordisDescription:
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
presetMaximumName: 'Maximum mode',
presetMaximumDescription:
'Every capability this deployment can compose — terminals, language servers, session history, MCP, external agents, and Code Mode beside the native tools — delivering each change through an architect, an implementer, and a reviewer.',
duplicate: 'Duplicate',
duplicateUnavailable: 'This deployment has no writable preset directory',
delete: 'Delete',
@@ -106,6 +110,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。',
presetCordisName: '创造模式',
presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。',
presetMaximumName: '全能模式',
presetMaximumDescription: '组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。',
duplicate: '复制',
duplicateUnavailable: '此部署未配置可写的预设目录',
delete: '删除',
@@ -171,6 +177,7 @@ const BUILT_IN_PRESET_KEYS: Readonly<Partial<Record<string, PresetLocaleKeys>>>
code: { name: 'presetCodeName', description: 'presetCodeDescription' },
minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' },
cordis: { name: 'presetCordisName', description: 'presetCordisDescription' },
maximum: { name: 'presetMaximumName', description: 'presetMaximumDescription' },
}
/**

View File

@@ -13,13 +13,21 @@ only once a step actually priced — no zero-cost group. An unknown-pricing step
count surfaces in a tooltip so the figure's coverage stays visible. `CostDock`
therefore owns no store, refresh chain, or event listener.
The figure covers the session plus its subagent subtree, walked over the
session list's `parentId`/`origin` rows; a subtree share names itself in the
label. Each projection value describes only its own session's work — the host
fold excludes a forked child's inherited prefix — so the walk sums rather than
double counts. Ordinary forks are not delegated work and stay out of it.
`BalanceBadge` is account-global, so it is not a session projection: the slot
inject face carries a `snapshot()` callback that polls the host
`ctx.remote.openRouterUsage.snapshot()` Remote on a 60s interval and drives
local state. In the collapsed rail it renders only the balance marker; in the
wide column a labeled pill. Unknown or absent figures render the empty
placeholder. Both entries render nothing before real data exists, so an
assembly without the OpenRouter gateway or key costs no layout.
local state. Clicking the badge calls the `refresh()` callback instead, which
makes the host fetch the account immediately; the badge marks itself
`aria-busy` for the duration and ignores further clicks until it settles, and
a failed refresh keeps the last-known figure. In the collapsed rail it renders
only the balance marker; in the wide column a labeled pill. Unknown or absent
figures render the empty placeholder.
The `/client` exports are the plugin body (`apply`/`inject`), the
`CostDock`/`BalanceBadge` components, and the injected face types.
@@ -42,5 +50,10 @@ account figure; neither is a new model-visible input.
projection holds; figures for already-folded history reflect the pricing
table at fold time (see `dsh-openrouter-usage`).
- **Polled, not pushed** — `BalanceBadge` polls the host snapshot; there is
no `openRouterUsage` forwarded-event channel, so the figure updates on the
poll interval rather than instantly.
no `openRouterUsage` forwarded-event channel, so an account change the user
did not click for surfaces on the poll interval rather than instantly.
- **A cold subagent contributes nothing** — the subtree walk reads the
descendants' projection values the client holds. A subagent session never
opened, and with no version-matching row in the host's cold projection
cache, carries no value and is indistinguishable from a zero-cost child, so
its spend is missing from the total rather than marked absent.

View File

@@ -14,7 +14,12 @@
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 28px;
cursor: default;
cursor: pointer;
}
.badge[aria-busy='true'] {
cursor: progress;
opacity: 0.6;
}
.badge:hover {

View File

@@ -2,10 +2,11 @@
// plus the latest snapshot's USD figure in both widths (rail = marker-only,
// wide = label + figure). The value is account-global, so it is not a session
// projection: an injected `snapshot` callback polls the host Remote gateway on
// an interval and drives local state. `wide` arrives from the sidebar's owner
// share; `snapshot` from the register's inject face.
// an interval, a click runs `refresh` for an on-demand read, and both drive
// local state. `wide` arrives from the sidebar's owner share; the two
// callbacks from the register's inject face.
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { formatUsd } from './money.ts'
@@ -14,25 +15,34 @@ import css from './BalanceBadge.module.css'
/** Poll interval for the balance figure, ms. */
export const BALANCE_POLL_MS = 60_000
/** Injected face: the balance read callback the badge polls. */
/** Injected face: the balance reads the badge polls and re-runs on demand. */
export interface BalanceBadgeActions {
/** Fetch the latest account snapshot from the host gateway. */
/** Read the host gateway's cached account snapshot. */
snapshot: () => Promise<OpenRouterBalance>
/** Make the host fetch the account NOW and serve the result. */
refresh: () => Promise<OpenRouterBalance>
}
/** Composed props: the sidebar footer-action owner share plus the injected read. */
/** Composed props: the sidebar footer-action owner share plus the injected reads. */
export type BalanceBadgeProps =
PropsRuntime<'sidebar.footer.action'> & BalanceBadgeActions & PropsLocale<'openRouterUsage'>
/**
* Poll `snapshot` on an interval and render the account balance. Unknown or
* absent figures render the empty placeholder so the seat costs no confusion.
* A transient failure keeps the last-known figure and retries next tick.
* @param props - owner state plus the injected snapshot callback.
* Poll `snapshot` on an interval, re-read through `refresh` when the user
* clicks, and render the account balance. Unknown or absent figures render the
* empty placeholder so the seat costs no confusion. A transient failure keeps
* the last-known figure and retries next tick; a click while one refresh is
* still outstanding is ignored rather than queued.
* @param props - owner state plus the injected snapshot/refresh callbacks.
* @returns the balance badge.
*/
export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
export function BalanceBadge({ wide, snapshot, refresh, t }: BalanceBadgeProps) {
const [balance, setBalance] = useState<OpenRouterBalance | undefined>(undefined)
const [refreshing, setRefreshing] = useState(false)
// Survives the unmount a disposed registration causes mid-fetch: the
// resolving callback must not set state on a gone component.
const mounted = useRef(true)
useEffect(() => () => { mounted.current = false }, [])
useEffect(() => {
let disposed = false
@@ -53,14 +63,38 @@ export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
}
}, [snapshot])
const onClick = useCallback(() => {
if (refreshing) return
setRefreshing(true)
void (async () => {
try {
const next = await refresh()
if (mounted.current) setBalance(next)
} catch (_failedRefresh) {
// The click keeps the last-known figure; the interval poll retries.
} finally {
if (mounted.current) setRefreshing(false)
}
})()
}, [refresh, refreshing])
const usd = balance?.balanceUsd ?? null
const amount = usd === null
? t('balance.empty')
: formatUsd(usd)
const label = t('balance.label', { amount })
const tooltip = balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label })
const tooltip = refreshing
? t('balance.refreshing')
: balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label })
return (
<button type="button" className={css.badge} title={tooltip} aria-label={label}>
<button
type="button"
className={css.badge}
title={tooltip}
aria-label={label}
aria-busy={refreshing}
onClick={onClick}
>
<span className={css.marker} aria-hidden>◈</span>
{wide && <span className={css.text}>{label}</span>}
</button>

View File

@@ -2,9 +2,15 @@
// Reads the durable openRouterCost projection, so paging and compaction
// cannot change the figure; the entry renders nothing until at least one
// step priced (no zero-cost group, mirroring the stats line's billing gate).
//
// The figure covers the session plus its subagent subtree. Each projection
// value describes only its own session's work — the host fold excludes a
// forked child's inherited prefix — so summing the lineage is a sum, not a
// double count.
import { memo } from 'react'
import { memo, useMemo } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: merges openRouterCost into SessionProjectionMap for useProjection.
import type {} from '@deepseek-ai/dsh-openrouter-usage/client'
import type { OpenRouterCost } from '@deepseek-ai/dsh-openrouter-usage/client'
@@ -15,22 +21,75 @@ import css from './CostDock.module.css'
export type CostDockProps =
PropsRuntime<'conversation.composer.dock'> & PropsLocale<'openRouterUsage'>
/** Summed spend over one session's subagent subtree. */
interface SubtreeCost {
totalUsd: number
pricedSteps: number
unknownModelSteps: number
}
/**
* Render the running session cost as `会话费用 $X.XX`, shown only when the
* session actually priced steps. An unknown-pricing step count feeds a
* tooltip so the figure's coverage stays visible without polluting the row.
* session or any of its subagents actually priced steps. An unknown-pricing
* step count feeds a tooltip so the figure's coverage stays visible without
* polluting the row.
* @param props - framework/runtime props.
* @returns the cost group, or null on a zero/absent projection.
*/
export const CostDock = memo(function CostDock({ useProjection, t }: CostDockProps) {
const cost = useProjection('openRouterCost') as OpenRouterCost | undefined
if (cost === undefined || cost.pricedSteps === 0) return null
const label = t('cost.label', { amount: formatUsd(cost.totalUsd) })
export const CostDock = memo(function CostDock({ sessionId, useProjection, useSessions, t }: CostDockProps) {
const cost: OpenRouterCost | undefined = useProjection('openRouterCost')
const sessionById = useSessions(s => s.byId)
const subagentCost = useMemo((): SubtreeCost => {
let totalUsd = 0
let pricedSteps = 0
let unknownModelSteps = 0
const rows = Object.values(sessionById)
// Breadth-first over subagent-origin descendants, one frontier at a time.
// `visited` also closes a parentId cycle a corrupt lineage could present.
const visited = new Set<SessionId>([sessionId])
let frontier: SessionId[] = [sessionId]
while (frontier.length > 0) {
const next: SessionId[] = []
for (const parent of frontier) {
for (const row of rows) {
if (row.parentId !== parent || row.origin !== 'subagent' || visited.has(row.id)) continue
visited.add(row.id)
next.push(row.id)
// A descendant the client holds no projection value for (never
// opened, and no version-matching cold cache row) contributes
// nothing; it cannot be told apart from a zero-cost child here.
const childCost = row.projectionValues?.openRouterCost
if (childCost === undefined) continue
totalUsd += childCost.totalUsd
pricedSteps += childCost.pricedSteps
unknownModelSteps += childCost.unknownModelSteps
}
}
frontier = next
}
return { totalUsd, pricedSteps, unknownModelSteps }
}, [sessionById, sessionId])
const selfPricedSteps = cost?.pricedSteps ?? 0
if (selfPricedSteps === 0 && subagentCost.pricedSteps === 0) return null
const selfUsd = cost?.totalUsd ?? 0
const totalUsd = selfUsd + subagentCost.totalUsd
const unknownModelSteps = (cost?.unknownModelSteps ?? 0) + subagentCost.unknownModelSteps
const label = subagentCost.pricedSteps > 0
? t('cost.labelWithSubagents', {
amount: formatUsd(totalUsd),
subagentAmount: formatUsd(subagentCost.totalUsd),
})
: t('cost.label', { amount: formatUsd(totalUsd) })
return (
<span
className={css.cost}
title={cost.unknownModelSteps > 0
? t('cost.tooltipUnknown', { count: String(cost.unknownModelSteps) })
title={unknownModelSteps > 0
? t('cost.tooltipUnknown', { count: String(unknownModelSteps) })
: undefined}
>
{label}

View File

@@ -63,6 +63,14 @@ export function apply(ctx: ClientContext): void {
return result.value
}
const refresh: BalanceBadgeActions['refresh'] = async () => {
const result = await ctx.remote.openRouterUsage.refresh()
if (!result.ok) {
throw new Error(`openRouterUsage.refresh failed: ${result.error.code}: ${result.error.message}`)
}
return result.value
}
// Account balance is account-global (root scope): the injected face carries
// the snapshot read the badge polls. The seat is declared but unhoused by
// default; this registration names it, so an assembly without ui-sidebar
@@ -72,6 +80,6 @@ export function apply(ctx: ClientContext): void {
id: 'openrouter-balance',
order: 10,
locale: NS,
inject: (): BalanceBadgeActions => ({ snapshot }),
inject: (): BalanceBadgeActions => ({ snapshot, refresh }),
}, BalanceBadge))
}

View File

@@ -3,10 +3,12 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'cost.label': '会话费用 {amount}',
'cost.labelWithSubagents': '会话费用 {amount}(含子代理 {subagentAmount})',
'cost.tooltipUnknown': '包含 {count} 个未知定价步骤',
'balance.label': '余额 {amount}',
'balance.tooltip': 'OpenRouter 账户余额({label})',
'balance.tooltip': 'OpenRouter 账户余额({label})— 点击刷新',
'balance.empty': '——',
'balance.refreshing': '正在刷新 OpenRouter 账户余额',
} satisfies Record<string, string>
/** The openrouterUsage namespace key union. */
@@ -15,8 +17,10 @@ export type OpenRouterUsageKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'cost.label': 'Session cost {amount}',
'cost.labelWithSubagents': 'Session cost {amount} (subagents {subagentAmount})',
'cost.tooltipUnknown': 'Includes {count} unpriced steps',
'balance.label': 'Balance {amount}',
'balance.tooltip': 'OpenRouter account balance ({label})',
'balance.tooltip': 'OpenRouter account balance ({label}) — click to refresh',
'balance.empty': '——',
'balance.refreshing': 'Refreshing the OpenRouter account balance',
} satisfies Record<OpenRouterUsageKey, string>

View File

@@ -1,8 +1,9 @@
// @vitest-environment jsdom
// BalanceBadge presentation: polls an injected snapshot callback and renders
// 余额 in both widths, hiding unknown figures behind the empty placeholder.
// 余额 in both widths, hiding unknown figures behind the empty placeholder,
// and re-reads through the injected refresh callback when the user clicks it.
import { act, cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -34,6 +35,7 @@ function makeProps(over: Partial<BalanceBadgeProps> = {}) {
return {
wide: true,
snapshot: vi.fn(async () => makeBalance()),
refresh: vi.fn(async () => makeBalance()),
t,
...over,
} as unknown as Parameters<typeof BalanceBadge>[0]
@@ -67,4 +69,37 @@ describe('BalanceBadge', () => {
render(<BalanceBadge {...makeProps({ snapshot: vi.fn(async () => makeBalance({ balanceUsd: null })) })} />)
expect(await screen.findByLabelText('余额 ——')).toBeTruthy()
})
it('re-reads through refresh on click and renders the fetched figure', async () => {
const refresh = vi.fn(async () => makeBalance({ balanceUsd: 42.25 }))
render(<BalanceBadge {...makeProps({ refresh })} />)
const badge = await screen.findByLabelText('余额 $10.50')
await act(async () => { fireEvent.click(badge) })
expect(refresh).toHaveBeenCalledOnce()
expect(await screen.findByLabelText('余额 $42.25')).toBeTruthy()
})
it('ignores a click while a refresh is still outstanding', async () => {
let release: (value: OpenRouterBalance) => void = () => {}
const refresh = vi.fn(() => new Promise<OpenRouterBalance>((resolve) => { release = resolve }))
render(<BalanceBadge {...makeProps({ refresh })} />)
const badge = await screen.findByLabelText('余额 $10.50')
await act(async () => { fireEvent.click(badge) })
expect(badge.getAttribute('aria-busy')).toBe('true')
await act(async () => { fireEvent.click(badge) })
expect(refresh).toHaveBeenCalledOnce()
await act(async () => { release(makeBalance({ balanceUsd: 7 })) })
expect(badge.getAttribute('aria-busy')).toBe('false')
await act(async () => { fireEvent.click(badge) })
expect(refresh).toHaveBeenCalledTimes(2)
})
it('keeps the last-known figure when a refresh fails', async () => {
const refresh = vi.fn(async () => { throw new Error('boom') })
render(<BalanceBadge {...makeProps({ refresh })} />)
const badge = await screen.findByLabelText('余额 $10.50')
await act(async () => { fireEvent.click(badge) })
expect(await screen.findByLabelText('余额 $10.50')).toBeTruthy()
expect(badge.getAttribute('aria-busy')).toBe('false')
})
})

View File

@@ -37,8 +37,10 @@ async function bench() {
new RemoteService(ctx)
const snapshot = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
.mockResolvedValue({ ok: true, value: balance() })
ctx.provide('remote.openRouterUsage', { snapshot })
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot }
const refresh = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
.mockResolvedValue({ ok: true, value: balance({ balanceUsd: 9 }) })
ctx.provide('remote.openRouterUsage', { snapshot, refresh })
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot, refresh }
}
function declare(slots: SlotRegistry): () => void {
@@ -84,11 +86,14 @@ describe('ui-openrouter-usage browser plugin', () => {
expect(badge.options).toMatchObject({ id: 'openrouter-balance', order: 10 })
expect(badge.locale).toBe(NS)
// Registration reads nothing live; the snapshot rolls at call time.
// Registration reads nothing live; both reads roll at call time.
expect(b.snapshot).not.toHaveBeenCalled()
expect(b.refresh).not.toHaveBeenCalled()
const injected = (badge.inject as unknown as () => BalanceBadgeActions)()
await expect(injected.snapshot()).resolves.toEqual(balance())
expect(b.snapshot).toHaveBeenCalledOnce()
await expect(injected.refresh()).resolves.toEqual(balance({ balanceUsd: 9 }))
expect(b.refresh).toHaveBeenCalledOnce()
await b.ctx.fiber.dispose()
})
@@ -102,6 +107,16 @@ describe('ui-openrouter-usage browser plugin', () => {
await b.ctx.fiber.dispose()
})
it('forwards a Remote failure out of the injected refresh verbatim', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.refresh.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'nope', details: {} } })
const injected = (b.slots.entries('sidebar.footer.action')[0]!.inject as unknown as () => BalanceBadgeActions)()
await expect(injected.refresh()).rejects.toThrow('openRouterUsage.refresh failed: REMOTE_ERROR: nope')
await b.ctx.fiber.dispose()
})
it('follows locale and recovers across late declaration and declarer reload', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })

View File

@@ -1,6 +1,7 @@
// @vitest-environment jsdom
// CostDock presentation: renders the running session cost from the
// openRouterCost projection, and nothing at all until a step was priced.
// openRouterCost projection plus the subagent subtree read off the session
// list, and nothing at all until a step was priced.
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -13,9 +14,37 @@ afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}) {
const PARENT = 'session-parent'
/** One session-list row as much of it as the dock reads. */
interface Row {
id: string
parentId?: string
origin?: 'subagent'
cost?: { totalUsd: number; pricedSteps: number; unknownModelSteps: number }
}
/** A `useSessions` stub serving `byId` built from the given rows. */
function sessionsHook(rows: readonly Row[]) {
const byId: Record<string, unknown> = {}
for (const row of rows) {
byId[row.id] = {
id: row.id,
...row.parentId === undefined ? {} : { parentId: row.parentId },
...row.origin === undefined ? {} : { origin: row.origin },
...row.cost === undefined
? {}
: { projectionValues: { openRouterCost: { ...row.cost, currency: 'USD' } } },
}
}
return (selector: (state: { byId: Record<string, unknown> }) => unknown) => selector({ byId })
}
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}, rows: readonly Row[] = []) {
return {
sessionId: PARENT,
useProjection: vi.fn(() => undefined),
useSessions: sessionsHook(rows),
t,
...over,
} as unknown as Parameters<typeof CostDock>[0]
@@ -57,4 +86,53 @@ describe('CostDock', () => {
act(() => {})
expect(useProjection).toHaveBeenCalledWith('openRouterCost')
})
it('adds the subagent subtree to the figure and names its share', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 1 } }],
)} />)
const row = screen.getByText('会话费用 $1.50(含子代理 $0.50)')
expect(row.getAttribute('title')).toBe('包含 1 个未知定价步骤')
})
it('follows a nested subagent chain and ignores non-subagent children', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[
{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.25, pricedSteps: 1, unknownModelSteps: 0 } },
{ id: 'grandchild', parentId: 'child', origin: 'subagent', cost: { totalUsd: 0.125, pricedSteps: 1, unknownModelSteps: 0 } },
// An ordinary fork of the same parent is not delegated work.
{ id: 'sibling', parentId: PARENT, cost: { totalUsd: 9, pricedSteps: 1, unknownModelSteps: 0 } },
],
)} />)
expect(screen.getByText('会话费用 $1.38(含子代理 $0.38)')).toBeTruthy()
})
it('shows the subagent subtree alone when the session itself priced nothing', () => {
render(<CostDock {...makeProps(
{ useProjection: () => undefined },
[{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.75, pricedSteps: 2, unknownModelSteps: 0 } }],
)} />)
expect(screen.getByText('会话费用 $0.75(含子代理 $0.75)')).toBeTruthy()
})
it('skips a descendant the client holds no projection value for', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 2, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[{ id: 'cold-child', parentId: PARENT, origin: 'subagent' }],
)} />)
expect(screen.getByText('会话费用 $2.00')).toBeTruthy()
})
it('terminates on a parentId cycle', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[
{ id: 'a', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 0 } },
{ id: PARENT, parentId: 'a', origin: 'subagent', cost: { totalUsd: 99, pricedSteps: 1, unknownModelSteps: 0 } },
],
)} />)
expect(screen.getByText('会话费用 $1.50(含子代理 $0.50)')).toBeTruthy()
})
})

View File

@@ -282,6 +282,10 @@ function CatalogRows({
entry.activity,
now,
)
const costUsd = summary?.projectionValues?.openRouterCost?.totalUsd
const costMetric = (costUsd !== undefined && costUsd > 0)
? `$${costUsd < 0.01 ? '<0.01' : costUsd.toFixed(2)}`
: undefined
const tokenMetric = totalTokens === undefined
? undefined
: `${formatTokens(totalTokens)} tok`
@@ -291,7 +295,7 @@ function CatalogRows({
compact: formatDuration(durationMs, t),
exact: formatExactDuration(durationMs, t),
}
const metrics = [tokenMetric, durationMetric?.exact]
const metrics = [costMetric, tokenMetric, durationMetric?.exact]
.filter(value => value !== undefined)
.join(' · ')

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-trajectory/README.md
README.md: 9ad42a3d3bcfb8fbf4842a66577e65794a716dfd
README.zh.md: df7661ef7d4861dbc6c8e365a18b3e3252d4d4af
README.md: e409485e23dc41fccbe38e39d5984783e4ae21ff
README.zh.md: f4163d8849241b6775e03ea343709a6a3e304407

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. When the `openRouterCost` session projection is composed, each priced assistant step shows its host-priced USD cost on the request boundary chip and in the inspector summary. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。当组合了 `openRouterCost` 会话投影时,每个已计价的助手 step 会在请求边界标记与检查器摘要中显示其宿主计价的美元成本。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
## 模型体验

View File

@@ -34,7 +34,8 @@
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-openrouter-usage"
],
"platform": "web"
}
@@ -54,6 +55,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
@@ -68,6 +70,7 @@
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-openrouter-usage": "workspace:^",
"@deepseek-ai/dsh-compaction": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -408,6 +408,8 @@ interface TrajectoryRequestNumberBase {
requestConfig?: AssistantRequestConfig
usage?: TrajectoryUsage
cumulativeUsage?: TrajectoryUsage
/** Host-priced OpenRouter spend for this step in USD, when priced. */
stepCostUsd?: number
}
/** One purpose-discriminated request identity paired with its session-global number. */
@@ -491,6 +493,25 @@ function requestStep(group: string): number | undefined {
return Number.isInteger(value) && value > 0 ? value : undefined
}
/**
* Format a per-step USD price: cents-style above a cent, three significant
* digits below it so sub-cent figures like $0.0028 stay readable instead of
* collapsing to $0.00.
* @param usd - non-negative USD amount.
* @returns display string.
*/
function formatStepUsd(usd: number): string {
if (usd >= 0.01 || usd === 0) {
return usd.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
return `$${usd.toPrecision(3).replace(/0+$/, '').replace(/\.$/, '')}`
}
function requestKey(turn: number | null, group: string): string {
return `${turn}\u0000${group}`
}
@@ -2296,7 +2317,7 @@ export function TrajectoryTable({
}
const requestLabel = request === undefined
? undefined
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}${requestInfo?.stepCostUsd === undefined ? '' : ` · ${formatStepUsd(requestInfo.stepCostUsd)}`}`
const requestSelected = request !== undefined
&& selectedRequest?.turn === record.turn
&& selectedRequest.group === record.group
@@ -2733,6 +2754,12 @@ export function TrajectoryTable({
</dd>
</div>
)}
{selectedRequestInfo?.stepCostUsd !== undefined && (
<div>
<dt>Cost</dt>
<dd>{formatStepUsd(selectedRequestInfo.stepCostUsd)}</dd>
</div>
)}
<div>
<dt>Tool calls</dt>
<dd>{selectedRequestToolCalls}</dd>

View File

@@ -3,6 +3,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: merges openRouterCost into SessionProjectionMap so the
// framework useProjection seat types the step-cost lookup below. The value
// itself comes from the runtime's per-session projection store, absent when
// the OpenRouter usage plugin is not composed.
import type {} from '@deepseek-ai/dsh-openrouter-usage/client'
import type {
AssistantBlock, AssistantMessageNode, ConversationSnapshot,
SnapshotStore,
@@ -79,6 +84,7 @@ interface UsageLike {
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
costUsd?: number
}
function requestUsage(value: unknown): TrajectoryUsage | undefined {
@@ -118,7 +124,7 @@ function addUsage(
}
export function TrajectoryView({
useSession, useDuration, loadOlder, setActualDuration,
useProjection, useSession, useDuration, loadOlder, setActualDuration,
inspect, onInspectDone, t,
}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
@@ -151,6 +157,9 @@ export function TrajectoryView({
const runningCalls = inspection.runningCalls
const requests = inspection.requests
const callSchemas = inspection.callSchemas
// Per-step OpenRouter spend (host-priced): keyed `${turn}:${step}`, only
// priced steps present; undefined when the projection is not composed.
const stepCosts = useProjection('openRouterCost')?.steps
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const node of nodes) {
@@ -197,6 +206,9 @@ export function TrajectoryView({
const provider = request?.provenance?.provider ?? node?.provenance?.provider
const model = request?.provenance?.model ?? node?.provenance?.model
const requestConfig = request?.requestConfig ?? node?.requestConfig
const directCostUsd = (entry.request?.usage as UsageLike | undefined)?.costUsd
?? (entry.node?.usage as UsageLike | undefined)?.costUsd
const stepCostUsd = directCostUsd ?? stepCosts?.[`${turn}:${step}`]
numbered.push({
seq: entry.seq,
turn,
@@ -218,10 +230,12 @@ export function TrajectoryView({
...(requestConfig === undefined ? {} : { requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
...(stepCostUsd === undefined ? {} : { stepCostUsd }),
})
continue
}
const request = entry.request
const directCostUsd = (request.usage as UsageLike | undefined)?.costUsd
numbered.push({
seq: request.startSeq,
turn: request.turn,
@@ -243,12 +257,13 @@ export function TrajectoryView({
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
...(directCostUsd === undefined ? {} : { stepCostUsd: directCostUsd }),
})
}
return numbered
}, [
nodes, requests,
nodes, requests, stepCosts,
])
const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null

View File

@@ -48,6 +48,7 @@ interface UsageLike {
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
costUsd?: number
}
/** Cell plus absolute ms for group wall-span descriptions. */

View File

@@ -18,6 +18,7 @@ interface UsageValue {
readonly cacheReadTokens?: number
readonly cacheWriteTokens?: number
readonly reasoningTokens?: number
readonly costUsd?: number
}
interface RetryValue {
@@ -101,6 +102,9 @@ function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue
...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined
? {}
: { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }),
...(current?.costUsd === undefined && next.costUsd === undefined
? {}
: { costUsd: (current?.costUsd ?? 0) + (next.costUsd ?? 0) }),
}
}

View File

@@ -871,4 +871,46 @@ describe('TrajectoryTable', () => {
expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
expect(onInspectApplied).not.toHaveBeenCalled()
})
it('shows a host-priced step cost on the request boundary and in the summary', () => {
render(
<TrajectoryTable
turns={TURNS}
requestNumbers={[{
turn: 1, step: 1, group: 'Step 1', number: 1, seq: 1,
stepCostUsd: 0.0028,
}]}
{...FOLD_PROPS}
/>,
)
const chip = screen.getByRole('button', { name: 'Request #1 · $0.0028' })
expect(chip).toBeTruthy()
fireEvent.click(chip)
expect(screen.getByText('Cost')).toBeTruthy()
expect(screen.getByText('$0.0028')).toBeTruthy()
})
it('formats step costs above a cent with the currency format', () => {
render(
<TrajectoryTable
turns={TURNS}
requestNumbers={[{
turn: 1, step: 1, group: 'Step 1', number: 1, seq: 1,
stepCostUsd: 1.234,
}]}
{...FOLD_PROPS}
/>,
)
const chip = screen.getByRole('button', { name: 'Request #1 · $1.23' })
expect(chip).toBeTruthy()
fireEvent.click(chip)
expect(screen.getByText('Cost')).toBeTruthy()
expect(screen.getByText('$1.23')).toBeTruthy()
})
it('omits the cost row when the step was not priced', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
expect(screen.queryByText('Cost')).toBeNull()
})
})

View File

@@ -255,7 +255,8 @@ function mount(slots: SlotRegistry, nodes: ConversationSnapshot['nodes'] = NODES
return (
<View
{...injectedProps}
{...({ sessionId: SID, useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
{...({ sessionId: SID, useSession, useProjection: () => undefined,
useSessions: emptySessions(), useWorkspaces: emptyWorkspaces() } as unknown as ConvViewProps)}
key={key}
/>
)
@@ -1199,6 +1200,53 @@ describe('TrajectoryView state', () => {
)).toBeTruthy()
})
it('attaches the per-step OpenRouter cost from the openRouterCost projection', () => {
render(
<TrajectoryView
{...standaloneProps(NODES)}
{...standaloneHistory(historySnapshot(NODES))}
{...standaloneDuration()}
useProjection={((key: string) => (key === 'openRouterCost'
? {
totalUsd: 0.00336,
pricedSteps: 2,
unknownModelSteps: 0,
steps: { '1:1': 0.0028 },
currency: 'USD',
}
: undefined)) as never}
/>,
)
// The turn-1/step-1 request is priced; the turn-2/step-1 one is not.
expect(screen.getByRole('button', { name: 'Request #1 · $0.0028' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Request #2 · $0.0028' })).toBeNull()
// The summary of the priced step shows its cost.
fireEvent.click(screen.getByRole('button', { name: 'Request #1 · $0.0028' }))
expect(screen.getByText('Cost')).toBeTruthy()
expect(screen.getByText('$0.0028')).toBeTruthy()
})
it('attaches the per-step cost directly from node usage even when projection is undefined', () => {
const nodesWithCost = [
NODES[0]!,
{
...NODES[1]!,
usage: { inputTokens: 100, outputTokens: 50, costUsd: 0.0045 },
},
NODES[2]!,
NODES[3]!,
]
render(
<TrajectoryView
{...standaloneProps(nodesWithCost)}
{...standaloneHistory(historySnapshot(nodesWithCost))}
{...standaloneDuration()}
useProjection={(() => undefined) as never}
/>,
)
expect(screen.getByRole('button', { name: 'Request #1 · $0.0045' })).toBeTruthy()
})
})
describe('node half', () => {

View File

@@ -32,6 +32,9 @@
{
"path": "../../compaction/compaction"
},
{
"path": "../../llm/openrouter-usage"
},
{
"path": "../../runtime-diagnostics/invariants"
}

View File

@@ -421,6 +421,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [
'client-ui-conversation StatsLine id \'stats\'',
'client-ui-openrouter-usage CostDock id \'openrouter-cost\'',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
@@ -1527,6 +1528,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: '',
declaredBy: 'an entry in \'sidebar\' (client-ui-sidebar), so it exists while that entry is mounted',
occupants: [
'client-ui-openrouter-usage BalanceBadge id \'openrouter-balance\'',
'client-ui-cordis CordisPanel id \'cordis-panel\'',
],
replaceRisk: 'none',

View File

@@ -56,12 +56,21 @@ export class CordisInspectRegistryService extends Service {
/**
* Register one Host provider.
*
* Throws only when a different manifest claims the same provider id.
* An identical re-registration (same id, description, and every method)
* replaces the stored entry so the newest registrant's query closure is
* active. This allows multiple plugin mounts (e.g. two agent presets) that
* register the same capability to coexist without collision.
* @param registration - manifest and local query handler.
* @returns idempotent disposer.
*/
register(registration: HostCordisInspectProviderRegistration): () => void {
const manifest = validateManifest(registration.manifest)
if (this.providers.has(manifest.id)) throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
const existing = this.providers.get(manifest.id)
if (existing !== undefined && !sameManifest(existing.manifest, manifest)) {
throw new Error(`Host Cordis inspect provider "${manifest.id}" is already registered`)
}
const stored = { ...registration, manifest }
this.providers.set(manifest.id, stored)
return () => {
@@ -198,6 +207,35 @@ export class CordisInspectRegistryService extends Service {
}
}
/** Deep equality for JSON-compatible values (objects, arrays, primitives). */
function jsonEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
return a.every((entry, index) => jsonEqual(entry, b[index]))
}
const left = a as Record<string, unknown>
const right = b as Record<string, unknown>
const keys = Object.keys(left)
if (keys.length !== Object.keys(right).length) return false
return keys.every(key => key in right && jsonEqual(left[key], right[key]))
}
/** Whether two manifests describe the same provider capability. */
function sameManifest(a: CordisInspectProviderManifest, b: CordisInspectProviderManifest): boolean {
if (a.id !== b.id || a.description !== b.description) return false
if (a.methods.length !== b.methods.length) return false
return a.methods.every((method, index) => {
const other = b.methods[index]
return other !== undefined
&& method.name === other.name
&& method.description === other.description
&& jsonEqual(method.inputSchema, other.inputSchema)
&& jsonEqual(method.outputSchema, other.outputSchema)
})
}
function view(platform: CordisInspectPlatform, manifest: CordisInspectProviderManifest): CordisInspectProviderView {
return { platform, ...manifest, methods: [...manifest.methods] }
}

View File

@@ -0,0 +1,86 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import { CordisInspectRegistryService, type HostCordisInspectProviderRegistration } from '../src/inspect-registry.ts'
function makeProvider(id: string, desc: string): HostCordisInspectProviderRegistration {
return {
manifest: {
id,
description: desc,
methods: [{
name: 'list',
description: 'List things.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
outputSchema: { description: 'anything' },
}],
},
async query() { return {} },
}
}
describe('CordisInspectRegistryService', () => {
it('accepts the first registration of a provider id', () => {
const ctx = new Context()
const svc = new CordisInspectRegistryService(ctx)
const p = makeProvider('Service', 'desc')
const dispose = svc.register(p)
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
dispose()
expect(svc.list()).not.toContainEqual(expect.objectContaining({ id: 'Service' }))
})
it('replaces on identical re-registration without throwing', () => {
const ctx = new Context()
const svc = new CordisInspectRegistryService(ctx)
const p = makeProvider('Service', 'desc')
const disposeA = svc.register(p)
const disposeB = svc.register(p) // same object — identical manifest
// Still in the directory (replaced, not removed)
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
// disposeA should be a no-op because the map entry was replaced
disposeA()
expect(svc.list()).toContainEqual(expect.objectContaining({ id: 'Service', platform: 'host' }))
// disposeB removes the current registration
disposeB()
expect(svc.list()).not.toContainEqual(expect.objectContaining({ id: 'Service' }))
})
it('rejects a different manifest claiming the same id', () => {
const ctx = new Context()
const svc = new CordisInspectRegistryService(ctx)
svc.register(makeProvider('Service', 'original'))
expect(() => svc.register(makeProvider('Service', 'different')))
.toThrow('Host Cordis inspect provider "Service" is already registered')
})
it('rejects empty provider id', () => {
const ctx = new Context()
const svc = new CordisInspectRegistryService(ctx)
expect(() => svc.register(makeProvider('', 'empty')))
.toThrow('Cordis inspect provider id must not be empty')
})
it('detects method-array length mismatch as unequal', () => {
const ctx = new Context()
const svc = new CordisInspectRegistryService(ctx)
const a: HostCordisInspectProviderRegistration = {
manifest: {
id: 'X', description: 'd',
methods: [{ name: 'a', description: 'a', inputSchema: {}, outputSchema: {} }],
},
async query() { return {} },
}
const b: HostCordisInspectProviderRegistration = {
manifest: {
id: 'X', description: 'd',
methods: [
{ name: 'a', description: 'a', inputSchema: {}, outputSchema: {} },
{ name: 'b', description: 'b', inputSchema: {}, outputSchema: {} },
],
},
async query() { return {} },
}
svc.register(a)
expect(() => svc.register(b)).toThrow('already registered')
})
})

View File

@@ -912,6 +912,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'openRouterUsage',
summary: 'OpenRouter usage + balance gateway (`ctx.openRouterUsage`).',
description: 'OpenRouter usage + balance gateway (`ctx.openRouterUsage`).',
methods: [
{
signature: '@Remote(\'snapshot\') snapshot(): OpenRouterBalance',
description: 'The latest known account snapshot.',
parameters: [],
returns: 'a fresh copy of the cached balance.',
},
],
},
{
key: 'permissionPresets',
summary: 'Owns the deployment\'s permission presets and their write path.',
@@ -3479,6 +3492,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'OneShotSubagentDescriptorData',
declaration: 'export interface OneShotSubagentDescriptorData extends SubagentDescriptorBase {\n readonly mode: \'one-shot\';\n readonly label?: string;\n}',
},
{
name: 'OpenRouterBalance',
declaration: 'export interface OpenRouterBalance {\n balanceUsd: number | null;\n label: string | null;\n usageTokens: number | null;\n limitTokens: number | null;\n isFreeTier: boolean | null;\n updatedAt: number | null;\n currency: \'USD\';\n}',
},
{
name: 'PermissionSelect',
declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}',

View File

@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-tool-lab",
"description": "Model-facing home-lab AI tools (lab_generate_image via ComfyUI, lab_ocr_pdf via Docling, lab_transcribe_audio via Whishper) over the lab's LAN services",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/extensions/tool-lab"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,129 @@
/**
* ComfyUI image generation tool. Talks to the lab's ComfyUI server (default
* http://192.168.31.240:8188) with a Juggernaut-XL workflow and returns the
* served image URL. @module @deepseek-ai/dsh-tool-lab
*/
import { Deadline, sleep } from './helpers.ts'
/** Root of the resolved lab config. */
export interface ResolvedLabConfig {
comfyBaseUrl: string
doclingBaseUrl: string
whishBaseUrl: string
timeoutMs: number
maxUploadBytes: number
maxOutputChars: number
}
/** Model-backed fields ComfyUI exposes on the workflow we build. */
interface ComfyApiResult {
prompt_id?: string
node_errors?: Record<string, unknown>
}
interface ComfyHistoryEntry {
outputs?: Record<string, { images?: { filename?: string; subfolder?: string; type?: string }[] }>
status?: { completed?: boolean; error?: unknown }
}
/** A single ComfyUI generation request. */
export interface GenerateImageArgs {
prompt: string
negative?: string
width?: number
height?: number
steps?: number
seed?: number
model?: string
upscale?: boolean
upscale_model?: string
filename_prefix?: string
}
/** Compose the fixed 28-step Juggernaut-XL workflow from user-facing arguments. */
export function buildWorkflow(args: GenerateImageArgs): Record<string, unknown> {
const seed = args.seed ?? Math.floor(Math.random() * 1_000_000)
const width = args.width ?? 512
const height = args.height ?? 512
const steps = args.steps ?? 28
const model = args.model ?? 'Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors'
const negative = args.negative ?? 'blurry, low quality, distorted, watermark, text, extra limbs'
const upscale = args.upscale !== false
const upscaleModel = args.upscale_model ?? 'RealESRGAN_x4plus.safetensors'
const prefix = args.filename_prefix ?? 'dsh'
const workflow: Record<string, unknown> = {
3: {
class_type: 'KSampler',
inputs: {
seed, steps, cfg: 4.0, sampler_name: 'dpmpp_2m_sde', scheduler: 'karras', denoise: 1.0,
model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0],
},
},
4: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: model } },
5: { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
6: { class_type: 'CLIPTextEncode', inputs: { text: args.prompt, clip: ['4', 1] } },
7: { class_type: 'CLIPTextEncode', inputs: { text: negative, clip: ['4', 1] } },
8: { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
9: { class_type: 'SaveImage', inputs: { filename_prefix: prefix, images: upscale ? ['10', 0] : ['8', 0] } },
}
if (upscale) {
workflow['10'] = { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['11', 0], image: ['8', 0] } }
workflow['11'] = { class_type: 'UpscaleModelLoader', inputs: { model_name: upscaleModel } }
}
return workflow
}
/**
* Poll ComfyUI `/history/{prompt_id}` until the image appears or the timeout
* budget elapses. Returns the served image URL.
*/
async function pollImage(
baseUrl: string,
promptId: string,
deadline: Deadline,
timeoutMs: number,
): Promise<string> {
const started = Date.now()
for (;;) {
deadline.check()
const res = await fetch(`${baseUrl}/history/${promptId}`)
if (!res.ok) throw new Error(`ComfyUI history ${res.status}: ${await res.text()}`)
const history = (await res.json()) as Record<string, ComfyHistoryEntry>
const entry = history[promptId]
if (entry?.outputs?.['9']?.images?.length) {
const img = entry.outputs['9'].images[0]
if (!img) throw new Error('ComfyUI history entry missing image 0')
return `${baseUrl}/view?filename=${encodeURIComponent(img.filename ?? '')}&subfolder=${encodeURIComponent(img.subfolder ?? '')}&type=${encodeURIComponent(img.type ?? 'output')}`
}
if (entry?.status?.error) throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status.error)}`)
if (Date.now() - started >= timeoutMs) throw new Error(`ComfyUI generation timed out after ${timeoutMs}ms`)
await sleep(1500, deadline.signal)
}
}
/** Run the ComfyUI tool. Reads no files; returns a served image URL string. */
export async function runGenerateImage(
config: ResolvedLabConfig,
args: GenerateImageArgs,
signal: AbortSignal | undefined,
): Promise<string> {
const deadline = new Deadline('generate_image', config.timeoutMs, signal)
try {
const clientId = `dsh-${Math.random().toString(36).slice(2)}`
const workflow = buildWorkflow(args)
const submit = await fetch(`${config.comfyBaseUrl}/prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
})
if (!submit.ok) throw new Error(`ComfyUI /prompt ${submit.status}: ${await submit.text()}`)
const result = (await submit.json()) as ComfyApiResult
if (!result.prompt_id) throw new Error(`ComfyUI /prompt no prompt_id: ${JSON.stringify(result)}`)
const errors = result.node_errors
if (errors && Object.keys(errors).length > 0) throw new Error(`ComfyUI node_errors: ${JSON.stringify(errors)}`)
return await pollImage(config.comfyBaseUrl, result.prompt_id, deadline, config.timeoutMs)
} finally {
deadline.dispose()
}
}

View File

@@ -0,0 +1,85 @@
/**
* Docling OCR tool. Reads a local PDF via `ctx.fs`, uploads it to the lab's
* Docling server, polls the async task, and returns the extracted markdown.
* @module @deepseek-ai/dsh-tool-lab
*/
import type { FileSystem } from '@deepseek-ai/dsh-fs'
import { Deadline, sleep } from './helpers.ts'
import type { ResolvedLabConfig } from './comfy.ts'
interface DoclingPoll {
task_id?: string
task_status?: string
}
interface DoclingResult {
document?: { filename?: string; md_content?: string | null }
status?: string
errors?: unknown[]
}
/** Build FormData upload of a PDF from bytes. */
export function buildPdfUpload(data: Uint8Array, filename: string): FormData {
const fd = new FormData()
fd.append('files', new Blob([data as unknown as BlobPart], { type: 'application/pdf' }), filename)
fd.append('options', JSON.stringify({}))
return fd
}
/** Poll Docling `/v1/status/poll/{task_id}` until success. */
async function pollTask(
baseUrl: string,
taskId: string,
deadline: Deadline,
timeoutMs: number,
): Promise<void> {
const started = Date.now()
for (;;) {
deadline.check()
const res = await fetch(`${baseUrl}/v1/status/poll/${taskId}?wait=2`)
if (!res.ok) throw new Error(`Docling poll ${res.status}: ${await res.text()}`)
const poll = (await res.json()) as DoclingPoll
if (poll.task_status === 'success') return
if (poll.task_status === 'failed' || poll.task_status === 'error') {
throw new Error(`Docling task failed: ${await (await fetch(`${baseUrl}/v1/result/${taskId}`)).text()}`)
}
if (Date.now() - started >= timeoutMs) throw new Error(`Docling OCR timed out after ${timeoutMs}ms`)
await sleep(1200, deadline.signal)
}
}
/** Run the Docling OCR tool: read file, upload, poll, return markdown text. */
export async function runOcrPdf(
fs: FileSystem,
config: ResolvedLabConfig,
filePath: string,
signal: AbortSignal | undefined,
): Promise<string> {
const deadline = new Deadline('ocr_pdf', config.timeoutMs, signal)
try {
const target = await fs.resolve(filePath, signal === undefined ? undefined : { signal })
const stat = await fs.stat(target, signal)
if (!stat) throw new Error(`ocr_pdf: file not found: ${filePath}`)
const data = await fs.readBytes(target, signal, config.maxUploadBytes)
const filename = filePath.split(/[\\/]/).pop() ?? 'document.pdf'
const form = buildPdfUpload(data, filename)
const submit = await fetch(`${config.doclingBaseUrl}/v1/convert/file/async`, {
method: 'POST',
body: form,
})
if (!submit.ok) throw new Error(`Docling /convert ${submit.status}: ${await submit.text()}`)
const poll = (await submit.json()) as DoclingPoll
if (!poll.task_id) throw new Error(`Docling no task_id: ${JSON.stringify(poll)}`)
await pollTask(config.doclingBaseUrl, poll.task_id, deadline, config.timeoutMs)
const res = await fetch(`${config.doclingBaseUrl}/v1/result/${poll.task_id}`)
if (!res.ok) throw new Error(`Docling result ${res.status}: ${await res.text()}`)
const result = (await res.json()) as DoclingResult
const md = result.document?.md_content ?? ''
if (md.length === 0) throw new Error('Docling returned empty markdown content')
if (md.length > config.maxOutputChars) return md.slice(0, config.maxOutputChars)
return md
} finally {
deadline.dispose()
}
}

View File

@@ -0,0 +1,62 @@
/**
* Shared helpers for the home-lab tool package: cooperative deadline enforcement
* and aborted sleep. @module @deepseek-ai/dsh-tool-lab
*/
/** Error thrown when a lab tool exceeds its cooperative timeout budget. */
export class LabToolTimeoutError extends Error {
constructor(tool: string, ms: number) {
super(`lab tool ${tool} timed out after ${ms}ms`)
this.name = 'LabToolTimeoutError'
}
}
/** A deadline that aborts when the caller cancels or the budget expires. */
export class Deadline {
readonly signal: AbortSignal | undefined
private readonly timer: ReturnType<typeof setTimeout> | undefined
private cancelled = false
private elapsed = false
constructor(
private readonly tool: string,
private readonly timeoutMs: number,
signal: AbortSignal | undefined,
) {
this.signal = signal
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
this.timer = setTimeout(() => this.expire(), timeoutMs)
}
signal?.addEventListener('abort', () => this.expire(), { once: true })
}
private expire(): void {
if (this.cancelled || this.elapsed) return
this.elapsed = true
}
/** Throw if the budget has elapsed or the caller aborted. Cheap in loops. */
check(): void {
if (this.signal?.aborted || this.elapsed) {
throw new LabToolTimeoutError(this.tool, this.timeoutMs)
}
}
/** Cancel the timer when the tool finishes normally. */
dispose(): void {
this.cancelled = true
if (this.timer !== undefined) clearTimeout(this.timer)
}
}
/** Abortable sleep; resolves early or throws on cancellation/expiry. */
export async function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
if (signal?.aborted) throw new Error('aborted')
await new Promise<void>((resolve) => {
const t = setTimeout(resolve, ms)
signal?.addEventListener('abort', () => {
clearTimeout(t)
resolve()
}, { once: true })
})
}

View File

@@ -0,0 +1,189 @@
/**
* Model-facing home-lab AI tools. Registers `lab_generate_image` (ComfyUI),
* `lab_ocr_pdf` (Docling), and `lab_transcribe_audio` (Whishper) over the
* lab's LAN services. Tools read local files only via `ctx.fs`; uploads and
* polls happen over HTTP. Image results stay server-hosted as URLs.
* @module @deepseek-ai/dsh-tool-lab
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { runGenerateImage, type ResolvedLabConfig } from './comfy.ts'
import { runOcrPdf } from './docling.ts'
import { runTranscribeAudio } from './whish.ts'
export { runGenerateImage, buildWorkflow, type GenerateImageArgs } from './comfy.ts'
export type { ResolvedLabConfig } from './comfy.ts'
export { runOcrPdf, buildPdfUpload } from './docling.ts'
export { runTranscribeAudio, buildAudioUpload } from './whish.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-lab'
/** Services required by the lab tool suite. */
export const inject = ['tools', 'fs']
/** Default cooperative tool-call timeout budget (ms). */
export const DEFAULT_LAB_TIMEOUT_MS = 120_000
/** Default cap on one upload in bytes and one output in characters. */
export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
export const DEFAULT_MAX_OUTPUT_CHARS = 200_000
/** Plugin config: which lab tools to register, the base URLs, and limits. */
export interface Config {
/** Register `lab_generate_image` (ComfyUI). Defaults to true. */
generateImage?: boolean
/** Register `lab_ocr_pdf` (Docling). Defaults to true. */
ocrPdf?: boolean
/** Register `lab_transcribe_audio` (Whishper). Defaults to true. */
transcribeAudio?: boolean
/** ComfyUI base URL. Defaults to http://192.168.31.240:8188 */
comfyBaseUrl?: string
/** Docling base URL. Defaults to http://192.168.31.159:5001 */
doclingBaseUrl?: string
/** Whishper base URL. Defaults to http://192.168.31.159:8082 */
whishBaseUrl?: string
/** Cooperative timeout budget (ms). Defaults to 120000. */
timeoutMs?: number
/** Cap on upload bytes. Defaults to 20971520. */
maxUploadBytes?: number
/** Cap on output characters. Defaults to 200000. */
maxOutputChars?: number
}
export const Config: z<Config> = z.object({
generateImage: z.boolean().default(true),
ocrPdf: z.boolean().default(true),
transcribeAudio: z.boolean().default(true),
comfyBaseUrl: z.string().default('http://192.168.31.240:8188'),
doclingBaseUrl: z.string().default('http://192.168.31.159:5001'),
whishBaseUrl: z.string().default('http://192.168.31.159:8082'),
timeoutMs: z.number().default(DEFAULT_LAB_TIMEOUT_MS),
maxUploadBytes: z.number().default(DEFAULT_MAX_UPLOAD_BYTES),
maxOutputChars: z.number().default(DEFAULT_MAX_OUTPUT_CHARS),
})
/** Complete config after schemastery applies every field default. */
type ResolvedConfig = Required<Config>
/** Configured limits must be positive integers. */
function assertPositiveInteger(field: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`tool-lab: ${field} must be a positive integer`)
}
}
/** Render one string value as a single text content block. */
function renderText(_args: unknown, value: string): ContentBlock[] {
return [{ type: 'text', text: String(value) }]
}
/**
* Register the enabled lab tools. Each tool's cooperative timeout budget is
* resolved here and attached as `ToolDefinition.timeoutMs`. Disposers are
* fiber-scoped (effect-based registries clean up on dispose), so no manual
* teardown is needed.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
assertPositiveInteger('maxUploadBytes', resolved.maxUploadBytes)
assertPositiveInteger('maxOutputChars', resolved.maxOutputChars)
const labConfig: ResolvedLabConfig = {
comfyBaseUrl: resolved.comfyBaseUrl,
doclingBaseUrl: resolved.doclingBaseUrl,
whishBaseUrl: resolved.whishBaseUrl,
timeoutMs: resolved.timeoutMs,
maxUploadBytes: resolved.maxUploadBytes,
maxOutputChars: resolved.maxOutputChars,
}
if (resolved.generateImage) {
ctx.tools.register(defineTool({
name: 'lab_generate_image',
description:
'Generate an image on the home-lab ComfyUI server (Juggernaut-XL V9, 512x512 base, upscaled 4x via RealESRGAN by default). Returns a URL to the generated PNG hosted on that server. Give a descriptive prompt; optionally adjust model, width, height, steps, seed, negative, upscale, upscale_model, or filename_prefix.',
parameters: {
prompt: { type: 'string', description: 'Positive prompt describing the desired image.', required: true },
negative: { type: 'string', description: 'Negative prompt.' },
width: { type: 'integer', description: 'Image width in pixels. Default 512.' },
height: { type: 'integer', description: 'Image height in pixels. Default 512.' },
steps: { type: 'integer', description: 'Sampling steps. Default 28 (Juggernaut).' },
seed: { type: 'integer', description: 'Random seed. Defaults to a random value.' },
model: { type: 'string', description: 'Checkpoint model filename. Default Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors.' },
upscale: { type: 'boolean', description: 'Upscale the result 4x with RealESRGAN_x4plus. Default true.' },
upscale_model: { type: 'string', description: 'Upscale model filename. Default RealESRGAN_x4plus.safetensors.' },
filename_prefix: { type: 'string', description: 'Filename prefix for the saved image. Default dsh.' },
},
output: {
schema: { type: 'string' },
render: renderText,
},
timeoutMs: resolved.timeoutMs,
isConcurrencySafe: () => true,
async execute(args, exec) {
const imageArgs = args as {
prompt: string
negative?: string
width?: number
height?: number
steps?: number
seed?: number
model?: string
upscale?: boolean
upscale_model?: string
filename_prefix?: string
}
return runGenerateImage(labConfig, imageArgs, exec.signal)
},
}))
}
if (resolved.ocrPdf) {
ctx.tools.register(defineTool({
name: 'lab_ocr_pdf',
description:
'Extract text from a PDF using the home-lab Docling OCR server. Reads the local PDF and returns the recognized markdown text. Provide the local path to the PDF.',
parameters: {
file_path: { type: 'string', description: 'Local path to the PDF file to OCR.', required: true },
},
output: {
schema: { type: 'string' },
render: renderText,
},
timeoutMs: resolved.timeoutMs,
isConcurrencySafe: () => true,
execute(args, exec) {
const fs = ctx.fs
return runOcrPdf(fs, labConfig, args.file_path, exec.signal)
},
}))
}
if (resolved.transcribeAudio) {
ctx.tools.register(defineTool({
name: 'lab_transcribe_audio',
description:
'Transcribe speech from an audio file using the home-lab Whishper server. Returns the recognized text. Provide a local path to an audio file and optionally a language hint.',
parameters: {
file_path: { type: 'string', description: 'Local path to the audio file (wav, mp3, ogg, or m4a).', required: true },
language: { type: 'string', description: 'Optional language code hint (e.g. ru, en).' },
model_size: { type: 'string', description: 'Optional model size (e.g. small, base).' },
},
output: {
schema: { type: 'string' },
render: renderText,
},
timeoutMs: resolved.timeoutMs,
isConcurrencySafe: () => true,
execute(args, exec) {
const fs = ctx.fs
return runTranscribeAudio(fs, labConfig, args.file_path, args.language, args.model_size, exec.signal)
},
}))
}
}

View File

@@ -0,0 +1,29 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-lab`.
* @module @deepseek-ai/dsh-tool-lab/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lab'
/** Cordis companion plugin name. */
export const name = 'tool-lab-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this model-facing adapter has no independent lifecycle
* stream; execution relations are owned by the capability seam it calls.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -0,0 +1,84 @@
/**
* Whishper speech-to-text tool. Reads local audio via `ctx.fs`, uploads it to
* the lab's Whishper server, polls the transcription, and returns the text.
* @module @deepseek-ai/dsh-tool-lab
*/
import type { FileSystem } from '@deepseek-ai/dsh-fs'
import { Deadline, sleep } from './helpers.ts'
import type { ResolvedLabConfig } from './comfy.ts'
interface WhishperTranscription {
id?: string
status?: number
result?: { text?: string | null; language?: string | null; duration?: number | null }
}
/** Build multipart upload of an audio file. */
export function buildAudioUpload(data: Uint8Array, filename: string, language?: string): FormData {
const fd = new FormData()
const ext = filename.split('.').pop()?.toLowerCase() ?? 'wav'
const type = ext === 'mp3' ? 'audio/mpeg' : ext === 'ogg' ? 'audio/ogg' : ext === 'm4a' ? 'audio/mp4' : 'audio/wav'
fd.append('files', new Blob([data as unknown as BlobPart], { type }), filename)
if (language) fd.append('language', language)
return fd
}
/** Poll Whishper `/api/transcriptions/{id}` until text is ready or done. */
async function pollTranscription(
baseUrl: string,
id: string,
deadline: Deadline,
timeoutMs: number,
): Promise<WhishperTranscription> {
const started = Date.now()
for (;;) {
deadline.check()
const res = await fetch(`${baseUrl}/api/transcriptions/${id}`)
if (!res.ok) throw new Error(`Whishper status ${res.status}: ${await res.text()}`)
const row = (await res.json()) as WhishperTranscription
const text = row.result?.text
if (typeof text === 'string' && text.length > 0 && row.status !== -1) return row
if (row.status !== -1 && (row.status === 1 || row.status === 2)) return row
if (Date.now() - started >= timeoutMs) throw new Error(`Whishper transcription timed out after ${timeoutMs}ms`)
await sleep(1500, deadline.signal)
}
}
/** Run the Whishper tool: read audio, upload, poll, return transcription text. */
export async function runTranscribeAudio(
fs: FileSystem,
config: ResolvedLabConfig,
filePath: string,
language: string | undefined,
modelSize: string | undefined,
signal: AbortSignal | undefined,
): Promise<string> {
const deadline = new Deadline('transcribe_audio', config.timeoutMs, signal)
try {
const target = await fs.resolve(filePath, signal === undefined ? undefined : { signal })
const statInfo = await fs.stat(target, signal)
if (!statInfo) throw new Error(`transcribe_audio: file not found: ${filePath}`)
const data = await fs.readBytes(target, signal, config.maxUploadBytes)
const filename = filePath.split(/[\\/]/).pop() ?? 'audio.wav'
const form = buildAudioUpload(data, filename, language)
if (modelSize) {
// Whishper server config may restrict model size; pass it as a field if supported.
form.append('modelSize', modelSize)
}
const submit = await fetch(`${config.whishBaseUrl}/api/transcriptions`, {
method: 'POST',
body: form,
})
if (!submit.ok) throw new Error(`Whishper upload ${submit.status}: ${await submit.text()}`)
const row = (await submit.json()) as WhishperTranscription
if (!row.id) throw new Error(`Whishper no id: ${JSON.stringify(row)}`)
const done = await pollTranscription(config.whishBaseUrl, row.id, deadline, config.timeoutMs)
const text = done.result?.text ?? ''
if (text.length === 0) return '(transcription completed with no text)'
if (text.length > config.maxOutputChars) return text.slice(0, config.maxOutputChars)
return text
} finally {
deadline.dispose()
}
}

View File

@@ -0,0 +1,60 @@
/**
* Unit tests for the pure helpers of `@deepseek-ai/dsh-tool-lab`. These
* assertions do not touch the network or the runtime; they check the workflow
* and upload builders only.
* @module @deepseek-ai/dsh-tool-lab/tests
*/
import { describe, expect, it } from 'vitest'
import { buildWorkflow } from '../src/comfy.ts'
import { buildPdfUpload } from '../src/docling.ts'
import { buildAudioUpload } from '../src/whish.ts'
/** One ComfyUI workflow node as seen by the assertions. */
interface WorkflowNode {
class_type: string
inputs: Record<string, unknown>
}
/** Reinterpret the workflow builder's JSON-serializable output for assertions. */
function wf(prompt: string, overrides: Record<string, unknown> = {}): Record<string, WorkflowNode> {
return buildWorkflow({ prompt, ...(overrides as object) }) as Record<string, WorkflowNode>
}
describe('buildWorkflow', () => {
it('renders a 28-step Juggernaut workflow with defaults', () => {
const flow = wf('a red fox')
expect(flow['4'].inputs.ckpt_name).toBe('Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors')
expect(flow['9'].class_type).toBe('SaveImage')
expect(flow['9'].inputs.images).toEqual(['10', 0])
expect(flow['10'].class_type).toBe('ImageUpscaleWithModel')
expect(flow['11'].inputs.model_name).toBe('RealESRGAN_x4plus.safetensors')
expect(flow['3'].inputs).toMatchObject({ steps: 28, cfg: 4.0, sampler_name: 'dpmpp_2m_sde', scheduler: 'karras' })
})
it('skips upscaling when upscale is false', () => {
const flow = wf('p', { upscale: false })
expect(flow['9'].inputs.images).toEqual(['8', 0])
expect(flow['10']).toBeUndefined()
expect(flow['11']).toBeUndefined()
})
it('honors overrides and seed', () => {
const flow = wf('p', { width: 768, height: 640, steps: 8, seed: 99 })
expect(flow['5'].inputs).toMatchObject({ width: 768, height: 640 })
expect(flow['3'].inputs).toMatchObject({ seed: 99, steps: 8 })
expect(flow['6'].inputs.text).toBe('p')
})
})
describe('upload builders', () => {
it('builds a PDF upload carrying files and options parts', () => {
const fd = buildPdfUpload(new Uint8Array([1, 2, 3]), 'scan.pdf')
expect(fd).toBeDefined()
})
it('builds an audio upload', () => {
const fd = buildAudioUpload(new Uint8Array([1, 2]), 'clip.mp3')
expect(fd).toBeDefined()
})
})

View File

@@ -0,0 +1,28 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/tools"
},
{
"path": "../../fs/fs"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -57,7 +57,7 @@ import {
truncateUnicodeCodePoints,
} from './api/session-search.ts'
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
import type {} from '@deepseek-ai/dsh-session-projection'
import { foldContextOf } from '@deepseek-ai/dsh-session-projection'
// Type-only: resolves `ctx.get('tasks')` to the background job registry.
import type {} from '@deepseek-ai/dsh-jobs'
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
@@ -838,11 +838,12 @@ function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session
/** Projection baseline for a detached history tail without Agent activation. */
function detachedProjectionsFor(
ctx: Context,
header: SessionHeader,
events: readonly SessionEvent[],
): SessionProjectionsBlock | undefined {
const registry = ctx.get('sessionProjections')
if (registry === undefined) return undefined
return registry.restore({}, events, 0).snapshot
return registry.restore({}, events, 0, foldContextOf(header)).snapshot
}
/**
@@ -1534,7 +1535,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
includeProjections: boolean,
): { events: SessionEvent[]; projections?: SessionProjectionsBlock } {
if (source.kind === 'detached') {
const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined
const projections = includeProjections ? detachedProjectionsFor(ctx, source.header, source.events) : undefined
return { events: source.events, ...projections === undefined ? {} : { projections } }
}
const events = [...source.session.events]
@@ -2632,7 +2633,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
header = inspected.meta
events = inspected.events
projections = beforeSeq === undefined
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.meta, inspected.events))
: undefined
} catch (error: unknown) {
if (signal?.aborted) {

View File

@@ -20,11 +20,17 @@ import { toPiReplayState } from './replay.ts'
* @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence).
*/
export function mapUsage(usage: PiUsage): TokenUsage {
const usageRecord = usage as unknown as Record<string, unknown>
const rawCost = usageRecord['cost'] ?? usageRecord['total_cost']
const costUsd = typeof rawCost === 'number' && Number.isFinite(rawCost) && rawCost >= 0
? rawCost
: undefined
return {
inputTokens: usage.input,
outputTokens: usage.output,
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
...costUsd !== undefined ? { costUsd } : {},
}
}

Some files were not shown because too many files have changed in this diff Show More