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
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:
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 服务。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 服务。
|
||||
|
||||
|
||||
6
packages/subagent/subagent-cursor/README.i18n.yaml
Normal file
6
packages/subagent/subagent-cursor/README.i18n.yaml
Normal 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
|
||||
106
packages/subagent/subagent-cursor/README.md
Normal file
106
packages/subagent/subagent-cursor/README.md
Normal 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.
|
||||
106
packages/subagent/subagent-cursor/README.zh.md
Normal file
106
packages/subagent/subagent-cursor/README.zh.md
Normal 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 深度强制均被共享服务针对本提供方拒绝。
|
||||
- **没有挂钟超时或副作用回滚** —— 由调用方取消长时间工作,取消前已改动的文件或外部系统不会被还原。
|
||||
59
packages/subagent/subagent-cursor/package.json
Normal file
59
packages/subagent/subagent-cursor/package.json
Normal 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:^"
|
||||
}
|
||||
}
|
||||
130
packages/subagent/subagent-cursor/src/index.ts
Normal file
130
packages/subagent/subagent-cursor/src/index.ts
Normal 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))
|
||||
}
|
||||
30
packages/subagent/subagent-cursor/src/invariant.ts
Normal file
30
packages/subagent/subagent-cursor/src/invariant.ts
Normal 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 */
|
||||
267
packages/subagent/subagent-cursor/src/run.ts
Normal file
267
packages/subagent/subagent-cursor/src/run.ts
Normal 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 */
|
||||
}
|
||||
265
packages/subagent/subagent-cursor/src/wire.ts
Normal file
265
packages/subagent/subagent-cursor/src/wire.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
704
packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts
Normal file
704
packages/subagent/subagent-cursor/tests/subagent-cursor.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
42
packages/subagent/subagent-cursor/tsconfig.json
Normal file
42
packages/subagent/subagent-cursor/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user