dsh-cursor-dev 0.12.1 → 0.12.2

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.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-选择安装来源)。
58
+ **版本说明(0.12.2):** 本版加入额度耗尽保护:调用 `cursor_code` 或通过原生子智能体派发之前先检查 Cursor 用量,总、Auto、API 三个额度都到上限时直接拒绝并说明原因,不再把任务派出去后中途失败;用量接口暂时取不到时不拦截调用。`cursor_subagent` 现在也会生成可点击的对话子会话,面板里点运行中或已完成的行都能查看过程,不再报「没有可恢复的会话」。面板重排为额度四张卡片置顶、代理输入框移到模型选择上方、模型目录与来源说明折叠收起。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
 
@@ -65,7 +65,7 @@ flowchart LR
65
65
  | --- | --- | --- |
66
66
  | `cursor_code` | 父模型派发完整任务,控制目录、模式、模型、会话与报告 | 工具结果回到调用它的父模型,包含答复、步骤和检测到的文件变更 |
67
67
  | `/cursor-subagent 任务内容` | 用户直接派发一次任务;可选前缀 `-m 模型` | 完成或失败后,插件把摘要交给原父代理继续处理 |
68
- | `cursor_subagent` | 当前源码新增的 DSH 原生一次性子智能体工具,可后台运行 | 使用 DSH 标准结果及后台任务机制;DSH 0.2.1 起这次运行在父会话里显示为一行 External 任务,可点击的镜像子会话只由 `cursor_code` 创建 |
68
+ | `cursor_subagent` | 当前源码新增的 DSH 原生一次性子智能体工具,可后台运行 | 使用 DSH 标准结果及后台任务机制;父会话保留一行 External 任务,Cursor 面板里的会话行可直接打开实时镜像子对话 |
69
69
 
70
70
  例如,在一个空测试工作区发送:
71
71
 
@@ -220,7 +220,7 @@ See the [installation guide](docs/INSTALL.md) for detailed setup and troubleshoo
220
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
- 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.
223
+ Version `0.12.2` adds quota exhaustion protection: 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` now 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 puts the four quota cards first, moves the proxy input above the model picker, and folds the model catalogue and the source/egress notes. 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
 
@@ -239,7 +239,7 @@ The default model order is call argument → saved panel choice → plugin confi
239
239
 
240
240
  The current source registers a native DSH provider named `cursor` and a standard one-shot tool named `cursor_subagent`. Rich `cursor_code` calls also use the native lifecycle when those host services exist. The existing generic `subagent` backend remains configured as before.
241
241
 
242
- Cursor executes externally. From DSH 0.2.1 on, the host writes a single non-interactive `External` row for a `cursor_subagent` run; the clickable mirrored child conversation is created by `cursor_code`, which starts the provider directly. Standard background runs use DSH's job result and cancellation tools. Persistent mailbox continuation through `send_message`, and Cursor as a resident Agent Teams teammate, are not implemented. Use `cursor_code` with `continueSession` for explicit follow-ups.
242
+ Cursor executes externally. From DSH 0.2.1 on, the host writes a single non-interactive `External` row for a `cursor_subagent` run. Both entrances also create a real transcript child that the Cursor panel opens while the first turn is running. The native service keeps its own lifecycle id; the separate transcript child does not add a duplicate catalog row. Standard background runs use DSH's job result and cancellation tools. Persistent mailbox continuation through `send_message`, and Cursor as a resident Agent Teams teammate, are not implemented. Use `cursor_code` with `continueSession` for explicit follow-ups.
243
243
 
244
244
  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
245
 
package/docs/INSTALL.md CHANGED
@@ -38,7 +38,7 @@ agent status --format json
38
38
 
39
39
  ## 2. 选择安装来源
40
40
 
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` 标签为准。插件市场已收录本插件,卡片版本随目录探测更新。
41
+ **当前源码版本:** `0.12.2`,新增额度耗尽保护(三个额度都到上限时提前拒绝,用量取不到时不拦截),`cursor_subagent` 生成的子会话可在面板点开查看轨迹,面板重排为额度置顶、代理输入框在模型选择上方。DSH 兼容性保持 `>=0.2.1-rc.2 <0.3.0-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.1.tgz` |
49
+ | 维护者提供的源码快照或发布包 | `.tgz` 文件的绝对路径,例如 `D:/Downloads/dsh-cursor-dev-0.12.2.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.1.tgz"
91
+ dsh plugin add "D:/Downloads/dsh-cursor-dev-0.12.2.tgz"
92
92
  ```
93
93
 
94
94
  终端没有 `dsh` 命令时,使用桌面插件管理即可。不要把在任意项目目录执行 `npm install` 当成已经在 DSH 当前 profile 中启用插件。
@@ -202,7 +202,7 @@ continueSession 设为 true。让 Cursor 说明上一轮做了什么,再核对
202
202
  | 显示 usage limit | 检查 Cursor 账号额度和模型可用性;更换父模型不会改变 Cursor 账号额度 |
203
203
  | 父模型没有调用插件 | 确认插件已启用、工具允许使用且父模型支持工具调用;明确要求使用 `cursor_code` 或直接用斜杠命令 |
204
204
  | 有 `cursor_code`,没有 `cursor_subagent` | 确认所装版本包含原生接入、bundle 配置已应用,以及当前 profile 提供子智能体和任务服务;升级后重启 |
205
- | DSH 升到 0.2.1 后 `cursor_subagent` 不再有可点击的镜像子会话 | 0.2.1 起宿主只用一行 External 任务表示外部子智能体,本插件的镜像因此只保留在 `cursor_code` 路径;运行结果、取消和后台机制不变 |
205
+ | 点击运行中的 `cursor_subagent` 会话只显示运行轨迹 | 更新插件后台并重启 DSH;新版从首轮运行时即建立镜像,在 Cursor 面板中点击会话键即可打开子对话。宿主仍保留一行 External 任务;旧版本留下的已完成记录可在历史会话中读回 |
206
206
  | 更新后页面还是旧的 | 检查更新的是当前 profile 中实际加载的包,再重启 DSH 并刷新浏览器;只修改另一份源码不会自动更新所有安装副本 |
207
207
  | 续聊没有原来的内容 | 确认父会话、`sessionLabel` 和工作区;检查是否重置过映射、删除过 Cursor 记录,或发生了加载失败回退 |
208
208
  | 想彻底开始新任务 | 使用新 `sessionLabel`,或对原标签设置 `resetSession:true`;不要只依赖 `continueSession:false` 清空历史 |
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: subagent-model-routing
3
+ description: 调用 subagent、选择子智能体模型或排查 provider/model/reasoning_effort 路由错误时使用;默认省略路由字段,指定前查询目录,禁止空字符串与猜测路由。
4
+ ---
5
+
6
+ # 子智能体模型路由与参数校验
7
+
8
+ ## 适用范围
9
+
10
+ 调用 `subagent`、选择子智能体的提供方、模型或推理等级,或排查以下错误时使用:
11
+
12
+ - `Error: child LLM \`provider\` must be non-empty`
13
+ - 子智能体模型路由不被当前 Session 允许
14
+ - provider 与 model 不成对,或推理等级无效
15
+
16
+ 本技能只约束子智能体模型参数。用户明确指定专家、专家团或其他工具时,遵守对应工具的要求。
17
+
18
+ ## 调用规则
19
+
20
+ 1. **使用默认模型时,省略 provider、model、reasoning_effort 三个键。** 不用空字符串、空白字符串、null 或猜测值表示默认。
21
+ 2. **显式指定模型前,先调用 list_subagent_models。** 先查提供方,再按需查模型及其推理等级。使用返回的 ID,不从父会话模型、自身模型身份或显示名称推断。
22
+ 3. **provider 和 model 必须成对且非空。** 只有一个字段时,先补齐可验证的信息;本意为默认时,删除全部路由字段。
23
+ 4. **reasoning_effort 只使用目标模型公布的有效值。** 不要求固定等级时省略;不要硬套父模型等级。
24
+ 5. **目录与当前 Session 的允许路由分别校验。** 目录存在不保证当前会话允许。如果路由被拒绝,不猜另一个提供方、不反复碰撞。默认模型满足用户要求时,删除全部路由字段重试一次;用户锁定模型时,说明具体限制,不擅自替换。
25
+ 6. **不同工具的模型接口不能混用。** subagent 的路由、cursor_code 的 Cursor 模型、cursor_subagent 的默认配置和专家工具的专家名称不是同一接口。subagent_fork 不接受这些模型覆盖字段。
26
+ 7. **只发送实际需要的受支持字段。** 不为填满对象而给可选字段补空字符串。若调用层强制要求这些字段,检查正确调用方式;仍无法表达省略时报告接口限制,不伪造 ID。
27
+
28
+ ## 发送前检查
29
+
30
+ - 默认调用:三个可选键都不存在。
31
+ - 指定调用:provider/model 是已查证、成对的非空 ID。
32
+ - reasoning_effort 若存在,与目标模型匹配。
33
+ - prompt 是完整、可独立执行的任务说明;cwd 是实际工作区。
34
+ - 没有把父会话或其他工具的模型名称当作已验证路由。
35
+
36
+ ## 默认调用示例
37
+
38
+ ```json
39
+ {
40
+ "cwd": "E:/example/project",
41
+ "description": "核对额度显示逻辑",
42
+ "prompt": "只读检查当前工作区的额度规范化和显示逻辑,给出字段语义与最小修复建议,不修改文件。"
43
+ }
44
+ ```
45
+
46
+ 指定模型时,查询目录后把返回的 provider/model 成对加入。仅在需要固定、且已确认有效时加入 reasoning_effort。本技能不保存固定提供方、模型或等级示例值,以免以后照抄失效。
47
+
48
+ 禁止发送:
49
+
50
+ ```json
51
+ {
52
+ "provider": "",
53
+ "model": "",
54
+ "reasoning_effort": ""
55
+ }
56
+ ```
57
+
58
+ ## 失败后的处理
59
+
60
+ - provider must be non-empty:检查是否把可选字段传成空字符串;默认分支删除三个键,再调用一次。
61
+ - provider/model 不成对:查询目录并补齐,或切回省略全部路由字段的默认分支。
62
+ - 路由不允许:按会话权限限制处理,不通过改拼写绕过。
63
+ - 推理等级无效:查询目标模型详情;不需要固定时省略。
64
+ - 参数校验失败时,子智能体没有启动。只有成功结果包含子智能体 ID,才记为已启动;交付前必须收集实际结果。
65
+
66
+ 修正参数后继续用户已授权的原任务;不要将工具参数错误归因于用户项目或账户额度。
package/lib/index.js CHANGED
@@ -26,6 +26,7 @@ import { registerCursorCommand } from './command.js';
26
26
  import { createMirrorHub } from './mirror.js';
27
27
  import { createRouteFence } from './fence.js';
28
28
  import { UsageClient, registerUsageRoutes } from './usage.js';
29
+ import { CursorQuotaGuard } from './quota-guard.js';
29
30
  import { effectiveModel, readSettings, registerSettingsRoutes, resolveModelSelection } from './settings.js';
30
31
  import { createNativeCursorBridge } from './native-provider.js';
31
32
  import { describeProxy, proxySettingIsDirect, resolveChildProxy, withProxyEnvironment } from './proxy.js';
@@ -174,6 +175,8 @@ export function apply(ctx, config) {
174
175
  // transcript Cursor actually produced. `bind` learns the `agents` service
175
176
  // from a context that declared the dependency, because reading it off this
176
177
  // fiber answers `undefined` while the agent loop is still activating.
178
+ const usage = new UsageClient({ baseUrl: config.usageBaseUrl });
179
+ const quotaGuard = new CursorQuotaGuard(usage);
177
180
  const mirrors = createMirrorHub({ logger, maxChildDepth: config.maxChildDepth });
178
181
  const pool = resolved === undefined
179
182
  ? new UnavailablePool()
@@ -195,6 +198,7 @@ export function apply(ctx, config) {
195
198
  index,
196
199
  logger,
197
200
  mirrors,
201
+ quotaGuard,
198
202
  maxScanFiles: config.maxScanFiles,
199
203
  maxChildDepth: config.maxChildDepth,
200
204
  unavailable,
@@ -207,7 +211,7 @@ export function apply(ctx, config) {
207
211
  resolveDefaultMode: () => readSettings(dataDir).mode,
208
212
  });
209
213
 
210
- const native = createNativeCursorBridge({ execute: rawTool.execute, mirrors, maxChildDepth: config.maxChildDepth });
214
+ const native = createNativeCursorBridge({ execute: rawTool.execute, mirrors, quotaGuard, maxChildDepth: config.maxChildDepth });
211
215
  const tool = { ...rawTool, execute: (args, exec) => native.execute(args, exec) };
212
216
  const disposeTool = ctx.tools.register({ ...tool, name: config.toolName });
213
217
 
@@ -256,6 +260,8 @@ export function apply(ctx, config) {
256
260
  dataDir,
257
261
  logger,
258
262
  mirrors,
263
+ usage,
264
+ quotaGuard,
259
265
  // The status route asks the CLI whether the account is signed in, which
260
266
  // means spawning it — same environment the ACP child gets.
261
267
  env: childEnvironment,
@@ -304,9 +310,9 @@ function mountHttpSurfaces(ctx, config, deps) {
304
310
  // the dashboard RPC itself and serves both a JSON view and a
305
311
  // self-contained HTML dashboard.
306
312
  if (config.usageUi === true) {
307
- const usage = new UsageClient({ baseUrl: config.usageBaseUrl });
308
313
  disposers.push(registerUsageRoutes(webCtx, {
309
- usage,
314
+ usage: deps.usage,
315
+ quotaGuard: deps.quotaGuard,
310
316
  pool: deps.pool,
311
317
  journal: deps.journal,
312
318
  index: deps.index,
@@ -5,6 +5,7 @@ import { isAbsolute } from 'node:path';
5
5
  import { stat } from 'node:fs/promises';
6
6
  import { renderCursorReport } from './tool.js';
7
7
  import { resolveChildDepth } from './mirror.js';
8
+ import { quotaBlockedMessage } from './quota-guard.js';
8
9
 
9
10
  export const CURSOR_PROVIDER = 'cursor';
10
11
 
@@ -33,7 +34,7 @@ function failure(error, cancelled = false) {
33
34
 
34
35
  /** Generic native calls use the panel settings and get their own conversation.
35
36
  * The cursor_code adapter supplies provider-private arguments for rich controls. */
36
- export function createCursorProvider({ execute, mirrors, maxChildDepth }) {
37
+ export function createCursorProvider({ execute, mirrors, quotaGuard, maxChildDepth }) {
37
38
  const active = new Set();
38
39
  let closed = false;
39
40
  return {
@@ -52,15 +53,17 @@ export function createCursorProvider({ execute, mirrors, maxChildDepth }) {
52
53
  throw new Error('cursor: native prompts must contain text blocks only');
53
54
  const task = request.prompt.map(block => block.text).join('\n\n').trim();
54
55
  if (!task) throw new Error('cursor: native prompt must not be empty');
56
+ const quotaStatus = await quotaGuard?.check?.();
57
+ if (quotaStatus?.blocked === true)
58
+ throw new Error(quotaBlockedMessage(quotaStatus));
55
59
  const originalParent = request.parent;
56
60
  // The recursion budget is the plugin's own now that the host leaves it
57
61
  // to the provider (`maxDepth: provider-managed`); refuse before any
58
62
  // process, child row, or lifecycle event exists.
59
63
  if (typeof maxChildDepth === 'number') resolveChildDepth(originalParent, maxChildDepth);
60
- // dsh >= 0.2.1 starts native providers through the subagents service.
61
- // That service only accepts an unregistered child id and models such a
62
- // child as an external row without a local transcript, so the mirror
63
- // belongs to the cursor_code bridge, which starts the provider directly.
64
+ // Keep the service's external lifecycle id separate from the local
65
+ // transcript child. The mirror is opened explicitly by the panel
66
+ // and does not publish another catalog row.
64
67
  const viaService = request.cursor === undefined;
65
68
  const args = request.cursor?.args ?? { task, sessionLabel: `native-${randomUUID()}` };
66
69
  const requestedCwd = typeof request.cwd === 'string' && request.cwd !== ''
@@ -82,15 +85,14 @@ export function createCursorProvider({ execute, mirrors, maxChildDepth }) {
82
85
  let settled = false;
83
86
  let disposal;
84
87
  const cursorLifecycle = {
85
- descriptor: request.descriptor,
88
+ // The service descriptor belongs to its external row.
89
+ descriptor: viaService ? undefined : request.descriptor,
86
90
  label: request.label,
87
91
  acquired(session) { leasedSession = session; },
88
- ...(viaService ? { mirror: false } : {
89
- opened(entry, key) {
90
- opened = { entry, key };
91
- ready.resolve();
92
- },
93
- }),
92
+ opened(entry, key) {
93
+ opened = { entry, key };
94
+ ready.resolve();
95
+ },
94
96
  };
95
97
  const execution = Promise.resolve().then(() => execute({ ...args, workspaceRoot: cwd }, {
96
98
  agent: originalParent,
@@ -125,8 +127,8 @@ export function createCursorProvider({ execute, mirrors, maxChildDepth }) {
125
127
  }
126
128
  })().finally(() => active.delete(dispose));
127
129
  active.add(dispose);
128
- // The service owns the child row for external runs; hand it a fresh id
129
- // and let it observe `result`, without waiting for a mirror child.
130
+ // Publish the external lifecycle immediately, including while queued.
131
+ // Its id stays distinct from the panel's real transcript child.
130
132
  if (viaService)
131
133
  return { id: `cursor-${randomUUID()}`, result, cursorReport, dispose };
132
134
  try {
@@ -168,13 +170,13 @@ function announceMirror(parent, child, label) {
168
170
  }
169
171
 
170
172
  /** Late-bound native service; absence preserves the existing standalone tool. */
171
- export function createNativeCursorBridge({ execute, mirrors, maxChildDepth }) {
173
+ export function createNativeCursorBridge({ execute, mirrors, quotaGuard, maxChildDepth }) {
172
174
  let binding;
173
175
  return {
174
176
  bind(ctx) {
175
177
  const runtime = ctx.get?.('subagents');
176
178
  if (typeof runtime?.registerProvider !== 'function') return;
177
- const provider = createCursorProvider({ execute, mirrors, maxChildDepth });
179
+ const provider = createCursorProvider({ execute, mirrors, quotaGuard, maxChildDepth });
178
180
  const unregister = runtime.registerProvider(provider);
179
181
  const current = { runtime, provider };
180
182
  binding = current;
@@ -0,0 +1,113 @@
1
+ const RECHECK_MS = 30_000;
2
+
3
+ const QUOTA_ERROR = /\b(?:resource[_ -]?exhausted|insufficient[_ -]?quota|quota[_ -]?exceeded|credit[_ -]?balance[_ -]?exhausted)\b|\b(?:insufficient|out of|no remaining|zero|empty|exhausted|depleted)\b.{0,50}\b(?:cursor\s+)?(?:usage|quota|credits?|balance)\b|\b(?:cursor\s+)?(?:usage|quota|credits?|balance)\b.{0,50}\b(?:exhausted|depleted|used up|is empty|limit (?:has been )?reached|limit exceeded|insufficient)\b|\b(?:you(?:'ve| have) hit your usage limit|usage limit has been reached|no usage credits remaining)\b/i;
4
+
5
+ /** Only the explicit upstream bucket percentages can establish global exhaustion. */
6
+ export function usageQuotaExhausted(usage) {
7
+ const percentages = ['total', 'auto', 'api'].map(name => usage?.[name]?.percentUsed);
8
+ return percentages.every(value => typeof value === 'number' && Number.isFinite(value) && value >= 100);
9
+ }
10
+
11
+ /** Inspect an actual Cursor/ACP failure, never the dashboard's advisory prose. */
12
+ export function cursorQuotaFailure(error) {
13
+ const code = error?.code;
14
+ if (code === 8 || /^(?:RESOURCE_EXHAUSTED|INSUFFICIENT_QUOTA|QUOTA_EXCEEDED|CREDIT_BALANCE_EXHAUSTED)$/i.test(String(code ?? '')))
15
+ return true;
16
+ const parts = [error?.name, error?.message, error?.data?.message, error?.cause?.message];
17
+ if (typeof error === 'string') parts.push(error);
18
+ try {
19
+ if (error !== null && typeof error === 'object') parts.push(JSON.stringify(error));
20
+ }
21
+ catch { /* An unstringifiable error can still match its message/code. */ }
22
+ return QUOTA_ERROR.test(parts.filter(value => typeof value === 'string').join(' '));
23
+ }
24
+
25
+ export function quotaBlockedMessage(state) {
26
+ return state?.source === 'cursor-error'
27
+ ? 'CURSOR_QUOTA_EXHAUSTED: Cursor reported that its usage is exhausted. New Cursor child-agent runs are paused until a successful quota refresh confirms capacity has returned.'
28
+ : 'CURSOR_QUOTA_EXHAUSTED: Cursor reports that all usage buckets are exhausted. No child agent was started. Check the quota panel and retry after a successful refresh confirms capacity has returned.';
29
+ }
30
+
31
+ /** Shared gate for native subagent starts and direct cursor_code calls. */
32
+ export class CursorQuotaGuard {
33
+ #usage;
34
+ #now;
35
+ #recheckMs;
36
+ #state = { blocked: false, source: 'unknown' };
37
+ #lastCheckAt = 0;
38
+ #revision = 0;
39
+ #inflight;
40
+
41
+ constructor(usage, options = {}) {
42
+ this.#usage = usage;
43
+ this.#now = options.now ?? Date.now;
44
+ this.#recheckMs = options.recheckMs ?? RECHECK_MS;
45
+ }
46
+
47
+ snapshot() {
48
+ return { ...this.#state };
49
+ }
50
+
51
+ async check() {
52
+ const now = this.#now();
53
+ if (this.#state.blocked && now - this.#lastCheckAt < this.#recheckMs)
54
+ return this.snapshot();
55
+ if (this.#inflight !== undefined)
56
+ return this.#inflight;
57
+
58
+ const revision = this.#revision;
59
+ this.#inflight = (async () => {
60
+ let snapshot;
61
+ try {
62
+ snapshot = await this.#usage?.get?.({ force: this.#state.blocked });
63
+ }
64
+ catch {
65
+ snapshot = undefined;
66
+ }
67
+ this.#lastCheckAt = this.#now();
68
+ if (revision !== this.#revision && this.#state.blocked)
69
+ return this.snapshot();
70
+ return this.observeUsage(snapshot);
71
+ })().finally(() => { this.#inflight = undefined; });
72
+ return this.#inflight;
73
+ }
74
+
75
+ observeUsage(snapshot) {
76
+ if (snapshot?.ok !== true) {
77
+ if (!this.#state.blocked)
78
+ this.#state = { blocked: false, source: 'unknown' };
79
+ return this.snapshot();
80
+ }
81
+ const fetchedAt = snapshot.fetchedAt;
82
+ if (this.#state.blocked
83
+ && (!Number.isFinite(fetchedAt) || fetchedAt <= this.#state.since))
84
+ return this.snapshot();
85
+ this.#lastCheckAt = this.#now();
86
+ if (usageQuotaExhausted(snapshot.usage)) {
87
+ this.#state = {
88
+ blocked: true,
89
+ source: 'usage',
90
+ reason: 'all-usage-buckets-exhausted',
91
+ since: Number.isFinite(fetchedAt) ? fetchedAt : this.#now(),
92
+ };
93
+ }
94
+ else {
95
+ this.#state = { blocked: false, source: 'usage' };
96
+ }
97
+ return this.snapshot();
98
+ }
99
+
100
+ observeFailure(error) {
101
+ if (!cursorQuotaFailure(error))
102
+ return this.snapshot();
103
+ this.#revision += 1;
104
+ this.#lastCheckAt = this.#now();
105
+ this.#state = {
106
+ blocked: true,
107
+ source: 'cursor-error',
108
+ reason: 'cursor-reported-exhaustion',
109
+ since: this.#lastCheckAt,
110
+ };
111
+ return this.snapshot();
112
+ }
113
+ }
@@ -57,6 +57,7 @@ export class SessionPool {
57
57
  /** Tail of the FIFO queue that serializes every Cursor turn. */
58
58
  #gate = Promise.resolve();
59
59
  #lockDepth = 0;
60
+ #activeKey;
60
61
 
61
62
  /**
62
63
  * @param config - plugin config (maxLiveSessions, sessionIdleMinutes, spawn/env).
@@ -99,6 +100,7 @@ export class SessionPool {
99
100
  modelChoice: entry.modelChoice,
100
101
  mode: entry.session.modes?.currentModeId,
101
102
  turns: entry.turns.length,
103
+ running: entry.key === this.#activeKey,
102
104
  reloaded: entry.reloaded,
103
105
  idleMs: Date.now() - entry.lastUsed,
104
106
  alive: entry.session.alive,
@@ -357,6 +359,7 @@ export class SessionPool {
357
359
  */
358
360
  async run(options, body) {
359
361
  const release = await this.#enter(options.signal);
362
+ this.#activeKey = options.key;
360
363
  try {
361
364
  options.signal?.throwIfAborted();
362
365
  const lease = await this.#acquire(options, options.signal);
@@ -373,6 +376,7 @@ export class SessionPool {
373
376
  }
374
377
  }
375
378
  finally {
379
+ this.#activeKey = undefined;
376
380
  release();
377
381
  }
378
382
  }
package/lib/tool.js CHANGED
@@ -22,6 +22,7 @@ import { delegationDepthOf, resolveChildDepth } from './mirror.js';
22
22
  import { sameModel } from './models.js';
23
23
  import { buildPrompt } from './prompt.js';
24
24
  import { storeKeyOf } from './session-pool.js';
25
+ import { cursorQuotaFailure, quotaBlockedMessage } from './quota-guard.js';
25
26
 
26
27
  /** Default wall-clock budget for one Cursor turn. */
27
28
  const DEFAULT_TIMEOUT_SECONDS = 1800;
@@ -89,7 +90,7 @@ function clean(value, seen) {
89
90
  * @param deps - plugin services and shared state.
90
91
  */
91
92
  export function cursorCodeTool(deps) {
92
- const { pool, journal, index, logger, unavailable, mirrors } = deps;
93
+ const { pool, journal, index, logger, unavailable, mirrors, quotaGuard } = deps;
93
94
  // The recursion budget this tool enforces: the child it starts is a real
94
95
  // DSH child session, so a subagent calling this tool would create a
95
96
  // GRANDCHILD. `1` is the platform's own default (only a top-level agent
@@ -291,6 +292,15 @@ export function cursorCodeTool(deps) {
291
292
  }
292
293
  }
293
294
 
295
+ const quotaStatus = await quotaGuard?.check?.();
296
+ if (quotaStatus?.blocked === true) {
297
+ return toLosslessJson({
298
+ ok: false, sessionKey: 'quota-exhausted', conversationKey,
299
+ stopReason: 'error', durationMs: 0,
300
+ error: quotaBlockedMessage(quotaStatus), steps: [], changes: [],
301
+ });
302
+ }
303
+
294
304
  if (input.resetSession) {
295
305
  pool.dropConversation(conversationKey);
296
306
  journal.clear(conversationKey);
@@ -377,8 +387,8 @@ export function cursorCodeTool(deps) {
377
387
  let reused = false;
378
388
  let reloaded = false;
379
389
  const lifecycle = exec.cursorLifecycle;
380
- // External native runs live in the subagents service as a row without a
381
- // local transcript; only a lifecycle that asks for the mirror creates one.
390
+ // The service's external lifecycle id is separate from this child:
391
+ // the panel follows the mirror, whose transcript is written below.
382
392
  const mirrorEnabled = lifecycle?.mirror !== false;
383
393
  const openChild = async () => {
384
394
  const entry = await mirrors?.open({
@@ -436,6 +446,13 @@ export function cursorCodeTool(deps) {
436
446
  if (pickedMode !== undefined && session.modes?.currentModeId !== pickedMode)
437
447
  await session.setMode(pickedMode);
438
448
  turn = await session.prompt(input.prompt, abort.signal);
449
+ if (cursorQuotaFailure(turn.text)) {
450
+ const status = quotaGuard?.observeFailure?.(turn.text);
451
+ if (status?.blocked === true) {
452
+ const partialTurn = { ...turn, stopReason: 'error' };
453
+ throw Object.assign(new Error(quotaBlockedMessage(status)), { partialTurn });
454
+ }
455
+ }
439
456
  pool.recordTurn(sessionKey, turn);
440
457
  const after = await fingerprint(workspaceRoot, deps.maxScanFiles);
441
458
  const changes = mergeChanges(turn.diffs, compareFingerprints(before, after), workspaceRoot);
@@ -484,7 +501,10 @@ export function cursorCodeTool(deps) {
484
501
  });
485
502
  }
486
503
  catch (error) {
487
- const message = error instanceof Error ? error.message : String(error);
504
+ const quotaStatus = cursorQuotaFailure(error) ? quotaGuard?.observeFailure?.(error) : undefined;
505
+ const message = quotaStatus?.blocked === true
506
+ ? quotaBlockedMessage(quotaStatus)
507
+ : error instanceof Error ? error.message : String(error);
488
508
  logger?.warn?.(`cursor_code failed: ${message}`);
489
509
  return toLosslessJson({
490
510
  ok: false, sessionKey, conversationKey, generation: picked.generation,
package/lib/usage.js CHANGED
@@ -485,7 +485,7 @@ export class UsageClient {
485
485
  * @param deps - `usage` client, `pool`, `journal`, `index`, `dataDir`, `mirrors`.
486
486
  */
487
487
  export function registerUsageRoutes(ctx, deps) {
488
- const { usage, pool, index, journal, dataDir, mirrors, fence } = deps;
488
+ const { usage, quotaGuard, pool, index, journal, dataDir, mirrors, fence } = deps;
489
489
  const registrations = [];
490
490
  // `ctx.get` rather than `ctx.webServer`: a missing service must degrade to
491
491
  // "no UI", never to a failed plugin load.
@@ -505,24 +505,42 @@ export function registerUsageRoutes(ctx, deps) {
505
505
  */
506
506
  const panelState = () => {
507
507
  const children = new Map((mirrors?.snapshot?.() ?? []).map(child => [child.key, child]));
508
+ const live = pool.snapshot();
509
+ const conversations = index.list();
510
+ const saved = new Map(conversations.map(entry => [conversationKeyOf(entry.key), entry]));
511
+ const running = new Set(live.filter(entry => entry.running === true).map(entry => conversationKeyOf(entry.key)));
512
+ const withRestore = entry => {
513
+ const key = conversationKeyOf(entry.key);
514
+ const recorded = saved.get(key);
515
+ return {
516
+ ...entry,
517
+ restorable: typeof entry.cursorSessionId === 'string' && entry.cursorSessionId !== ''
518
+ && recorded?.cursorSessionId === entry.cursorSessionId && !running.has(key),
519
+ };
520
+ };
508
521
  const withActivity = entry => {
509
522
  const activity = journal?.lastActivity?.(entry.key);
510
523
  return activity === undefined ? entry : { ...entry, activity };
511
524
  };
512
525
  const withChild = entry => {
513
526
  const child = children.get(entry.key);
514
- const childSessionId = child?.childSessionId ?? entry.childSessionId;
527
+ const recorded = saved.get(conversationKeyOf(entry.key));
528
+ // Native run disposal releases the live mirror; its persisted log is
529
+ // still openable. Only use that address for the same Cursor session.
530
+ const remembered = typeof entry.cursorSessionId === 'string'
531
+ && recorded?.cursorSessionId === entry.cursorSessionId ? recorded : undefined;
532
+ const childSessionId = child?.childSessionId ?? entry.childSessionId ?? remembered?.childSessionId;
515
533
  if (typeof childSessionId !== 'string' || childSessionId === '')
516
534
  return entry;
517
535
  return {
518
536
  ...entry,
519
537
  childSessionId,
520
- parentSessionId: child?.parentSessionId ?? entry.parentSessionId ?? parentOfKey(entry.key),
538
+ parentSessionId: child?.parentSessionId ?? entry.parentSessionId ?? remembered?.parentSessionId ?? parentOfKey(entry.key),
521
539
  };
522
540
  };
523
541
  return {
524
- live: pool.snapshot().map(withActivity).map(withChild),
525
- conversations: index.list().map(withActivity).map(withChild),
542
+ live: live.map(withRestore).map(withActivity).map(withChild),
543
+ conversations: conversations.map(withRestore).map(withActivity).map(withChild),
526
544
  };
527
545
  };
528
546
 
@@ -532,6 +550,7 @@ export function registerUsageRoutes(ctx, deps) {
532
550
  handler: async (req, res) => {
533
551
  const force = typeof req?.url === 'string' && req.url.includes('force=1');
534
552
  const snapshot = await usage.get({ force });
553
+ quotaGuard?.observeUsage?.(snapshot);
535
554
  // `raw` is dropped: the normalized view is the contract, and the
536
555
  // upstream payload carries 200+ model ids nobody polls for.
537
556
  const publicView = { ...snapshot };
@@ -544,6 +563,7 @@ export function registerUsageRoutes(ctx, deps) {
544
563
  queueDepth: pool.queued,
545
564
  conversations: state.conversations,
546
565
  journal: journal?.stats?.(),
566
+ quotaGuard: quotaGuard?.snapshot?.() ?? { blocked: false },
547
567
  dataDir,
548
568
  },
549
569
  });
@@ -631,8 +651,18 @@ export function registerUsageRoutes(ctx, deps) {
631
651
  // conversation key (`parent::label`), so try both before giving up.
632
652
  const durableKey = conversationKeyOf(key);
633
653
  const entry = index?.get?.(key) ?? index?.get?.(durableKey);
634
- if (entry === undefined)
635
- sendJson(res, 404, { ok: false, key, error: 'no durable conversation for that key' });
654
+ const live = pool.snapshot().filter(row => conversationKeyOf(row.key) === durableKey);
655
+ const current = live.find(row => row.key === key);
656
+ if (entry === undefined) {
657
+ if (live.length > 0)
658
+ sendJson(res, 409, { ok: false, key, code: 'conversation_pending', error: 'that conversation has not completed a saved turn; open its live timeline instead' });
659
+ else
660
+ sendJson(res, 404, { ok: false, key, error: 'no durable conversation for that key' });
661
+ }
662
+ else if (live.some(row => row.running === true))
663
+ sendJson(res, 409, { ok: false, key, code: 'conversation_running', error: 'that conversation is still running; open its live timeline instead' });
664
+ else if (current && current.cursorSessionId !== entry.cursorSessionId)
665
+ sendJson(res, 409, { ok: false, key, code: 'conversation_pending', error: 'this generation has not completed a saved turn; open its live timeline instead' });
636
666
  else if (typeof entry.cursorSessionId !== 'string' || entry.cursorSessionId === '')
637
667
  sendJson(res, 409, { ok: false, key, error: 'that conversation never recorded a Cursor session id' });
638
668
  else if (typeof pool?.replay !== 'function')
@@ -781,6 +811,7 @@ export function registerUsageRoutes(ctx, deps) {
781
811
  handler: (req, res) => {
782
812
  sendHtml(res, dashboardHtml({
783
813
  ...panelState(),
814
+ quotaGuard: quotaGuard?.snapshot?.() ?? { blocked: false },
784
815
  journal,
785
816
  dataDir,
786
817
  configModel: deps.configModel,
@@ -904,6 +935,7 @@ function dashboardHtml(state) {
904
935
  conversations: state.conversations ?? [],
905
936
  journal: state.journal?.stats?.() ?? { keys: 0, events: 0 },
906
937
  queueDepth: state.queueDepth ?? 0,
938
+ quotaGuard: state.quotaGuard ?? { blocked: false },
907
939
  endpoint: '/plugins/dsh-cursor-dev/api/usage',
908
940
  journalEndpoint: '/plugins/dsh-cursor-dev/api/journal',
909
941
  restoreEndpoint: '/plugins/dsh-cursor-dev/api/restore',
@@ -949,33 +981,52 @@ function dashboardHtml(state) {
949
981
  color-scheme: dark;
950
982
  --bg: #16161a; --card: #1e1e24; --line: #2c2c34; --fg: #e8e8ea;
951
983
  --muted: #9a9aa4; --accent: #6d8cff; --warn: #ffb454; --bad: #ff6b6b; --good: #56d364;
952
- --hover: rgba(255, 255, 255, .04);
984
+ --hover: rgba(255, 255, 255, .04); --on-accent: #0d1020;
953
985
  }
954
986
  @media (prefers-color-scheme: light) {
955
987
  :root {
956
988
  color-scheme: light;
957
989
  --bg: #f6f6f8; --card: #ffffff; --line: #e2e2e8; --fg: #1c1c22;
958
990
  --muted: #6b6b76; --accent: #3b5bdb; --warn: #b26a00; --bad: #c92a2a; --good: #2b8a3e;
959
- --hover: rgba(0, 0, 0, .035);
991
+ --hover: rgba(0, 0, 0, .035); --on-accent: #ffffff;
960
992
  }
961
993
  }
962
994
  * { box-sizing: border-box; }
963
- body { margin: 0; padding: 20px; background: var(--bg); color: var(--fg);
995
+ html { background: var(--bg); }
996
+ body { margin: 0 auto; padding: 20px; max-width: 1080px; background: var(--bg); color: var(--fg);
964
997
  font: 13px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
998
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
965
999
  h1 { font-size: 15px; margin: 0 0 2px; font-weight: 600; }
966
1000
  h2 { font-size: 12px; margin: 0 0 10px; font-weight: 600; text-transform: uppercase;
967
1001
  letter-spacing: .06em; color: var(--muted); }
968
1002
  .head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
969
1003
  .muted { color: var(--muted); }
970
- /* min() keeps the track from overflowing a panel narrower than 220px — plain
971
- minmax(220px, 1fr) makes the grid wider than its card and clips the last tile. */
972
- .grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); }
973
1004
  .card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
974
- .big { font-size: 26px; font-weight: 650; font-variant-numeric: tabular-nums; }
1005
+ .big { font-size: 26px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.2; }
975
1006
  .bar { height: 7px; border-radius: 4px; background: var(--line); overflow: hidden; margin: 8px 0 6px; }
976
- .bar > i { display: block; height: 100%; background: var(--accent); }
1007
+ .bar > i { display: block; width: 0; height: 100%; background: var(--accent); transition: width .3s ease; }
977
1008
  .bar.warn > i { background: var(--warn); }
978
1009
  .bar.bad > i { background: var(--bad); }
1010
+ /* Keep the four usage figures in the same two-by-two layout as the panel. */
1011
+ .quota-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
1012
+ .quota-card { min-width: 0; min-height: 174px; padding: 16px 18px; border-radius: 12px; }
1013
+ .quota-card h2 { margin-bottom: 16px; font-size: 13px; letter-spacing: .02em; }
1014
+ .quota-card .big { font-size: 34px; font-weight: 700; letter-spacing: -.025em; overflow-wrap: anywhere; }
1015
+ .quota-card .bar { height: 8px; margin: 14px 0 8px; }
1016
+ .quota-card .muted { font-size: 13px; overflow-wrap: anywhere; }
1017
+ .quota-meta { margin-top: 12px; font-size: 12px; }
1018
+ .quota-period { display: flex; flex-wrap: wrap; gap: 2px 8px; }
1019
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
1020
+ overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
1021
+ details.fold { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 8px; }
1022
+ details.fold > summary { cursor: pointer; width: fit-content; max-width: 100%; color: var(--muted); font-size: 12px; border-radius: 4px; }
1023
+ details.fold > summary:hover { color: var(--fg); }
1024
+ .quota-meta > details.fold { margin-top: 8px; }
1025
+ .status { margin-top: 6px; font-size: 12px; }
1026
+ .quota-alert { margin: 0 0 12px; padding: 10px 12px; border: 1px solid var(--bad);
1027
+ border-radius: 8px; background: var(--card); color: var(--bad); }
1028
+ .quota-alert[hidden] { display: none; }
1029
+ #model-state, #model-status, #message, #error, .note { overflow-wrap: anywhere; }
979
1030
  /* A session key is one ~84-character token with no break opportunity, so an
980
1031
  uncontained table stretches <body> instead of scrolling: the panel then
981
1032
  grows a page-wide scrollbar and the cards drift off-screen. The scroll
@@ -1035,12 +1086,23 @@ function dashboardHtml(state) {
1035
1086
  font: inherit; color: var(--fg); background: var(--bg);
1036
1087
  border: 1px solid var(--line); border-radius: 7px; padding: 5px 9px;
1037
1088
  }
1038
- select { flex: 1 1 260px; min-width: 180px; max-width: 100%; }
1039
- input[type="search"] { flex: 0 1 200px; min-width: 130px; max-width: 100%; }
1040
- input[type="text"] { flex: 1 1 240px; min-width: 150px; max-width: 100%; }
1041
- select:focus, input[type="search"]:focus, input[type="text"]:focus { outline: none; border-color: var(--accent); }
1042
- button { cursor: pointer; }
1089
+ /* min() lets a control shrink inside a panel narrower than its usual minimum. */
1090
+ select { flex: 1 1 260px; min-width: min(180px, 100%); max-width: 100%; }
1091
+ input[type="search"] { flex: 0 1 200px; min-width: min(130px, 100%); max-width: 100%; }
1092
+ input[type="text"] { flex: 1 1 240px; min-width: min(150px, 100%); max-width: 100%; }
1093
+ select:focus, input[type="search"]:focus, input[type="text"]:focus { border-color: var(--accent); }
1094
+ button { cursor: pointer; transition: border-color .15s ease, background-color .15s ease; }
1043
1095
  button:hover { border-color: var(--accent); }
1096
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; padding: 5px 16px; }
1097
+ button.primary:hover { background: var(--fg); border-color: var(--fg); color: var(--bg); }
1098
+ .lock { display: flex; align-items: center; gap: 8px; margin: 8px 0 0; }
1099
+ /* The proxy is machine configuration, not the main choice, so it gets one
1100
+ thin strip above the picker rather than a box of its own. It stays open:
1101
+ a wrong proxy is the usual reason the model list looks short. */
1102
+ .env { margin-top: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
1103
+ .env .controls { margin: 0; }
1104
+ .env input[type="text"] { font-size: 12px; padding: 4px 8px; }
1105
+ .env label { font-size: 12px; }
1044
1106
  button.ghost { color: var(--muted); }
1045
1107
  button[disabled] { opacity: .5; cursor: default; border-color: var(--line); }
1046
1108
  .filter-note { font-size: 12px; }
@@ -1090,10 +1152,10 @@ function dashboardHtml(state) {
1090
1152
  /* The settings page hands this dashboard a short slot and clips the
1091
1153
  overflow, so a tall layout hides everything that matters. When the
1092
1154
  viewport is short everything tightens and the long explanatory note
1093
- goes away, which keeps the model picker and the tiles in view. */
1155
+ goes away, which keeps the quota and the model picker in view. */
1094
1156
  body.compact { padding: 12px; }
1095
1157
  body.compact .head { margin-bottom: 10px; }
1096
- body.compact .grid { gap: 8px; }
1158
+ body.compact .quota-grid { gap: 12px; }
1097
1159
  body.compact .card { padding: 8px 10px; border-radius: 8px; }
1098
1160
  body.compact h2 { margin-bottom: 6px; }
1099
1161
  body.compact .big { font-size: 20px; }
@@ -1103,7 +1165,18 @@ function dashboardHtml(state) {
1103
1165
  body.compact .controls { margin: 8px 0 4px; }
1104
1166
  body.compact .controls label { margin: 6px 0; }
1105
1167
  body.compact th, body.compact td { padding: 4px 7px; }
1106
- body.compact [data-t="modelNote"] { display: none; }
1168
+ body.compact [data-t="modelNote"], body.compact [data-t="modelHint"] { display: none; }
1169
+ body.compact .quota-card { min-height: 158px; padding: 14px 16px; border-radius: 12px; }
1170
+ body.compact .quota-card .big { font-size: 32px; }
1171
+ body.compact .quota-card .bar { height: 8px; margin: 12px 0 8px; }
1172
+ @media (max-width: 420px) {
1173
+ body { padding: 12px; }
1174
+ .quota-grid { grid-template-columns: minmax(0, 1fr); }
1175
+ .quota-card { min-height: 158px; }
1176
+ }
1177
+ @media (prefers-reduced-motion: reduce) {
1178
+ *, *::before, *::after { transition: none !important; }
1179
+ }
1107
1180
  </style>
1108
1181
  </head>
1109
1182
  <body>
@@ -1115,82 +1188,98 @@ function dashboardHtml(state) {
1115
1188
  <span class="muted" style="margin-left:auto"><a href="#" class="link" id="refresh" data-t="refresh">刷新</a></span>
1116
1189
  </div>
1117
1190
 
1191
+ <div id="quota-alert" class="quota-alert" role="alert" aria-live="assertive" hidden data-t="quotaBlocked">Cursor 额度已耗尽,已暂停新的子智能体任务;额度刷新确认恢复后会自动解除拦截。</div>
1118
1192
  <div id="error" class="card err" style="display:none"></div>
1119
1193
  <div id="setup" class="card" style="display:none"></div>
1120
1194
 
1121
- <!-- The host embeds this dashboard in a slot that can be only a couple of
1122
- hundred pixels tall, and it clips whatever falls below that. The model
1123
- picker is the one control here, so it comes first: in a short slot the
1124
- quota tiles scroll away while the picker stays reachable. -->
1125
- <div class="card" id="modelcard" style="margin-bottom:14px">
1126
- <h2 data-t="model">子智能体模型</h2>
1195
+ <!-- Usage stays above the model controls; each figure keeps its own card. -->
1196
+ <section id="usage-overview" aria-labelledby="usage-title">
1197
+ <h2 class="sr-only" id="usage-title" data-t="usage">额度与用量</h2>
1198
+ <div class="quota-grid">
1199
+ <article class="card quota-card">
1200
+ <h2 id="total-label" data-t="included">总包含额度</h2>
1201
+ <div class="big" id="total-pct">—</div>
1202
+ <div class="bar" id="total-bar" role="progressbar" aria-labelledby="total-label" aria-valuemin="0" aria-valuemax="100"><i></i></div>
1203
+ <div class="muted" id="total-sub">—</div>
1204
+ </article>
1205
+ <article class="card quota-card">
1206
+ <h2 id="auto-label" data-t="auto">Auto 额度</h2>
1207
+ <div class="big" id="auto-pct">—</div>
1208
+ <div class="bar" id="auto-bar" role="progressbar" aria-labelledby="auto-label" aria-valuemin="0" aria-valuemax="100"><i></i></div>
1209
+ <div class="muted" id="auto-sub">—</div>
1210
+ </article>
1211
+ <article class="card quota-card">
1212
+ <h2 id="api-label" data-t="api">API / 指定模型额度</h2>
1213
+ <div class="big" id="api-pct">—</div>
1214
+ <div class="bar" id="api-bar" role="progressbar" aria-labelledby="api-label" aria-valuemin="0" aria-valuemax="100"><i></i></div>
1215
+ <div class="muted" id="api-sub">—</div>
1216
+ </article>
1217
+ <article class="card quota-card">
1218
+ <h2 data-t="spend">本期消费</h2>
1219
+ <div class="big" id="spend">—</div>
1220
+ <div class="muted" id="spend-sub" style="margin-top:8px">—</div>
1221
+ </article>
1222
+ </div>
1223
+ <div class="quota-meta muted">
1224
+ <div class="quota-period">
1225
+ <span data-t="period">计费周期</span>
1226
+ <span id="cycle">—</span>
1227
+ </div>
1228
+ <details class="fold" id="message-box" hidden>
1229
+ <summary data-t="officialNote">官方说明</summary>
1230
+ <div id="message"></div>
1231
+ </details>
1232
+ </div>
1233
+ </section>
1234
+
1235
+ <section class="row card" id="modelcard" aria-labelledby="model-title">
1236
+ <h2 id="model-title" data-t="model">子智能体模型</h2>
1127
1237
  <div class="muted" data-t="modelHint"></div>
1128
- <div class="controls">
1129
- <select id="model"></select>
1130
- <button type="button" id="model-save" data-t="save">保存</button>
1131
- <button type="button" id="model-reload" class="ghost" data-t="reloadModels">重新读取模型列表</button>
1238
+ <!-- The child CLI does not read the Windows registry, so a machine whose proxy
1239
+ lives only in Internet Settings gets a child that lists a fraction of the
1240
+ models. Empty means "ask the machine", which is what makes the full
1241
+ catalogue appear with nobody configuring anything. -->
1242
+ <div class="env">
1243
+ <div class="controls">
1244
+ <label class="muted" for="proxy" data-t="proxyLabel">代理</label>
1245
+ <input type="text" id="proxy" autocomplete="off" spellcheck="false">
1246
+ </div>
1132
1247
  </div>
1133
- <!-- The child CLI does not read the Windows registry, so a machine whose
1134
- proxy lives only in Internet Settings gets a child that lists a fraction
1135
- of the models. Empty means "ask the machine", which is what makes the
1136
- full catalogue appear with nobody configuring anything. -->
1137
1248
  <div class="controls">
1138
- <span class="muted" data-t="proxyLabel">代理</span>
1139
- <input type="text" id="proxy" autocomplete="off" spellcheck="false">
1249
+ <select id="model" aria-labelledby="model-title"></select>
1250
+ <button type="button" id="model-save" class="primary" data-t="save">保存</button>
1140
1251
  </div>
1141
- <label style="display:flex;align-items:center;gap:8px;margin:10px 0">
1252
+ <label class="lock">
1142
1253
  <input type="checkbox" id="model-lock">
1143
1254
  <span data-t="modelLock">锁定面板模型(忽略单次调用的 model 参数)</span>
1144
1255
  </label>
1145
- <div class="muted" id="model-state"></div>
1256
+ <div class="status" id="model-status" role="status" aria-live="polite"></div>
1146
1257
  <!-- The catalogue the CLI reports is too long for a dropdown, so it gets a
1147
1258
  browser: search, filter, and a switch per model. Switching a model off
1148
1259
  only hides it from the picker — it is never deleted, so switching it
1149
1260
  back on restores the same entry. -->
1150
- <div class="picker">
1151
- <div class="picker-top">
1152
- <input type="search" id="model-filter" autocomplete="off" spellcheck="false">
1153
- <div class="tabs" id="model-tabs"></div>
1261
+ <details class="fold" id="model-browser">
1262
+ <summary data-t="modelBrowse">模型目录、搜索与筛选</summary>
1263
+ <div class="controls">
1264
+ <button type="button" id="model-reload" class="ghost" data-t="reloadModels">重新读取模型列表</button>
1154
1265
  </div>
1155
- <div class="picker-body">
1156
- <div class="picker-list" id="model-list"></div>
1157
- <div class="picker-detail" id="model-detail"></div>
1266
+ <div class="picker">
1267
+ <div class="picker-top">
1268
+ <input type="search" id="model-filter" autocomplete="off" spellcheck="false">
1269
+ <div class="tabs" id="model-tabs"></div>
1270
+ </div>
1271
+ <div class="picker-body">
1272
+ <div class="picker-list" id="model-list"></div>
1273
+ <div class="picker-detail" id="model-detail"></div>
1274
+ </div>
1158
1275
  </div>
1159
- </div>
1160
- <div class="muted" data-t="modelNote" style="margin-top:6px"></div>
1161
- </div>
1162
-
1163
- <div class="grid">
1164
- <div class="card">
1165
- <h2 data-t="included">总包含额度</h2>
1166
- <div class="big" id="total-pct">—</div>
1167
- <div class="bar" id="total-bar"><i style="width:0%"></i></div>
1168
- <div class="muted" id="total-sub">—</div>
1169
- </div>
1170
- <div class="card">
1171
- <h2 data-t="auto">Auto 额度</h2>
1172
- <div class="big" id="auto-pct">—</div>
1173
- <div class="bar" id="auto-bar"><i style="width:0%"></i></div>
1174
- <div class="muted" id="auto-sub">—</div>
1175
- </div>
1176
- <div class="card">
1177
- <h2 data-t="api">API / 指定模型额度</h2>
1178
- <div class="big" id="api-pct">—</div>
1179
- <div class="bar" id="api-bar"><i style="width:0%"></i></div>
1180
- <div class="muted" id="api-sub">—</div>
1181
- </div>
1182
- <div class="card">
1183
- <h2 data-t="spend">本期消费</h2>
1184
- <div class="big" id="spend">—</div>
1185
- <div class="muted" id="spend-sub">—</div>
1186
- </div>
1187
- </div>
1188
-
1189
- <div class="row card">
1190
- <h2 data-t="period">计费周期</h2>
1191
- <div id="cycle" class="muted">—</div>
1192
- <div id="message" class="muted" style="margin-top:6px"></div>
1193
- </div>
1276
+ <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>
1280
+ <div class="muted" id="model-state"></div>
1281
+ </details>
1282
+ </section>
1194
1283
 
1195
1284
  <div class="row card">
1196
1285
  <h2 data-t="live">运行中的 Cursor 会话</h2>
@@ -1230,11 +1319,23 @@ function dashboardHtml(state) {
1230
1319
  account: '账号',
1231
1320
  loading: '加载中…',
1232
1321
  refresh: '刷新',
1322
+ usage: '额度与用量',
1233
1323
  included: '总包含额度',
1324
+ used: '已用',
1234
1325
  auto: 'Auto 额度',
1235
1326
  api: 'API / 指定模型额度',
1236
1327
  spend: '本期消费',
1328
+ remaining: '剩余 ',
1329
+ noData: '暂无数据',
1330
+ levelWarn: '偏高',
1331
+ levelBad: '接近上限',
1332
+ officialNote: '官方说明',
1237
1333
  model: '子智能体模型',
1334
+ modelBrowse: '模型目录、搜索与筛选',
1335
+ modelAdvanced: '来源与出口',
1336
+ appliesNext: '下次调用生效',
1337
+ unsavedShort: '有改动未保存',
1338
+ seeAdvanced: '模型列表或代理有异常,见「来源与出口」',
1238
1339
  modelHint: '默认跟随这里的模型;勾选锁定后,智能体传入的 model 参数也不会覆盖你的选择。',
1239
1340
  modelLock: '锁定面板模型(忽略单次调用的 model 参数)',
1240
1341
  modelLocked: '已锁定模型',
@@ -1301,11 +1402,10 @@ function dashboardHtml(state) {
1301
1402
  none: '暂无',
1302
1403
  unreachable: '无法连接',
1303
1404
  usageUnavailable: '用量暂时取不到:',
1405
+ quotaBlocked: 'Cursor 额度已耗尽,已暂停新的子智能体任务;额度刷新确认恢复后会自动解除拦截。',
1304
1406
  updated: '更新于 ',
1305
1407
  failed: '获取失败 ',
1306
- spentOf: '已用 ',
1307
- of: ' / 共 ',
1308
- includedIn: '包含 ',
1408
+ includedIn: '套餐内 ',
1309
1409
  bonus: ' · 赠送 ',
1310
1410
  starts: '开始 ',
1311
1411
  renews: ' → 续订 ',
@@ -1346,11 +1446,23 @@ function dashboardHtml(state) {
1346
1446
  account: 'account',
1347
1447
  loading: 'loading…',
1348
1448
  refresh: 'refresh',
1449
+ usage: 'Quota & usage',
1349
1450
  included: 'Included total',
1451
+ used: 'used',
1350
1452
  auto: 'Auto bucket',
1351
1453
  api: 'API / named bucket',
1352
- spend: 'Spend',
1454
+ spend: 'Spend this period',
1455
+ remaining: 'remaining ',
1456
+ noData: 'no data yet',
1457
+ levelWarn: 'high',
1458
+ levelBad: 'near the limit',
1459
+ officialNote: 'Official note',
1353
1460
  model: 'Child-agent model',
1461
+ modelBrowse: 'Model catalogue, search and filters',
1462
+ modelAdvanced: 'Source and egress',
1463
+ appliesNext: 'applies on the next call',
1464
+ unsavedShort: 'unsaved changes',
1465
+ seeAdvanced: 'model list or proxy problem — see Source and egress',
1354
1466
  modelHint: 'Calls follow this default unless they specify a model. Lock it to prevent the agent from overriding your choice.',
1355
1467
  modelLock: 'Lock the panel model (ignore per-call model arguments)',
1356
1468
  modelLocked: 'model locked',
@@ -1417,10 +1529,9 @@ function dashboardHtml(state) {
1417
1529
  none: 'none',
1418
1530
  unreachable: 'unreachable',
1419
1531
  usageUnavailable: 'Usage unavailable: ',
1532
+ quotaBlocked: 'Cursor quota is exhausted. New child-agent runs are paused until a quota refresh confirms capacity has returned.',
1420
1533
  updated: 'updated ',
1421
1534
  failed: 'failed ',
1422
- spentOf: 'spent ',
1423
- of: ' of ',
1424
1535
  includedIn: 'included ',
1425
1536
  bonus: ' · bonus ',
1426
1537
  starts: 'starts ',
@@ -1469,6 +1580,7 @@ function dashboardHtml(state) {
1469
1580
  var models = (boot.models && boot.models.list) || [];
1470
1581
  var modelsFetchedAt = (boot.models && boot.models.fetchedAt) || 0;
1471
1582
  var modelsSource = (boot.models && boot.models.source) || '';
1583
+ var quotaGate = boot.quotaGuard || { blocked: false };
1472
1584
  var configModel = boot.configModel || '';
1473
1585
  var setupState = null;
1474
1586
  var LANG = pickLang();
@@ -1513,7 +1625,9 @@ function dashboardHtml(state) {
1513
1625
  }
1514
1626
  renderLangs();
1515
1627
  renderModel();
1516
- renderLive(lastPayload);
1628
+ renderQuotaGuard(quotaGate);
1629
+ if (lastPayload.ok === true) render(lastPayload);
1630
+ else renderLive(lastPayload);
1517
1631
  renderSetup();
1518
1632
  }
1519
1633
  /** The 中文 / English switch, in the header. */
@@ -1545,8 +1659,15 @@ function dashboardHtml(state) {
1545
1659
  });
1546
1660
  }
1547
1661
 
1662
+ function known(value) { return typeof value === 'number' && isFinite(value); }
1548
1663
  function pct(value) {
1549
- return typeof value === 'number' ? value.toFixed(1) + '%' : '—';
1664
+ return known(value) ? value.toFixed(1) + '%' : '—';
1665
+ }
1666
+ /** The note under a bar, read off the same upstream percentage as the bar. */
1667
+ function remaining(value) {
1668
+ if (!known(value)) return t('noData');
1669
+ var level = value >= 90 ? ' · ' + t('levelBad') : value >= 70 ? ' · ' + t('levelWarn') : '';
1670
+ return t('remaining') + Math.max(0, 100 - value).toFixed(1) + '%' + level;
1550
1671
  }
1551
1672
  function money(value) {
1552
1673
  return typeof value === 'number' ? '$' + value.toFixed(2) : '—';
@@ -1565,9 +1686,12 @@ function dashboardHtml(state) {
1565
1686
  function text(node, value) { node.textContent = value == null ? '—' : String(value); }
1566
1687
  function setBar(id, percent) {
1567
1688
  var bar = $(id);
1568
- var p = typeof percent === 'number' ? Math.max(0, Math.min(100, percent)) : 0;
1689
+ var p = known(percent) ? Math.max(0, Math.min(100, percent)) : 0;
1569
1690
  bar.firstElementChild.style.width = p + '%';
1570
1691
  bar.className = 'bar' + (p >= 90 ? ' bad' : p >= 70 ? ' warn' : '');
1692
+ if (known(percent)) bar.setAttribute('aria-valuenow', String(p));
1693
+ else bar.removeAttribute('aria-valuenow');
1694
+ bar.setAttribute('aria-valuetext', known(percent) ? pct(percent) : t('noData'));
1571
1695
  }
1572
1696
 
1573
1697
  /** Compare the CLI's Auto id with the panel alias without conflating variants. */
@@ -1759,6 +1883,12 @@ function dashboardHtml(state) {
1759
1883
  if (proxyError) bits.push(t('proxyFailed') + proxyError);
1760
1884
  if (modelsError) bits.push(t('modelsFailed') + modelsError);
1761
1885
  text($('model-state'), bits.join(' · '));
1886
+ var dirty = (draftModel !== null && canonicalModel(draftModel) !== canonicalModel(settings.model)) || switchesDirty();
1887
+ var status = [t('inEffect') + modelLabel(inEffectNow),
1888
+ t(settings.modelPolicy === 'panel' && inEffectNow ? 'modelLocked' : 'modelOverridable'),
1889
+ t(dirty ? 'unsavedShort' : 'appliesNext')];
1890
+ if (modelsError || proxyError) status.push(t('seeAdvanced'));
1891
+ text($('model-status'), status.join(' · '));
1762
1892
  renderPicker(rows, selected, inEffectNow);
1763
1893
  }
1764
1894
 
@@ -2016,7 +2146,7 @@ function dashboardHtml(state) {
2016
2146
  * (an agent loop to hold it, a recorded Cursor session to read).
2017
2147
  */
2018
2148
  function canRestore(entry) {
2019
- return boot.children.available === true
2149
+ return boot.children.available === true && entry.restorable !== false && entry.running !== true
2020
2150
  && typeof entry.cursorSessionId === 'string' && entry.cursorSessionId !== '';
2021
2151
  }
2022
2152
 
@@ -2056,6 +2186,10 @@ function dashboardHtml(state) {
2056
2186
  });
2057
2187
  return;
2058
2188
  }
2189
+ if (body.code === 'conversation_pending' || body.code === 'conversation_running') {
2190
+ openJournal(entry.key);
2191
+ return;
2192
+ }
2059
2193
  notice(t('restoreFailed') + (body.error || 'unknown error'));
2060
2194
  })
2061
2195
  .catch(function (error) {
@@ -2342,8 +2476,14 @@ function dashboardHtml(state) {
2342
2476
  renderCount(convos.length);
2343
2477
  applyFolded();
2344
2478
  }
2479
+ function renderQuotaGuard(state) {
2480
+ if (state && typeof state.blocked === 'boolean') quotaGate = state;
2481
+ $('quota-alert').hidden = quotaGate.blocked !== true;
2482
+ }
2345
2483
  function render(payload) {
2346
2484
  lastPayload = payload;
2485
+ var gate = payload.extended && payload.extended.quotaGuard;
2486
+ if (gate) renderQuotaGuard(gate);
2347
2487
  var err = $('error');
2348
2488
  // A failed snapshot carries no extended block; keep whatever the table
2349
2489
  // already shows rather than blanking it.
@@ -2357,20 +2497,26 @@ function dashboardHtml(state) {
2357
2497
  }
2358
2498
  err.style.display = 'none';
2359
2499
  var u = payload.usage || {};
2360
- setBar('total-bar', u.total && u.total.percentUsed);
2361
- setBar('auto-bar', u.auto && u.auto.percentUsed);
2362
- setBar('api-bar', u.api && u.api.percentUsed);
2363
- text($('total-pct'), pct(u.total && u.total.percentUsed));
2364
- text($('auto-pct'), pct(u.auto && u.auto.percentUsed));
2365
- text($('api-pct'), pct(u.api && u.api.percentUsed));
2500
+ var d = u.dollars || {};
2501
+ // Every percentage is the upstream percentUsed: the dollar fields count
2502
+ // different things (spend includes bonus, limit does not), so a ratio of
2503
+ // them disagrees with the bar.
2504
+ ['total', 'auto', 'api'].forEach(function (name) {
2505
+ var p = u[name] && u[name].percentUsed;
2506
+ setBar(name + '-bar', p);
2507
+ text($(name + '-pct'), pct(p));
2508
+ text($(name + '-sub'), remaining(p));
2509
+ });
2510
+ $('auto-sub').title = u.autoMessage || '';
2511
+ $('api-sub').title = u.namedMessage || '';
2366
2512
  text($('tier'), u.plan || payload.tier || t('account'));
2367
- text($('total-sub'), t('spentOf') + money(u.dollars && u.dollars.total) + t('of') + money(u.dollars && u.dollars.limit));
2368
- text($('auto-sub'), u.autoMessage || t('auto'));
2369
- text($('api-sub'), u.namedMessage || t('api'));
2370
- text($('spend'), money(u.dollars && u.dollars.total));
2371
- text($('spend-sub'), t('includedIn') + money(u.dollars && u.dollars.included) + t('bonus') + money(u.dollars && u.dollars.bonus));
2372
- text($('cycle'), t('starts') + when(u.billingCycle && u.billingCycle.start) + t('renews') + when(u.billingCycle && u.billingCycle.end));
2513
+ text($('spend'), money(d.total));
2514
+ text($('spend-sub'), t('includedIn') + money(d.included) + t('bonus') + money(d.bonus));
2515
+ var cycle = u.billingCycle || {};
2516
+ text($('cycle'), known(cycle.start) || known(cycle.end)
2517
+ ? t('starts') + when(cycle.start) + t('renews') + when(cycle.end) : t('noData'));
2373
2518
  text($('message'), u.displayMessage || '');
2519
+ $('message-box').hidden = !u.displayMessage;
2374
2520
  text($('stamp'), t('updated') + when(payload.fetchedAt));
2375
2521
  text($('source'), t('source') + (payload.source || '') + (payload.authPath ? t('credentials') + payload.authPath : ''));
2376
2522
  renderLive(payload);
@@ -2437,6 +2583,7 @@ function dashboardHtml(state) {
2437
2583
  })
2438
2584
  .catch(function (e) {
2439
2585
  text($('model-state'), t('saveFailed') + String(e));
2586
+ text($('model-status'), t('saveFailed') + String(e));
2440
2587
  return undefined;
2441
2588
  });
2442
2589
  }
@@ -2543,6 +2690,7 @@ function dashboardHtml(state) {
2543
2690
  var nextProxy = proxyBox ? proxyBox.value.trim() : '';
2544
2691
  var proxyChanged = nextProxy !== (settings.proxy == null ? '' : String(settings.proxy));
2545
2692
  text($('model-state'), '…');
2693
+ text($('model-status'), '…');
2546
2694
  post({
2547
2695
  model: value,
2548
2696
  modelPolicy: $('model-lock').checked ? 'panel' : 'per-call',
@@ -2556,8 +2704,9 @@ function dashboardHtml(state) {
2556
2704
  draftDisabled = null;
2557
2705
  renderLive(lastPayload);
2558
2706
  var announce = function () {
2559
- text($('model-state'), t('saved') + ' · ' + t('inEffect') + modelLabel(data.effectiveModel)
2560
- + ' · ' + t(settings.modelPolicy === 'panel' && data.effectiveModel ? 'modelLocked' : 'modelOverridable'));
2707
+ var lock = t(settings.modelPolicy === 'panel' && data.effectiveModel ? 'modelLocked' : 'modelOverridable');
2708
+ text($('model-state'), t('saved') + ' · ' + t('inEffect') + modelLabel(data.effectiveModel) + ' · ' + lock);
2709
+ text($('model-status'), t('saved') + ' · ' + modelLabel(data.effectiveModel) + ' · ' + lock + ' · ' + t('appliesNext'));
2561
2710
  };
2562
2711
  // A new route reaches a different catalogue, which makes the list on
2563
2712
  // screen a fact about the old one — so re-read it before saying "saved".
@@ -2567,6 +2716,7 @@ function dashboardHtml(state) {
2567
2716
  });
2568
2717
  $('model-reload').addEventListener('click', function () {
2569
2718
  text($('model-state'), t('loading'));
2719
+ text($('model-status'), t('loading'));
2570
2720
  loadSettings(true);
2571
2721
  });
2572
2722
  // Filtering is client-side on purpose: the list is already in the payload, so
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-cursor-dev",
3
3
  "displayName": "Cursor 子智能体",
4
- "description": "把 Cursor CLI 当作子编码智能体来写业务代码:cursor_code 每次运行都在 DSH 里开一个真实的、可点开的子会话,cursor_subagent 则显示为宿主写入的 External 记录;可按任务选模型、会话在 DSH 重启后仍能续聊、返回逐步工具时间线与真实文件 diff,并自带额度/模型/会话面板。",
5
- "version": "0.12.1",
4
+ "description": "把 Cursor CLI 当作子编码智能体来写业务代码:cursor_code 与 cursor_subagent 都提供可从面板点开的实时子对话,原生任务保留宿主的 External 运行记录;可按任务选模型、会话在 DSH 重启后仍能续聊、返回逐步工具时间线与真实文件 diff,并自带额度/模型/会话面板。",
5
+ "version": "0.12.2",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "main": "lib/index.js",