dsh-cursor-dev 0.11.1 → 0.12.1

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
- **版本说明(2026-10-17 核对):** `0.11.1` 已推送到公开仓库的 `main`,适配 DSH `0.2.1-rc.2`:插件改用新的外部子智能体契约(`startActivation`、必须返回未被占用的子会话 id、External 行由宿主写入),在包顶层声明 `engines.dsh: >=0.2.1-rc.2 <0.3.0-0`(只覆盖实际实现并验证过的契约;`-0` 让 `0.3.0` 的预发布也不匹配),并清理了 DSH 0.2.1 已删除的工具配置项。插件市场(dshmarket)已收录本插件(目录条目合并于 2026-10-17),可以直接在市场里找到并安装;`0.11.1` 已发布到 npm 公共源(包名 `dsh-cursor-dev`),按包名安装不再返回 404,目录下一次探测后市场卡片会显示版本和 `DSH >=0.2.1-rc.2 <0.3.0-0` 宿主要求。要跟进后续改动请用 Git 来源;`.tgz` 是某一刻的固定快照。详见[安装来源与版本](docs/INSTALL.md#2-选择安装来源)。
58
+ **版本说明(0.12.1):** 本版新增 macOS Keychain 登录凭据读取,修正 CLI 子进程的代理默认值和直连时的环境变量继承:未配置时为 `none`(直连),主动留空时使用系统代理,填写 URL 时使用显式代理。已有空值配置保持系统代理语义,面板仍使用原来的输入框,没有 UI 改版。macOS Keychain 路径尚未实机验证。DSH 兼容性仍为 `>=0.2.1-rc.2 <0.3.0-0`。插件市场已收录本插件,卡片版本随目录探测更新;npm 包名为 `dsh-cursor-dev`,实际已发布版本以 npm 的 `latest` 标签为准。要跟进后续改动请用 Git 来源;`.tgz` 是某一刻的固定快照。详见[安装来源与版本](docs/INSTALL.md#2-选择安装来源)。
59
59
 
60
60
  运行要求:DSH `>=0.2.1-rc.2 <0.3.0-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
 
@@ -152,7 +152,7 @@ flowchart LR
152
152
 
153
153
  从 DSH 的 Cursor 插件入口打开面板,也可以访问当前 DSH 地址下的 `/plugins/dsh-cursor-dev/`。例如,DSH 运行在 `http://127.0.0.1:19387` 时,面板地址为 `http://127.0.0.1:19387/plugins/dsh-cursor-dev/`;其他用户应使用自己的地址和端口。
154
154
 
155
- 面板包含安装/登录状态、账号额度、模型和模式设置、活动与历史会话。点击会话可查看镜像记录。新版本页面可能与上面的截图不同。
155
+ 面板包含安装/登录状态、账号额度、模型和模式设置、活动与历史会话。点击会话可查看镜像记录。历史会话会一直累积,所以列表默认收在一个限高的滚动框里,点标题即可整段折叠(选择会被记住);每行右侧的 **删除** 会清掉这次运行的全部痕迹——索引记录、轨迹和镜像子会话,和 `resetSession` 走同一条链路。删除要连点两次(第一次只是变成「确认删除」),因为删掉之后无法恢复。新版本页面可能与上面的截图不同。
156
156
 
157
157
  | 插件配置 | 默认值 | 用途 |
158
158
  | --- | --- | --- |
@@ -169,17 +169,17 @@ flowchart LR
169
169
 
170
170
  ### 代理设置
171
171
 
172
- 面板的「子智能体模型」卡片里有**代理**输入框,未设置时默认为空。
172
+ 面板的「子智能体模型」卡片里有**代理**输入框,未设置时默认为 `none`(直连)。
173
173
 
174
174
  | 取值 | 含义 |
175
175
  | --- | --- |
176
- | 留空(默认) | 自动探测:先看 `HTTPS_PROXY` 等环境变量,再看 Windows「Internet 选项」里的代理 |
177
- | `none` | 直连,不用代理 |
176
+ | `none`(默认) | 直连,并从 CLI 子进程环境中移除继承的代理地址变量 |
177
+ | 留空 | 自动探测:先看 `HTTPS_PROXY` 等环境变量,再看 Windows「Internet 选项」里的代理 |
178
178
  | `http://127.0.0.1:7890` | 显式指定代理,优先于环境变量和注册表 |
179
179
 
180
180
  **为什么需要它:** Cursor CLI 只读 `HTTPS_PROXY` / `HTTP_PROXY` / `https_proxy` / `http_proxy`——不读注册表,也不读 `ALL_PROXY`。所以一台只把代理写在「Internet 选项」里的机器,插件启动的 CLI 子进程会直连 Cursor,而直连只会拿到模型目录里的**一小部分**:本机实测 `agent --list-models` 为 **31 个**(走代理 **265 个**),ACP 会话清单为 **10 个**(走代理 **46 个**)。这就是「有些模型看不到」的全部原因。
181
181
 
182
- 留空即自动探测,所以正常情况下不需要配置任何东西;插件会把探测到的地址写进子进程环境(`HTTPS_PROXY`、`HTTP_PROXY` 及小写形式),并在模型卡片的「可选模型」那一行末尾显示**子进程出口**。面板保存后立刻生效,不必重启应用;如果填的值解析不了,那一行会直接说明。
182
+ 需要系统代理时,主动清空输入框并保存;插件会把探测到的地址写进子进程环境(`HTTPS_PROXY`、`HTTP_PROXY` 及小写形式),并在模型卡片的「可选模型」那一行末尾显示**子进程出口**。升级时已保存的空值仍表示自动探测,不会被改成 `none`。面板保存后对后续启动的子进程生效,不必重启应用;如果填的值解析不了,那一行会直接说明。
183
183
 
184
184
  模型卡片下方还有可搜索的模型浏览器:点一行设为面板模型,右边的开关决定它是否出现在上方下拉框里。关掉的模型只是藏起来(写入 `disabledModels`),点「保存」后生效,随时可以再打开——这样在完整目录有上百个模型时也能只留下写业务常用的几条。
185
185
 
@@ -194,7 +194,7 @@ npm install
194
194
  node --test --test-force-exit --test-skip-pattern="the child agent really edits a file|a bad model is rejected|the real Cursor account returns live quota data" "test/**/*.test.mjs"
195
195
  ```
196
196
 
197
- 完整测试命令为 `npm test`,共 213 项;上面那条命令跳过其中三个真实 CLI/账号测试,剩下 210 项离线测试必须全绿。真实测试需要 Cursor 登录和网络,执行任务会使用该账号额度。当前实现已通过 210 项离线测试,并完成两轮真实 Auto 调用、文件修改和上下文续接验证。打包使用 `npm pack` 或 `pnpm pack`,发布前按[分发检查](docs/INSTALL.md#7-维护者分发前检查)核对安装包和版本。
197
+ 完整测试命令为 `npm test`。上面的命令跳过三个真实 CLI/账号测试,不派发真实编码任务;其余 smoke 测试仍包含只读额度 HTTP 检查,因此并非完全离线。真实编码测试需要 Cursor 登录和网络,执行任务会使用该账号额度。Windows 下的 Keychain 测试因需要 POSIX shell 而跳过,不能据此认定 macOS 已验证;企业证书环境可使用 Node.js 的 `--use-system-ca`。打包使用 `npm pack` 或 `pnpm pack`,发布前按[分发检查](docs/INSTALL.md#7-维护者分发前检查)核对安装包和版本。
198
198
 
199
199
  ## English
200
200
 
@@ -217,10 +217,10 @@ See the [installation guide](docs/INSTALL.md) for detailed setup and troubleshoo
217
217
  dsh plugin add dsh-cursor-dev
218
218
  ```
219
219
 
220
- 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.
220
+ 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
221
  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
222
 
223
- As checked on **2026-10-17**, version `0.11.1` is pushed to `main` on the public repository and adapts the plugin to DSH `0.2.1-rc.2`: it follows the new external-subagent contract (`startActivation`, a child id the host has not registered yet, and the `External` catalog row written by the host), declares `engines.dsh: >=0.2.1-rc.2 <0.3.0-0` at the package top level (only the contract it actually implements and tests; the `-0` suffix also excludes `0.3.0` prereleases), and drops the tool-config keys that DSH 0.2.1 removed. The dsh-market catalog already lists this plugin (entry merged 2026-10-17), so it can be installed from the market; version `0.11.1` is published to the public npm registry as `dsh-cursor-dev`, so a bare package-name installation works and the market card shows the version and the `DSH >=0.2.1-rc.2 <0.3.0-0` host requirement after the catalog's next probe. Use the Git source to follow later changes; a `.tgz` is a fixed snapshot.
223
+ Version `0.12.1` adds macOS Keychain credential lookup and fixes the child CLI's proxy defaults and inherited environment: a missing setting means `none` (direct), an explicitly empty value follows the system proxy, and a URL selects an explicit proxy. Existing empty settings keep their meaning. The panel still uses the same text input; this is not a UI redesign. The macOS Keychain path has not been verified on a real Mac. DSH compatibility remains `>=0.2.1-rc.2 <0.3.0-0`. 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
224
 
225
225
  Requirements: DSH `>=0.2.1-rc.2 <0.3.0-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
226
 
package/docs/INSTALL.md CHANGED
@@ -38,15 +38,15 @@ agent status --format json
38
38
 
39
39
  ## 2. 选择安装来源
40
40
 
41
- **发布状态(2026-10-17 核对):** 公共 GitHub 仓库存在,`0.11.1` 已推送到 `main`(适配 DSH `0.2.1-rc.2` 的外部子智能体契约、顶层 `engines.dsh: >=0.2.1-rc.2 <0.3.0-0`、面板模型锁定、父级专属的 `maxChildDepth` 递归限制,以及本指南都在其中)。插件市场(dshmarket)的目录条目已于 2026-10-17 合并,可以直接在市场里找到本插件;`0.11.1` 已发布到 npm 公共源(包名 `dsh-cursor-dev`),按包名安装不再返回 404,目录下一次探测后市场会显示版本和 `DSH >=0.2.1-rc.2 <0.3.0-0` 宿主要求。
41
+ **当前源码版本:** `0.12.1`,新增 macOS Keychain 登录凭据读取,并修正 CLI 子进程的默认直连与代理环境变量继承;面板没有 UI 改版。macOS Keychain 路径尚未实机验证。DSH 兼容性保持 `>=0.2.1-rc.2 <0.3.0-0`。npm 包名为 `dsh-cursor-dev`,实际已发布版本以 npm 的 `latest` 标签为准。插件市场已收录本插件,卡片版本随目录探测更新。
42
42
 
43
43
  | 你要安装的内容 | 安装来源 |
44
44
  | --- | --- |
45
45
  | 插件市场里的本插件 | 市场条目解析为 `github:Dclef/dsh-cursor-dev`,安装后会跟随仓库默认分支 |
46
- | npm 公共源上的已发布版本 | `dsh-cursor-dev`(`0.11.0` 起可用,按包内 `files` 白名单安装,当前为 `0.11.1`) |
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.11.1.tgz` |
49
+ | 维护者提供的源码快照或发布包 | `.tgz` 文件的绝对路径,例如 `D:/Downloads/dsh-cursor-dev-0.12.1.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.11.1.tgz"
91
+ dsh plugin add "D:/Downloads/dsh-cursor-dev-0.12.1.tgz"
92
92
  ```
93
93
 
94
94
  终端没有 `dsh` 命令时,使用桌面插件管理即可。不要把在任意项目目录执行 `npm install` 当成已经在 DSH 当前 profile 中启用插件。
@@ -113,9 +113,15 @@ http://127.0.0.1:19387/plugins/dsh-cursor-dev/
113
113
 
114
114
  `auto` 与 `default[]` 都表示 Auto。面板保存的选择与当前已启动任务的模型不是同一项状态;新选择在后续运行时应用。锁定只决定选用哪个模型,不增加账号额度或模型访问权限。
115
115
 
116
+ ### 历史会话列表太长或想删掉某一次运行
117
+
118
+ 历史会话按「父会话 + 标签」累积,每一次运行留一行,时间久了列表会很长。所以面板把它收在一个限高的滚动框里,点标题即可整段折叠,折叠状态记在浏览器本地,刷新后仍然保持。
119
+
120
+ 每行最右侧是 **删除**:第一次点击只会变成 **确认删除**,再点一次才真正执行。它会清掉这次运行的索引记录、轨迹和镜像子会话,与工具参数 `resetSession` 走同一条链路,所以删掉之后无法恢复,也无法再从这个面板续聊那次运行。Cursor 自己保存的原始会话不受影响;需要它时可从 Cursor 侧查看。
121
+
116
122
  ### 模型列表不全时先看代理
117
123
 
118
- 同一张卡片上有 **代理** 输入框,留空即为自动探测,正常情况不需要填。它决定的是**插件启动的 CLI 子进程**走哪条网络出口。
124
+ 同一张卡片上有 **代理** 输入框,默认值为 `none`(直连);主动留空表示自动探测系统代理,填写 URL 表示显式代理。它决定的是**插件启动的 CLI 子进程**走哪条网络出口。升级时已保存的空值仍表示系统代理,不会被覆盖。
119
125
 
120
126
  Cursor CLI 只读 `HTTPS_PROXY` / `HTTP_PROXY`(以及小写形式),不读注册表,也不读 `ALL_PROXY`。代理只写在 Windows「Internet 选项」里的机器,子进程会直连 Cursor,而直连只返回模型目录的一部分——本机实测 `agent --list-models` 为 31 个(走代理 265 个),面板真正使用的 ACP 清单为 10 个(走代理 46 个)。
121
127
 
@@ -123,8 +129,8 @@ Cursor CLI 只读 `HTTPS_PROXY` / `HTTP_PROXY`(以及小写形式),不读
123
129
 
124
130
  1. 打开 `http://<你的 DSH 地址>/plugins/dsh-cursor-dev/`。
125
131
  2. 看「子智能体模型」卡片:**可选模型** 那一行末尾会显示 **子进程出口**,例如 `http://127.0.0.1:7890 (registry:Internet Settings)`。
126
- 3. 如果显示 `direct (no proxy configured)`,而你的机器确实有代理,就在 **代理** 框里填上地址(如 `http://127.0.0.1:7890`)并点保存。保存后立即生效,不需要重启应用;面板会自动重新读取模型列表。
127
- 4. 想强制直连时填 `none`。
132
+ 3. 如果显示 `direct (no proxy configured)`,而你想使用系统代理,清空 **代理** 框并保存;也可以填上地址(如 `http://127.0.0.1:7890`)使用显式代理。保存后对后续启动的子进程生效,不需要重启应用;面板会自动重新读取模型列表。
133
+ 4. 想强制直连时填 `none`,插件会从 CLI 子进程环境中移除继承的代理地址变量。
128
134
 
129
135
  也可以在终端里直接对照,两个数字应当与面板一致:
130
136
 
@@ -189,7 +195,7 @@ continueSession 设为 true。让 Cursor 说明上一轮做了什么,再核对
189
195
 
190
196
  | 现象 | 检查与处理 |
191
197
  | --- | --- |
192
- | 按包名安装返回 404 | `0.11.0` 起已发布到 npm 公共源(当前 `0.11.1`);仍 404 时确认 registry 指向公共源且包名拼写为 `dsh-cursor-dev`,或改用插件市场、上面的 Git 来源、已取得的源码目录或安装包 |
198
+ | 按包名安装返回 404 | 确认 registry 指向 npm 公共源且包名拼写为 `dsh-cursor-dev`,或改用插件市场、上面的 Git 来源、已取得的源码目录或安装包 |
193
199
  | DSH 报告找不到 Cursor CLI | 检查 `agent status --format json`;确认 CLI 位于 DSH 可见的 PATH 或常见安装目录,然后重启 DSH。仍找不到时,在插件配置里设置 `command` 为可启动二进制、安装根目录、`agent.cmd`,或 `"node.exe" "index.js"`(非空时只走该路径) |
194
200
  | 未登录或账号不可用 | 在运行 DSH 的同一操作系统用户下执行 `agent login`,检查账号和网络状态 |
195
201
  | 面板选了 Auto,实际仍是其他模型 | 当前任务不会中途切换;新版请保存“锁定面板模型”。查看返回的 `modelSource` 与 `requestedModel`,确认是否有显式调用参数 |
@@ -210,7 +216,7 @@ CLI 模型切换与任务执行使用同一个全局锁。进程可以缓存和
210
216
  修改文档或本地源码不会自动发布给其他用户。分发新的功能前:
211
217
 
212
218
  1. 核对 [package.json](../package.json) 的版本与兼容性声明,为正式发布更新版本。
213
- 2. 运行适当的测试;离线命令见 [README](../README.md#开发与验证)。完整 `npm test` 会包含需要真实账号的 CLI 测试。
219
+ 2. 运行适当的测试;跳过真实编码任务的命令见 [README](../README.md#开发与验证),其中仍有只读网络检查。完整 `npm test` 会包含需要真实账号的 CLI 测试。
214
220
  3. 在源码目录运行 `npm pack` 或 `pnpm pack`,检查包中包括 `lib/`、`cordis.patch.yml`、README、本文及截图。
215
221
  4. 使用独立的 DSH profile 验证安装包:安装、登录检测、父模型派发、结果返回、续聊和取消。
216
222
  5. 将确认过的改动推送并打 tag,或分发检查过的 `.tgz` 包。GitHub 安装需要代码已经推送;npm 包名安装需要实际完成 npm 发布。
package/lib/journal.js CHANGED
@@ -88,6 +88,29 @@ export class RunJournal {
88
88
  this.#entries.delete(key);
89
89
  }
90
90
 
91
+ /**
92
+ * Drop every generation's journal for one conversation.
93
+ *
94
+ * `clear` is exact-keyed, which is right for a live run but useless for a
95
+ * conversation being forgotten: its buckets are keyed `parent::label#2` and
96
+ * the caller only knows `parent::label`. Sweeping the prefix is what makes
97
+ * "delete this history row" actually delete the timeline behind it.
98
+ *
99
+ * @param conversationKey - `parent::label`.
100
+ * @returns how many buckets were dropped.
101
+ */
102
+ clearConversation(conversationKey) {
103
+ const prefix = `${conversationKey}#`;
104
+ let dropped = 0;
105
+ for (const key of [...this.#entries.keys()]) {
106
+ if (key === conversationKey || key.startsWith(prefix)) {
107
+ this.#entries.delete(key);
108
+ dropped += 1;
109
+ }
110
+ }
111
+ return dropped;
112
+ }
113
+
91
114
  /**
92
115
  * Read the newest journal belonging to one conversation.
93
116
  *
package/lib/proxy.js CHANGED
@@ -371,15 +371,16 @@ export function proxyEnvironment(proxy) {
371
371
  /**
372
372
  * `env` with the proxy variables merged in.
373
373
  *
374
- * A direct connection returns the caller's own object untouched, so a machine
375
- * with no proxy spawns children with exactly the environment it always had.
374
+ * A direct connection removes inherited proxy variables without changing the
375
+ * caller's environment.
376
376
  *
377
377
  * @param env - the environment to start from.
378
378
  * @param proxy - a resolved proxy, or undefined for a direct connection.
379
379
  */
380
380
  export function withProxyEnvironment(env, proxy) {
381
381
  const additions = proxyEnvironment(proxy);
382
- if (Object.keys(additions).length === 0) return env;
382
+ if (Object.keys(additions).length === 0)
383
+ return Object.fromEntries(Object.entries(env).filter(([key]) => !PROXY_ENV_KEYS.includes(key)));
383
384
  return { ...env, ...additions };
384
385
  }
385
386
 
@@ -389,11 +390,8 @@ export function withProxyEnvironment(env, proxy) {
389
390
  * The panel's `proxy` setting drives this, and its three kinds of answer mean
390
391
  * genuinely different things:
391
392
  *
392
- * - `''` (the default) is AUTO: ask the machine. This is what makes the full
393
- * model catalogue appear without anyone configuring anything, and it is why
394
- * the default is not "direct".
395
- * - `none` (also `direct`, `off`, `no`) is the escape hatch for a machine
396
- * whose registry proxy is stale or wrong.
393
+ * - `none` (the default; also `direct`, `off`, `no`) connects directly.
394
+ * - `''` is AUTO: ask the environment, then the machine.
397
395
  * - anything else is taken literally as a proxy endpoint, in every shape
398
396
  * {@link parseProxy} accepts.
399
397
  *
@@ -402,7 +400,7 @@ export function withProxyEnvironment(env, proxy) {
402
400
  * @returns the resolved proxy, or undefined for a direct connection.
403
401
  */
404
402
  export async function resolveChildProxy(configured, options = {}) {
405
- const value = typeof configured === 'string' ? configured.trim() : '';
403
+ const value = typeof configured === 'string' ? configured.trim() : 'none';
406
404
  if (DIRECT_PROXY_VALUES.has(value.toLowerCase())) return undefined;
407
405
  if (value.length === 0) return await readSystemProxy(options);
408
406
 
package/lib/settings.js CHANGED
@@ -57,11 +57,7 @@ export function emptySettings() {
57
57
  modelPolicy: 'per-call',
58
58
  mode: '',
59
59
  language: '',
60
- // Empty means "ask the machine", which on Windows finds the registry
61
- // proxy. That default is the whole point: the child CLI does not read
62
- // the registry itself, so without this it lists only the models the
63
- // direct route publishes.
64
- proxy: '',
60
+ proxy: 'none',
65
61
  // Models the operator switched off in the picker. A DENY list, not an
66
62
  // allow list, so a catalogue that gains entries keeps showing them: an
67
63
  // empty list means "everything the CLI knows about" and a fresh install
@@ -577,16 +573,16 @@ const MAX_PROXY_CHARS = 200;
577
573
  /**
578
574
  * Coerce the `proxy` setting.
579
575
  *
580
- * `''` means auto-detect and is the default; `none` means never proxy; anything
576
+ * `none` is the default and means never proxy; `''` means auto-detect; anything
581
577
  * else is an endpoint. Only the shape of the value is checked here — whether it
582
578
  * parses is `resolveChildProxy`'s job, and it can report that failure without
583
579
  * the settings file having to be valid first. An over-long value is dropped
584
580
  * rather than truncated, because half a URL is worse than none.
585
581
  */
586
582
  function proxySetting(value) {
587
- if (typeof value !== 'string') return '';
583
+ if (typeof value !== 'string') return 'none';
588
584
  const text = value.trim();
589
- return text.length > MAX_PROXY_CHARS ? '' : text;
585
+ return text.length > MAX_PROXY_CHARS ? 'none' : text;
590
586
  }
591
587
 
592
588
  /** Longest model id accepted in the picker's deny list; ids are short by nature. */
package/lib/usage.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * is created, and the token is never logged, echoed, or returned over HTTP.
13
13
  */
14
14
  import { readFile } from 'node:fs/promises';
15
+ import { execFile } from 'node:child_process';
15
16
  import { join } from 'node:path';
16
17
  import { homedir } from 'node:os';
17
18
  import { proxyAwareFetch, readSystemProxy, describeProxy } from './proxy.js';
@@ -36,8 +37,59 @@ const REQUEST_TIMEOUT_MS = 15_000;
36
37
  const RESTORE_TIMEOUT_MS = 60_000;
37
38
 
38
39
  /**
39
- * Cursor's own auth store. The CLI keeps the tokens here rather than under
40
- * `~/.cursor`, which is what makes this discoverable at all.
40
+ * The macOS Keychain account the Cursor CLI stores its login under.
41
+ *
42
+ * The CLI's credential store is not one file across platforms: on macOS it
43
+ * keeps the login in the login Keychain as generic-password items named
44
+ * `cursor-access-token`, `cursor-refresh-token` and `cursor-api-key`, all under
45
+ * the account `cursor-user`. Windows and Linux write the same domain
46
+ * ("cursor") to `auth.json` inside the per-user config directory instead. Both
47
+ * stores are tried below, because the store is a user-visible CLI setting
48
+ * (`AGENT_CLI_CREDENTIAL_STORE=file` opts a macOS install into the file), and
49
+ * the earlier builds of this plugin only knew the file spelling.
50
+ */
51
+ const KEYCHAIN_ACCOUNT = 'cursor-user';
52
+ const KEYCHAIN_ACCESS_TOKEN_SERVICE = 'cursor-access-token';
53
+
54
+ /**
55
+ * Every `auth.json` location the CLI may have written, in lookup order.
56
+ *
57
+ * The CLI's file credential store (`../cli-credentials` in the CLI bundle) names
58
+ * the file after its credential-store domain: `%APPDATA%\Cursor\auth.json` on
59
+ * Windows (that platform title-cases the domain), `$XDG_CONFIG_HOME/cursor/auth.json`
60
+ * on Linux, and `~/.cursor/auth.json` when the macOS CLI is forced onto the file
61
+ * store. `~/.config/Cursor` is the spelling earlier builds of this plugin
62
+ * assumed, so it stays in the list for machines whose token was already put
63
+ * there. {@link authFilePath} keeps returning that historical single answer.
64
+ */
65
+ export function authFileCandidates(env = process.env) {
66
+ const paths = [];
67
+ const add = value => {
68
+ if (typeof value === 'string' && value !== '' && !paths.includes(value))
69
+ paths.push(value);
70
+ };
71
+ const appData = typeof env.APPDATA === 'string' ? env.APPDATA.trim() : '';
72
+ if (appData !== '')
73
+ add(join(appData, 'Cursor', 'auth.json'));
74
+ const xdg = typeof env.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
75
+ if (xdg !== '')
76
+ add(join(xdg, 'cursor', 'auth.json'));
77
+ // Honouring HOME through the injected environment keeps the "env is the
78
+ // only input" contract the rest of this section promises (and lets a test
79
+ // point the reader at a throw-away directory).
80
+ const home = typeof env.HOME === 'string' && env.HOME.trim() !== '' ? env.HOME.trim() : homedir();
81
+ add(join(home, '.cursor', 'auth.json'));
82
+ add(join(home, '.config', 'cursor', 'auth.json'));
83
+ add(join(home, '.config', 'Cursor', 'auth.json'));
84
+ return paths;
85
+ }
86
+
87
+ /**
88
+ * The file the plugin has always named for the token.
89
+ *
90
+ * Kept as the historical public answer ("where is the token") for callers and
91
+ * error messages; the reader itself walks {@link authFileCandidates}, because
92
+ * the real store is Keychain on macOS.
41
93
  */
42
94
  export function authFilePath(env = process.env) {
43
95
  const appData = env.APPDATA;
@@ -46,33 +98,139 @@ export function authFilePath(env = process.env) {
46
98
  return join(homedir(), '.config', 'Cursor', 'auth.json');
47
99
  }
48
100
 
101
+ /**
102
+ * Read one generic password out of the macOS login Keychain.
103
+ *
104
+ * The CLI itself reads these items with `/usr/bin/security` — that is exactly
105
+ * what its own keychain credential store does — so using the same tool keeps
106
+ * this out-of-band read on the code path Cursor's login already relies on: no
107
+ * native binding, no extra dependency, and the same failure modes when the
108
+ * keychain is locked or the item is missing. `-g` prints the secret to stderr
109
+ * as either `password: 0x<hex>` or, on newer macOS releases, as
110
+ * `password: "<text>"`; both shapes are decoded here. The value is returned to
111
+ * the caller and never logged.
112
+ *
113
+ * @param service - keychain item service name (e.g. `cursor-access-token`).
114
+ * @param account - keychain item account; the CLI always uses `cursor-user`.
115
+ * @param options - `securityPath` and `timeoutMs` exist for tests.
116
+ */
117
+ async function keychainPassword(service, account = KEYCHAIN_ACCOUNT, options = {}) {
118
+ const executable = options.securityPath ?? '/usr/bin/security';
119
+ const timeout = options.timeoutMs ?? 5000;
120
+ return await new Promise(resolve => {
121
+ try {
122
+ execFile(executable, ['find-generic-password', '-a', account, '-s', service, '-g'], { timeout, windowsHide: true }, (error, stdout, stderr) => {
123
+ if (error !== null) {
124
+ // A locked keychain asks the user for approval through a GUI
125
+ // dialog; the timeout is what stops that from hanging a panel
126
+ // refresh, and the error must say so instead of claiming the
127
+ // item does not exist.
128
+ const reason = error.killed === true
129
+ ? `the keychain read timed out after ${timeout}ms (is the login keychain locked?)`
130
+ : error.message;
131
+ resolve({ ok: false, error: `no keychain item "${service}" for "${account}": ${reason}` });
132
+ return;
133
+ }
134
+ const text = `${stdout ?? ''}\n${stderr ?? ''}`;
135
+ const hex = /^password: 0x([0-9a-fA-F]+)$/m.exec(text);
136
+ const quoted = /^password: "(.*)"$/m.exec(text);
137
+ let password;
138
+ if (hex !== null)
139
+ password = Buffer.from(hex[1], 'hex').toString('utf8');
140
+ else if (quoted !== null)
141
+ password = quoted[1].replace(/\\"/g, '"');
142
+ if (typeof password !== 'string' || password === '') {
143
+ resolve({ ok: false, error: `cannot parse the keychain answer for "${service}"` });
144
+ return;
145
+ }
146
+ resolve({ ok: true, password });
147
+ });
148
+ }
149
+ catch (error) {
150
+ resolve({ ok: false, error: `cannot run ${executable}: ${errorText(error)}` });
151
+ }
152
+ });
153
+ }
154
+
49
155
  /**
50
156
  * Read the access token the CLI is already signed in with.
51
157
  *
158
+ * Every store the CLI may have used is tried, in the order the CLI itself
159
+ * prefers on this platform: an explicit `CURSOR_AUTH_TOKEN` first (the CLI
160
+ * accepts the same variable), then the macOS Keychain, then the file stores.
161
+ * `AGENT_CLI_CREDENTIAL_STORE` is honoured so a CLI deliberately running on the
162
+ * file store, or on the memory-only store, is not shadowed by a stale item.
52
163
  * The token is deliberately returned to the caller and never logged.
53
164
  *
54
165
  * @param env - environment source (injectable for tests).
166
+ * @param options - `platform`, `securityPath` and `timeoutMs` exist for tests.
55
167
  */
56
- export async function readAccessToken(env = process.env) {
57
- const path = authFilePath(env);
58
- let raw;
59
- try {
60
- raw = await readFile(path, 'utf8');
61
- }
62
- catch (error) {
63
- return { ok: false, path, error: `cannot read ${path}: ${errorText(error)}` };
64
- }
65
- let parsed;
66
- try {
67
- parsed = JSON.parse(raw);
68
- }
69
- catch (error) {
70
- return { ok: false, path, error: `cannot parse ${path}: ${errorText(error)}` };
168
+ export async function readAccessToken(env = process.env, options = {}) {
169
+ const explicit = typeof env.CURSOR_AUTH_TOKEN === 'string' ? env.CURSOR_AUTH_TOKEN.trim() : '';
170
+ if (explicit !== '')
171
+ return { ok: true, path: 'CURSOR_AUTH_TOKEN', token: explicit };
172
+
173
+ const platform = options.platform ?? process.platform;
174
+ const store = typeof env.AGENT_CLI_CREDENTIAL_STORE === 'string' ? env.AGENT_CLI_CREDENTIAL_STORE.trim() : '';
175
+ const filePaths = authFileCandidates(env);
176
+ const attempted = [];
177
+
178
+ // `memory` keeps the login in the CLI process only; nothing else can read
179
+ // it, so only the environment variable above could have answered.
180
+ if (store !== 'memory') {
181
+ if (platform === 'darwin' && store !== 'file') {
182
+ const keychain = await keychainPassword(KEYCHAIN_ACCESS_TOKEN_SERVICE, KEYCHAIN_ACCOUNT, {
183
+ securityPath: options.securityPath,
184
+ timeoutMs: options.timeoutMs,
185
+ });
186
+ if (keychain.ok === true)
187
+ return { ok: true, path: `macOS keychain: ${KEYCHAIN_ACCESS_TOKEN_SERVICE} (${KEYCHAIN_ACCOUNT})`, token: keychain.password };
188
+ attempted.push(keychain.error);
189
+ }
190
+ for (const path of filePaths) {
191
+ let raw;
192
+ try {
193
+ raw = await readFile(path, 'utf8');
194
+ }
195
+ catch (error) {
196
+ attempted.push(`cannot read ${path}: ${errorText(error)}`);
197
+ continue;
198
+ }
199
+ let parsed;
200
+ try {
201
+ parsed = JSON.parse(raw);
202
+ }
203
+ catch (error) {
204
+ attempted.push(`cannot parse ${path}: ${errorText(error)}`);
205
+ continue;
206
+ }
207
+ const token = parsed?.accessToken;
208
+ if (typeof token !== 'string' || token === '') {
209
+ attempted.push(`${path} has no accessToken field`);
210
+ continue;
211
+ }
212
+ return { ok: true, path, token };
213
+ }
71
214
  }
72
- const token = parsed?.accessToken;
73
- if (typeof token !== 'string' || token === '')
74
- return { ok: false, path, error: `${path} has no accessToken field` };
75
- return { ok: true, path, token };
215
+
216
+ const path = authFilePath(env);
217
+ const stores = [];
218
+ if (platform === 'darwin' && store !== 'file')
219
+ stores.push(`macOS keychain "${KEYCHAIN_ACCESS_TOKEN_SERVICE}"`);
220
+ stores.push(...filePaths);
221
+ // "Missing" is the normal signed-out state and repeating one ENOENT per
222
+ // candidate would bury the actionable sentence; a corrupt file or a locked
223
+ // keychain is worth quoting because it needs a different fix.
224
+ const failures = attempted.filter(reason => !/ENOENT|not be found|ItemNotFound/i.test(reason));
225
+ const parts = [store === 'memory'
226
+ ? `the Cursor CLI runs with AGENT_CLI_CREDENTIAL_STORE=memory, so its login is not readable by another process`
227
+ : `no Cursor login found (tried: ${stores.join(', ')})`];
228
+ if (store !== 'memory')
229
+ parts.push(`sign the CLI in with "agent login"`);
230
+ parts.push(`set CURSOR_AUTH_TOKEN to supply a token explicitly`);
231
+ if (failures.length > 0)
232
+ parts.push(`failures: ${failures.slice(0, 2).join('; ')}`);
233
+ return { ok: false, path, error: parts.join('; ') };
76
234
  }
77
235
 
78
236
  /**
@@ -533,6 +691,90 @@ export function registerUsageRoutes(ctx, deps) {
533
691
  },
534
692
  };
535
693
 
694
+ /**
695
+ * Forget one conversation: every trace of it, not just the row.
696
+ *
697
+ * The dashboard's delete button, and it deliberately does exactly what
698
+ * `cursor_code`'s `resetSession` does — kill the live generations, drop the
699
+ * journals, remove the durable index row and close the mirrored child —
700
+ * because a row that vanished from the table while any of those survived
701
+ * would come back on the next run and confuse the operator twice.
702
+ *
703
+ * Forgetting something already gone answers `ok` with zero counts: the
704
+ * button's promise is "make it invisible", and a second click on a stale row
705
+ * must not produce a scary 404.
706
+ */
707
+ const forgetRoute = {
708
+ kind: 'exact',
709
+ path: '/plugins/dsh-cursor-dev/api/forget',
710
+ handler: async (req, res) => {
711
+ const method = String(req?.method ?? 'GET').toUpperCase();
712
+ if (method !== 'POST') {
713
+ sendJson(res, 405, { ok: false, error: `method ${method} is not supported` });
714
+ return;
715
+ }
716
+ const url = new URL(req?.url ?? '/', 'http://localhost');
717
+ const key = url.searchParams.get('key');
718
+ if (key === null || key === '') {
719
+ sendJson(res, 400, { ok: false, error: 'a conversation key is required' });
720
+ return;
721
+ }
722
+ // Same two key spaces as the restore route: a live row arrives
723
+ // generation-scoped (`a::default#1`), the rest of the host knows the
724
+ // bare conversation (`a::default`).
725
+ const durableKey = conversationKeyOf(key);
726
+ const entry = index?.get?.(key) ?? index?.get?.(durableKey);
727
+ const live = pool.snapshot().filter(row => conversationKeyOf(row.key) === durableKey);
728
+ // The pool goes first: a disposed session cannot append to the
729
+ // journal or rewrite the index behind the deletions that follow.
730
+ try {
731
+ pool.dropConversation?.(durableKey);
732
+ }
733
+ catch (error) {
734
+ ctx.logger?.warn?.(`dsh-cursor-dev: conversation ${durableKey} was not evicted: ${errorText(error)}`);
735
+ }
736
+ let journals = 0;
737
+ try {
738
+ if (typeof journal?.clearConversation === 'function')
739
+ journals = journal.clearConversation(durableKey);
740
+ else
741
+ journal?.clear?.(durableKey);
742
+ }
743
+ catch (error) {
744
+ ctx.logger?.warn?.(`dsh-cursor-dev: journal for ${durableKey} was not cleared: ${errorText(error)}`);
745
+ }
746
+ let indexes = 0;
747
+ for (const each of new Set([durableKey, key])) {
748
+ if (index?.get?.(each) === undefined)
749
+ continue;
750
+ try {
751
+ await index.delete(each);
752
+ indexes += 1;
753
+ }
754
+ catch (error) {
755
+ ctx.logger?.warn?.(`dsh-cursor-dev: index row ${each} was not removed: ${errorText(error)}`);
756
+ }
757
+ }
758
+ let mirrorsDropped = 0;
759
+ if (typeof mirrors?.dropConversation === 'function') {
760
+ try {
761
+ await mirrors.dropConversation(durableKey);
762
+ mirrorsDropped = 1;
763
+ }
764
+ catch (error) {
765
+ ctx.logger?.warn?.(`dsh-cursor-dev: mirror for ${durableKey} was not closed: ${errorText(error)}`);
766
+ }
767
+ }
768
+ sendJson(res, 200, {
769
+ ok: true,
770
+ key,
771
+ conversationKey: durableKey,
772
+ existed: entry !== undefined || live.length > 0,
773
+ dropped: { live: live.length, journals, index: indexes, mirrors: mirrorsDropped },
774
+ });
775
+ },
776
+ };
777
+
536
778
  const dashboardRoute = {
537
779
  kind: 'exact',
538
780
  path: '/plugins/dsh-cursor-dev/',
@@ -587,7 +829,7 @@ export function registerUsageRoutes(ctx, deps) {
587
829
  }
588
830
  };
589
831
 
590
- for (const route of [jsonRoute, journalRoute, restoreRoute, dashboardRoute]) {
832
+ for (const route of [jsonRoute, journalRoute, restoreRoute, forgetRoute, dashboardRoute]) {
591
833
  try {
592
834
  registrations.push(server.register({
593
835
  ...route,
@@ -665,6 +907,7 @@ function dashboardHtml(state) {
665
907
  endpoint: '/plugins/dsh-cursor-dev/api/usage',
666
908
  journalEndpoint: '/plugins/dsh-cursor-dev/api/journal',
667
909
  restoreEndpoint: '/plugins/dsh-cursor-dev/api/restore',
910
+ forgetEndpoint: '/plugins/dsh-cursor-dev/api/forget',
668
911
  settingsEndpoint: '/plugins/dsh-cursor-dev/api/settings',
669
912
  statusEndpoint: '/plugins/dsh-cursor-dev/status',
670
913
  settings: {
@@ -738,6 +981,25 @@ function dashboardHtml(state) {
738
981
  grows a page-wide scrollbar and the cards drift off-screen. The scroll
739
982
  belongs to this wrapper, never to the page. */
740
983
  .tablewrap { overflow-x: auto; padding-bottom: 2px; }
984
+ /* The history table is unbounded (one row per run ever made), so it scrolls
985
+ inside a box of its own instead of stretching the card. The inner .tablewrap
986
+ still owns the horizontal scroll; this box owns the vertical one. */
987
+ #convos-body { max-height: 300px; overflow-y: auto; }
988
+ /* A 300px window over a table with one row per run is useless if the column
989
+ labels scroll away with the first row, so the header stays put. */
990
+ #convos-body thead th { position: sticky; top: 0; background: var(--card); z-index: 1; }
991
+ /* A section heading that doubles as the collapse control. */
992
+ h2 > .disclose { color: inherit; text-decoration: none; }
993
+ h2 > .disclose::before { content: '▾ '; color: var(--muted); }
994
+ h2.collapsed > .disclose::before { content: '▸ '; }
995
+ h2 > .disclose:hover { color: var(--accent); }
996
+ h2 > .pill { margin-left: 8px; }
997
+ td.act { white-space: nowrap; }
998
+ /* One row is one run: model names and timestamps must not wrap into a second
999
+ line, or a 300px window shows four rows instead of eight. The long key and
1000
+ path columns already clip themselves. */
1001
+ #convos td { white-space: nowrap; }
1002
+ a.link.danger { color: var(--bad); }
741
1003
  table { width: 100%; border-collapse: collapse; }
742
1004
  th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }
743
1005
  th { color: var(--muted); font-weight: 500; white-space: nowrap; }
@@ -758,6 +1020,9 @@ function dashboardHtml(state) {
758
1020
  .err { border-color: var(--bad); }
759
1021
  .journal { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
760
1022
  .journal h3 { font-size: 12px; margin: 0 0 8px; font-weight: 600; }
1023
+ .journal.warn { border-top-color: var(--warn); color: var(--warn); }
1024
+ .journal.bad { border-top-color: var(--bad); color: var(--bad); }
1025
+ .journal.ok { border-top-color: var(--good); color: var(--good); }
761
1026
  .journal pre { white-space: pre-wrap; word-break: break-word; margin: 6px 0 0;
762
1027
  background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px;
763
1028
  max-height: 260px; overflow: auto; font-size: 12px; }
@@ -936,11 +1201,17 @@ function dashboardHtml(state) {
936
1201
  </div>
937
1202
 
938
1203
  <div class="row card">
939
- <h2 data-t="conversations">历史会话(可恢复,重装后仍在)</h2>
1204
+ <h2><a href="#" class="disclose" id="convos-toggle" data-t="conversations">历史会话(可恢复,重装后仍在)</a><span class="pill" id="convos-count">0</span></h2>
940
1205
  <div class="muted" data-t="conversationsHint"></div>
941
- <div class="tablewrap"><table><thead><tr>
942
- <th data-t="colKey">会话键</th><th data-t="colSession">会话号</th><th data-t="colWorkspace">工作目录</th><th data-t="colModel">模型</th><th class="num" data-t="colUpdated">更新时间</th>
943
- </tr></thead><tbody id="convos"></tbody></table></div>
1206
+ <!-- The history table grows one row per run the operator has ever made, so it
1207
+ gets its own bounded scroll box and can be dropped entirely: on a busy
1208
+ machine this card alone used to push everything else off the page. -->
1209
+ <div id="convos-body">
1210
+ <div class="tablewrap"><table><thead><tr>
1211
+ <th data-t="colKey">会话键</th><th data-t="colSession">会话号</th><th data-t="colWorkspace">工作目录</th><th data-t="colModel">模型</th><th class="num" data-t="colUpdated">更新时间</th><th class="num" data-t="colActions">操作</th>
1212
+ </tr></thead><tbody id="convos"></tbody></table></div>
1213
+ </div>
1214
+ <div class="journal" id="convos-notice" style="display:none"></div>
944
1215
  </div>
945
1216
 
946
1217
  <div class="note" id="source"></div>
@@ -950,6 +1221,8 @@ function dashboardHtml(state) {
950
1221
  (function () {
951
1222
  var boot = JSON.parse(document.getElementById('bootstrap').textContent);
952
1223
  var STORE_KEY = 'dsh-cursor-dev:lang';
1224
+ /** Where the "history table folded" choice is remembered. */
1225
+ var FOLD_KEY = 'dsh-cursor-dev:convos-folded';
953
1226
 
954
1227
  var T = {
955
1228
  zh: {
@@ -999,6 +1272,16 @@ function dashboardHtml(state) {
999
1272
  live: '运行中的 Cursor 会话',
1000
1273
  conversations: '历史会话(可恢复,重装后仍在)',
1001
1274
  conversationsHint: '点击会话键打开这次运行:有子智能体对话时直接跳过去;旧对话(这个功能上线前跑的)会当场从 Cursor 里读回来建成子会话;读不到才退回去展开轨迹。',
1275
+ colActions: '操作',
1276
+ forget: '删除',
1277
+ forgetArmed: '确认删除',
1278
+ forgetHint: '删除这条历史会话(会话记录、轨迹、镜像一起清掉)',
1279
+ forgetting: '正在删除这条历史会话…',
1280
+ forgetConfirm: '删除这条历史会话?它的会话记录、轨迹和镜像都会一起清掉,无法恢复。再点一次「确认删除」就执行。',
1281
+ forgetFailed: '删除失败:',
1282
+ forgotten: '已删除:',
1283
+ expandHint: '展开或收起历史会话列表',
1284
+ countRows: '条',
1002
1285
  openChildHint: '打开这次运行的子智能体对话',
1003
1286
  openJournalHint: '展开这次运行的轨迹',
1004
1287
  restoreHint: '把这次运行的旧对话读回来,建成一个能点开的子智能体会话',
@@ -1105,6 +1388,16 @@ function dashboardHtml(state) {
1105
1388
  live: 'Live Cursor sessions',
1106
1389
  conversations: 'Known conversations (durable)',
1107
1390
  conversationsHint: 'Click a key to open the run: its child conversation when there is one; an older run (made before this feature) is read back out of Cursor and published as one; only if that fails does the step timeline open instead.',
1391
+ colActions: 'actions',
1392
+ forget: 'Delete',
1393
+ forgetArmed: 'Confirm delete',
1394
+ forgetHint: 'Delete this history row (its index record, journal and mirror go too)',
1395
+ forgetting: 'Deleting this history row…',
1396
+ forgetConfirm: 'Delete this history row? Its index record, journal and mirror go with it, and it cannot be undone. Click Confirm delete to go ahead.',
1397
+ forgetFailed: 'Delete failed: ',
1398
+ forgotten: 'Deleted: ',
1399
+ expandHint: 'Fold or unfold the history list',
1400
+ countRows: 'rows',
1108
1401
  openChildHint: 'Open the child conversation for this run',
1109
1402
  openJournalHint: 'Unfold this run’s step timeline',
1110
1403
  restoreHint: 'Read this run’s old conversation back and publish it as an openable child conversation',
@@ -1639,7 +1932,8 @@ function dashboardHtml(state) {
1639
1932
  if (spec.cls) td.className = spec.cls;
1640
1933
  if (spec.link) {
1641
1934
  var a = document.createElement('a');
1642
- a.className = 'link' + (spec.on && spec.on(entry) ? ' on' : '');
1935
+ a.className = 'link' + (spec.on && spec.on(entry) ? ' on' : '')
1936
+ + (spec.danger ? ' danger' : '');
1643
1937
  a.href = '#';
1644
1938
  a.textContent = spec.value(entry);
1645
1939
  var hint = spec.title ? spec.title(entry) : '';
@@ -1782,6 +2076,97 @@ function dashboardHtml(state) {
1782
2076
  renderLive(lastPayload || {});
1783
2077
  }
1784
2078
 
2079
+ /**
2080
+ * One history row's delete control.
2081
+ *
2082
+ * Destroying a row is irreversible here (the index, the journal and the
2083
+ * mirrored child all go), so the first click only arms the link and the second
2084
+ * one within the same row does the work. Armed state lives in a module-level
2085
+ * variable rather than in the DOM because the table is re-rendered by the
2086
+ * poll: a pending confirmation must survive a re-render of its own row.
2087
+ */
2088
+ function forgetRow(entry) {
2089
+ var key = entry.key;
2090
+ if (armedFor !== key) {
2091
+ armedFor = key;
2092
+ forgetNotice(t('forgetConfirm'), 'warn');
2093
+ renderLive(lastPayload || {});
2094
+ return;
2095
+ }
2096
+ armedFor = '';
2097
+ forgetNotice(t('forgetting'), '');
2098
+ fetch(boot.forgetEndpoint + '?key=' + encodeURIComponent(key), {
2099
+ method: 'POST',
2100
+ headers: { accept: 'application/json' },
2101
+ })
2102
+ .then(function (response) {
2103
+ return response.json().then(function (body) { return { ok: response.ok, body: body }; });
2104
+ })
2105
+ .then(function (answer) {
2106
+ var body = answer.body || {};
2107
+ if (answer.ok === true && body.ok === true) {
2108
+ if (journalKey === key) {
2109
+ journalKey = '';
2110
+ var panel = $('journal');
2111
+ panel.style.display = 'none';
2112
+ panel.textContent = '';
2113
+ }
2114
+ // The row is gone on disk, so drop it from the payload that is about
2115
+ // to be re-painted instead of waiting for the next poll to notice.
2116
+ var extended = lastPayload && lastPayload.extended;
2117
+ if (extended && Array.isArray(extended.conversations)) {
2118
+ extended.conversations = extended.conversations.filter(function (row) { return row.key !== key; });
2119
+ }
2120
+ forgetNotice(t('forgotten') + shortKey(key), 'ok');
2121
+ renderLive(lastPayload || {});
2122
+ return;
2123
+ }
2124
+ forgetNotice(t('forgetFailed') + (body.error || 'unknown error'), 'bad');
2125
+ renderLive(lastPayload || {});
2126
+ })
2127
+ .catch(function (error) {
2128
+ forgetNotice(t('forgetFailed') + (error && error.message ? error.message : String(error)), 'bad');
2129
+ renderLive(lastPayload || {});
2130
+ });
2131
+ }
2132
+
2133
+ /** One line of feedback under the history table (never the journal panel). */
2134
+ function forgetNotice(message, tone) {
2135
+ var host = $('convos-notice');
2136
+ if (!host) return;
2137
+ host.style.display = message === '' ? 'none' : '';
2138
+ host.className = 'journal' + (tone ? ' ' + tone : '');
2139
+ host.textContent = message;
2140
+ }
2141
+
2142
+ /**
2143
+ * Fold or unfold the history table.
2144
+ *
2145
+ * The card is the longest thing on the page on a busy machine, and the fold
2146
+ * state is remembered per browser so a panel that fits the operator's screen
2147
+ * stays that way across reloads and language switches.
2148
+ */
2149
+ function setFolded(folded) {
2150
+ convosFolded = folded === true;
2151
+ try { window.localStorage.setItem(FOLD_KEY, convosFolded ? '1' : '0'); } catch (e) { /* private mode */ }
2152
+ applyFolded();
2153
+ }
2154
+ function applyFolded() {
2155
+ var body = $('convos-body');
2156
+ if (body) body.style.display = convosFolded ? 'none' : '';
2157
+ var title = $('convos-toggle');
2158
+ if (title && title.parentNode && title.parentNode.classList) {
2159
+ title.parentNode.classList[convosFolded ? 'add' : 'remove']('collapsed');
2160
+ }
2161
+ if (title) title.title = t('expandHint');
2162
+ }
2163
+ /** The row count pill next to the heading. */
2164
+ function renderCount(count) {
2165
+ var pill = $('convos-count');
2166
+ if (!pill) return;
2167
+ pill.textContent = String(count) + ' ' + t('countRows');
2168
+ }
2169
+
1785
2170
  /**
1786
2171
  * The key cell: the conversation when there is one, else the step timeline.
1787
2172
  *
@@ -1899,6 +2284,11 @@ function dashboardHtml(state) {
1899
2284
 
1900
2285
  var lastPayload = {};
1901
2286
  var journalKey = '';
2287
+ /** The row whose delete is armed; empty means nothing is pending. */
2288
+ var armedFor = '';
2289
+ /** Whether the history table is folded; remembered per browser. */
2290
+ var convosFolded = false;
2291
+ try { convosFolded = window.localStorage.getItem(FOLD_KEY) === '1'; } catch (e) { convosFolded = false; }
1902
2292
  /** Live text in the model search box; survives a re-render and a language switch. */
1903
2293
  var modelFilter = '';
1904
2294
  /** Which chip is active in the model browser; also survives a re-render. */
@@ -1944,8 +2334,13 @@ function dashboardHtml(state) {
1944
2334
  { value: function (e) { return e.workspaceRoot; }, cls: 'path',
1945
2335
  title: function (e) { return e.workspaceRoot; } },
1946
2336
  { value: function (e) { return e.model; } },
1947
- { value: function (e) { return when(e.updatedAt); }, num: true }
2337
+ { value: function (e) { return when(e.updatedAt); }, num: true },
2338
+ { value: function (e) { return armedFor === e.key ? t('forgetArmed') : t('forget'); },
2339
+ cls: 'act', link: true, danger: true, onClick: forgetRow,
2340
+ title: function () { return t('forgetHint'); } }
1948
2341
  ]);
2342
+ renderCount(convos.length);
2343
+ applyFolded();
1949
2344
  }
1950
2345
  function render(payload) {
1951
2346
  lastPayload = payload;
@@ -2133,6 +2528,15 @@ function dashboardHtml(state) {
2133
2528
  loadSettings(false);
2134
2529
  refreshAuth(true);
2135
2530
  });
2531
+ // The heading is the fold control: the table is the one unbounded list on the
2532
+ // page, and this is how a busy machine keeps it out of the way.
2533
+ try {
2534
+ $('convos-toggle').addEventListener('click', function (event) {
2535
+ event.preventDefault();
2536
+ setFolded(!convosFolded);
2537
+ });
2538
+ }
2539
+ catch (error) { /* a stubbed DOM in the tests has no heading */ }
2136
2540
  $('model-save').addEventListener('click', function () {
2137
2541
  var value = $('model').value;
2138
2542
  var proxyBox = $('proxy');
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 每次运行都在 DSH 里开一个真实的、可点开的子会话,cursor_subagent 则显示为宿主写入的 External 记录;可按任务选模型、会话在 DSH 重启后仍能续聊、返回逐步工具时间线与真实文件 diff,并自带额度/模型/会话面板。",
5
- "version": "0.11.1",
5
+ "version": "0.12.1",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "main": "lib/index.js",
Binary file