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
This commit is contained in:
2026-08-23 22:12:30 +07:00
parent e818e37f0a
commit 8719fa84f0
112 changed files with 5686 additions and 169 deletions

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/subagent/README.md
README.md: a863ed3f5ef864b6eb6eb9a7a0c1ee2f40f247d6
README.zh.md: 1c9bf8ba0814a74c5774d81a34a3266daa9c375b
README.md: a3bbfdf415006e6321c7f60f6678a828e8295013
README.zh.md: accc3a2216b9b0d27bb8e23c391d0206f813ff5f

View File

@@ -13,6 +13,7 @@ This family lets an agent delegate work to child agents. Multiple named provider
| [`subagent-acp/`](subagent-acp/README.md) | Starts an out-of-process child over ACP | registers on `ctx.subagents` |
| [`subagent-codex/`](subagent-codex/README.md) | Starts a real Codex app-server child | registers on `ctx.subagents` |
| [`subagent-claude-code/`](subagent-claude-code/README.md) | Starts a real Claude Code child through the official Claude Agent SDK | registers on `ctx.subagents` |
| [`subagent-cursor/`](subagent-cursor/README.md) | Starts a real Cursor child through the `cursor-agent` print-mode CLI | registers on `ctx.subagents` |
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | Starts an out-of-process Harness child through the TypeScript SDK | registers on `ctx.subagents` |
| [`tool-subagent/`](tool-subagent/README.md) | Exposes delegation to the model | registers on `ctx.tools` |
| [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` |

View File

@@ -13,6 +13,7 @@
| [`subagent-acp/`](subagent-acp/README.md) | 通过 ACP(Agent Client Protocol)启动进程外子 agent | 注册到 `ctx.subagents` |
| [`subagent-codex/`](subagent-codex/README.md) | 启动真实的 Codex app-server 子 agent | 注册到 `ctx.subagents` |
| [`subagent-claude-code/`](subagent-claude-code/README.md) | 通过官方 Claude Agent SDK 启动真实的 Claude Code 子 agent | 注册到 `ctx.subagents` |
| [`subagent-cursor/`](subagent-cursor/README.md) | 通过 `cursor-agent` print 模式 CLI 启动真实的 Cursor 子 agent | 注册到 `ctx.subagents` |
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` |
| [`tool-subagent/`](tool-subagent/README.md) | 向模型公开委派操作 | 注册到 `ctx.tools` |
| [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.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 packages/subagent/subagent-claude-code/README.md
README.md: 1a0d6e32b8610769dcc5d8342a4fe88d0c884085
README.zh.md: 78dab14e5eaddc06ccd07b69dc952a09380e0428
README.md: 9ded255eddefbed524a94216df8142411c91f547
README.zh.md: e24d32578b462946dea27e2b13bdd1341efd52bb

View File

@@ -31,7 +31,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden.
Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and mount it once on the host plane; loading the provider starts no Claude process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_claude_code` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Claude process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services.

View File

@@ -31,7 +31,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。
生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Claude 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。
`dsh-base` 宿主平面会挂载本提供方,`code`、`cordis` 与 `standard` Agent Preset 均携带已启用的 `subagent_claude_code` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Claude 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。
下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。

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/subagent/subagent-codex/README.md
README.md: 848d170585710b682fa4ce331010fce7080de673
README.zh.md: 34e9105e6a78bc16f16997c7df89d4f6412eb50c
README.md: de64a9136c3f1a5573cc169bcd3bd2ea11cd15e8
README.zh.md: 9490e1084def998be9352111dbc8137642c92263

View File

@@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and mount it once on the host plane; loading the provider starts no Codex process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_codex` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Codex process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services.

View File

@@ -27,7 +27,7 @@
生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。
生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并在 host plane(宿主平面)挂载一次;加载提供方本身不会在工具调用前启动 Codex 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。
`dsh-base` 宿主平面会挂载本提供方,`code`、`cordis` 与 `standard` Agent Preset 均携带已启用的 `subagent_codex` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Codex 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。base host(基础宿主)与完整 preset 已提供通用作业注册表和控制工具。
下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。

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 packages/subagent/subagent-cursor/README.md
README.md: bbf6a4bd763e24c09a34fdc77fecc6f2c66a1ade
README.zh.md: db15b551cd95a763a3768b09ab4a9527c72afa10

View File

@@ -0,0 +1,106 @@
# @deepseek-ai/dsh-subagent-cursor
English | [中文](README.zh.md)
This package registers the fixed `cursor` subagent provider. Each accepted run starts the official `cursor-agent` CLI in non-interactive print mode in the delegating Session's workspace, submits one self-contained text task, reads the CLI's `stream-json` event stream, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract.
## Start and ownership
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It resolves `cursor-agent` through [`dsh-subprocess`](../../subprocess/subprocess/README.md), spawns the fixed command, and publishes the run only after the CLI's `system`/`init` event announces its own chat id — the point at which the CLI has started and resolved its credentials and model. A failure or cancellation before that announcement terminates the managed process tree, waits for it to exit, and rejects `start()`.
The task crosses the boundary as a positional command-line argument, because that is the only prompt channel print mode offers: the CLI documents no `--` end-of-options separator and reads no prompt from stdin. Two consequences are enforced rather than papered over. A task whose first character is `-` is rejected at admission, since the CLI would parse it as an option. A resolved Windows `.cmd` or `.bat` shim is rejected as well, because only `cmd.exe` can run it and its command tail would reparse model-authored text as shell syntax; PATHEXT resolution prefers the `cursor-agent.exe` that the native Windows installer provides. Stdin is closed immediately after spawn, so a prompt the CLI still tries to read fails fast instead of stalling an unattended child on an answer nobody can give.
The published `run.result` waits for the authoritative terminal `result` event and accepts only `subtype: "success"` with `is_error: false` and a nonblank `result`. Every other terminal event, malformed stdout line, stream failure, or end of stream without a result maps to `error`; print mode carries no machine-readable failure taxonomy, so the provider produces neither `max-tokens` nor `refusal`. `user`, `tool_call`, and event kinds a newer CLI adds contribute nothing to this contract.
Print mode has no reply channel, so there is no protocol interrupt: cancellation is the run's abort signal, which the subprocess seam turns into its termination escalation while the result settles immediately as `aborted` with the last non-empty assistant message collected so far. `dispose()` is idempotent: it detaches the event stream, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate.
## Capabilities and context
The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Cursor receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The CLI's own chat id and model stay private to the run and are never persisted in the parent Session.
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
| `force` | `false` | Pass `--force`, letting the child apply file changes and run commands. Cursor's own print-mode default only PROPOSES changes, so a delegation expected to edit the workspace needs this on. |
| `trust` | `false` | Pass `--trust`, letting the child act in the workspace without Cursor's interactive trust prompt an unattended child cannot answer. |
Production resolves `cursor-agent` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and uses the host's native Cursor configuration and authentication. The plugin does not install the CLI, select a model, create a Cursor home, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so a `CURSOR_API_KEY` intended for the child must be supplied in `env`; it is never passed as `--api-key`, where a process listing would expose it. Ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_cursor` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Cursor process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`.
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows and provider row and enables only the preset tool row instead of mounting duplicates.
```yaml
- id: subagent-cursor
name: '@deepseek-ai/dsh-subagent-cursor'
config:
force: true
trust: true
env:
CURSOR_API_KEY: !!js process.env.CURSOR_API_KEY
- id: jobs
name: '@deepseek-ai/dsh-jobs-local'
- id: tool-jobs
name: '@deepseek-ai/dsh-tool-jobs'
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed
```
## Product compatibility and evidence
The provider reads only the print-mode events this one-shot contract needs: `system`/`init` to publish, `assistant` to snapshot partial output, and `result` to settle. Deployments supply `cursor-agent` on `PATH`; the CLI is not an npm dependency of this package, so unit evidence drives the real event stream through the subprocess seam rather than a pinned binary.
The CLI also speaks the Agent Client Protocol as `cursor-agent acp`, which the generic [`dsh-subagent-acp`](../subagent-acp/README.md) provider can drive with configuration alone. That path exists for a deployment that wants ACP's permission auto-answer policy or a long-lived remote session; this package exists for the one-shot delegation contract, its own `subagent_cursor` tool row, and print mode's simpler failure surface.
## Model Experience
### Child request
#### What the model sees
The Cursor child receives the concatenated text task as one positional prompt in a fresh print-mode run. Its workspace is the parent Session cwd, and its model, system instructions, tools, and authentication come from the native Cursor installation and configuration.
#### Token effect
The child pays for an independent Cursor context and turn. Child tokens do not enter the parent's context.
#### KV Cache effect
Independent of the parent request cache. Reuse depends only on Cursor's own model, instructions, tools, and per-run request.
### Parent scheduling and results, indirectly
#### What the model sees
Through `dsh-tool-subagent`, a foreground call gives the parent the terminal Cursor answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Cursor's intermediate messages, tool activity, stderr, workspace diffs, usage, and product ids are not copied into the parent Session.
#### Token effect
Foreground input grows by the retained final answer or error. Background input also includes the start acknowledgement, completion notice, and any `job_output`, `job_kill`, or later status results; child tokens still do not enter the parent context. This provider adds no parent tool schema by itself.
#### KV Cache effect
Append-only: foreground adds one result after the reusable parent prefix, while background appends the Job acknowledgement, notice, and later control or collection results. Background scheduling can add a notice-driven turn, but none of these messages rewrites the earlier prefix.
## Known Limitations and Deferred Work
- **One fresh process and run per delegation** — there is no continuation, `--resume`, pooling, progress stream, or product-session persistence, even though the CLI itself supports resuming a chat by id.
- **The task cannot begin with `-`** — print mode takes the prompt positionally and documents no `--` separator, so such a task is rejected at admission instead of being mis-parsed as an option.
- **Windows needs the native executable** — a resolved `.cmd` or `.bat` shim is rejected rather than run through `cmd.exe`, whose command tail would reparse the task text.
- **Host-managed product installation and account state** — a missing `cursor-agent`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate.
- **No graduated approval path** — `force` and `trust` are whole-run deployment switches; there is no per-call policy, allowlist, or human approval channel through this package.
- **No failure taxonomy** — print mode reports only a terminal subtype and error marker, so a context-window ending is indistinguishable from any other failure and never maps to `max-tokens`.
- **Product payload is final text only** — intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime.
- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.
- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored.

View File

@@ -0,0 +1,106 @@
# @deepseek-ai/dsh-subagent-cursor
[English](README.md) | 中文
本包注册固定的 `cursor` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中以非交互 print 模式启动官方 `cursor-agent` CLI,提交一个自包含的文本任务,读取该 CLI 的 `stream-json` 事件流,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。
## 启动与所有权
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 解析 `cursor-agent`,spawn 固定命令,且仅在该 CLI 的 `system`/`init` 事件公布其自有会话 ID 之后才发布此次运行——那正是该 CLI 已启动并解析出自身凭证与模型的时刻。若在此公布之前发生失败或取消,它会终止受管进程树并等待其退出,然后拒绝 `start()` 调用。
任务以位置命令行参数的形式跨越进程边界,因为这是 print 模式提供的唯一提示词通道:该 CLI 未记载 `--` 选项终止符,也不从标准输入读取提示词。由此产生的两项后果被强制约束,而非含糊掩盖。首字符为 `-` 的任务在准入阶段即被拒绝,因为该 CLI 会将其解析为选项。解析到 Windows `.cmd` 或 `.bat` 包装脚本同样被拒绝,因为只有 `cmd.exe` 能运行它,而其命令尾部会把模型撰写的文本重新解析为 shell 语法;PATHEXT 解析会优先选择原生 Windows 安装程序提供的 `cursor-agent.exe`。标准输入在 spawn 后立即关闭,因此该 CLI 若仍尝试读取提示词,会快速失败,而不是让无人值守的子级停滞在无人能给出的答案上。
已发布的 `run.result` 会等待权威的终止 `result` 事件,且只接受 `subtype: "success"` 且 `is_error: false` 并带非空白 `result` 的事件。其他任何终止事件、格式错误的标准输出行、流失败,或流结束时仍无结果,都映射为 `error`;print 模式不携带可供程序判读的失败分类,因此该提供方既不会产生 `max-tokens` 也不会产生 `refusal`。`user`、`tool_call` 以及更新版 CLI 新增的事件类别对本约定没有贡献。
print 模式没有回复通道,因此不存在协议层中断:取消即本次运行的中止信号,子进程 seam 会将其转为逐级终止机制,同时结果立即判为 `aborted`,并携带此前收集到的最后一条非空助手消息。`dispose()`(资源释放)具有幂等性:它会摘除事件流监听、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
## 能力与上下文
本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Cursor 会接收独立文本任务和父会话 cwd,但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。该 CLI 自有的会话 ID 与模型仅在此次运行内部可见,绝不会持久化到父会话。
## 配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
| `force` | `false` | 传入 `--force`,允许子级实际应用文件改动并执行命令。Cursor 自身的 print 模式默认只“提出”改动,因此预期要编辑工作区的委托需要开启此项。 |
| `trust` | `false` | 传入 `--trust`,允许子级在不经过 Cursor 交互式信任提示的情况下在工作区中行动——无人值守的子级无法回答该提示。 |
生产环境会从子进程执行环境中已清除凭证的 `PATH` 里解析 `cursor-agent`,并叠加显式 `env` 条目,同时使用宿主机原生的 Cursor 配置与身份验证。本插件不安装该 CLI、不选择模型、不创建 Cursor 主目录、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 `CURSOR_API_KEY` 必须在 `env` 中提供;它绝不会作为 `--api-key` 传入——那会让进程列表暴露它。除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。
`dsh-base` 宿主平面会挂载本提供方,`code`、`cordis` 与 `standard` Agent Preset 均携带已启用的 `subagent_cursor` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Cursor 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID,供 `job_output` 或 `job_kill` 使用。
下面的独立组装展示了完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 会保留其既有的 Job 行与提供方行,只需启用 preset 中的工具行,而不必挂载重复项。
```yaml
- id: subagent-cursor
name: '@deepseek-ai/dsh-subagent-cursor'
config:
force: true
trust: true
env:
CURSOR_API_KEY: !!js process.env.CURSOR_API_KEY
- id: jobs
name: '@deepseek-ai/dsh-jobs-local'
- id: tool-jobs
name: '@deepseek-ai/dsh-tool-jobs'
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed
```
## 产品兼容性与证据
本提供方只读取这一 one-shot 约定所需的 print 模式事件:`system`/`init` 用于发布运行,`assistant` 用于快照部分输出,`result` 用于结束运行。部署方需在 `PATH` 上提供 `cursor-agent`;该 CLI 不是本包的 npm 依赖,因此单元证据通过子进程 seam 驱动真实事件流,而不依赖固定版本的二进制文件。
该 CLI 还能以 `cursor-agent acp` 讲 Agent Client Protocol,通用的 [`dsh-subagent-acp`](../subagent-acp/README.md) 提供方仅凭配置即可驱动它。那条路径适用于需要 ACP 权限自动应答策略或长期远端会话的部署;本包存在的意义在于这一 one-shot 委托约定、自有的 `subagent_cursor` 工具行,以及 print 模式更简单的失败面。
## Model Experience
### 子级请求
#### 模型看到什么
Cursor 子级会以一次全新的 print 模式运行,接收拼接后的文本任务作为单个位置提示词。其工作区是父会话 cwd,而模型、系统指令、工具与身份验证均来自宿主机原生的 Cursor 安装与配置。
#### Token 影响
子级为独立的 Cursor 上下文与轮次付费。子级 token 不会进入父级上下文。
#### KV 缓存影响
与父级请求缓存互相独立。复用只取决于 Cursor 自身的模型、指令、工具与本次运行的请求。
### 父级调度与结果(间接)
#### 模型看到什么
通过 `dsh-tool-subagent`,前台调用会把终止事件中的 Cursor 答案交给父级,若结果并非完成状态,则交给消费方的确切错误。后台调用先返回一个 Job ID;通用作业控制工具随后送达完成通知,通过 `job_output` 暴露最终答案与状态,并允许 `job_kill` 请求取消。Cursor 的中间消息、工具活动、标准错误、工作区差异、用量与产品 ID 都不会复制进父会话。
#### Token 影响
前台输入会因保留的最终答案或错误而增长。后台输入还包含启动确认、完成通知,以及任何 `job_output`、`job_kill` 或后续状态结果;子级 token 仍不会进入父级上下文。本提供方自身不添加任何父级工具 schema。
#### KV 缓存影响
仅追加:前台在可复用的父级前缀之后追加一条结果,后台则追加 Job 确认、通知以及后续的控制或收集结果。后台调度可能新增一个由通知驱动的轮次,但上述任何消息都不会改写此前的前缀。
## Known Limitations and Deferred Work
- **每次委托对应一个全新进程与一次运行** —— 没有续接、`--resume`、进程池化、进度流或产品会话持久化,尽管该 CLI 本身支持按 ID 恢复对话。
- **任务不能以 `-` 开头** —— print 模式按位置接收提示词且未记载 `--` 终止符,因此这类任务在准入阶段即被拒绝,而不是被误解析为选项。
- **Windows 需要原生可执行文件** —— 解析到 `.cmd` 或 `.bat` 包装脚本会被拒绝,而不会通过 `cmd.exe` 运行——其命令尾部会重新解析任务文本。
- **产品安装与账户状态由宿主机管理** —— 缺失 `cursor-agent`、配置错误或身份验证失败会作为启动错误或运行错误上报;本插件不提供安装器、登录流程或运行时版本闸门。
- **没有分级审批路径** —— `force` 与 `trust` 是整次运行级别的部署开关;本包不提供按调用的策略、允许清单或人工审批通道。
- **没有失败分类** —— print 模式只报告终止子类型与错误标记,因此上下文窗口耗尽与其他任何失败无法区分,且绝不会映射为 `max-tokens`。
- **产品载荷仅为最终文本** —— 中间消息、工具流量、用量、标准错误与工作区差异仍留在产品本地;通用 Job ID、通知与状态来自共享作业运行时。
- **没有可选共享能力** —— 输出 schema、子级角色设定、工具筛选与 harness 深度强制均被共享服务针对本提供方拒绝。
- **没有挂钟超时或副作用回滚** —— 由调用方取消长时间工作,取消前已改动的文件或外部系统不会被还原。

View File

@@ -0,0 +1,59 @@
{
"name": "@deepseek-ai/dsh-subagent-cursor",
"description": "One-shot Cursor subagent provider over the cursor-agent print-mode stream-json protocol",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/subagent/subagent-cursor"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,130 @@
/**
* Fixed Cursor one-shot subagent provider. Every accepted run starts a fresh
* non-interactive `cursor-agent --print --output-format stream-json` process
* in the delegating Session's workspace and publishes only after the CLI
* announces its own session.
*
* @module @deepseek-ai/dsh-subagent-cursor
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
assertPositiveFinite,
NO_START_CAPABILITIES,
resolveChildCwd,
type ResolvedSubagentStartRequest,
type SubagentCapabilities,
type SubagentProvider,
} from '@deepseek-ai/dsh-subagent'
import {
assertDirectlySpawnable,
DEFAULT_DISPOSE_GRACE_MS,
startCursorRun,
type CursorRunSpec,
} from './run.ts'
export const name = 'subagent-cursor'
export const inject = ['subagents', 'subprocess']
/** Fixed native executable; Cursor's own configuration stays authoritative. */
const CURSOR_EXECUTABLE = 'cursor-agent'
/** 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
}
export const Config: z<Config> = z.object({
env: z.dict(z.string()).default({}),
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
force: z.boolean().default(false),
trust: z.boolean().default(false),
})
type ResolvedConfig = Required<Config>
class CursorProvider implements SubagentProvider {
readonly name = 'cursor'
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
readonly inheritsParentContext = false
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
) {}
async start(request: ResolvedSubagentStartRequest) {
const parentCwd = request.parent.session.header.cwd
if (parentCwd === undefined) {
throw new Error(
'subagent-cursor: no working directory for the child — delegate from a parent session that has one',
)
}
const executable = assertDirectlySpawnable(
await this.ctx.subprocess.resolveExecutable(
CURSOR_EXECUTABLE,
this.config.env,
request.signal,
),
)
const spec: CursorRunSpec = {
cwd: resolveChildCwd(
'subagent-cursor',
undefined,
parentCwd,
),
executable,
env: this.config.env,
disposeGraceMs: this.config.disposeGraceMs,
force: this.config.force,
trust: this.config.trust,
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
onError: (error, stopReason) => {
this.ctx.logger.warn(
`subagent-cursor: child run failed (${stopReason}): ${error.message}`,
)
},
}
return startCursorRun(request, spec)
}
}
/**
* Register the fixed `cursor` provider.
* @param ctx - context carrying shared subagent and subprocess services.
* @param config - explicit child environment, permissions, and disposal grace.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite(
'subagent-cursor',
'disposeGraceMs',
resolved.disposeGraceMs,
)
if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`subagent-cursor: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
ctx.subagents.registerProvider(new CursorProvider(ctx, resolved))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-cursor`.
* @module @deepseek-ai/dsh-subagent-cursor/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-subagent-cursor'
/** Cordis companion plugin name. */
export const name = 'subagent-cursor-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: lifecycle pairing belongs to the shared subagent
* service and process-tree ownership belongs to the subprocess service.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - plugin context carrying the invariant registry.
* @returns the installed registration's disposer.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,267 @@
/**
* One-shot Cursor child lifecycle: spawn the real `cursor-agent` print-mode
* command through the subprocess seam, publish only after the CLI announces
* its session, flatten post-publication failures, and dispose to whole-tree
* quiescence.
*
* @module @deepseek-ai/dsh-subagent-cursor/run
*/
import { randomUUID } from 'node:crypto'
import { extname } from 'node:path'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
settleRunResult,
subprocessRunHandle,
type SubagentResult,
type SubagentRun,
type SubagentStartRequest,
type SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { CursorStreamWire } from './wire.ts'
/** Default POSIX grace between subprocess termination tiers. */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/* jscpd:ignore-start -- sibling providers intentionally keep product-private
* run inputs and error normalization instead of adding a shared lifecycle owner. */
/** Fully resolved inputs for one `cursor-agent` print-mode run. */
export interface CursorRunSpec {
/** Parent Session workspace, also passed as the CLI `--workspace`. */
readonly cwd: string
/** Exact native `cursor-agent` executable resolved from the host PATH. */
readonly executable: string
/** Explicit deployment/test environment layered after the shared scrub. */
readonly env: Record<string, string>
/** Subprocess termination grace passed to the shared process-tree owner. */
readonly disposeGraceMs: number
/** Whether the child may apply changes instead of only proposing them. */
readonly force: boolean
/** Whether the child may act in the workspace without a trust prompt. */
readonly trust: boolean
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/** Diagnostic sink for a post-publication error flattened into a result. */
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
}
function thrown(value: unknown): Error {
/* v8 ignore next -- typed subprocess/wire failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/* jscpd:ignore-end */
/**
* Validate and preserve the one-shot task before it becomes a command-line
* argument.
*
* `cursor-agent` takes the task as a POSITIONAL argument and documents no
* `--` end-of-options separator, so a task whose first character is `-` would
* be parsed as an option. The seam has no way to escape that, so it is
* rejected here rather than silently mis-parsed by the CLI.
* @param prompt - task content accepted from the shared subagent service.
* @returns the exact concatenated text task.
*/
export function textTask(prompt: readonly ContentBlock[]): string {
if (prompt.length === 0) {
throw new Error('subagent-cursor: the one-shot task must contain only text blocks')
}
const texts: string[] = []
for (const block of prompt) {
if (block.type !== 'text') {
throw new Error('subagent-cursor: the one-shot task must contain only text blocks')
}
texts.push(block.text)
}
if (texts.every(text => text.trim().length === 0)) {
throw new Error('subagent-cursor: the one-shot task must not be empty')
}
const task = texts.join('')
if (task.startsWith('-')) {
throw new Error(
'subagent-cursor: the one-shot task must not begin with "-" — cursor-agent takes it as a positional argument and would parse it as an option',
)
}
return task
}
/**
* Assert the resolved executable can be started without a shell.
*
* The task text is ordinary argv on every direct spawn, but a Windows `.cmd`
* or `.bat` shim can only run through `cmd.exe`, which reparses its command
* tail — model-authored task text would then be shell syntax. PATHEXT
* resolution prefers `cursor-agent.exe`, which the native Windows installer
* provides, so this fails loud instead of opening that boundary.
* @param executable - the resolved absolute executable path.
* @param platform - host platform selecting the batch-shim rejection.
* @returns the executable, validated.
*/
export function assertDirectlySpawnable(
executable: string,
platform: NodeJS.Platform = process.platform,
): string {
const extension = extname(executable).toLowerCase()
if (platform === 'win32' && (extension === '.cmd' || extension === '.bat')) {
throw new Error(
`subagent-cursor: resolved ${executable} is a batch shim that requires a shell; install the native cursor-agent executable so the task text stays ordinary argv`,
)
}
return executable
}
/**
* Build the fixed print-mode command for one run.
* @param spec - resolved executable, workspace, and permission selections.
* @param task - the validated positional task text.
* @returns argv for one non-interactive `cursor-agent` run.
*/
export function cursorAgentArgv(
spec: Pick<CursorRunSpec, 'executable' | 'cwd' | 'force' | 'trust'>,
task: string,
): string[] {
return [
spec.executable,
'--print',
'--output-format',
'stream-json',
'--workspace',
spec.cwd,
...(spec.force ? ['--force'] : []),
...(spec.trust ? ['--trust'] : []),
task,
]
}
/**
* Close the event stream, terminate the managed process tree, and wait for the
* subprocess owner to prove it is gone.
* @param wire - the run's private stdout event decoder.
* @param child - shared-service handle that owns the process tree.
*/
export async function disposeCursorChild(
wire: CursorStreamWire,
child: SubprocessHandle,
): Promise<void> {
wire.close()
if (child.pid <= 0) {
await child.done.catch(() => {})
return
}
child.terminate()
await child.waitForExit()
await child.done
}
/**
* Start one real `cursor-agent` print-mode child and publish its one-shot run.
* @param request - resolved shared subagent request.
* @param spec - executable, workspace, environment, permissions, process
* service, and diagnostic policy.
* @returns the published run after the CLI announces its session.
*/
export async function startCursorRun(
request: SubagentStartRequest,
spec: CursorRunSpec,
): Promise<SubagentRun> {
const task = textTask(request.prompt)
if (request.signal.aborted) {
throw new Error('subagent-cursor: request was aborted before cursor-agent startup')
}
const runAbort = new AbortController()
const child = spec.spawn({
argv: cursorAgentArgv(spec, task),
cwd: spec.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs: spec.disposeGraceMs,
// Print mode offers no reply channel, so there is no protocol interrupt to
// send: stopping the child IS the cancellation, and the subprocess seam
// owns that termination escalation.
signal: runAbort.signal,
env: spec.env,
})
const wire = new CursorStreamWire(child.stdout as NonNullable<SubprocessHandle['stdout']>)
const disposeProcess = (): Promise<void> => disposeCursorChild(wire, child)
const processFailure: Promise<never> = child.done.then(
outcome => Promise.reject(new Error(
'subagent-cursor: cursor-agent exited before the run settled '
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`,
)),
(error: unknown) => Promise.reject(thrown(error)),
)
// A normal post-result dispose also closes the process. Keep that expected
// late rejection observed after the result race has already settled.
processFailure.catch(() => {})
const requestCancel = (): void => {
if (runAbort.signal.aborted) return
runAbort.abort(new Error('subagent-cursor: run cancelled locally'))
}
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
// The spawn signal stops the child; this race settles the run. Waiting for
// the termination ladder instead would leave a cancelled result pending for
// the whole grace period, so cancellation is observed here directly.
const cancellation: Promise<never> = new Promise((_resolve, reject) => {
runAbort.signal.addEventListener(
'abort',
() => { reject(new Error('subagent-cursor: run cancelled locally')) },
{ once: true },
)
})
// Both races below observe this rejection, but only until one of them
// settles; keep it observed for the window after that.
cancellation.catch(() => {})
try {
wire.start()
// The task is already on the command line and print mode reads no input.
// Closing stdin now makes any prompt the CLI still tries to read fail fast
// instead of stalling an unattended child on an answer nobody can give.
child.stdin?.end()
await Promise.race([wire.ready(), processFailure, cancellation])
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
try {
await disposeProcess()
} catch (disposeError: unknown) {
throw new AggregateError(
[thrown(error), thrown(disposeError)],
'subagent-cursor: startup failed and cursor-agent cleanup also failed',
)
}
if (runAbort.signal.aborted) {
throw new Error('subagent-cursor: request was aborted before run publication')
}
throw thrown(error)
}
/* jscpd:ignore-start -- the seam documents settlement and publication as two
* primitives, so every out-of-process provider ends with this same literal
* composition; folding them into a third seam function would trade two named
* steps for one eight-parameter call. */
const result: Promise<SubagentResult> = settleRunResult({
attempt: () => Promise.race([wire.awaitResult(), processFailure, cancellation]),
collectOutput: () => wire.collectOutput(),
cancelled: () => runAbort.signal.aborted,
onError: spec.onError,
signal: request.signal,
onAbort,
})
return subprocessRunHandle({
id: SessionId(randomUUID()),
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: disposeProcess,
})
/* jscpd:ignore-end */
}

View File

@@ -0,0 +1,265 @@
/**
* Decoder for one `cursor-agent --print --output-format stream-json` run. The
* CLI writes newline-delimited JSON events on stdout and never reads a reply,
* so this module owns line framing, event validation at the process boundary,
* the `system`/`init` gate that run publication waits on, assistant-message
* selection, and terminal-answer selection. It sends nothing: cancellation is
* local and process termination belongs to the subprocess seam.
*
* @module @deepseek-ai/dsh-subagent-cursor/wire
*/
import type { Readable } from 'node:stream'
import { StringDecoder } from 'node:string_decoder'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
type JsonObject = Record<string, unknown>
/**
* The `system`/`init` facts observed once before publication. They prove the
* CLI started and resolved its own credentials and model, which is this
* provider's equivalent of a remote session existing; both values stay
* private to the run and are never persisted in the parent Session.
*/
export interface CursorSessionInfo {
/** Cursor's own chat id for this run. */
readonly sessionId: string
/** Model display name Cursor selected from its native configuration. */
readonly model: string | undefined
}
function object(value: unknown, label: string): JsonObject {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`subagent-cursor: cursor-agent emitted an invalid ${label}`)
}
return value as JsonObject
}
/**
* Select the text blocks of one Cursor message. Non-text blocks are dropped
* rather than rejected: the CLI may add block kinds this one-shot contract has
* no representation for, and dropping them cannot corrupt the selected answer.
* @param message - the event's `message` value.
* @param label - event name used in the boundary diagnostic.
* @returns the message's text blocks, possibly empty.
*/
function messageText(message: unknown, label: string): ContentBlock[] {
const content = object(message, label).content
if (!Array.isArray(content)) {
throw new Error(`subagent-cursor: cursor-agent emitted an invalid ${label} content`)
}
const blocks: ContentBlock[] = []
for (const block of content) {
if (
block !== null
&& typeof block === 'object'
&& !Array.isArray(block)
&& (block as JsonObject).type === 'text'
&& typeof (block as JsonObject).text === 'string'
) {
blocks.push({ type: 'text', text: (block as JsonObject).text as string })
}
}
return blocks
}
/**
* Describe why a terminal `result` event cannot complete the run. The CLI
* carries no machine-readable failure taxonomy in print mode, so the reported
* detail is its own `subtype` plus the error marker.
* @param event - the terminal event.
* @returns a stable single-line diagnostic detail.
*/
function resultFailureDetail(event: JsonObject): string {
const subtype = typeof event.subtype === 'string' ? event.subtype : 'unknown'
return subtype === 'success'
? 'success result was marked as an error or contained no answer'
: `terminal result subtype ${subtype}`
}
/**
* One `cursor-agent` print-mode event stream.
*
* The class deliberately exposes no generic event surface. Observing another
* event kind must first become part of the provider contract.
*/
export class CursorStreamWire {
private readonly decoder = new StringDecoder('utf8')
private readonly initialization = Promise.withResolvers<CursorSessionInfo>()
private readonly terminal = Promise.withResolvers<SubagentResult>()
private buffer = ''
private lastAssistantMessage: ContentBlock[] | undefined
private started = false
private closed = false
private initialized = false
private settled = false
/**
* Attach to one run's stdout stream.
* @param input - the child's stdout pipe; the wire owns its listeners only
* between {@link start} and {@link close}.
*/
constructor(private readonly input: Readable) {
// Both gates can reject before their awaiter exists: a startup failure
// rejects `initialization` and abandons `terminal`, and a failure racing
// publication rejects `terminal` before `settleRunResult` adopts it. Keep
// both observed so neither becomes an unhandled rejection.
void this.initialization.promise.catch(() => {})
void this.terminal.promise.catch(() => {})
}
/** Begin reading events. Idempotent. */
start(): void {
if (this.started) return
this.started = true
this.input.on('data', this.onData)
this.input.on('error', this.onError)
this.input.on('end', this.onEnd)
}
/**
* Detach listeners and fail both gates if they are still open. Idempotent,
* and safe before {@link start}.
*/
close(): void {
if (this.closed) return
this.closed = true
this.input.off('data', this.onData)
this.input.off('error', this.onError)
this.input.off('end', this.onEnd)
this.fail(new Error('subagent-cursor: cursor-agent event stream closed'))
}
/**
* Await the `system`/`init` event that gates run publication.
* @returns Cursor's own chat id and selected model for this run.
*/
ready(): Promise<CursorSessionInfo> {
return this.initialization.promise
}
/**
* Await the terminal `result` event.
* @returns the completed result; rejects for every non-success ending,
* malformed event, stream failure, or end of stream without a result.
*/
awaitResult(): Promise<SubagentResult> {
return this.terminal.promise
}
/**
* Snapshot the child's output for a cancelled or failed settlement.
* @returns the last non-empty assistant message, or `[]` when the child
* produced none. Without `--stream-partial-output` each `assistant` event
* is one complete message, so this is the seam's selection rule directly
* rather than a delta accumulation.
*/
collectOutput(): ContentBlock[] {
return this.lastAssistantMessage ?? []
}
private readonly onData = (chunk: Buffer): void => {
try {
// The wire owns stdout between `start` and `close` and never sets an
// encoding on it, so every chunk is bytes that may split a code point.
this.buffer += this.decoder.write(chunk)
for (;;) {
const newline = this.buffer.indexOf('\n')
if (newline < 0) break
const line = this.buffer.slice(0, newline)
this.buffer = this.buffer.slice(newline + 1)
if (line.trim().length > 0) this.handleLine(line)
}
} catch (error: unknown) {
/* v8 ignore next -- handleLine and the decoder throw only Error. */
this.fail(error instanceof Error ? error : new Error(String(error)))
}
}
private readonly onError = (error: Error): void => {
this.fail(error)
}
private readonly onEnd = (): void => {
this.fail(new Error('subagent-cursor: cursor-agent ended without a terminal result'))
}
private handleLine(line: string): void {
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch {
// The CLI's stdout is a process boundary, so a line that is not JSON is
// a protocol failure rather than something to skip: skipping it would
// hide a version whose stream this contract cannot read.
throw new Error('subagent-cursor: cursor-agent emitted a stdout line that is not JSON')
}
const event = object(parsed, 'event')
switch (event.type) {
case 'system':
if (event.subtype === 'init') this.handleInit(event)
return
case 'assistant': {
const blocks = messageText(event.message, 'assistant message')
if (blocks.length > 0) this.lastAssistantMessage = blocks
return
}
case 'result':
this.handleResult(event)
return
default:
// `user`, `tool_call`, and any event kind a newer CLI adds contribute
// nothing to this one-shot contract: publication is gated on `init`
// and the answer comes from `result`.
return
}
}
private handleInit(event: JsonObject): void {
if (typeof event.session_id !== 'string' || event.session_id.length === 0) {
throw new Error('subagent-cursor: cursor-agent emitted an invalid init session id')
}
this.initialized = true
this.initialization.resolve({
sessionId: event.session_id,
model: typeof event.model === 'string' ? event.model : undefined,
})
}
private handleResult(event: JsonObject): void {
const answer = event.result
if (
event.subtype !== 'success'
|| event.is_error === true
|| typeof answer !== 'string'
|| answer.trim().length === 0
) {
this.fail(new Error(`subagent-cursor: cursor-agent failed: ${resultFailureDetail(event)}`))
return
}
this.settled = true
if (!this.initialized) {
// A run that never announced itself cannot be published, so its answer
// has nowhere to go: fail startup instead of resolving a result for a
// run the caller was never handed.
const error = new Error('subagent-cursor: cursor-agent produced a result without announcing a session')
this.initialization.reject(error)
this.terminal.reject(error)
return
}
// The terminal event carries the full assistant answer, so it wins over
// the last streamed message for a completed run.
this.terminal.resolve({
output: [{ type: 'text', text: answer }],
stopReason: 'completed',
})
}
private fail(error: Error): void {
if (this.settled) return
this.settled = true
this.initialization.reject(error)
this.terminal.reject(error)
}
}

View File

@@ -0,0 +1,54 @@
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
LOADER_SMOKE_TEST_TIMEOUT_MS,
runLoaderSmoke,
} from '@deepseek-ai/dsh-loader-smoke'
const fixtureDir = fileURLToPath(new URL(
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-cursor/',
import.meta.url,
))
const driver = join(fixtureDir, 'driver.ts')
const configPath = join(fixtureDir, 'cordis.yml')
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
describe('Cursor provider public Loader composition', () => {
it('loads the opt-in package, one-shot task tool, and job controls without starting Cursor', async () => {
const { stdout, stderr } = await runLoaderSmoke({
label: 'subagent-cursor Loader composition',
tempDirPrefix: 'dsh-subagent-cursor-loader-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
env: {
// Loading the optional package must not probe or start a cursor-agent binary.
PATH: '',
},
})
expect(stderr).toBe('')
expect(JSON.parse(stdout)).toEqual({
providers: ['cursor'],
provider: {
name: 'cursor',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
},
tool: {
name: 'subagent_cursor',
parameterNames: ['description', 'prompt', 'run_in_background'],
required: ['description', 'prompt'],
},
jobTools: ['job_kill', 'job_list', 'job_output'],
starts: 0,
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,704 @@
import { PassThrough } from 'node:stream'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SubagentRuntime, { type SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {
SubprocessHandle,
SubprocessOutcome,
} from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as cursor from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
import {
assertDirectlySpawnable,
cursorAgentArgv,
DEFAULT_DISPOSE_GRACE_MS,
disposeCursorChild,
startCursorRun,
textTask,
type CursorRunSpec,
} from '../src/run.ts'
import { CursorStreamWire } from '../src/wire.ts'
type JsonObject = Record<string, unknown>
const EXECUTABLE = '/opt/cursor/cursor-agent'
const fakeParent = {
id: 'parent',
session: { header: { cwd: process.cwd() } },
} as unknown as Agent
function request(
prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
signal = new AbortController().signal,
) {
return { prompt, parent: fakeParent, signal }
}
async function nextTask(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
function initEvent(overrides: JsonObject = {}): JsonObject {
return {
type: 'system',
subtype: 'init',
apiKeySource: 'env',
cwd: process.cwd(),
session_id: 'chat-1',
model: 'cursor-model',
permissionMode: 'default',
...overrides,
}
}
function assistantEvent(content: unknown): JsonObject {
return {
type: 'assistant',
message: { role: 'assistant', content },
session_id: 'chat-1',
}
}
function resultEvent(overrides: JsonObject = {}): JsonObject {
return {
type: 'result',
subtype: 'success',
duration_ms: 12,
duration_api_ms: 10,
is_error: false,
result: 'the final answer',
session_id: 'chat-1',
...overrides,
}
}
/** Writes stream-json events the way the real CLI writes its stdout. */
class StreamPeer {
constructor(private readonly output: PassThrough) {}
send(...events: readonly JsonObject[]): void {
this.output.write(`${events.map(event => JSON.stringify(event)).join('\n')}\n`)
}
raw(text: string): void {
this.output.write(text)
}
}
interface FakeChildOptions {
readonly pid?: number
readonly exitOnTerminate?: boolean
readonly doneError?: Error
}
interface FakeChild {
readonly handle: SubprocessHandle
readonly peer: StreamPeer
readonly fromChild: PassThrough
readonly toChild: PassThrough
readonly settle: (outcome?: SubprocessOutcome) => void
readonly fail: (error: Error) => void
readonly terminate: () => void
readonly waitForExit: (signal?: AbortSignal) => Promise<boolean>
}
function fakeChild(options: FakeChildOptions = {}): FakeChild {
const fromChild = new PassThrough()
const toChild = new PassThrough()
const peer = new StreamPeer(fromChild)
let exited = false
let resolveDone!: (outcome: SubprocessOutcome) => void
let rejectDone!: (error: Error) => void
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
resolveDone = resolve
rejectDone = reject
})
const settle = (
outcome: SubprocessOutcome = { exitCode: 0, signal: null },
): void => {
if (exited) return
exited = true
resolveDone(outcome)
}
const fail = (error: Error): void => {
if (exited) return
exited = true
rejectDone(error)
}
if (options.doneError !== undefined) fail(options.doneError)
const terminate = vi.fn(() => {
if (options.exitOnTerminate !== false) settle()
})
const waitForExit = vi.fn(async (signal?: AbortSignal) => {
if (exited) return true
if (signal === undefined) {
await done.catch(() => {})
return true
}
return await new Promise<boolean>((resolve) => {
const onAbort = (): void => { resolve(false) }
signal.addEventListener('abort', onAbort, { once: true })
void done.then(
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
)
})
})
const handle: SubprocessHandle = {
pid: options.pid ?? 4321,
stdin: toChild,
stdout: fromChild,
stderr: undefined,
collected: {},
done,
terminate,
waitForExit,
}
return {
handle,
peer,
fromChild,
toChild,
settle,
fail,
terminate,
waitForExit,
}
}
function runSpec(
child: FakeChild,
overrides: Partial<CursorRunSpec> = {},
): CursorRunSpec {
return {
cwd: process.cwd(),
executable: EXECUTABLE,
env: {},
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
force: false,
trust: false,
spawn: () => child.handle,
...overrides,
}
}
async function publishRun(
child = fakeChild(),
signal = new AbortController().signal,
specOverrides: Partial<CursorRunSpec> = {},
) {
const starting = startCursorRun(request(undefined, signal), runSpec(child, specOverrides))
await nextTask()
child.peer.send(initEvent())
return { child, run: await starting }
}
function startedWire(): { readonly child: FakeChild; readonly wire: CursorStreamWire } {
const child = fakeChild()
const wire = new CursorStreamWire(child.handle.stdout!)
wire.start()
return { child, wire }
}
describe('task admission and command construction', () => {
it('accepts one or more text blocks and rejects empty, non-text, or option-shaped tasks', () => {
expect(textTask([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
expect(() => textTask([])).toThrow('must contain only text blocks')
expect(() => textTask([{ type: 'reasoning', text: 'x' }]))
.toThrow('must contain only text blocks')
expect(() => textTask([{ type: 'text', text: ' ' }])).toThrow('must not be empty')
expect(() => textTask([{ type: 'text', text: '--force me' }]))
.toThrow('must not begin with "-"')
})
it('refuses a Windows batch shim so task text never reaches a shell', () => {
expect(assertDirectlySpawnable('C:\\bin\\cursor-agent.exe', 'win32'))
.toBe('C:\\bin\\cursor-agent.exe')
expect(assertDirectlySpawnable('/usr/bin/cursor-agent', 'linux'))
.toBe('/usr/bin/cursor-agent')
// A shim is only unusable where it needs cmd.exe.
expect(assertDirectlySpawnable('/usr/bin/cursor-agent.cmd', 'linux'))
.toBe('/usr/bin/cursor-agent.cmd')
for (const shim of ['C:\\bin\\cursor-agent.cmd', 'C:\\bin\\cursor-agent.BAT']) {
expect(() => assertDirectlySpawnable(shim, 'win32')).toThrow('is a batch shim')
}
// The omitted platform reads the host, so the expectation follows it.
const underHostPlatform = (): string => assertDirectlySpawnable('C:\\bin\\cursor-agent.cmd')
if (process.platform === 'win32') {
expect(underHostPlatform).toThrow('is a batch shim')
} else {
expect(underHostPlatform()).toBe('C:\\bin\\cursor-agent.cmd')
}
})
it('builds the fixed print-mode argv and adds only selected permissions', () => {
const base = { executable: EXECUTABLE, cwd: '/work', force: false, trust: false }
expect(cursorAgentArgv(base, 'ship it')).toEqual([
EXECUTABLE,
'--print',
'--output-format',
'stream-json',
'--workspace',
'/work',
'ship it',
])
expect(cursorAgentArgv({ ...base, force: true, trust: true }, 'ship it')).toEqual([
EXECUTABLE,
'--print',
'--output-format',
'stream-json',
'--workspace',
'/work',
'--force',
'--trust',
'ship it',
])
})
})
describe('package contracts', () => {
it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const fiber = await ctx.plugin(cursor, {})
expect(ctx.subagents.getProvider('cursor')).toMatchObject({
name: 'cursor',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
})
expect(ctx.subagents.list()).toEqual(['cursor'])
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
await expect(ctx.plugin(cursor, { disposeGraceMs }))
.rejects.toThrow('disposeGraceMs must be a positive finite number')
}
await expect(ctx.plugin(cursor, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
await ctx.fiber.dispose()
})
it('requires a parent session cwd before resolving or spawning anything', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
await ctx.plugin(cursor, {})
await expect(ctx.subagents.start('cursor', {
prompt: [{ type: 'text', text: 'task' }],
parent: {
id: 'parent-without-cwd',
session: { header: {} },
} as unknown as Agent,
signal: new AbortController().signal,
})).rejects.toThrow(
'subagent-cursor: no working directory for the child — delegate from a parent session that has one',
)
expect(resolveExecutable).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('keeps the namespace export shape and package-owned empty invariant', async () => {
expect('default' in cursor).toBe(false)
expect(cursor.name).toBe('subagent-cursor')
expect(cursor.inject).toEqual(['subagents', 'subprocess'])
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(cursor)).toBe(cursor)
const dispose = vi.fn()
const register = vi.fn((
_packageName: string,
_installer: InvariantInstaller,
) => dispose)
const ctx = { invariants: { register } } as unknown as Context
await expect(invariant.apply(ctx)).resolves.toBe(dispose)
expect(register).toHaveBeenCalledWith(
'@deepseek-ai/dsh-subagent-cursor',
expect.any(Function),
)
const install = register.mock.calls[0]![1]
await install(new Context(), (message) => { throw new Error(message) })
expect(invariant.name).toBe('subagent-cursor-invariant')
expect(invariant.inject).toEqual(['invariants'])
})
})
describe('CursorStreamWire', () => {
it('gates on init, keeps the terminal answer, and ignores unrelated events', async () => {
const { child, wire } = startedWire()
wire.start()
child.peer.send(initEvent())
await expect(wire.ready()).resolves.toEqual({
sessionId: 'chat-1',
model: 'cursor-model',
})
child.peer.send(
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'task' }] } },
{ type: 'tool_call', subtype: 'started', call_id: 'c1', tool_call: {} },
{ type: 'system', subtype: 'usage', tokens: 12 },
assistantEvent([{ type: 'text', text: 'thinking out loud' }]),
{ type: 'newer_cli_event', payload: 1 },
)
await nextTask()
expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'thinking out loud' }])
child.peer.send(resultEvent())
await expect(wire.awaitResult()).resolves.toEqual({
output: [{ type: 'text', text: 'the final answer' }],
stopReason: 'completed',
})
})
it('reads events split across chunks and skips blank lines', async () => {
const { child, wire } = startedWire()
const frame = JSON.stringify(initEvent())
child.peer.raw(`\n \n${frame.slice(0, 10)}`)
await nextTask()
child.peer.raw(`${frame.slice(10)}\n`)
await expect(wire.ready()).resolves.toMatchObject({ sessionId: 'chat-1' })
expect(wire.collectOutput()).toEqual([])
})
it('reports an absent model rather than inventing one', async () => {
const { child, wire } = startedWire()
child.peer.send(initEvent({ model: 42 }))
await expect(wire.ready()).resolves.toEqual({
sessionId: 'chat-1',
model: undefined,
})
})
it('keeps the last non-empty assistant message and drops non-text blocks', async () => {
const { child, wire } = startedWire()
child.peer.send(
initEvent(),
assistantEvent([{ type: 'text', text: 'first' }]),
assistantEvent([{ type: 'image', source: {} }]),
assistantEvent([]),
assistantEvent([{ type: 'text', text: 'second' }, { type: 'image', source: {} }]),
)
await nextTask()
expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'second' }])
})
it('fails closed on every unusable terminal result', async () => {
for (const [overrides, detail] of [
[{ subtype: 'error' }, 'terminal result subtype error'],
[{ subtype: 7 }, 'terminal result subtype unknown'],
[{ is_error: true }, 'success result was marked as an error'],
[{ result: 12 }, 'success result was marked as an error'],
[{ result: ' ' }, 'success result was marked as an error'],
] as const) {
const { child, wire } = startedWire()
child.peer.send(initEvent(), resultEvent(overrides))
await expect(wire.awaitResult()).rejects.toThrow(detail)
}
})
it('refuses a result that arrives without an announced session', async () => {
const { child, wire } = startedWire()
child.peer.send(resultEvent())
await expect(wire.ready()).rejects.toThrow('without announcing a session')
await expect(wire.awaitResult()).rejects.toThrow('without announcing a session')
})
it('treats malformed stdout as a protocol failure', async () => {
for (const [line, detail] of [
['not json at all', 'a stdout line that is not JSON'],
['[1,2]', 'an invalid event'],
['null', 'an invalid event'],
[JSON.stringify({ type: 'system', subtype: 'init', session_id: '' }), 'an invalid init session id'],
[JSON.stringify({ type: 'assistant', message: 'text' }), 'an invalid assistant message'],
[JSON.stringify(assistantEvent('not an array')), 'an invalid assistant message content'],
] as const) {
const { child, wire } = startedWire()
child.peer.raw(`${line}\n`)
await expect(wire.awaitResult()).rejects.toThrow(detail)
}
})
it('fails pending gates on stream error, end of stream, and close', async () => {
const broken = startedWire()
broken.child.fromChild.emit('error', new Error('stdout broke'))
await expect(broken.wire.awaitResult()).rejects.toThrow('stdout broke')
const ended = startedWire()
ended.child.fromChild.end()
await expect(ended.wire.awaitResult()).rejects.toThrow('ended without a terminal result')
const closed = startedWire()
closed.wire.close()
closed.wire.close()
await expect(closed.wire.ready()).rejects.toThrow('event stream closed')
// A completed run keeps its result across teardown and end of stream.
const done = startedWire()
done.child.peer.send(initEvent(), resultEvent())
await expect(done.wire.awaitResult()).resolves.toMatchObject({ stopReason: 'completed' })
done.wire.close()
done.child.fromChild.end()
await expect(done.wire.awaitResult()).resolves.toMatchObject({ stopReason: 'completed' })
})
it('is safe to close before it is started', async () => {
const child = fakeChild()
const wire = new CursorStreamWire(child.handle.stdout!)
wire.close()
await expect(wire.ready()).rejects.toThrow('event stream closed')
})
})
describe('run lifecycle and quiescence', () => {
it('spawns the fixed command, publishes after init, and disposes once', async () => {
const child = fakeChild()
const spawn = vi.fn(() => child.handle)
const starting = startCursorRun(
request(),
runSpec(child, { spawn, force: true, trust: true, disposeGraceMs: 40, env: { CURSOR_API_KEY: 'fake' } }),
)
await nextTask()
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: [
EXECUTABLE,
'--print',
'--output-format',
'stream-json',
'--workspace',
process.cwd(),
'--force',
'--trust',
'do the task',
],
cwd: process.cwd(),
graceMs: 40,
env: { CURSOR_API_KEY: 'fake' },
}))
child.peer.send(initEvent())
const run = await starting
expect(run.id).toMatch(/^[0-9a-f-]{36}$/)
expect(run.localAgent).toBeUndefined()
child.peer.send(resultEvent())
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'the final answer' }],
stopReason: 'completed',
})
const first = run.dispose()
expect(run.dispose()).toBe(first)
await first
expect(child.terminate).toHaveBeenCalledTimes(1)
})
it('closes stdin so an unattended prompt cannot stall the child', async () => {
const { child, run } = await publishRun()
expect(child.toChild.writableEnded).toBe(true)
child.peer.send(resultEvent())
await run.result
await run.dispose()
})
it('settles a cancelled run as aborted with the output collected so far', async () => {
const controller = new AbortController()
const child = fakeChild({ exitOnTerminate: false })
const { run } = await publishRun(child, controller.signal)
child.peer.send(assistantEvent([{ type: 'text', text: 'partial work' }]))
await nextTask()
controller.abort(new Error('parent stopped waiting'))
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'partial work' }],
stopReason: 'aborted',
})
child.settle({ exitCode: null, signal: 'SIGTERM' })
await run.dispose()
})
it('flattens a child exit and a protocol failure after publication', async () => {
const exited = await publishRun()
exited.child.settle({ exitCode: 2, signal: null })
await expect(exited.run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
await exited.run.dispose()
const onError = vi.fn<(error: Error, stopReason: SubagentStopReason) => void>()
const malformed = await publishRun(fakeChild(), new AbortController().signal, { onError })
malformed.child.peer.raw('garbage\n')
await expect(malformed.run.result).resolves.toMatchObject({ stopReason: 'error' })
expect(onError).toHaveBeenCalledTimes(1)
const [reported, reportedReason] = onError.mock.calls[0]!
expect(reported.message).toContain('not JSON')
expect(reportedReason).toBe('error')
await malformed.run.dispose()
})
it('rejects before spawn when the request is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const child = fakeChild()
const spawn = vi.fn(() => child.handle)
await expect(startCursorRun(request(undefined, controller.signal), runSpec(child, { spawn })))
.rejects.toThrow('aborted before cursor-agent startup')
expect(spawn).not.toHaveBeenCalled()
})
it('rolls the child back when startup fails or is aborted before publication', async () => {
const failed = fakeChild()
const failing = startCursorRun(request(), runSpec(failed))
await nextTask()
failed.peer.raw('not json\n')
await expect(failing).rejects.toThrow('not JSON')
expect(failed.terminate).toHaveBeenCalledTimes(1)
const controller = new AbortController()
const aborted = fakeChild()
const aborting = startCursorRun(request(undefined, controller.signal), runSpec(aborted))
await nextTask()
controller.abort()
await expect(aborting).rejects.toThrow('aborted before run publication')
expect(aborted.terminate).toHaveBeenCalledTimes(1)
const exited = fakeChild()
const exiting = startCursorRun(request(), runSpec(exited))
await nextTask()
exited.settle({ exitCode: 3, signal: null })
await expect(exiting).rejects.toThrow('exited before the run settled')
// A failed spawn reports pid -1, so rollback has no tree to signal.
const broken = fakeChild({ pid: -1, doneError: new Error('spawn observer failed') })
await expect(startCursorRun(request(), runSpec(broken)))
.rejects.toThrow('spawn observer failed')
expect(broken.terminate).not.toHaveBeenCalled()
})
it('reports a startup failure whose cleanup also failed as an aggregate', async () => {
const child = fakeChild()
child.handle.waitForExit = vi.fn(() => Promise.reject(new Error('tree never exited')))
const starting = startCursorRun(request(), runSpec(child))
await nextTask()
child.peer.raw('not json\n')
const failure = await starting.catch((error: unknown) => error)
expect(failure).toBeInstanceOf(AggregateError)
expect((failure as AggregateError).message)
.toContain('startup failed and cursor-agent cleanup also failed')
})
it('keeps overlapping runs isolated', async () => {
const first = await publishRun()
const second = await publishRun()
first.child.peer.send(resultEvent({ result: 'first answer' }))
second.child.peer.send(resultEvent({ subtype: 'error' }))
await expect(first.run.result).resolves.toMatchObject({
output: [{ type: 'text', text: 'first answer' }],
stopReason: 'completed',
})
await expect(second.run.result).resolves.toMatchObject({ stopReason: 'error' })
await first.run.dispose()
await second.run.dispose()
})
it('uses the registered provider config, the resolved executable, and logs flattened errors', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const child = fakeChild()
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
.mockResolvedValue(EXECUTABLE)
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => {
warnings.push(String(message))
}) as typeof ctx.logger.warn
await ctx.plugin(cursor, {
env: { CURSOR_API_KEY: 'fake' },
disposeGraceMs: 25,
force: true,
})
const starting = ctx.subagents.start('cursor', {
prompt: [{ type: 'text', text: 'task' }],
parent: fakeParent,
signal: new AbortController().signal,
})
await nextTask()
child.peer.send(initEvent())
const run = await starting
child.settle({ exitCode: 1, signal: null })
await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
expect(resolveExecutable).toHaveBeenCalledWith(
'cursor-agent',
{ CURSOR_API_KEY: 'fake' },
expect.any(AbortSignal),
)
expect(spawn).toHaveBeenCalledTimes(1)
const spawned = spawn.mock.calls[0]![0]
expect(spawned.argv).toContain('--force')
expect(spawned.env).toEqual({ CURSOR_API_KEY: 'fake' })
expect(spawned.graceMs).toBe(25)
expect(spawned.cwd).toBe(process.cwd())
expect(warnings).toEqual([
expect.stringContaining('subagent-cursor: child run failed (error):'),
])
await run.dispose().catch(() => {})
await ctx.fiber.dispose()
})
})
describe('disposeCursorChild', () => {
it('closes the stream, terminates, and waits for the managed tree', async () => {
const child = fakeChild()
const wire = new CursorStreamWire(child.handle.stdout!)
wire.start()
await disposeCursorChild(wire, child.handle)
expect(child.terminate).toHaveBeenCalledTimes(1)
expect(child.waitForExit).toHaveBeenCalledTimes(1)
})
it('does not finish disposal before the managed tree exits', async () => {
const child = fakeChild({ exitOnTerminate: false })
const wire = new CursorStreamWire(child.handle.stdout!)
let settled = false
const disposing = disposeCursorChild(wire, child.handle).then(() => { settled = true })
await nextTask()
expect(settled).toBe(false)
child.settle()
await disposing
expect(settled).toBe(true)
})
it('skips signalling a failed spawn and contains its observer rejection', async () => {
const child = fakeChild({ pid: -1, doneError: new Error('spawn failed') })
const wire = new CursorStreamWire(child.handle.stdout!)
await expect(disposeCursorChild(wire, child.handle)).resolves.toBeUndefined()
expect(child.terminate).not.toHaveBeenCalled()
})
it('reports a direct-child observer failure from a live tree', async () => {
const child = fakeChild({ exitOnTerminate: false })
const wire = new CursorStreamWire(child.handle.stdout!)
const disposing = disposeCursorChild(wire, child.handle)
child.fail(new Error('observer failed'))
await expect(disposing).rejects.toThrow('observer failed')
})
})

View File

@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../subagent"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -19,6 +19,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldContextOf } from '@deepseek-ai/dsh-session-projection'
import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
import { SubagentError } from './error.ts'
@@ -395,7 +396,7 @@ async function resolveColdIdentity(
}
let identity: SubagentIdentityProjection | null | undefined
try {
identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
identity = projections.restore({}, inspected.events, 0, foldContextOf(inspected.meta)).snapshot.values.subagent
} catch {
// The restore folds EVERY registered unit over this child's log, so any
// unit's fold or schema can reject damaged payloads — deterministic data

View File

@@ -2,9 +2,13 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection'
import SubagentRuntime from '../src/index.ts'
import { subagentTimingProjectionDefinition } from '../src/projection.ts'
/** The unit under test ignores the fold context; this session inherits no fork prefix. */
const applyTiming = (state: Parameters<typeof subagentTimingProjectionDefinition.apply>[0], event: SessionEvent) =>
subagentTimingProjectionDefinition.apply(state, event, foldContextOf({}))
function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent {
return { type, seq, time, data: {} } as SessionEvent
@@ -12,7 +16,7 @@ function event(type: SessionEvent['type'], seq: number, time: number): SessionEv
function fold(events: SessionEvent[]) {
let state = subagentTimingProjectionDefinition.init()
for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item)
for (const item of events) state = applyTiming(state, item)
return subagentTimingProjectionDefinition.view(state)
}
@@ -59,19 +63,19 @@ describe('subagent timing projection', () => {
it('ignores completed pre-descriptor turns and unrelated events', () => {
const initial = subagentTimingProjectionDefinition.init()
expect(subagentTimingProjectionDefinition.apply(
expect(applyTiming(
initial,
event('assistant/chunk', 0, 1),
)).toBe(initial)
expect(subagentTimingProjectionDefinition.apply(
expect(applyTiming(
initial,
event('turn/end', 1, 2),
)).toBe(initial)
const descriptor = subagentTimingProjectionDefinition.apply(
const descriptor = applyTiming(
initial,
event('subagent/descriptor', 2, 3),
)
expect(subagentTimingProjectionDefinition.apply(
expect(applyTiming(
descriptor,
event('turn/end', 3, 4),
)).toBe(descriptor)