dsh-cursor-dev 0.12.3 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,7 +55,7 @@ flowchart LR
55
55
  3. 按 DSH 提示完成加载;涉及后台代码更新时重启 DSH,然后刷新页面。在 Cursor 面板中选择模型并保存。
56
56
  4. 明确告诉父模型使用 `cursor_code`,或者用下面的斜杠命令派发第一个任务。
57
57
 
58
- **版本说明(0.12.3):** 本版显式声明支持 DSH `0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-rc.1` 和 `0.2.1-rc.2`,开发依赖 `@deepseek-ai/dsh` 精确固定为 `0.2.1-rc.2`,保留 `>=0.2.1 <0.3.0` 的稳定版本支持。保留额度耗尽保护:调用 `cursor_code` 或通过原生子智能体派发之前先检查 Cursor 用量,总、Auto、API 三个额度都到上限时直接拒绝并说明原因,不再把任务派出去后中途失败;用量接口暂时取不到时不拦截调用。`cursor_subagent` 也会生成可点击的对话子会话,面板里点运行中或已完成的行都能查看过程,不再报「没有可恢复的会话」。面板保留额度四张卡片置顶、代理输入框在模型选择上方、模型目录与来源说明折叠收起的布局。插件市场已收录本插件,卡片版本随目录探测更新;npm 包名为 `dsh-cursor-dev`,实际已发布版本以 npm 的 `latest` 标签为准。要跟进后续改动请用 Git 来源;`.tgz` 是某一刻的固定快照。详见[安装来源与版本](docs/INSTALL.md#2-选择安装来源)。
58
+ **版本说明(0.12.4):** 本版把两个原本要手改配置才能调的开关搬进了面板的模型卡片。`maxChildDepth` 默认值从 `1` 改为 `2`——Cursor 是叶节点(CLI 里没有 DSH 工具,不可能再往下派),`2` 就是实际上限,旧的默认值只是把「子智能体派 Cursor」这件事拒掉了;该值改为每次调用现读,面板「子代理深度」的选择优先于配置,下一次调用生效、不用重载插件,越界的值(如手改成 `9`)当作「没设」回落到配置而不是被截断。新增「Cursor 署名」开关:Cursor 会给子智能体做的 commit 追加 `Co-authored-by: Cursor` 并在它开的 PR 上署名,而这个开关没有 CLI 参数也没有环境变量,只存在于 Cursor 自己的 `cli-config.json`,所以插件替你写那个文件——只改 `attribution` 两项、其余键原样保留、临时文件加改名、解析不了的文件绝不覆盖,默认关闭;保存后立即写入并在每次启动 CLI 前再确认一次(CLI 只在进程启动时读一次配置)。面板布局调整为模型选择器与保存按钮置顶、代理输入框移到它所解释的状态行下方,并删掉「来源与出口」折叠、并入「模型目录、搜索与来源」。保留额度耗尽保护:调用 `cursor_code` 或通过原生子智能体派发之前先检查 Cursor 用量,总、Auto、API 三个额度都到上限时直接拒绝并说明原因,不再把任务派出去后中途失败;用量接口暂时取不到时不拦截调用。`cursor_subagent` 也会生成可点击的对话子会话,面板里点运行中或已完成的行都能查看过程,不再报「没有可恢复的会话」。DSH 兼容性不变,仍显式声明支持 `0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-rc.1`、`0.2.1-rc.2` 以及 `>=0.2.1 <0.3.0` 的稳定版本,开发依赖固定为 `0.2.1-rc.2`。插件市场已收录本插件,卡片版本随目录探测更新;npm 包名为 `dsh-cursor-dev`,实际已发布版本以 npm 的 `latest` 标签为准。要跟进后续改动请用 Git 来源;`.tgz` 是某一刻的固定快照。详见[安装来源与版本](docs/INSTALL.md#2-选择安装来源)。
59
59
 
60
60
  运行要求:DSH `0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-rc.1`、`0.2.1-rc.2` 或 `>=0.2.1 <0.3.0` 的稳定版本(已在 `0.2.1-rc.2` 上验证);Node.js `^22.19.0 || >=24.0.0`;已登录且可访问服务的 Cursor CLI。原生子智能体功能需要 DSH 的 `subagents`、`agents` 服务,后台任务还需要对应任务服务,建议使用当前 DSH 桌面版。CLI 安装方法见 [Cursor 官方文档](https://cursor.com/docs/cli)。
61
61
 
@@ -163,7 +163,7 @@ flowchart LR
163
163
  | `toolName` | `cursor_code` | 编码工具的注册名 |
164
164
  | `usageUi` | `true` | 是否挂载状态、额度和面板 HTTP 路由 |
165
165
  | `usageBaseUrl` | `https://api2.cursor.sh` | Cursor 额度 API 地址 |
166
- | `maxChildDepth` | `1` | Cursor 子会话允许达到的深度上限,调用方深度必须小于该值 |
166
+ | `maxChildDepth` | `2` | Cursor 子会话允许达到的深度上限,调用方深度必须小于该值(面板「子代理深度」可覆盖) |
167
167
 
168
168
  插件自动探测 CLI 的 PATH 和已知安装目录。探测失败时,可在插件配置里设置 `command` 指向可启动的二进制、安装根目录、`agent.cmd`,或 `"node.exe" "index.js"`;非空时只使用该路径,不再回退到自动探测。安装排错以[指南](docs/INSTALL.md#6-常见问题)为准。
169
169
 
@@ -183,10 +183,30 @@ flowchart LR
183
183
 
184
184
  模型卡片下方还有可搜索的模型浏览器:点一行设为面板模型,右边的开关决定它是否出现在上方下拉框里。关掉的模型只是藏起来(写入 `disabledModels`),点「保存」后生效,随时可以再打开——这样在完整目录有上百个模型时也能只留下写业务常用的几条。
185
185
 
186
- `maxChildDepth` 限制的是**谁能启动 Cursor 子会话**:深度为 `0` 的顶层智能体是正常调用方,深度 `1` 的子智能体再调用就会被拒绝(默认上限 `1`,即只允许父智能体创建子智能体)。拒绝发生在创建任何进程或子会话之前,`cursor_code` 立即返回 `sessionKey: "depth-refused"`,`cursor_subagent` 抛出同类错误,错误信息里带有当前深度和上限。宿主本身对 `subagent` / `subagent_fork` 的深度限制(面板「子智能体 → 运行限制 → 最大递归深度」)仍然生效;`cursor` provider 声明 `depthLimit: false`,无法接收数值型 `maxDepth`,所以这道限制由本插件自己执行。把 `maxChildDepth` 设为 `0` 可以完全禁止子会话,设为 `2` 或更大则允许更深一层。镜像历史(面板里的记录)不受此限制,仍可正常打开查看。
186
+ `maxChildDepth` 限制的是**谁能启动 Cursor 子会话**:深度为 `0` 的顶层智能体是正常调用方,深度 `1` 的子智能体再调用就会被拒绝。默认值是 `2`,即允许父智能体和子智能体都创建 Cursor 子会话——Cursor 是个叶节点(CLI 里没有 DSH 工具,它不可能再往下派),所以 `2` 就是实际上限,再大的值和 `2` 完全等价。这个值可以在面板的「子代理深度」里改,面板的选择优先于配置;改完下一次调用生效,不需要重载插件。拒绝发生在创建任何进程或子会话之前,`cursor_code` 立即返回 `sessionKey: "depth-refused"`,`cursor_subagent` 抛出同类错误,错误信息里带有当前深度和上限。宿主本身对 `subagent` / `subagent_fork` 的深度限制(面板「子智能体 → 运行限制 → 最大递归深度」)仍然生效;`cursor` provider 声明 `depthLimit: false`,无法接收数值型 `maxDepth`,所以这道限制由本插件自己执行。把 `maxChildDepth` 设为 `0` 可以完全禁止子会话,设为 `2` 或更大则允许更深一层。镜像历史(面板里的记录)不受此限制,仍可正常打开查看。
187
187
 
188
188
  插件设置与会话映射优先写入环境变量 `DSH_CURSOR_DEV_DATA` 指定的目录;否则使用 `%LOCALAPPDATA%/dsh-cursor-dev`;没有该环境变量时使用 DSH 提供的 `baseDir` 下的 `.data`(再无 `baseDir` 时回退到进程当前目录)。Cursor 登录和原始会话由 Cursor 自己保存。面板写操作包含同源检查,HTTP 可访问范围由 DSH 的监听配置决定。
189
189
 
190
+ ### Cursor 署名
191
+
192
+ Cursor 默认会在子智能体做的提交上追加 `Co-authored-by: Cursor`,在它开的 PR 上署名。这些提交发生在别人的仓库里,而仓库的读者并没有同意这件事,所以面板里默认**关掉**:模型卡片底部的「Cursor 署名」选择「不加 Cursor 署名」,插件在下一次启动 CLI 前把 Cursor 自己的配置文件改成不署名。
193
+
194
+ 这个开关没有 CLI 参数,也没有环境变量,只存在于 Cursor 的 `cli-config.json`:
195
+
196
+ ```json
197
+ { "attribution": { "attributeCommitsToAgent": false, "attributePRsToAgent": false } }
198
+ ```
199
+
200
+ 文件位置按 Cursor 自己的顺序解析:`CURSOR_CONFIG_DIR` → `XDG_CONFIG_HOME/cursor` → `~/.cursor`。插件只改 `attribution` 这两项,权限、模型、登录信息等其他键原样保留,写入走临时文件 + 改名;解析不了的文件不会被覆盖(那是 Cursor 自己的报错,不该由插件掩盖)。面板的三个选项:
201
+
202
+ | 选项 | 行为 |
203
+ | --- | --- |
204
+ | 不加 Cursor 署名(默认) | 把两项都写成 `false` |
205
+ | 保留 Cursor 署名 | 把两项都写成 `true` |
206
+ | 跟随 Cursor 自己的配置 | 插件不碰这个文件,你用自己的 Cursor 设置 |
207
+
208
+ 保存后立即写入,并在每次启动 CLI 前再确认一次——因为 CLI 只在进程启动时读一次配置,已经在跑的会话要等新会话才生效。选择器下面那行「Cursor 当前:…」显示的是配置文件的实际状态,不是你的选择:用了「跟随」或者写入失败时,两者会不一样。团队管理员在服务端禁用署名(`attributionControls`)时,本地怎么写都不会恢复署名。
209
+
190
210
  ### 开发与验证
191
211
 
192
212
  ```sh
@@ -220,7 +240,7 @@ See the [installation guide](docs/INSTALL.md) for detailed setup and troubleshoo
220
240
  3. Restart DSH when backend changes require it, then refresh the page. Open the Cursor panel under `/plugins/dsh-cursor-dev/`, select a model and save it. The history list keeps one row per run forever, so it sits in a height-capped scroll box and folds away when you click its heading (the choice is remembered); a row's **Delete** link clears that run's index record, journal and mirrored child — the same chain `resetSession` uses — and arms on the first click so the irreversible second click is deliberate.
221
241
  4. Ask the parent to use `cursor_code`, or send `/cursor-subagent` followed by a complete task. The command dispatches directly and hands a completion or failure summary back to the original parent.
222
242
 
223
- Version `0.12.3` explicitly declares support for DSH `0.2.0-rc.1`, `0.2.0-rc.2`, `0.2.1-rc.1` and `0.2.1-rc.2`, and pins the `@deepseek-ai/dsh` development dependency to exactly `0.2.1-rc.2`. Existing support for stable versions `>=0.2.1 <0.3.0` remains. Quota exhaustion protection is retained: before `cursor_code` or a native subagent call starts, the plugin reads Cursor usage and refuses the call, explaining why, when the total, Auto and API buckets are all at their limit, instead of dispatching a run that later fails mid-way. A usage lookup that cannot be completed never blocks a call. `cursor_subagent` also creates a clickable transcript child, so rows for running and finished runs open in the panel instead of reporting that no durable conversation exists. The panel keeps the four quota cards first, the proxy input above the model picker, and the model catalogue and source/egress notes folded. The dsh-market catalog already lists this plugin and updates its displayed version on subsequent probes. The public npm package is `dsh-cursor-dev`; its `latest` tag identifies the published version. Use the Git source to follow later changes; a `.tgz` is a fixed snapshot.
243
+ Version `0.12.4` moves two settings that used to need a hand-edited profile, or a hand-edited file belonging to another program, into the model card. `maxChildDepth` now defaults to `2` instead of `1`: a Cursor run is a leaf — the CLI has no DSH tools, so it can never delegate further, which makes `2` the real ceiling and the old default refused the delegation people actually try. The cap is read per call, the panel's **Subagent depth** picker wins over the config, it applies on the next call without a reload, and an out-of-range value (a hand-edited `9`) is treated as unset rather than clamped. A new **Cursor credit** switch exists because Cursor appends `Co-authored-by: Cursor` to the commits its agent makes and credits itself on the pull requests it opens, and that switch has no flag and no environment variable — it lives only in Cursor's own `cli-config.json`, so the plugin writes that file: exactly the two `attribution` keys, every other key preserved, through a temporary file and a rename, and a file it cannot parse is never replaced. It is off by default, applied on save and re-checked before every spawn, because the CLI reads its settings once at startup. The panel now leads the model card with the model picker and its save button, puts the proxy box under the status line it is about, and folds the former "sources" note into the catalogue fold. Quota exhaustion protection is retained: before `cursor_code` or a native subagent call starts, the plugin reads Cursor usage and refuses the call, explaining why, when the total, Auto and API buckets are all at their limit, instead of dispatching a run that later fails mid-way. A usage lookup that cannot be completed never blocks a call. `cursor_subagent` also creates a clickable transcript child, so rows for running and finished runs open in the panel instead of reporting that no durable conversation exists. DSH compatibility is unchanged: support is declared for `0.2.0-rc.1`, `0.2.0-rc.2`, `0.2.1-rc.1`, `0.2.1-rc.2` and stable versions `>=0.2.1 <0.3.0`, with the `@deepseek-ai/dsh` development dependency pinned to exactly `0.2.1-rc.2`. The dsh-market catalog already lists this plugin and updates its displayed version on subsequent probes. The public npm package is `dsh-cursor-dev`; its `latest` tag identifies the published version. Use the Git source to follow later changes; a `.tgz` is a fixed snapshot.
224
244
 
225
245
  Requirements: DSH `0.2.0-rc.1`, `0.2.0-rc.2`, `0.2.1-rc.1`, `0.2.1-rc.2` or a stable version `>=0.2.1 <0.3.0` (verified against `0.2.1-rc.2`); Node.js `^22.19.0 || >=24.0.0`; an authenticated Cursor CLI account with service access. Native dispatch additionally needs DSH's `subagents` and `agents` services, and background runs need the host's job services. A current DSH desktop installation is recommended.
226
246
 
@@ -243,7 +263,7 @@ Cursor executes externally. From DSH 0.2.1 on, the host writes a single non-inte
243
263
 
244
264
  The parent remains responsible for reviewing the actual changes and verification evidence. File reports have scan limits and are not a substitute for final workspace checks. Cursor model availability and usage depend on the Cursor account; the parent model uses its separate DSH provider configuration.
245
265
 
246
- `maxChildDepth` (default `1`) caps who may start a Cursor child session. A top-level agent at depth `0` is the intended caller; a subagent at depth `1` that calls `cursor_code` or `cursor_subagent` is refused before any process, child session, or reset is created — `cursor_code` returns `sessionKey: "depth-refused"` and the error names both the current depth and the cap. This exists because the `cursor` provider declares `depthLimit: false` and cannot accept a numeric `maxDepth` from the host's `tool-subagent`, so the plugin enforces the limit itself. Set it to `0` to forbid Cursor children entirely, or higher to allow deeper nesting. The host's own delegation limit for `subagent` / `subagent_fork` (panel → Subagents → Run limits → maximum recursion depth) still applies independently. Opening a mirrored child conversation in the panel is a read-only record and is not affected.
266
+ `maxChildDepth` (default `2`) caps who may start a Cursor child session. A top-level agent at depth `0` is the intended caller; a subagent at depth `1` that calls `cursor_code` or `cursor_subagent` is refused before any process, child session, or reset is created — `cursor_code` returns `sessionKey: "depth-refused"` and the error names both the current depth and the cap. This exists because the `cursor` provider declares `depthLimit: false` and cannot accept a numeric `maxDepth` from the host's `tool-subagent`, so the plugin enforces the limit itself. Set it to `0` to forbid Cursor children entirely, or `1` to keep delegation to the top-level agent only. The default of `2` lets a subagent start one too, because a Cursor run is a leaf — the CLI has no DSH tools, so it can never delegate further and every value above `2` behaves exactly like `2`. The settings panel's **Subagent depth** picker writes the same value and wins over this config; a change applies on the next call without reloading the plugin. The host's own delegation limit for `subagent` / `subagent_fork` (panel → Subagents → Run limits → maximum recursion depth) still applies independently. Opening a mirrored child conversation in the panel is a read-only record and is not affected.
247
267
 
248
268
  ### Proxy
249
269
 
@@ -259,6 +279,26 @@ Why it exists: the Cursor CLI reads `HTTPS_PROXY` / `HTTP_PROXY` / `https_proxy`
259
279
 
260
280
  Leaving the box empty already fixes it, so no configuration is normally needed: the plugin resolves the route, writes `HTTPS_PROXY`/`HTTP_PROXY` (and the lowercase spellings) into every child process it starts, and shows the result as **子进程出口** at the end of the *available models* line. Saving takes effect on the next spawn rather than the next restart, and a value that cannot be parsed is reported on that same line instead of being silently ignored.
261
281
 
282
+ ### Cursor attribution
283
+
284
+ Cursor appends `Co-authored-by: Cursor` to the commits its agent makes, and credits itself on the pull requests it opens. Those commits land in repositories whose readers never agreed to that, so the panel turns it **off** by default: the **Cursor credit** picker at the bottom of the model card keeps the CLI's own settings file saying "no attribution", and the plugin applies that before the next CLI process starts.
285
+
286
+ There is no flag and no environment variable for this — the only switch is Cursor's `cli-config.json`:
287
+
288
+ ```json
289
+ { "attribution": { "attributeCommitsToAgent": false, "attributePRsToAgent": false } }
290
+ ```
291
+
292
+ The file lives where Cursor looks for it: `CURSOR_CONFIG_DIR`, else `XDG_CONFIG_HOME/cursor`, else `~/.cursor`. The plugin writes only those two keys — permissions, models, and auth information are preserved byte for byte — through a temporary file and a rename, and a file it cannot parse is left alone rather than replaced (that is Cursor's error to report). The three choices:
293
+
294
+ | Choice | Effect |
295
+ | --- | --- |
296
+ | No Cursor credit (default) | writes both flags as `false` |
297
+ | Credit Cursor | writes both flags as `true` |
298
+ | Leave Cursor own setting alone | the plugin never touches the file |
299
+
300
+ Saving applies immediately, and every spawn re-checks the file, because the CLI reads its configuration once at startup — a session already running keeps the old value until a new session starts. The `Cursor now:` line under the picker reports the state of the file, not your choice: with "leave alone" or after a failed write, the two differ. When a team administrator disables attribution server-side (`attributionControls`), no local setting turns it back on.
301
+
262
302
  ## License
263
303
 
264
304
  [MIT](LICENSE)
package/docs/INSTALL.md CHANGED
@@ -38,7 +38,7 @@ agent status --format json
38
38
 
39
39
  ## 2. 选择安装来源
40
40
 
41
- **当前源码版本:** `0.12.3`,显式声明支持 DSH `0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-rc.1` 和 `0.2.1-rc.2`,开发依赖 `@deepseek-ai/dsh` 精确固定为 `0.2.1-rc.2`,保留 `>=0.2.1 <0.3.0` 的稳定版本支持。同时保留额度耗尽保护(三个额度都到上限时提前拒绝,用量取不到时不拦截)、可点击的子会话轨迹,以及额度置顶、代理输入框在模型选择上方的面板布局。npm 包名为 `dsh-cursor-dev`,实际已发布版本以 npm 的 `latest` 标签为准。插件市场已收录本插件,卡片版本随目录探测更新。
41
+ **当前源码版本:** `0.12.4`,把两个原本要手改配置才能调的开关搬进了面板:`maxChildDepth` 默认值改为 `2`(Cursor 是叶节点,`2` 即实际上限),改为每次调用现读、面板「子代理深度」优先于配置;新增「Cursor 署名」开关,插件按你的选择写 Cursor 自己的 `cli-config.json`(只改 `attribution` 两项、其余键原样保留、解析不了的文件不覆盖),默认关闭。面板布局调整为模型选择器置顶、代理输入框在它所解释的状态行下方,「来源与出口」并入「模型目录、搜索与来源」。同时保留额度耗尽保护(三个额度都到上限时提前拒绝,用量取不到时不拦截)、可点击的子会话轨迹。DSH 兼容性不变,仍显式声明支持 `0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-rc.1`、`0.2.1-rc.2` 以及 `>=0.2.1 <0.3.0` 的稳定版本。npm 包名为 `dsh-cursor-dev`,实际已发布版本以 npm 的 `latest` 标签为准。插件市场已收录本插件,卡片版本随目录探测更新。
42
42
 
43
43
  | 你要安装的内容 | 安装来源 |
44
44
  | --- | --- |
@@ -46,7 +46,7 @@ agent status --format json
46
46
  | npm 公共源上的已发布版本 | `dsh-cursor-dev`(`0.11.0` 起可用,按包内 `files` 白名单安装,版本以 npm 的 `latest` 标签为准) |
47
47
  | 公开仓库的当前版本(推荐) | `git+https://github.com/Dclef/dsh-cursor-dev.git` |
48
48
  | 你本地的源码目录 | 该目录的绝对路径,例如 `D:/plugins/dsh-cursor-dev` |
49
- | 维护者提供的源码快照或发布包 | `.tgz` 文件的绝对路径,例如 `D:/Downloads/dsh-cursor-dev-0.12.3.tgz` |
49
+ | 维护者提供的源码快照或发布包 | `.tgz` 文件的绝对路径,例如 `D:/Downloads/dsh-cursor-dev-0.12.4.tgz` |
50
50
 
51
51
  表中本地路径是示例,需要换成实际位置。npm 来源取 registry 上发布的最新版本;Git 来源会取得仓库默认分支的最新提交;`.tgz` 是某一刻的固定快照——要跟进后续改动就用 Git 来源。
52
52
 
@@ -88,7 +88,7 @@ dsh plugin add "/home/you/plugins/dsh-cursor-dev"
88
88
  安装收到的包:
89
89
 
90
90
  ```powershell
91
- dsh plugin add "D:/Downloads/dsh-cursor-dev-0.12.3.tgz"
91
+ dsh plugin add "D:/Downloads/dsh-cursor-dev-0.12.4.tgz"
92
92
  ```
93
93
 
94
94
  终端没有 `dsh` 命令时,使用桌面插件管理即可。不要把在任意项目目录执行 `npm install` 当成已经在 DSH 当前 profile 中启用插件。
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Cursor's commit attribution, and the only switch that turns it off.
3
+ *
4
+ * When the agent runs `git commit`, the CLI appends a `Co-authored-by: Cursor`
5
+ * trailer, and it credits Cursor on pull requests it opens. There is no flag and
6
+ * no environment variable for this — `agent --help` has nothing, and the plugin
7
+ * never gets a per-run channel for it — so the setting lives in the CLI's own
8
+ * `cli-config.json` under `attribution`:
9
+ *
10
+ * ```json
11
+ * { "attribution": { "attributeCommitsToAgent": true, "attributePRsToAgent": true } }
12
+ * ```
13
+ *
14
+ * Team admins can force this off server-side (`attributionControls`), which the
15
+ * CLI checks separately; nothing a user writes locally can turn it back on.
16
+ *
17
+ * This module reads and writes that file. Two rules keep it safe to call from a
18
+ * plugin that does not own it: everything except `attribution` is preserved
19
+ * byte for byte, and a file we cannot parse is never overwritten — a broken
20
+ * config is the CLI's problem to report, not ours to replace.
21
+ *
22
+ * @module dsh-cursor-dev/attribution
23
+ */
24
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
25
+ import { homedir } from 'node:os';
26
+ import { join } from 'node:path';
27
+
28
+ /** The file the CLI keeps its settings in, inside its config directory. */
29
+ const FILE = 'cli-config.json';
30
+
31
+ /** The two things Cursor can attribute. */
32
+ const KEYS = ['attributeCommitsToAgent', 'attributePRsToAgent'];
33
+
34
+ /**
35
+ * Where the CLI looks for its config directory.
36
+ *
37
+ * Mirrors the CLI's own resolution order — `CURSOR_CONFIG_DIR`, then
38
+ * `XDG_CONFIG_HOME/cursor`, then `~/.cursor` — because writing anywhere else
39
+ * would silently do nothing.
40
+ *
41
+ * @param env - environment to read.
42
+ */
43
+ export function cursorConfigDir(env = process.env) {
44
+ const explicit = env.CURSOR_CONFIG_DIR;
45
+ if (typeof explicit === 'string' && explicit.trim() !== '')
46
+ return explicit.trim();
47
+ const xdg = env.XDG_CONFIG_HOME;
48
+ if (typeof xdg === 'string' && xdg.trim() !== '')
49
+ return join(xdg.trim(), 'cursor');
50
+ return join(homedir(), '.cursor');
51
+ }
52
+
53
+ /**
54
+ * Absolute path of the CLI settings file.
55
+ *
56
+ * @param env - environment to read.
57
+ */
58
+ export function attributionPath(env = process.env) {
59
+ return join(cursorConfigDir(env), FILE);
60
+ }
61
+
62
+ /**
63
+ * The attribution block as it currently stands on disk.
64
+ *
65
+ * A missing file reports the CLI's own defaults (`true` twice) because that is
66
+ * what a run would do; `present: false` says we read nothing.
67
+ *
68
+ * @param env - environment to read.
69
+ */
70
+ export function readAttribution(env = process.env) {
71
+ const path = attributionPath(env);
72
+ let raw;
73
+ try {
74
+ if (!existsSync(path))
75
+ return { path, present: false, attribution: defaults() };
76
+ raw = JSON.parse(readFileSync(path, 'utf8'));
77
+ }
78
+ catch (error) {
79
+ return { path, present: true, attribution: defaults(), error: messageOf(error) };
80
+ }
81
+ return { path, present: true, attribution: pick(raw), error: undefined };
82
+ }
83
+
84
+ /**
85
+ * Coerce a panel choice into the two flags, or `undefined` for "leave alone".
86
+ *
87
+ * `keep` is a real choice: an operator who manages Cursor themselves must be
88
+ * able to stop the plugin from rewriting their file on every spawn.
89
+ *
90
+ * @param choice - `off`, `on`, or `keep`.
91
+ */
92
+ export function desiredAttribution(choice) {
93
+ if (choice === 'off') return { attributeCommitsToAgent: false, attributePRsToAgent: false };
94
+ if (choice === 'on') return { attributeCommitsToAgent: true, attributePRsToAgent: true };
95
+ return undefined;
96
+ }
97
+
98
+ /**
99
+ * Make the CLI settings file say what the panel asked for.
100
+ *
101
+ * Writes only when a flag actually differs, so a machine that is already
102
+ * configured is never touched. The write goes through a temporary file and a
103
+ * rename: the CLI reads this file at startup, and half a JSON document would
104
+ * take the child down with it.
105
+ *
106
+ * @param choice - `off`, `on`, or `keep`.
107
+ * @param options - `env` override and a `logger` for the one failure worth reporting.
108
+ */
109
+ export function applyAttribution(choice, options = {}) {
110
+ const env = options.env ?? process.env;
111
+ const desired = desiredAttribution(choice);
112
+ if (desired === undefined)
113
+ return { ...readAttribution(env), changed: false };
114
+ const path = attributionPath(env);
115
+ let raw;
116
+ try {
117
+ raw = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { version: 1 };
118
+ }
119
+ catch (error) {
120
+ const failure = messageOf(error);
121
+ return { path, present: true, attribution: defaults(), changed: false, error: failure };
122
+ }
123
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
124
+ return { path, present: true, attribution: defaults(), changed: false, error: 'the CLI settings file is not an object' };
125
+ }
126
+ const current = pick(raw);
127
+ const changed = KEYS.some(key => current[key] !== desired[key]);
128
+ if (!changed)
129
+ return { path, present: true, attribution: current, changed: false };
130
+ // Keep every other key — permissions, model, auth — exactly as it was, and
131
+ // merge into the attribution block rather than replacing it: a key we do
132
+ // not know about is still the CLI's to read.
133
+ const block = raw.attribution !== null && typeof raw.attribution === 'object' && !Array.isArray(raw.attribution)
134
+ ? raw.attribution
135
+ : {};
136
+ raw.attribution = { ...block, ...desired };
137
+ try {
138
+ const temporary = `${path}.dsh-cursor-dev.tmp`;
139
+ writeFileSync(temporary, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
140
+ renameSync(temporary, path);
141
+ }
142
+ catch (error) {
143
+ const failure = messageOf(error);
144
+ return { path, present: true, attribution: current, changed: false, error: failure };
145
+ }
146
+ return { path, present: true, attribution: { ...current, ...desired }, changed: true };
147
+ }
148
+
149
+ /** The CLI's own defaults, used when it has no opinion yet. */
150
+ function defaults() {
151
+ return { attributeCommitsToAgent: true, attributePRsToAgent: true };
152
+ }
153
+
154
+ /** Read just the two flags out of a parsed settings document. */
155
+ function pick(raw) {
156
+ const block = raw !== null && typeof raw === 'object' ? raw.attribution : undefined;
157
+ const source = block !== null && typeof block === 'object' ? block : {};
158
+ const picked = { ...defaults() };
159
+ for (const key of KEYS) {
160
+ if (typeof source[key] === 'boolean')
161
+ picked[key] = source[key];
162
+ }
163
+ return picked;
164
+ }
165
+
166
+ /** Render an unknown thrown value as text. */
167
+ function messageOf(error) {
168
+ return error instanceof Error ? error.message : String(error);
169
+ }
package/lib/index.js CHANGED
@@ -18,6 +18,7 @@
18
18
  import z from '@deepseek-ai/schemastery';
19
19
  import { resolveAgent } from './resolve-agent.js';
20
20
  import { CLI_SETUP, authStatus, describeAgent } from './cli.js';
21
+ import { applyAttribution } from './attribution.js';
21
22
  import { SessionPool } from './session-pool.js';
22
23
  import { RunJournal } from './journal.js';
23
24
  import { ConversationIndex } from './conversations.js';
@@ -27,7 +28,7 @@ import { createMirrorHub } from './mirror.js';
27
28
  import { createRouteFence } from './fence.js';
28
29
  import { UsageClient, registerUsageRoutes } from './usage.js';
29
30
  import { CursorQuotaGuard } from './quota-guard.js';
30
- import { effectiveModel, readSettings, registerSettingsRoutes, resolveModelSelection } from './settings.js';
31
+ import { effectiveMaxChildDepth, effectiveModel, readSettings, registerSettingsRoutes, resolveModelSelection } from './settings.js';
31
32
  import { createNativeCursorBridge } from './native-provider.js';
32
33
  import { describeProxy, proxySettingIsDirect, resolveChildProxy, withProxyEnvironment } from './proxy.js';
33
34
 
@@ -65,12 +66,18 @@ export const Config = z.object({
65
66
  /** Disable the usage routes entirely (the coding tool is unaffected). */
66
67
  usageUi: z.boolean().default(true),
67
68
  /**
68
- * Recursion budget for Cursor children. `1` (the platform default for
69
- * delegation tools) allows only a top-level agent to start a Cursor run;
70
- * `0` refuses Cursor children entirely; a higher number allows a subagent to
71
- * start one too. Raise it deliberately — every level multiplies cost.
69
+ * Recursion budget for Cursor children: `0` refuses them entirely, `1`
70
+ * allows only a top-level agent to start one, `2` lets a subagent start one
71
+ * too.
72
+ *
73
+ * `2` is the default here, against the platform convention of `1`, because
74
+ * the levels above it do not exist: a Cursor run is a leaf — the CLI has no
75
+ * DSH tools, so it can never delegate further. That makes this setting
76
+ * effectively a switch, and the value that matches how people actually use
77
+ * the tool is the one where a teammate can drive it. The panel writes to
78
+ * the same idea (`data/settings.json` wins over this default).
72
79
  */
73
- maxChildDepth: z.natural().default(1),
80
+ maxChildDepth: z.natural().default(2),
74
81
  });
75
82
 
76
83
  /**
@@ -166,9 +173,48 @@ export function apply(ctx, config) {
166
173
  resolution = resolveProxy();
167
174
  return resolution;
168
175
  };
169
- const childEnvironment = () => resolution.then(() => childEnv);
176
+ // Cursor credits itself in the commits and pull requests the child makes,
177
+ // and the only switch is a key in Cursor's own config file — so this plugin
178
+ // keeps that file in the state the panel asks for. Best effort by design: a
179
+ // read-only Cursor install must not stop a run, and the change is logged
180
+ // once instead of on every spawn.
181
+ let attributionComplaint = '';
182
+ const syncAttribution = () => {
183
+ try {
184
+ const choice = readSettings(dataDir).attribution;
185
+ const applied = applyAttribution(choice, { env: process.env });
186
+ if (applied.error !== undefined) {
187
+ if (applied.error !== attributionComplaint) {
188
+ attributionComplaint = applied.error;
189
+ logger?.warn?.(`dsh-cursor-dev: could not update Cursor attribution in ${applied.path}: ${applied.error}`);
190
+ }
191
+ return applied;
192
+ }
193
+ if (applied.changed)
194
+ logger?.info?.(`dsh-cursor-dev: Cursor commit attribution ${choice === 'on' ? 'on' : 'off'} in ${applied.path}`);
195
+ attributionComplaint = '';
196
+ return applied;
197
+ }
198
+ catch (error) {
199
+ logger?.warn?.(`dsh-cursor-dev: could not update Cursor attribution: ${error instanceof Error ? error.message : String(error)}`);
200
+ return undefined;
201
+ }
202
+ };
203
+ const childEnvironment = () => resolution.then(() => {
204
+ // The child reads its settings file once, at startup, and there is no
205
+ // per-run channel for commit attribution. So the file has to be right
206
+ // *before* the process exists — and this hook is the last moment before
207
+ // that happens, on every path that spawns the CLI.
208
+ syncAttribution();
209
+ return childEnv;
210
+ });
170
211
  const routeNow = () => (settled ? proxyRoute : '');
171
212
  void refreshProxy().then(route => logger?.info?.(`dsh-cursor-dev: Cursor CLI egress via ${route}`));
213
+ // Applied at load as well as before every spawn: an operator who installs
214
+ // this plugin should not have to wait for a run to stop being credited. With
215
+ // no CLI resolved there is no settings file worth writing.
216
+ if (resolved !== undefined)
217
+ syncAttribution();
172
218
 
173
219
  // Child conversations: every Cursor run also becomes a real DSH child
174
220
  // session, so the parent's header can list it and clicking it opens the
@@ -177,7 +223,11 @@ export function apply(ctx, config) {
177
223
  // fiber answers `undefined` while the agent loop is still activating.
178
224
  const usage = new UsageClient({ baseUrl: config.usageBaseUrl });
179
225
  const quotaGuard = new CursorQuotaGuard(usage);
180
- const mirrors = createMirrorHub({ logger, maxChildDepth: config.maxChildDepth });
226
+ // The depth cap the panel can change without a reload. Read per call from
227
+ // the same file the model comes from: a cached number would keep enforcing
228
+ // the cap that was in force when the plugin loaded.
229
+ const maxChildDepthNow = () => effectiveMaxChildDepth(readSettings(dataDir), config.maxChildDepth);
230
+ const mirrors = createMirrorHub({ logger, maxChildDepth: maxChildDepthNow });
181
231
  const pool = resolved === undefined
182
232
  ? new UnavailablePool()
183
233
  : new SessionPool({
@@ -200,7 +250,7 @@ export function apply(ctx, config) {
200
250
  mirrors,
201
251
  quotaGuard,
202
252
  maxScanFiles: config.maxScanFiles,
203
- maxChildDepth: config.maxChildDepth,
253
+ maxChildDepth: maxChildDepthNow,
204
254
  unavailable,
205
255
  // The panel writes the model the user picked; a tool call that names no
206
256
  // model follows that choice, so switching models does not need a profile
@@ -211,7 +261,7 @@ export function apply(ctx, config) {
211
261
  resolveDefaultMode: () => readSettings(dataDir).mode,
212
262
  });
213
263
 
214
- const native = createNativeCursorBridge({ execute: rawTool.execute, mirrors, quotaGuard, maxChildDepth: config.maxChildDepth });
264
+ const native = createNativeCursorBridge({ execute: rawTool.execute, mirrors, quotaGuard, maxChildDepth: maxChildDepthNow });
215
265
  const tool = { ...rawTool, execute: (args, exec) => native.execute(args, exec) };
216
266
  const disposeTool = ctx.tools.register({ ...tool, name: config.toolName });
217
267
 
@@ -318,6 +368,13 @@ function mountHttpSurfaces(ctx, config, deps) {
318
368
  index: deps.index,
319
369
  dataDir: deps.dataDir,
320
370
  configModel: config.model,
371
+ // The depth picker lives on this dashboard, and its value is
372
+ // the stored choice over this config default.
373
+ maxChildDepth: config.maxChildDepth,
374
+ // The dashboard reports what Cursor's config file says about
375
+ // attribution, and that file is looked up from the same
376
+ // environment the child will be spawned with.
377
+ env: deps.env,
321
378
  fence,
322
379
  // The dashboard offers a jump into the mirrored child
323
380
  // conversation, so it has to know both whether this host has
@@ -338,6 +395,9 @@ function mountHttpSurfaces(ctx, config, deps) {
338
395
  cwd: deps.cwd,
339
396
  dataDir: deps.dataDir,
340
397
  configModel: config.model,
398
+ // The panel shows the cap in force: the stored choice when there
399
+ // is one, otherwise what the profile hands this plugin.
400
+ maxChildDepth: config.maxChildDepth,
341
401
  logger: deps.logger,
342
402
  fence,
343
403
  // The panel's model list must be the ids `set_config_option`
package/lib/mirror.js CHANGED
@@ -1016,8 +1016,12 @@ export function createMirrorHub(options = {}) {
1016
1016
  // The recursion budget this hub enforces on every child it opens. `undefined`
1017
1017
  // keeps the previous behaviour (no cap); a number refuses a parent that is
1018
1018
  // already at that depth, which is how "only a top-level agent delegates"
1019
- // (1) or "nobody delegates" (0) is configured.
1020
- const maxChildDepth = options.maxChildDepth;
1019
+ // (1) or "nobody delegates" (0) is configured. A function is resolved per
1020
+ // call, so the settings panel can lift the cap without a reload — otherwise
1021
+ // the tool would allow a run that the mirror then refuses to record.
1022
+ const maxChildDepthNow = () => (typeof options.maxChildDepth === 'function'
1023
+ ? options.maxChildDepth()
1024
+ : options.maxChildDepth);
1021
1025
  const entries = new Map();
1022
1026
  let agents;
1023
1027
  let reportedMissing = false;
@@ -1122,7 +1126,7 @@ export function createMirrorHub(options = {}) {
1122
1126
  parent: options.parent,
1123
1127
  childSessionId: remembered,
1124
1128
  model: options.model,
1125
- maxChildDepth,
1129
+ maxChildDepth: maxChildDepthNow(),
1126
1130
  logger,
1127
1131
  });
1128
1132
  if (resumed !== undefined) {
@@ -1146,7 +1150,7 @@ export function createMirrorHub(options = {}) {
1146
1150
  model: options.model,
1147
1151
  descriptor: options.descriptor,
1148
1152
  announce: options.announce,
1149
- maxChildDepth,
1153
+ maxChildDepth: maxChildDepthNow(),
1150
1154
  logger,
1151
1155
  });
1152
1156
  if (opened === undefined) {
@@ -60,7 +60,8 @@ export function createCursorProvider({ execute, mirrors, quotaGuard, maxChildDep
60
60
  // The recursion budget is the plugin's own now that the host leaves it
61
61
  // to the provider (`maxDepth: provider-managed`); refuse before any
62
62
  // process, child row, or lifecycle event exists.
63
- if (typeof maxChildDepth === 'number') resolveChildDepth(originalParent, maxChildDepth);
63
+ const depthCap = typeof maxChildDepth === 'function' ? maxChildDepth() : maxChildDepth;
64
+ if (typeof depthCap === 'number') resolveChildDepth(originalParent, depthCap);
64
65
  // Keep the service's external lifecycle id separate from the local
65
66
  // transcript child. The mirror is opened explicitly by the panel
66
67
  // and does not publish another catalog row.
package/lib/settings.js CHANGED
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
+ import { applyAttribution, readAttribution } from './attribution.js';
26
27
  import { runOnce } from './cli.js';
27
28
  import { normalizeModelId } from './models.js';
28
29
 
@@ -44,6 +45,15 @@ const MODEL_SOURCES = new Set(['acp', 'cli']);
44
45
  /** Modes a Cursor session can run in (`agent` is the default). */
45
46
  const MODES = new Set(['agent', 'plan', 'ask']);
46
47
 
48
+ /**
49
+ * What the panel may say about Cursor's commit attribution.
50
+ *
51
+ * `keep` exists so that an operator who sets attribution in Cursor itself can
52
+ * stop this plugin from rewriting that file — the flag lives in Cursor's config,
53
+ * not ours, and a plugin that always wrote it would fight the owner.
54
+ */
55
+ const ATTRIBUTIONS = new Set(['off', 'on', 'keep']);
56
+
47
57
  /** Absolute path of the settings file. */
48
58
  export function settingsPath(dataDir) {
49
59
  return join(dataDir, FILE);
@@ -58,6 +68,16 @@ export function emptySettings() {
58
68
  mode: '',
59
69
  language: '',
60
70
  proxy: 'none',
71
+ // How deep in the delegation chain a Cursor child may still be started.
72
+ // `''` means "not chosen here, use the plugin config" — the panel writes
73
+ // a number, a hand-edited file can leave it out entirely.
74
+ maxChildDepth: '',
75
+ // Whether commits the child makes carry a `Co-authored-by: Cursor`
76
+ // trailer. The switch itself lives in Cursor's own config file, which
77
+ // this plugin writes on the operator's behalf — see `attribution.js`.
78
+ // `off` is the default because these runs are unattended and nobody
79
+ // reading the repository later agreed to have Cursor credited in it.
80
+ attribution: 'off',
61
81
  // Models the operator switched off in the picker. A DENY list, not an
62
82
  // allow list, so a catalogue that gains entries keeps showing them: an
63
83
  // empty list means "everything the CLI knows about" and a fresh install
@@ -89,6 +109,8 @@ export function normalizeSettings(raw) {
89
109
  mode: MODES.has(value.mode) ? value.mode : '',
90
110
  language: LANGUAGES.has(value.language) ? value.language : '',
91
111
  proxy: proxySetting(value.proxy),
112
+ maxChildDepth: depthSetting(value.maxChildDepth),
113
+ attribution: ATTRIBUTIONS.has(value.attribution) ? value.attribution : 'off',
92
114
  disabledModels: modelIdList(value.disabledModels),
93
115
  updatedAt: numberOrUndefined(value.updatedAt) ?? 0,
94
116
  modelsFetchedAt: numberOrUndefined(value.modelsFetchedAt) ?? 0,
@@ -192,6 +214,25 @@ function modelChoice(value) {
192
214
  return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
193
215
  }
194
216
 
217
+ /**
218
+ * Resolve the depth cap in force for the next call.
219
+ *
220
+ * The panel choice wins over the config default, the same way the panel model
221
+ * wins over `config.model`: an operator who clicked something expects the click
222
+ * to be what runs. A settings value that is absent hands control back to the
223
+ * config, so a fresh install runs on whatever the profile says.
224
+ *
225
+ * @param settings - result of `readSettings`.
226
+ * @param configDepth - `maxChildDepth` from the plugin config.
227
+ */
228
+ export function effectiveMaxChildDepth(settings, configDepth) {
229
+ const chosen = depthSetting(settings?.maxChildDepth);
230
+ if (chosen !== '') return chosen;
231
+ return typeof configDepth === 'number' && Number.isSafeInteger(configDepth) && configDepth >= 0
232
+ ? configDepth
233
+ : MAX_DEPTH_CHOICE;
234
+ }
235
+
195
236
  /**
196
237
  * Parse `agent --list-models`.
197
238
  *
@@ -393,6 +434,8 @@ export function registerSettingsRoutes(ctx, deps) {
393
434
  mode: settings.mode,
394
435
  language: settings.language,
395
436
  proxy: settings.proxy,
437
+ maxChildDepth: settings.maxChildDepth,
438
+ attribution: settings.attribution,
396
439
  disabledModels: settings.disabledModels,
397
440
  updatedAt: settings.updatedAt,
398
441
  },
@@ -406,6 +449,15 @@ export function registerSettingsRoutes(ctx, deps) {
406
449
  },
407
450
  effectiveModel: effectiveModel(settings, deps.configModel),
408
451
  configModel: deps.configModel ?? '',
452
+ // The number the next call will enforce, so the picker shows the cap
453
+ // in force rather than the value this file happens to hold.
454
+ effectiveMaxChildDepth: effectiveMaxChildDepth(settings, deps.maxChildDepth),
455
+ configMaxChildDepth: deps.maxChildDepth ?? '',
456
+ // What Cursor's own config file says right now. The panel needs this
457
+ // to tell "asked for off" apart from "off is actually in effect":
458
+ // the flag lives in a file this plugin does not own, and a failed
459
+ // write has to be visible instead of looking like success.
460
+ attribution: { choice: settings.attribution, ...readAttribution(deps.env) },
409
461
  agent: deps.resolved === undefined ? null : { command: deps.resolved.command, source: deps.resolved.source },
410
462
  models: { ...models, source: models.source ?? 'none' },
411
463
  };
@@ -449,10 +501,30 @@ export function registerSettingsRoutes(ctx, deps) {
449
501
  patch.mode = body.mode;
450
502
  if (typeof body?.proxy === 'string')
451
503
  patch.proxy = body.proxy;
504
+ // A depth outside 0..2 is refused by normalizeSettings, which stores
505
+ // `''` and hands control back to the config — a number the panel
506
+ // never offers must not become a cap this plugin enforces.
507
+ if (body?.maxChildDepth !== undefined)
508
+ patch.maxChildDepth = body.maxChildDepth;
509
+ // Refuse a word the panel never offers instead of storing it: the
510
+ // normalizer would fall back to `off`, which would silently disable
511
+ // Cursor attribution because of a typo.
512
+ if (typeof body?.attribution === 'string' && ATTRIBUTIONS.has(body.attribution))
513
+ patch.attribution = body.attribution;
452
514
  if (Array.isArray(body?.disabledModels))
453
515
  patch.disabledModels = body.disabledModels;
454
- if (Object.keys(patch).length > 0)
455
- writeSettings(deps.dataDir, patch);
516
+ if (Object.keys(patch).length > 0) {
517
+ const saved = writeSettings(deps.dataDir, patch);
518
+ // The attribution switch is not stored here — it lives in
519
+ // Cursor's own config file — so saving has to write it now.
520
+ // Otherwise the answer below would report a choice that is not
521
+ // yet true and would only take effect on some later spawn.
522
+ if (patch.attribution !== undefined) {
523
+ const applied = applyAttribution(saved.attribution, { env: deps.env });
524
+ if (applied.error !== undefined)
525
+ ctx.logger?.warn?.(`dsh-cursor-dev: could not update Cursor attribution in ${applied.path}: ${applied.error}`);
526
+ }
527
+ }
456
528
  // Re-resolve before answering: the payload reports the route a child
457
529
  // would take, and it has to describe the proxy that was just saved,
458
530
  // not the one that was in force a moment ago.
@@ -610,6 +682,24 @@ function modelIdList(value) {
610
682
  return [...seen];
611
683
  }
612
684
 
685
+ /** Deepest delegation depth the panel offers. */
686
+ const MAX_DEPTH_CHOICE = 2;
687
+
688
+ /**
689
+ * Coerce the `maxChildDepth` setting.
690
+ *
691
+ * `''` means "the panel never wrote this, follow the plugin config", which is
692
+ * why a missing value must not collapse to 0 — 0 is a real choice that refuses
693
+ * every Cursor run. Anything outside 0..2 is treated as not set rather than
694
+ * clamped: a hand-edited 9 is a typo, and silently turning it into 2 would make
695
+ * the panel claim a cap the operator never picked.
696
+ */
697
+ function depthSetting(value) {
698
+ const parsed = numberOrUndefined(value);
699
+ if (parsed === undefined) return '';
700
+ return Number.isSafeInteger(parsed) && parsed >= 0 && parsed <= MAX_DEPTH_CHOICE ? parsed : '';
701
+ }
702
+
613
703
  /** Coerce a finite number, else undefined. */
614
704
  function numberOrUndefined(value) {
615
705
  if (typeof value === 'number')
package/lib/tool.js CHANGED
@@ -93,9 +93,11 @@ export function cursorCodeTool(deps) {
93
93
  const { pool, journal, index, logger, unavailable, mirrors, quotaGuard } = deps;
94
94
  // The recursion budget this tool enforces: the child it starts is a real
95
95
  // DSH child session, so a subagent calling this tool would create a
96
- // GRANDCHILD. `1` is the platform's own default (only a top-level agent
97
- // delegates) and `0` forbids Cursor children entirely.
98
- const maxChildDepth = deps.maxChildDepth;
96
+ // GRANDCHILD. Resolved per call, because the settings panel can change it
97
+ // without a reload — a cached number would keep enforcing the old cap.
98
+ const maxChildDepthNow = () => (typeof deps.maxChildDepth === 'function'
99
+ ? deps.maxChildDepth()
100
+ : deps.maxChildDepth);
99
101
 
100
102
  return {
101
103
  name: 'cursor_code',
@@ -274,6 +276,7 @@ export function cursorCodeTool(deps) {
274
276
  // The depth cap is checked BEFORE anything is created or spawned, so
275
277
  // a refused call costs nothing: no child session, no CLI process, no
276
278
  // Cursor conversation left half-started.
279
+ const maxChildDepth = maxChildDepthNow();
277
280
  if (typeof maxChildDepth === 'number') {
278
281
  try {
279
282
  resolveChildDepth(exec.agent, maxChildDepth);
package/lib/usage.js CHANGED
@@ -16,7 +16,8 @@ import { execFile } from 'node:child_process';
16
16
  import { join } from 'node:path';
17
17
  import { homedir } from 'node:os';
18
18
  import { proxyAwareFetch, readSystemProxy, describeProxy } from './proxy.js';
19
- import { readSettings } from './settings.js';
19
+ import { readAttribution } from './attribution.js';
20
+ import { effectiveMaxChildDepth, readSettings } from './settings.js';
20
21
  import { conversationKeyOf } from './session-pool.js';
21
22
 
22
23
  /** Cursor's API host (mirrors `cli-config.json` → serverConfigCache.backendUrl). */
@@ -470,6 +471,43 @@ export class UsageClient {
470
471
  }
471
472
  }
472
473
 
474
+ /**
475
+ * The depth cap the next call would enforce, for the dashboard's first paint.
476
+ *
477
+ * The panel would otherwise have to guess between the stored choice and the
478
+ * config default and would paint the wrong one for a moment. A settings file
479
+ * that cannot be read falls back to the config value, exactly like `readSettings`.
480
+ *
481
+ * @param deps - route dependencies; `dataDir` and the config `maxChildDepth`.
482
+ */
483
+ function effectiveDepthFor(deps) {
484
+ try {
485
+ return effectiveMaxChildDepth(readSettings(deps.dataDir), deps.maxChildDepth);
486
+ }
487
+ catch {
488
+ return typeof deps.maxChildDepth === 'number' ? deps.maxChildDepth : 2;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * The attribution state the dashboard paints first.
494
+ *
495
+ * Two facts, not one: the choice stored in this plugin's settings, and what
496
+ * Cursor's own config file says right now. A panel that showed only the choice
497
+ * would claim a state the CLI might not be in.
498
+ *
499
+ * @param deps - route dependencies; `dataDir` and the child environment.
500
+ */
501
+ function attributionFor(deps) {
502
+ try {
503
+ const choice = readSettings(deps.dataDir).attribution;
504
+ return { choice, ...readAttribution(deps.env) };
505
+ }
506
+ catch {
507
+ return { choice: 'off', path: '', present: false, attribution: undefined };
508
+ }
509
+ }
510
+
473
511
  /**
474
512
  * Register the usage surfaces on the host web server.
475
513
  *
@@ -815,6 +853,13 @@ export function registerUsageRoutes(ctx, deps) {
815
853
  journal,
816
854
  dataDir,
817
855
  configModel: deps.configModel,
856
+ // First paint only: the settings payload corrects this on load.
857
+ // Seeding it here keeps the picker from briefly showing a cap the
858
+ // operator never chose.
859
+ effectiveMaxChildDepth: effectiveDepthFor(deps),
860
+ // What Cursor's own config file says right now, so the first
861
+ // paint can report the effect instead of the stored choice.
862
+ attribution: attributionFor(deps),
818
863
  // Whether this host can open a mirrored child conversation at
819
864
  // all. When it cannot, a key click must keep opening the step
820
865
  // timeline instead of offering a jump nobody is listening for.
@@ -947,6 +992,7 @@ function dashboardHtml(state) {
947
992
  mode: settings.mode,
948
993
  language: settings.language,
949
994
  proxy: settings.proxy,
995
+ attribution: settings.attribution,
950
996
  disabledModels: settings.disabledModels,
951
997
  },
952
998
  // The route a child process would take right now. Without it the panel
@@ -963,6 +1009,10 @@ function dashboardHtml(state) {
963
1009
  source: settings.modelsSource ?? '',
964
1010
  },
965
1011
  configModel: state.configModel ?? '',
1012
+ effectiveMaxChildDepth: state.effectiveMaxChildDepth ?? 2,
1013
+ // The stored choice plus what Cursor's own file says, so the first paint
1014
+ // can report the effect instead of repeating the choice back.
1015
+ attribution: state.attribution ?? { choice: settings.attribution, path: '', present: false },
966
1016
  // When the host has an agent loop, a row's key opens the mirrored child
967
1017
  // conversation in the app itself. When it does not, the key keeps
968
1018
  // opening the step timeline, which is then the only record of a run.
@@ -1101,6 +1151,9 @@ function dashboardHtml(state) {
1101
1151
  a wrong proxy is the usual reason the model list looks short. */
1102
1152
  .env { margin-top: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
1103
1153
  .env .controls { margin: 0; }
1154
+ /* Each env row is one setting, and a row that follows an explanation needs to
1155
+ look like a new setting rather than the tail of the previous one. */
1156
+ .env .controls + .controls, .env .muted + .controls { margin-top: 10px; }
1104
1157
  .env input[type="text"] { font-size: 12px; padding: 4px 8px; }
1105
1158
  .env label { font-size: 12px; }
1106
1159
  button.ghost { color: var(--muted); }
@@ -1234,8 +1287,20 @@ function dashboardHtml(state) {
1234
1287
 
1235
1288
  <section class="row card" id="modelcard" aria-labelledby="model-title">
1236
1289
  <h2 id="model-title" data-t="model">子智能体模型</h2>
1290
+ <!-- The choice comes first: it is the one thing this card exists for. -->
1291
+ <div class="controls">
1292
+ <select id="model" aria-labelledby="model-title"></select>
1293
+ <button type="button" id="model-save" class="primary" data-t="save">保存</button>
1294
+ </div>
1237
1295
  <div class="muted" data-t="modelHint"></div>
1238
- <!-- The child CLI does not read the Windows registry, so a machine whose proxy
1296
+ <label class="lock">
1297
+ <input type="checkbox" id="model-lock">
1298
+ <span data-t="modelLock">锁定面板模型(忽略单次调用的 model 参数)</span>
1299
+ </label>
1300
+ <div class="status" id="model-status" role="status" aria-live="polite"></div>
1301
+ <!-- Everything below is how the child gets launched, not what it will say:
1302
+ the egress route and how deep in the delegate chain it may be started.
1303
+ The child CLI does not read the Windows registry, so a machine whose proxy
1239
1304
  lives only in Internet Settings gets a child that lists a fraction of the
1240
1305
  models. Empty means "ask the machine", which is what makes the full
1241
1306
  catalogue appear with nobody configuring anything. -->
@@ -1244,22 +1309,24 @@ function dashboardHtml(state) {
1244
1309
  <label class="muted" for="proxy" data-t="proxyLabel">代理</label>
1245
1310
  <input type="text" id="proxy" autocomplete="off" spellcheck="false">
1246
1311
  </div>
1312
+ <div class="controls">
1313
+ <label class="muted" for="depth" data-t="depthLabel">子代理深度</label>
1314
+ <select id="depth" aria-describedby="depth-hint"></select>
1315
+ </div>
1316
+ <div class="muted" id="depth-hint" data-t="depthHint"></div>
1317
+ <div class="controls">
1318
+ <label class="muted" for="attribution" data-t="attributionLabel">Cursor 署名</label>
1319
+ <select id="attribution" aria-describedby="attribution-hint"></select>
1320
+ </div>
1321
+ <div class="muted" id="attribution-hint" data-t="attributionHint"></div>
1322
+ <div class="muted" id="attribution-status"></div>
1247
1323
  </div>
1248
- <div class="controls">
1249
- <select id="model" aria-labelledby="model-title"></select>
1250
- <button type="button" id="model-save" class="primary" data-t="save">保存</button>
1251
- </div>
1252
- <label class="lock">
1253
- <input type="checkbox" id="model-lock">
1254
- <span data-t="modelLock">锁定面板模型(忽略单次调用的 model 参数)</span>
1255
- </label>
1256
- <div class="status" id="model-status" role="status" aria-live="polite"></div>
1257
1324
  <!-- The catalogue the CLI reports is too long for a dropdown, so it gets a
1258
1325
  browser: search, filter, and a switch per model. Switching a model off
1259
1326
  only hides it from the picker — it is never deleted, so switching it
1260
1327
  back on restores the same entry. -->
1261
1328
  <details class="fold" id="model-browser">
1262
- <summary data-t="modelBrowse">模型目录、搜索与筛选</summary>
1329
+ <summary data-t="modelBrowse">模型目录、搜索与来源</summary>
1263
1330
  <div class="controls">
1264
1331
  <button type="button" id="model-reload" class="ghost" data-t="reloadModels">重新读取模型列表</button>
1265
1332
  </div>
@@ -1274,9 +1341,9 @@ function dashboardHtml(state) {
1274
1341
  </div>
1275
1342
  </div>
1276
1343
  <div class="muted" data-t="modelNote" style="margin-top:6px"></div>
1277
- </details>
1278
- <details class="fold" id="model-advanced">
1279
- <summary data-t="modelAdvanced">来源与出口</summary>
1344
+ <!-- The catalogue and where it came from are one question — "is the list in
1345
+ front of me the real list?" — so the diagnostics sit in the same fold
1346
+ instead of a second one nobody opens. -->
1280
1347
  <div class="muted" id="model-state"></div>
1281
1348
  </details>
1282
1349
  </section>
@@ -1331,11 +1398,24 @@ function dashboardHtml(state) {
1331
1398
  levelBad: '接近上限',
1332
1399
  officialNote: '官方说明',
1333
1400
  model: '子智能体模型',
1334
- modelBrowse: '模型目录、搜索与筛选',
1335
- modelAdvanced: '来源与出口',
1401
+ modelBrowse: '模型目录、搜索与来源',
1336
1402
  appliesNext: '下次调用生效',
1337
1403
  unsavedShort: '有改动未保存',
1338
- seeAdvanced: '模型列表或代理有异常,见「来源与出口」',
1404
+ seeAdvanced: '模型列表或代理有异常,见「模型目录、搜索与来源」',
1405
+ depthLabel: '子代理深度',
1406
+ depthHint: '决定「子代理里的代理」能不能再开一次 Cursor。Cursor 自己不会再往下派,所以 2 就是上限。',
1407
+ depthNone: '0 · 禁止(cursor_code 一律拒绝)',
1408
+ depthTop: '1 · 仅顶层代理可用',
1409
+ depthAll: '2 · 顶层与子代理都可用(默认)',
1410
+ depthInEffect: '当前生效:',
1411
+ attributionLabel: 'Cursor 署名',
1412
+ attributionHint: 'Cursor 会在子智能体提交的 commit 和 PR 上署名。开关在 Cursor 自己的 cli-config.json 里,改这里等于替你改那个文件,其余设置不动。新会话生效。',
1413
+ attributionOff: '不加 Cursor 署名(默认)',
1414
+ attributionOn: '保留 Cursor 署名',
1415
+ attributionKeep: '跟随 Cursor 自己的配置(本插件不写文件)',
1416
+ attributionNowOff: 'Cursor 当前:不加署名',
1417
+ attributionNowOn: 'Cursor 当前:会署名',
1418
+ attributionFailed: '写入 Cursor 配置失败:',
1339
1419
  modelHint: '默认跟随这里的模型;勾选锁定后,智能体传入的 model 参数也不会覆盖你的选择。',
1340
1420
  modelLock: '锁定面板模型(忽略单次调用的 model 参数)',
1341
1421
  modelLocked: '已锁定模型',
@@ -1458,11 +1538,24 @@ function dashboardHtml(state) {
1458
1538
  levelBad: 'near the limit',
1459
1539
  officialNote: 'Official note',
1460
1540
  model: 'Child-agent model',
1461
- modelBrowse: 'Model catalogue, search and filters',
1462
- modelAdvanced: 'Source and egress',
1541
+ modelBrowse: 'Model catalogue, search and sources',
1463
1542
  appliesNext: 'applies on the next call',
1464
1543
  unsavedShort: 'unsaved changes',
1465
- seeAdvanced: 'model list or proxy problem — see Source and egress',
1544
+ seeAdvanced: 'model list or proxy problem — see Catalogue, search and sources',
1545
+ depthLabel: 'Subagent depth',
1546
+ depthHint: 'Whether a subagent may start a Cursor run of its own. Cursor never delegates further, so 2 is the ceiling.',
1547
+ depthNone: '0 · never (cursor_code always refuses)',
1548
+ depthTop: '1 · top-level agent only',
1549
+ depthAll: '2 · top-level and subagents (default)',
1550
+ depthInEffect: 'in effect: ',
1551
+ attributionLabel: 'Cursor credit',
1552
+ attributionHint: 'Cursor credits itself on the commits and pull requests its child makes. The switch lives in Cursor own cli-config.json, so changing it here edits that file and nothing else in it. New sessions only.',
1553
+ attributionOff: 'No Cursor credit (default)',
1554
+ attributionOn: 'Credit Cursor',
1555
+ attributionKeep: 'Leave Cursor own setting alone (this plugin writes nothing)',
1556
+ attributionNowOff: 'Cursor now: no credit',
1557
+ attributionNowOn: 'Cursor now: credits itself',
1558
+ attributionFailed: 'Could not write Cursor config: ',
1466
1559
  modelHint: 'Calls follow this default unless they specify a model. Lock it to prevent the agent from overriding your choice.',
1467
1560
  modelLock: 'Lock the panel model (ignore per-call model arguments)',
1468
1561
  modelLocked: 'model locked',
@@ -1582,6 +1675,21 @@ function dashboardHtml(state) {
1582
1675
  var modelsSource = (boot.models && boot.models.source) || '';
1583
1676
  var quotaGate = boot.quotaGuard || { blocked: false };
1584
1677
  var configModel = boot.configModel || '';
1678
+ // What the plugin will actually enforce on the next call: the panel value if
1679
+ // one was saved, otherwise the config default. The server computes it, so the
1680
+ // panel never has to guess which of the two wins.
1681
+ var effectiveDepth = typeof boot.effectiveMaxChildDepth === 'number'
1682
+ ? boot.effectiveMaxChildDepth : 2;
1683
+ // The depth picker's choices: the value the tool compares against, then the
1684
+ // dictionary key for its label. Ordered shallowest first.
1685
+ var DEPTH_CHOICES = [['0', 'depthNone'], ['1', 'depthTop'], ['2', 'depthAll']];
1686
+ // Whether the child credits Cursor in the commits it makes. The switch is not
1687
+ // ours — it is a key in Cursor own config file — so the panel tracks both the
1688
+ // choice and what that file says right now.
1689
+ var attributionChoice = boot.attribution && boot.attribution.choice
1690
+ ? boot.attribution.choice : 'off';
1691
+ var attributionState = boot.attribution || null;
1692
+ var ATTRIBUTION_CHOICES = [['off', 'attributionOff'], ['on', 'attributionOn'], ['keep', 'attributionKeep']];
1585
1693
  var setupState = null;
1586
1694
  var LANG = pickLang();
1587
1695
 
@@ -1824,6 +1932,54 @@ function dashboardHtml(state) {
1824
1932
  proxyBox.value = configuredProxy;
1825
1933
  }
1826
1934
  }
1935
+ var depthBox = $('depth');
1936
+ if (depthBox && depthBox.options) {
1937
+ if (depthBox.options.length === 0) {
1938
+ DEPTH_CHOICES.forEach(function (pair) {
1939
+ var option = document.createElement('option');
1940
+ option.value = pair[0];
1941
+ depthBox.appendChild(option);
1942
+ });
1943
+ }
1944
+ // Relabelled every paint, so a language switch renames them in place
1945
+ // without the <option> elements carrying their own data-t.
1946
+ for (var di = 0; di < depthBox.options.length; di += 1) {
1947
+ if (DEPTH_CHOICES[di]) depthBox.options[di].textContent = t(DEPTH_CHOICES[di][1]);
1948
+ }
1949
+ // Same rule as the proxy box: a value the operator just picked survives
1950
+ // the poll that arrives before they press 保存.
1951
+ if (depthBox !== document.activeElement) depthBox.value = String(effectiveDepth);
1952
+ }
1953
+ var attributionBox = $('attribution');
1954
+ if (attributionBox && attributionBox.options) {
1955
+ if (attributionBox.options.length === 0) {
1956
+ ATTRIBUTION_CHOICES.forEach(function (pair) {
1957
+ var option = document.createElement('option');
1958
+ option.value = pair[0];
1959
+ attributionBox.appendChild(option);
1960
+ });
1961
+ }
1962
+ for (var ai = 0; ai < attributionBox.options.length; ai += 1) {
1963
+ if (ATTRIBUTION_CHOICES[ai]) attributionBox.options[ai].textContent = t(ATTRIBUTION_CHOICES[ai][1]);
1964
+ }
1965
+ if (attributionBox !== document.activeElement) attributionBox.value = String(attributionChoice);
1966
+ }
1967
+ // The choice is ours to store; whether it took effect is Cursor's answer, so
1968
+ // the line under the picker reports the file rather than repeating the pick.
1969
+ var attributionNote = $('attribution-status');
1970
+ if (attributionNote) {
1971
+ var state = attributionState;
1972
+ if (state && state.error) {
1973
+ attributionNote.textContent = t('attributionFailed') + state.error;
1974
+ }
1975
+ else if (state && state.attribution) {
1976
+ attributionNote.textContent = state.attribution.attributeCommitsToAgent === false
1977
+ ? t('attributionNowOff') : t('attributionNowOn');
1978
+ }
1979
+ else {
1980
+ attributionNote.textContent = '';
1981
+ }
1982
+ }
1827
1983
  var query = modelFilter.trim().toLowerCase();
1828
1984
  var fallback = document.createElement('option');
1829
1985
  fallback.value = '';
@@ -2537,6 +2693,12 @@ function dashboardHtml(state) {
2537
2693
  if (typeof data.proxy.route === 'string') proxyRoute = data.proxy.route;
2538
2694
  if (typeof data.proxy.error === 'string') proxyError = data.proxy.error;
2539
2695
  }
2696
+ function applyAttribution(data) {
2697
+ if (!data || !data.attribution) return;
2698
+ var block = data.attribution;
2699
+ if (typeof block.choice === 'string' && block.choice !== '') attributionChoice = block.choice;
2700
+ attributionState = block;
2701
+ }
2540
2702
  function loadSettings(force) {
2541
2703
  return fetch(boot.settingsEndpoint + (force ? '?refreshModels=1' : ''), { headers: { accept: 'application/json' } })
2542
2704
  .then(function (r) { return r.json(); })
@@ -2552,6 +2714,8 @@ function dashboardHtml(state) {
2552
2714
  modelsError = data.models.ok === false ? String(data.models.error || '') : '';
2553
2715
  }
2554
2716
  if (typeof data.configModel === 'string') configModel = data.configModel;
2717
+ if (typeof data.effectiveMaxChildDepth === 'number') effectiveDepth = data.effectiveMaxChildDepth;
2718
+ applyAttribution(data);
2555
2719
  applyProxy(data);
2556
2720
  renderModel();
2557
2721
  return data;
@@ -2577,6 +2741,9 @@ function dashboardHtml(state) {
2577
2741
  if (typeof data.models.source === 'string' && data.models.source !== '') modelsSource = data.models.source;
2578
2742
  modelsError = data.models.ok === false ? String(data.models.error || '') : '';
2579
2743
  }
2744
+ // The answer already carries what Cursor file says after the write, so
2745
+ // the line under the picker reports the result rather than the intent.
2746
+ applyAttribution(data);
2580
2747
  applyProxy(data);
2581
2748
  renderModel();
2582
2749
  return data;
@@ -2689,12 +2856,23 @@ function dashboardHtml(state) {
2689
2856
  var proxyBox = $('proxy');
2690
2857
  var nextProxy = proxyBox ? proxyBox.value.trim() : '';
2691
2858
  var proxyChanged = nextProxy !== (settings.proxy == null ? '' : String(settings.proxy));
2859
+ var depthBox = $('depth');
2860
+ // An absent picker leaves the stored value alone: a page without it (an
2861
+ // older panel, a test stub) must not overwrite the cap with NaN.
2862
+ var nextDepth = depthBox && depthBox.options ? Number(depthBox.value) : undefined;
2863
+ if (!Number.isFinite(nextDepth)) nextDepth = undefined;
2864
+ var attributionBox = $('attribution');
2865
+ // Same rule as the depth picker: no element, no change.
2866
+ var nextAttribution = attributionBox && attributionBox.options ? String(attributionBox.value) : undefined;
2867
+ if (!nextAttribution) nextAttribution = undefined;
2692
2868
  text($('model-state'), '…');
2693
2869
  text($('model-status'), '…');
2694
2870
  post({
2695
2871
  model: value,
2696
2872
  modelPolicy: $('model-lock').checked ? 'panel' : 'per-call',
2697
2873
  proxy: nextProxy,
2874
+ maxChildDepth: nextDepth,
2875
+ attribution: nextAttribution,
2698
2876
  disabledModels: disabledModels()
2699
2877
  }).then(function (data) {
2700
2878
  if (!data || !data.settings) return;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-cursor-dev",
3
3
  "displayName": "Cursor 子智能体",
4
4
  "description": "把 Cursor CLI 当作子编码智能体来写业务代码:cursor_code 与 cursor_subagent 都提供可从面板点开的实时子对话,原生任务保留宿主的 External 运行记录;可按任务选模型、会话在 DSH 重启后仍能续聊、返回逐步工具时间线与真实文件 diff,并自带额度/模型/会话面板。",
5
- "version": "0.12.3",
5
+ "version": "0.12.4",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "main": "lib/index.js",