dsh-agy-link 0.4.57 → 0.4.59

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/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.59 (2026-10-16)
4
+
5
+ ### English
6
+
7
+ - **Fix: turns queued for the Windows credential slot could pend forever as invisible zombies, immune to Stop (cardboard-empire incident, 2026-10-16 22:44).**
8
+ - **Incident**: a turn dispatched while another session's 32-minute run held the process-global credential slot never received the release wake-up. The turn sat at `request/context` with no agy process spawned for 30+ minutes; the idle watchdog never armed (it only arms after spawn); Stop did nothing because no wait in the dispatch phase observed the abort signal; and the leaked dispatch held one of the 3 `maxConcurrent` slots, starving later turns of other sessions into the same wedge.
9
+ - **Changes**:
10
+ 1. **`AsyncMutex` rewrite** (`credential-slot.ts`): the promise-chain FIFO could never remove a waiter — an abandoned/aborted one would wedge the whole chain behind it. Now an explicit queue: a cancelled or timed-out waiter is REMOVED and the holder's release wakes the head directly. Cancelling a waiter deliberately does NOT grant the mutex (it is still held).
11
+ 2. **Abort + bounded wait for the slot** (`acquireCredentialSlot`): new `signal` / `dispatchTimeoutMs` resolve-options. Stop now ends a queued dispatch immediately (`AbortedWaitError`); a bounded wait fails visibly (`WaitTimeoutError`) instead of parking silently.
12
+ 3. **Lost-wake-up self-heal**: while queued, a liveness poll (5s) re-checks and takes the mutex directly if it is actually free — the exact failure shape from the incident now self-corrects within seconds even if a wake-up is ever lost again.
13
+ 4. **Dispatch observability**: a 2-minute heartbeat logs the queue state ("dispatch still waiting Nm — X waiter(s), Y holder(s) … press Stop to cancel") so a stuck dispatch is visible in the console instead of a silent spinner.
14
+ 5. **Dispatch retry** (`adapter.ts`): on a hard-cap slot timeout the dispatch releases the concurrency slot, backs off abort-aware (2s→4s→…→15s), and retries (`dispatchRetries`, default 1); exhaustion fails the turn VISIBLY with guidance — never an infinite pending state. Retry is confined to the pre-spawn dispatch phase; a turn that already emitted output is never re-driven.
15
+ 6. **Rate-limit spacing sleep** is now abort-aware (Stop during the 60s window no longer spawns a turn nobody is reading).
16
+ - **Config**: new `dispatchTimeoutMinutes` (default **15** — each attempt waits up to 15 minutes for the slot, so persistence ≈ (1+retries)×15 min before a visible failure; 0 = wait indefinitely, still abort-aware / poll-healed / heartbeat-logged) and `dispatchRetries` (default 1). User-facing entry: `/agy dispatch` (inspect live queue + effective caps), `/agy dispatch <minutes|off>` and `/agy dispatch-retries <n>` — persisted via runtime overrides, effective next turn.
17
+ - **Fix: artificial concurrency limits removed — native agy parallelism restored.**
18
+ - agy is a stateless CLI and handles concurrent processes natively; the only machine-global constraint on Windows is the single credential store, and only when its IDENTITY changes.
19
+ - **Same-account fast path** (`parallelSameAccount`, default **on**): a run whose credential already sits in the slot starts immediately WITHOUT the mutex, so N sessions on one account run truly in parallel (concurrent refreshes on one grant are safe — refresh tokens are not single-use). `/agy parallel <on|off>` toggles it.
20
+ - **Account switches still serialize** (correctness): a secondary waits for same-account runs to drain before swapping the slot, new fast paths are refused while a switch pends, and the switch restores the primary afterwards — the anti-corruption invariants are unchanged and covered by new tests.
21
+ - **`maxConcurrent` default 3 → 0 (unlimited)**: no artificial admission bound; Google rate limits and the pool's cooldowns are the natural regulators. `/agy max-concurrent <n|off>`.
22
+ - **Tests**: mutex cancel-removes-and-wakes-next; timeout keeps the queue flowing; lost-wake-up poll grant; `acquireCredentialSlot` rejects when already aborted, honors Stop while queued, and fails visibly on the bounded wait.
23
+
24
+ ### 中文 (Chinese)
25
+
26
+ - **修复:排队等 Windows 凭据槽位的回合可能永远挂起成隐形僵尸,且 Stop 无法取消(cardboard-empire 事件,2026-10-16 22:44)。**
27
+ - **事件**:一个回合在另一会话 32 分钟长任务持有进程级凭据槽位期间派发,之后永远没有收到释放唤醒。该回合停在 `request/context`,30 多分钟没有拉起任何 agy 进程;空闲看门狗从未武装(只在 spawn 后才武装);按 Stop 无效,因为派发阶段的所有等待都没有观察中止信号;泄漏的派发还占着 3 个 `maxConcurrent` 槽位之一,把其他会话的新回合也拖进同样的卡死。
28
+ - **修改**:
29
+ 1. **重写 `AsyncMutex`**(credential-slot.ts):原来的 promise 链式 FIFO 无法移除等待者——一个被放弃/中止的等待者会把整条链卡死。现在改为显式队列:被取消/超时的等待者会被移除,持有者释放时直接唤醒队头。取消等待者刻意不发放互斥锁(锁仍被持有)。
30
+ 2. **槽位等待支持中止与限时**(`acquireCredentialSlot`):新增 `signal` / `dispatchTimeoutMs` 选项。Stop 现在立即结束排队中的派发(`AbortedWaitError`);限时等待可见地失败(`WaitTimeoutError`),不再静默挂起。
31
+ 3. **丢失唤醒自愈**:排队期间每 5 秒自检一次,发现锁实际空闲就直接取锁——即使唤醒再次丢失,本事件形态也会在数秒内自愈。
32
+ 4. **派发可观测性**:每 2 分钟心跳记录队列状态("dispatch still waiting Nm — X waiter(s), Y holder(s) … press Stop to cancel"),卡死的派发在控制台可见,而不是无声转圈。
33
+ 5. **派发重试**(adapter.ts):槽位硬超时后释放并发槽位、可中止退避(2s→4s→…→15s)并重试(`dispatchRetries`,默认 1);重试耗尽则让回合可见地失败并给出处理指引——绝不无限 pending。重试仅限 spawn 前的派发阶段;已经产出内容的回合绝不重跑。
34
+ 6. **限流退避 sleep** 支持中止(60 秒窗口内的 Stop 不再照常 spawn 一个没人读的回合)。
35
+ - **配置**:新增 `dispatchTimeoutMinutes`(默认 **15** —— 每轮最多等 15 分钟,含重试总坚持 ≈ (1+重试)×15 分钟后可见失败;0 = 无限等待,仍可中止/轮询自愈/心跳可见)与 `dispatchRetries`(默认 1)。用户入口:`/agy dispatch`(查看实时队列与当前配置)、`/agy dispatch <分钟|off>` 与 `/agy dispatch-retries <次数>` —— 经 runtime overrides 持久化,下一回合生效。
36
+ - **修复:移除人为并发限制,恢复 agy 原生并行能力。**
37
+ - agy 是无状态 CLI,天然支持多进程并发;Windows 上唯一的机器级约束是单一凭据库,且仅在**账号身份切换**时才需要互斥。
38
+ - **同账号快路径**(`parallelSameAccount`,默认**开**):凭据已在槽位中的账号立即启动、完全不取互斥锁 —— N 个会话在同一账号上真并行(同一授权的并发 token 刷新是安全的,refresh token 非一次性)。`/agy parallel <on|off>` 开关。
39
+ - **账号切换仍然串行**(正确性):副账号先等同账号 run 全部退出再换槽位;切换等待期间拒绝新的快路径;切换完成后恢复主账号 —— 防腐蚀不变量原样保留,新增测试覆盖。
40
+ - **`maxConcurrent` 默认 3 → 0(不限制)**:移除人为准入上限;Google 速率限制与账号池冷却是天然调节器。`/agy max-concurrent <n|off>`。
41
+ - **测试**:互斥锁取消后移除并唤醒下一个;超时不阻塞队列;丢失唤醒的轮询补偿;`acquireCredentialSlot` 已中止即拒绝、排队中响应 Stop、限时等待可见失败。
42
+
43
+ ## 0.4.58 (2026-10-16)
44
+
45
+ - **Fix: DSH `/goal` loop restart bug (autonomous runs continuing indefinitely instead of marking complete).**
46
+ - **Root Cause**:
47
+ 1. In `src/host/mapper.ts`, whenever an agy run finished with `!ev.ok` (e.g. background EOF/network timeout right as generation finishes), it yielded `{ type: 'finish', reason: { kind: 'stop' } }` and returned early, bypassing the `supportsGoalUpdate` synthetic `update_goal` completion call even when agy generated a full, successful text response. DSH interpreted this as an uncompleted round and scheduled subsequent rounds indefinitely.
48
+ 2. In `src/index.ts`, `sessionGoal` attempted to read `session.events`, but DSH `Session` instances store events in `session.log` / `session.snapshotEvents()`, causing session goal resolution to always return `null`.
49
+ 3. In `src/host/dsh-goal.ts`, `isGoalToolContinuation` only inspected `messages[messages.length - 1]`. When DSH executes `update_goal`, it defers a `<goal_complete>` user notice (`source.kind: 'tool-goal'`), placing the tool result at `messages.length - 2` and causing agy to respawn on the completion notice.
50
+ - **Fix**:
51
+ 1. `EventMapper.map` now unifies the finish path so `update_goal` and `exit_plan_mode` are always dispatched upon successful response generation regardless of trailing transport EOF flags.
52
+ 2. `sessionGoal` queries DSH's live `ctx.goals.get(agent)` in O(1) and falls back to `session.log` / `session.snapshotEvents()`.
53
+ 3. `isGoalToolContinuation` checks the trailing span for `agytc-bridge-goal-`, `source.kind === 'tool-goal'`, and `<goal_complete>`, finishing cleanly without respawning agy.
54
+ 4. Marked `tool-goal` and `<goal_complete>` as synthetic harness messages so they never get treated as human user instructions.
55
+
56
+ - **Fix: real-time 5-hour quota consumption and rolling countdown now accurately display instead of staying frozen at 100% / blank.**
57
+ - **Root Cause**:
58
+ 1. Google's `retrieveUserQuotaSummary` endpoint returns coarse group-level numbers (`gemini-5h: 1.0`), while `fetchAvailableModels` reports the actual per-model 5-hour consumption and active rolling reset time (e.g. `0.70`, reset in 12 minutes). `QuotaService.refreshAccountQuota` gated the per-model tightening behind `else if (familyQuotas[fam]!.remainingFraction === undefined)`, so whenever the coarse summary returned `1.0`, active model usage was completely ignored and the family quota was locked at `100%`.
59
+ 2. In `fetchAvailableModels`, hitting any staging endpoint (`daily-cloudcode-pa`) set `this.preferredEndpointIndex`, causing subsequent `retrieveUserQuotaSummary` calls to target the staging endpoint (which omits Gemini entirely).
60
+ 3. `mergeFallbackFamilyQuota` returned stale `prev` data whenever `prev.remainingFraction` existed, freezing accounts at their 10:04 AM state (`resetTime` in the past), which caused `formatQuotaWindow` to drop the expired timestamp as empty and render no duration at all.
61
+ - **Fix**:
62
+ 1. `fetchAvailableModels` now only sets `preferredEndpointIndex` if the endpoint is the official production endpoint (index 0).
63
+ 2. Active 5-hour model consumption (window <= 6h) now tightens `familyQuotas[fam].remainingFraction` and its `resetTime` even when summary returned a coarse `1.0`.
64
+ 3. Full-quota rows where `resetTime` has expired safely fall back to "已重置 · 可用" instead of a blank row, while active windows display the real rolling countdown (e.g. `↻ 02:50 (4小时 55分钟)`).
65
+
3
66
  ## 0.4.57 (2026-10-16)
4
67
 
5
68
  ### English
package/README.md CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  # 中文
26
26
 
27
- 把 **Google Antigravity 模型接入 DeepSeek Harness(DSH)** —— 支持**多账号智能池化轮换**、**官方双 Bucket 配额监控(5小时滚动 + 7天周额度)**、**浏览器免粘贴一键登录**与**浅色/深色主题高对比度自适应**,由官方未修改的 `agy` CLI 驱动,完整支持流式输出、思考折叠(thinking)、原生工具卡片与精确 token 用量。
27
+ 把 **Google Antigravity 模型接入 DeepSeek Harness(DSH)** —— 支持**多账号智能池化轮换**、**官方多 Bucket 配额监控(5小时滚动 + 7天周额度 + Argon 独立周池)**、**浏览器免粘贴一键登录**与**浅色/深色主题高对比度自适应**,由官方未修改的 `agy` CLI 驱动,完整支持流式输出、思考折叠(thinking)、原生工具卡片与精确 token 用量。
28
28
 
29
29
  <p align="center">
30
30
  <img src="./docs/assets/dashboard.png" alt="Antigravity 管理控制台 - 多账号池化与配额监控" width="460" />
@@ -54,11 +54,14 @@
54
54
 
55
55
  - 👥 **多账号池化管理 (Account Pool)**:在 DSH 内添加任意数量的 Google 账号(主账号 + 备用账号群),每个账号拥有独立的 HOME 目录沙箱与凭证隔离。
56
56
  - 🔄 **顺序耗尽故障转移 (Sequential Drain)**:当前账号额度用尽(触发 429 限流或 5h / 周额度耗尽)时,系统**自动平滑接力至下一个可用账号**,对话完全不中断!
57
- - 📊 **官方双 Bucket 配额监控 (Dual-Bucket Quota)**:直接调用 Antigravity 官方专用的 `v1internal:retrieveUserQuotaSummary` 接口,同时展示 **5小时滚动额度** 与 **7天周额度** 真实数据及精准重置倒计时。
57
+ - 🕰 **额度到期优先轮换 (Quota-Expiry-First)**:多个账号可用时,优先切换到**重置窗口最早到期**的账号,把"还剩很久才重置"的额度留给后面,整体池化利用率最大化。
58
+ - 🧠 **网络抖动智能甄别 (Jitter-Safe Auth)**:代理抖动导致的 keyring 鉴权超时会被自动识别为**可重试的网络错误**而非"登录失效"——基于 agy 本次运行日志的 `keyringAuth` 传输证据精准分类,且粘性"需重新登录"隔离需二次确认,健康账号绝不会被误标。
59
+ - 📊 **官方多 Bucket 配额监控 (Multi-Bucket Quota)**:直接调用 Antigravity 官方专用的 `v1internal:retrieveUserQuotaSummary` 接口,同时展示 **5小时滚动额度**、**7天周额度**,以及付费 Pro/Ultra 账号专属的 **Gemini 4 Argon 独立周额度池**(不占用普通 Gemini 池)。
60
+ - 🔀 **手动切换当前账号 (Manual Switch-Active)**:控制台卡片一键「设为当前使用」,即刻把调度指针钉到指定账号。
58
61
  - 🔑 **GUI 内浏览器免粘贴登录 (In-GUI Zero-Paste OAuth)**:点击「➕ 添加账号」自动调起系统浏览器,本地回环监听自动捕获授权凭证,**全程无需手动复制粘贴授权码**。
59
62
  - 🎨 **多主题高对比度自适应 (Light & Dark Themes)**:原生适配 DSH 浅色/深色主题,告别发暗发灰与看不清文字,UI 视觉清晰锐利。
60
- - 🌐 **独立账号网络代理 (Per-Account Proxy)**:支持为每个账号独立绑定 HTTP(S) / SOCKS 代理,避免多账号同 IP 引起风控。
61
- - ⚡ **分模型族独立冷却 (Family-Scoped Cooldown)**:Gemini 与 Claude / GPT-OSS 速率限制独立计算,某个模型族受限不影响其他模型族正常调用。
63
+ - 🌐 **独立账号网络代理 (Per-Account Proxy)**:支持为每个账号独立绑定 HTTP(S) / SOCKS 代理,避免多账号同 IP 引起风控;未显式配置时自动探测系统代理(macOS `scutil` / Windows 注册表 / Linux gsettings)。
64
+ - ⚡ **分模型族独立冷却 (Family-Scoped Cooldown)**:Gemini、Claude、GPT-OSS 与 Argon 的速率限制独立计算,某个模型族受限不影响其他模型族正常调用。
62
65
 
63
66
  ---
64
67
 
@@ -66,20 +69,23 @@
66
69
 
67
70
  | 能力 | 说明 |
68
71
  | ---- | ---- |
69
- | 👥 **多账号池化轮换** | 支持主账号 + 多个备用账号;遇到 429 或配额耗尽自动平滑切换,模型族独立冷却 |
70
- | 📊 **官方双 Bucket 配额监控** | 实时拉取官方端点,同时呈现 **5h 滚动额度** 与 **7d 周额度** 双进度条与倒计时,单模型明细可展开 |
72
+ | 👥 **多账号池化轮换** | 支持主账号 + 多个备用账号;遇到 429 或配额耗尽自动平滑切换,模型族独立冷却,额度到期早的账号优先接班 |
73
+ | 🧠 **抖动安全鉴权 (Jitter-Safe Auth)** | 基于 agy 运行日志的 keyring 传输证据区分"网络抖动"与"真登出",抖动自动按可重试网络错误处理;粘性隔离需二次确认,杜绝误标"需重新登录" |
74
+ | 📊 **官方多 Bucket 配额监控** | 实时拉取官方端点,同时呈现 **5h 滚动额度**、**7d 周额度**与 **Argon 独立周池**(付费 Pro/Ultra)多进度条与倒计时,单模型明细可展开 |
71
75
  | 🔑 **浏览器一键登录** | 点击添加账号自动唤起 Google 授权页,本地回环自动捕获,全程无需复制粘贴任何代码 |
76
+ | 🔀 **手动切换当前账号** | 控制台「设为当前使用」按钮即刻钉住调度指针;顶部徽标始终显示服务器实际调度的账号 |
72
77
  | 🎨 **多主题高对比 UI** | 浅色、深色主题自适应变色,纯矢量 SVG 图标(无 Emoji),字迹清晰不发暗 |
73
- | 🌐 **独立账号代理** | 每个账号支持独立配置 HTTP(S) 代理,隔离 IP 出口 |
74
- | 🔌 **原生模型路由** | 在 DSH 模型配置中注册 `antigravity` 提供方,`/model` 选择器随心切换 Gemini / Claude / GPT-OSS |
78
+ | 🌐 **独立账号代理** | 每个账号支持独立配置 HTTP(S) 代理,隔离 IP 出口;亦可自动探测系统代理 |
79
+ | 🔌 **原生模型路由** | 在 DSH 模型配置中注册 `antigravity` 提供方,`/model` 选择器随心切换 Gemini / Claude / GPT-OSS / Argon |
75
80
  | 🌊 **完整流式映射** | 文本、思考过程(thinking turn)与工具活动均无损映射到 DSH 原生流协议 |
76
81
  | 🃏 **原生工具卡片** | agy 执行的终端命令(`run_command`)、文件修改(inline diff)均以 DSH 原生卡片渲染 |
77
82
  | 🔗 **会话上下文保真** | DSH 会话与 agy 原生会话绑定(`--conversation`),由 agy 管理完整历史,每轮仅发送增量 |
83
+ | 🎯 **DSH 目标与计划集成** | 长任务自动携带 DSH goal / plan 上下文,工具续跑不丢任务状态 |
78
84
  | 📊 **精确 Token 统计** | 输入 / 输出 / 思考 / 缓存读取 token 完整计入 DSH 用量统计,全面适配 1M 上下文 |
79
85
  | 🎛 **顶部控制台徽标** | 顶部栏 `AGY (n)` 徽标一键弹出控制台,直观查看账号池、配额进度条与代理状态 |
80
86
  | 🖼 **图片多模态** | 支持多模态图片传入,自动落盘本地媒体目录并通过 `--add-dir` 安全授权给 agy 查看 |
81
87
  | 🤝 **`agy_ask` 辅助工具** | 允许任意 DSH 模型把单次任务委托给 Antigravity 模型(AskAntigravity 协作模式) |
82
- | ⌨️ **`/agy` 快捷命令族** | `status` / `auth` / `models` / `mode` / `effort` / `workspace` / `clear` / `doctor` / `help` |
88
+ | ⌨️ **`/agy` 快捷命令族** | `status` / `pool` / `auth` / `models` / `mode` / `effort` / `workspace` / `goal` / `session` / `enable-account` / `disable-account` / `clear` / `doctor` / `help` |
83
89
 
84
90
  ---
85
91
 
@@ -144,8 +150,9 @@ dsh plugin --profile web add dsh-agy-link
144
150
 
145
151
  1. **短生命周期进程驱动**:每一轮 DSH 对话启动一个独立的官方 `agy -p --output-format stream-json` 进程,解析 NDJSON 事件流。
146
152
  2. **多账号沙箱机制**:除主账号沿用系统默认 Keychain / HOME 外,所有备用账号在插件私有目录下建立专属 HOME 隔离区,独立写入 agy 格式的标准 OAuth 凭证。
147
- 3. **双 Bucket 官方配额汇总**:插件定时后台静默轮询官方 `v1internal:retrieveUserQuotaSummary`,精准掌控每个账号的 5h 滚动削峰水位与 7d 账号阶梯周额度。
148
- 4. **会话级无缝故障转移**:当遭遇 HTTP 429、额度耗尽或凭证异常时,号池调度器自动标记当前账号家族进入冷却,并无缝将后续请求路由到池内下一个有效账号。
153
+ 3. **多 Bucket 官方配额汇总**:插件定时后台静默轮询官方 `v1internal:retrieveUserQuotaSummary`,精准掌控每个账号的 5h 滚动削峰水位、7d 账号阶梯周额度,以及付费账号的 Argon 独立周池。
154
+ 4. **会话级无缝故障转移**:当遭遇 HTTP 429、额度耗尽或凭证异常时,号池调度器自动标记当前账号家族进入冷却(或按额度到期时间择优轮换),并无缝将后续请求路由到池内下一个有效账号。
155
+ 5. **失败分类与自愈**:错误按 AUTH / NETWORK / RATE_LIMIT / ELIGIBILITY 等精确分类;网络抖动借由 agy 运行日志自动甄别为可重试故障,绝不误伤健康账号的登录状态。
149
156
 
150
157
  ---
151
158
 
@@ -170,7 +177,7 @@ MIT License.
170
177
 
171
178
  # English
172
179
 
173
- Bring **Google Antigravity models into DeepSeek Harness (DSH)** — featuring **Multi-Account Pooling & Automatic Rotation**, **Official Dual-Bucket Quota Tracking (5h Rolling + 7d Weekly)**, **Zero-Paste In-GUI OAuth Login**, and **Theme-Adaptive High-Contrast UI**, powered by the official unmodified `agy` CLI.
180
+ Bring **Google Antigravity models into DeepSeek Harness (DSH)** — featuring **Multi-Account Pooling & Automatic Rotation**, **Official Multi-Bucket Quota Tracking (5h Rolling + 7d Weekly + Argon Weekly Pool)**, **Zero-Paste In-GUI OAuth Login**, and **Theme-Adaptive High-Contrast UI**, powered by the official unmodified `agy` CLI.
174
181
 
175
182
  <p align="center">
176
183
  <img src="./docs/assets/dashboard.png" alt="Antigravity Management Console - Multi-Account Pool & Quota Monitor" width="460" />
@@ -199,11 +206,14 @@ Have multiple Google Antigravity / Gemini accounts? **dsh-agy-link makes multi-a
199
206
 
200
207
  - 👥 **Multi-Account Pooling**: Manage unlimited Google accounts (Primary + Secondary pool) with fully isolated HOME sandbox environments and credentials.
201
208
  - 🔄 **Sequential Drain Failover**: When an account reaches its rate limits (429) or runs out of 5h/weekly quotas, requests **automatically and smoothly fail over to the next available account** without interrupting your conversation!
202
- - 📊 **Official Dual-Bucket Quota Tracking**: Direct integration with Antigravity's official `v1internal:retrieveUserQuotaSummary` endpoint, providing real-time visibility for both **5-Hour Rolling Limit** and **7-Day Weekly Limit** with live countdowns.
209
+ - 🕰 **Quota-Expiry-First Rotation**: With several accounts available, the scheduler prefers the one whose **reset window expires soonest**, maximizing utilization across the whole pool.
210
+ - 🧠 **Jitter-Safe Auth Classification**: A keyring-auth timeout caused by proxy jitter is recognized as a **retryable network error**, not a sign-out — classified from the `keyringAuth` transport evidence in agy's own run log, and sticky "needs re-login" quarantine requires a second confirmation, so healthy accounts are never falsely flagged.
211
+ - 📊 **Official Multi-Bucket Quota Tracking**: Direct integration with Antigravity's official `v1internal:retrieveUserQuotaSummary` endpoint, providing real-time visibility for the **5-Hour Rolling Limit**, the **7-Day Weekly Limit**, and the **Gemini 4 Argon weekly pool** (paid Pro/Ultra accounts, billed separately from the plain Gemini pool).
212
+ - 🔀 **Manual Switch-Active**: One click on "Set active" pins the scheduler pointer to the account you choose.
203
213
  - 🔑 **In-GUI Zero-Paste OAuth**: One-click "Add Account" opens your browser and automatically captures OAuth tokens via local loopback — **no copying and pasting of authorization codes required**.
204
214
  - 🎨 **Theme-Adaptive High-Contrast UI**: Seamlessly adapts to Light and Dark DSH themes with crystal clear typography and crisp contrast.
205
- - 🌐 **Per-Account Proxy**: Configure dedicated HTTP(S) / SOCKS proxy for each individual account to isolate IP addresses.
206
- - ⚡ **Family-Scoped Cooldowns**: Gemini, Claude, and GPT-OSS rate limits are calculated independently, preventing one exhausted model from blocking others.
215
+ - 🌐 **Per-Account Proxy**: Configure dedicated HTTP(S) / SOCKS proxy for each individual account to isolate IP addresses; when unset, the system proxy is auto-detected (macOS `scutil` / Windows registry / Linux gsettings).
216
+ - ⚡ **Family-Scoped Cooldowns**: Gemini, Claude, GPT-OSS, and Argon rate limits are calculated independently, preventing one exhausted model from blocking others.
207
217
 
208
218
  ---
209
219
 
@@ -211,20 +221,23 @@ Have multiple Google Antigravity / Gemini accounts? **dsh-agy-link makes multi-a
211
221
 
212
222
  | Feature | Description |
213
223
  | --- | --- |
214
- | 👥 **Multi-Account Pool** | Primary + unlimited spare accounts with automatic Sequential Drain rotation upon rate limits |
215
- | 📊 **Dual-Bucket Quota HUD** | Real-time dual progress bars for both **5h rolling** and **7d weekly** quota limits with exact reset countdowns |
224
+ | 👥 **Multi-Account Pool** | Primary + unlimited spare accounts with automatic Sequential Drain rotation upon rate limits; quota-expiry-first scheduling |
225
+ | 🧠 **Jitter-Safe Auth** | Distinguishes network jitter from a real sign-out via keyring transport evidence in agy's run log; sticky quarantine requires confirmation — no false "needs re-login" flags |
226
+ | 📊 **Multi-Bucket Quota HUD** | Real-time progress bars for the **5h rolling**, **7d weekly**, and **Argon weekly** (paid Pro/Ultra) pools with exact reset countdowns |
216
227
  | 🔑 **Zero-Paste Browser Login** | Automatic browser-based Google OAuth login with loopback callback handler |
228
+ | 🔀 **Manual Switch-Active** | Pin the scheduler to any account from the console; the header badge always names the server-scheduled account |
217
229
  | 🎨 **Theme-Adaptive UI** | Dynamic light/dark theme switching, pure SVG vector marks (no emojis), high contrast |
218
- | 🌐 **Per-Account Proxy** | Dedicated HTTP(S) proxy per account slot |
219
- | 🔌 **Native Model Routing** | Registers `antigravity` provider in DSH; choose Gemini / Claude / GPT-OSS in `/model` picker |
230
+ | 🌐 **Per-Account Proxy** | Dedicated HTTP(S) proxy per account slot, or auto-detected system proxy |
231
+ | 🔌 **Native Model Routing** | Registers `antigravity` provider in DSH; choose Gemini / Claude / GPT-OSS / Argon in `/model` picker |
220
232
  | 🌊 **Full Streaming Protocol** | Native streaming for text, thinking turn annotations, and tool calls |
221
233
  | 🃏 **Native Tool UI Cards** | Command runs and inline diffs render as clean, native DSH tool cards |
222
234
  | 🔗 **Session Continuity** | Binds DSH sessions to native agy conversations (`--conversation`) for zero-redundancy context |
235
+ | 🎯 **DSH Goal & Plan Integration** | Long-running tasks carry DSH goal / plan context across tool continuations |
223
236
  | 📊 **Accurate Token Metering** | Input, output, thinking, and cacheRead tokens are fully reported to DSH with 1M context support |
224
237
  | 🎛 **Top Header Console Badge** | Click the `AGY (n)` badge in the header to instantly open the management console |
225
238
  | 🖼 **Multimodal Images** | Stages images to local storage and passes safe absolute paths via `--add-dir` |
226
239
  | 🤝 **`agy_ask` Delegation** | Allows other DSH models to delegate subtasks to Antigravity models |
227
- | ⌨️ **`/agy` Commands** | Full command suite: `status`, `auth`, `models`, `mode`, `effort`, `workspace`, `clear`, `doctor`, `help` |
240
+ | ⌨️ **`/agy` Commands** | Full command suite: `status`, `pool`, `auth`, `models`, `mode`, `effort`, `workspace`, `goal`, `session`, `enable-account`, `disable-account`, `clear`, `doctor`, `help` |
228
241
 
229
242
  ---
230
243
 
@@ -273,6 +286,7 @@ dsh plugin --profile web add dsh-agy-link
273
286
  | `defaultEffort` | `DSH_AGY_DEFAULT_EFFORT` | `(model default)` | Thinking budget: `low` / `medium` / `high` |
274
287
  | `hiddenModels` | `DSH_AGY_HIDDEN_MODELS` | `[]` | Model slugs hidden from the `/model` picker (deny-list, newly discovered models stay visible; manage them via the "Model visibility" row in the settings panel) |
275
288
  | `timeoutMs` | `DSH_AGY_TIMEOUT_MS` | `600000` | Activity watchdog timeout in milliseconds |
289
+ | `longToolIdleMinutes` | `DSH_AGY_LONG_TOOL_IDLE_MINUTES` | `240` | Idle budget while a tool step is ACTIVE (minutes); `0` keeps the normal `timeoutMs` budget so wedged tools fail fast |
276
290
  | `workspaceRoot` | `DSH_AGY_WORKSPACE_ROOT` | session cwd | Working directory root |
277
291
 
278
292
  ---
package/dist/client.js CHANGED
@@ -105,6 +105,7 @@ window.__ModuleLoader__.load({
105
105
  "quota.fiveHour": "5 小时额度",
106
106
  "quota.weekly": "周额度",
107
107
  "quota.refreshed": "额度已刷新",
108
+ "quota.windowReset": "已重置 · 可用",
108
109
  "proxy.button": "代理",
109
110
  "proxy.placeholder": "专属代理 URL(如:http://127.0.0.1:7890;留空则使用全局)",
110
111
  "proxy.save": "保存",
@@ -244,6 +245,7 @@ window.__ModuleLoader__.load({
244
245
  "quota.fiveHour": "5-hour quota",
245
246
  "quota.weekly": "Weekly quota",
246
247
  "quota.refreshed": "Quota refreshed",
248
+ "quota.windowReset": "Reset · available",
247
249
  "proxy.button": "Proxy",
248
250
  "proxy.placeholder": "Dedicated proxy URL (for example: http://127.0.0.1:7890; leave blank to use the global proxy)",
249
251
  "proxy.save": "Save",
@@ -383,6 +385,7 @@ window.__ModuleLoader__.load({
383
385
  "quota.fiveHour": "Cota de 5 horas",
384
386
  "quota.weekly": "Cota semanal",
385
387
  "quota.refreshed": "Cota atualizada",
388
+ "quota.windowReset": "Redefinida · disponível",
386
389
  "proxy.button": "Proxy",
387
390
  "proxy.placeholder": "URL do proxy exclusivo (por exemplo: http://127.0.0.1:7890; deixe vazio para usar o proxy global)",
388
391
  "proxy.save": "Salvar",
@@ -522,6 +525,7 @@ window.__ModuleLoader__.load({
522
525
  "quota.fiveHour": "Cuota de 5 horas",
523
526
  "quota.weekly": "Cuota semanal",
524
527
  "quota.refreshed": "Cuota actualizada",
528
+ "quota.windowReset": "Reiniciada · disponible",
525
529
  "proxy.button": "Proxy",
526
530
  "proxy.placeholder": "URL de proxy exclusivo (por ejemplo: http://127.0.0.1:7890; déjelo vacío para usar el proxy global)",
527
531
  "proxy.save": "Guardar",
@@ -3592,11 +3596,17 @@ div[data-variant="think"] [class*="thinkBody"] {
3592
3596
  * Format reset timestamp into readable date/time + relative countdown.
3593
3597
  */
3594
3598
  function formatQuotaWindow(t, resetTimeStr) {
3595
- if (!resetTimeStr) return { resetText: "" };
3599
+ if (!resetTimeStr) return {
3600
+ resetText: "",
3601
+ expired: false
3602
+ };
3596
3603
  try {
3597
3604
  const d = new Date(resetTimeStr);
3598
3605
  const diffMs = d.getTime() - Date.now();
3599
- if (diffMs <= 0) return { resetText: "" };
3606
+ if (Number.isNaN(d.getTime()) || diffMs <= 0) return {
3607
+ resetText: "",
3608
+ expired: true
3609
+ };
3600
3610
  const totalMins = Math.ceil(diffMs / 6e4);
3601
3611
  const days = Math.floor(totalMins / 1440);
3602
3612
  const hours = Math.floor(totalMins % 1440 / 60);
@@ -3605,9 +3615,15 @@ div[data-variant="think"] [class*="thinkBody"] {
3605
3615
  const countdown = days > 0 ? `${days}${t("duration.day")} ${hours}${t("duration.hour")}` : hours > 0 ? `${hours}${t("duration.hour")} ${mins}${t("duration.minute")}` : `${mins}${t("duration.minute")}`;
3606
3616
  const hh = d.getHours().toString().padStart(2, "0");
3607
3617
  const mm = d.getMinutes().toString().padStart(2, "0");
3608
- return { resetText: isWeekly ? `${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm} (${countdown})` : `${hh}:${mm} (${countdown})` };
3618
+ return {
3619
+ resetText: isWeekly ? `${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm} (${countdown})` : `${hh}:${mm} (${countdown})`,
3620
+ expired: false
3621
+ };
3609
3622
  } catch {
3610
- return { resetText: "" };
3623
+ return {
3624
+ resetText: "",
3625
+ expired: true
3626
+ };
3611
3627
  }
3612
3628
  }
3613
3629
  /** Inline brand logo (Gemini / Claude / OpenAI) as a crisp SVG mark. */
@@ -4603,8 +4619,11 @@ body.dark,
4603
4619
  const pct5h = has5h ? Math.max(0, Math.min(100, Math.round(info.remainingFraction * 100))) : -1;
4604
4620
  const cdNote = inCooldown ? t("account.localCooldown") : "";
4605
4621
  const w5h = formatQuotaWindow(t, info?.resetTime);
4606
- const pctWeekly = typeof info?.weeklyFraction === "number" && Number.isFinite(info.weeklyFraction) ? Math.max(0, Math.min(100, Math.round(info.weeklyFraction * 100))) : -1;
4622
+ const expiredFull5h = w5h.expired && has5h && pct5h >= 100;
4623
+ const hasWeekly = typeof info?.weeklyFraction === "number" && Number.isFinite(info.weeklyFraction);
4624
+ const pctWeekly = hasWeekly ? Math.max(0, Math.min(100, Math.round(info.weeklyFraction * 100))) : -1;
4607
4625
  const wWeekly = formatQuotaWindow(t, info?.weeklyResetTime);
4626
+ const expiredFullWeekly = wWeekly.expired && hasWeekly && pctWeekly >= 100;
4608
4627
  const getColors = (pct) => {
4609
4628
  if (pct < 0) return {
4610
4629
  bar: "var(--agy-quota-none-border)",
@@ -4689,7 +4708,7 @@ body.dark,
4689
4708
  fontSize: "12.5px",
4690
4709
  marginBottom: "5px",
4691
4710
  color: "var(--agy-text-primary)"
4692
- } }, brandIcon(FAMILY_BRAND[familyKey], 14), h("span", null, label)), familyKey !== "argon" || has5h ? renderLine(t("quota.fiveHour"), pct5h, c5h, w5h.resetText ? w5h.resetText + cdNote : cdNote.replace(/^ · /, "")) : null, renderLine(t("quota.weekly"), pctWeekly, cWeekly, wWeekly.resetText));
4711
+ } }, brandIcon(FAMILY_BRAND[familyKey], 14), h("span", null, label)), familyKey !== "argon" || has5h ? renderLine(t("quota.fiveHour"), pct5h, c5h, w5h.resetText ? w5h.resetText + cdNote : expiredFull5h ? t("quota.windowReset") + cdNote : cdNote.replace(/^ · /, "")) : null, renderLine(t("quota.weekly"), pctWeekly, cWeekly, wWeekly.resetText ? wWeekly.resetText : expiredFullWeekly ? t("quota.windowReset") : ""));
4693
4712
  };
4694
4713
  const activeAccountId = pool?.activeAccountId ?? pool?.lastActiveAccountId ?? pool?.activeAccountIds?.google ?? pool?.primaryAccountId;
4695
4714
  const renderedAccountCards = accounts.map((acc) => {
package/dist/index.js CHANGED
@@ -77,7 +77,32 @@ function defaultConfig() {
77
77
  defaultEffort: "",
78
78
  timeoutMs: 6e5,
79
79
  longToolIdleMinutes: 240,
80
- maxConcurrent: 3,
80
+ /**
81
+ * Hard cap (minutes) on waiting for the Windows credential slot before the
82
+ * dispatch releases, backs off and retries (dispatchRetries). Each attempt
83
+ * waits up to this long, so persistence ≈ (1 + dispatchRetries) × N
84
+ * minutes before the turn fails visibly. 0 = wait indefinitely (the wait
85
+ * stays abort-aware, poll-healed and heartbeat-logged).
86
+ */
87
+ dispatchTimeoutMinutes: 15,
88
+ /** Extra dispatch attempts after a credential-slot hard-cap timeout. */
89
+ dispatchRetries: 1,
90
+ /**
91
+ * Same-account credential-slot fast path (default true): sessions running
92
+ * on the account whose credential already sits in the Windows slot start
93
+ * immediately WITHOUT exclusivity — agy is a stateless CLI and handles
94
+ * concurrent processes natively. Only account SWITCHES serialize.
95
+ * false restores full-run slot exclusivity (one agy process at a time).
96
+ */
97
+ parallelSameAccount: true,
98
+ /**
99
+ * Cross-session cap on concurrent agy request streams. 0 = UNLIMITED
100
+ * (default): native agy concurrency — agy is a stateless CLI and runs
101
+ * concurrent processes natively; Google rate limits and the account
102
+ * pool's cooldowns are the natural regulators. Set a positive number to
103
+ * re-introduce an admission bound.
104
+ */
105
+ maxConcurrent: 0,
81
106
  contextWindowDefault: 1048576,
82
107
  maxTokensDefault: 65536,
83
108
  forwardSystemPrompt: false,
@@ -363,6 +388,9 @@ function resolveConfig(entry, env = process.env, overrides = readOverrides()) {
363
388
  defaultEffort: asString(get("defaultEffort")) ?? base.defaultEffort,
364
389
  timeoutMs: asNum(get("timeoutMs")) ?? base.timeoutMs,
365
390
  longToolIdleMinutes: asNum(get("longToolIdleMinutes")) ?? base.longToolIdleMinutes,
391
+ dispatchTimeoutMinutes: asNum(get("dispatchTimeoutMinutes")) ?? base.dispatchTimeoutMinutes,
392
+ dispatchRetries: asNum(get("dispatchRetries")) ?? base.dispatchRetries,
393
+ parallelSameAccount: asBool(get("parallelSameAccount")) ?? base.parallelSameAccount,
366
394
  maxConcurrent: asNum(get("maxConcurrent")) ?? base.maxConcurrent,
367
395
  contextWindowDefault: asNum(get("contextWindowDefault")) ?? base.contextWindowDefault,
368
396
  maxTokensDefault: asNum(get("maxTokensDefault")) ?? base.maxTokensDefault,
@@ -1408,9 +1436,9 @@ function parseGoalRoundText(text) {
1408
1436
  const maxRounds = roundMatch && roundMatch[2] ? parseInt(roundMatch[2], 10) : void 0;
1409
1437
  const objMatch = text.match(/Objective:\s*(?:"([^"]*)"|'([^']*)'|([^\n]+))/);
1410
1438
  const objective = objMatch ? (objMatch[1] || objMatch[2] || objMatch[3])?.trim() : void 0;
1411
- const idMatch = text.match(/goal-[a-zA-Z0-9_-]+/);
1439
+ const idMatch = text.match(/goal-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/) ?? text.match(/\bid:\s*["']?([a-zA-Z0-9_-]+)["']?/);
1412
1440
  return {
1413
- goalId: idMatch ? idMatch[0] : void 0,
1441
+ goalId: idMatch ? idMatch[1] ?? idMatch[0] : void 0,
1414
1442
  round,
1415
1443
  maxRounds,
1416
1444
  objective
@@ -1472,14 +1500,21 @@ function isPlanMode(messages) {
1472
1500
  return false;
1473
1501
  }
1474
1502
  /**
1475
- * Check if the trailing message is the tool result of a bridge-emitted `update_goal` call.
1503
+ * Check if the trailing span represents completion of a bridge-emitted `update_goal` call.
1504
+ * This covers both the direct tool-result message and DSH's deferred `<goal_complete>` notice.
1476
1505
  */
1477
1506
  function isGoalToolContinuation(messages) {
1478
1507
  if (messages.length === 0) return false;
1479
- const last = messages[messages.length - 1];
1480
- if (!last) return false;
1481
- const callId = last.toolCallId ?? last.source?.callId;
1482
- if (typeof callId === "string" && callId.startsWith("agytc-bridge-goal-")) return true;
1508
+ const spanStart = Math.max(0, messages.length - 4);
1509
+ for (let i = messages.length - 1; i >= spanStart; i--) {
1510
+ const m = messages[i];
1511
+ if (!m) continue;
1512
+ const callId = m.toolCallId ?? m.source?.callId;
1513
+ if (typeof callId === "string" && callId.startsWith("agytc-bridge-goal-")) return true;
1514
+ const src = m.source;
1515
+ if (src && typeof src === "object" && src.kind === "tool-goal") return true;
1516
+ if (extractText$1(m).includes("<goal_complete>")) return true;
1517
+ }
1483
1518
  return false;
1484
1519
  }
1485
1520
  /**
@@ -1817,21 +1852,7 @@ var EventMapper = class {
1817
1852
  const d = this.appendDelta("[agy finished with error] " + ev.error + "\n");
1818
1853
  if (d) yield d;
1819
1854
  }
1820
- const closeErr = this.closeOpen();
1821
- if (closeErr) yield closeErr;
1822
- yield {
1823
- type: "usage",
1824
- usage: usageFromRaw(this.opts.usage?.finalUsage(ev.usage) ?? ev.usage)
1825
- };
1826
- yield {
1827
- type: "finish",
1828
- reason: { kind: "stop" },
1829
- ...ev.conversationId !== "" ? { replayState: { response: { conversationId: ev.conversationId } } } : {}
1830
- };
1831
- this.finished = true;
1832
- return;
1833
- }
1834
- if (!this.sawTextStep && ev.response !== "") {
1855
+ } else if (!this.sawTextStep && ev.response !== "") {
1835
1856
  yield* this.ensureBlock("text");
1836
1857
  const d = this.appendDelta(ev.response);
1837
1858
  if (d) yield d;
@@ -1842,7 +1863,7 @@ var EventMapper = class {
1842
1863
  type: "usage",
1843
1864
  usage: usageFromRaw(this.opts.usage?.finalUsage(ev.usage) ?? ev.usage)
1844
1865
  };
1845
- if (this.opts.supportsGoalUpdate && this.opts.goalContext?.goalId) {
1866
+ if (this.opts.supportsGoalUpdate && this.opts.goalContext) {
1846
1867
  for (const ch of buildGoalCompleteToolCallBlock(this.opts.goalContext, this.blockIdx)) yield ch;
1847
1868
  this.blockIdx++;
1848
1869
  yield {
@@ -28260,30 +28281,138 @@ function accountTokenFilePath(account, homeDir) {
28260
28281
  /**
28261
28282
  * FIFO async mutex. One instance guards the single Windows credential slot;
28262
28283
  * holding it across an entire agy run is what makes the handoff safe.
28284
+ *
28285
+ * The original implementation chained promises (`tail.then(gate)`), which is
28286
+ * FIFO but structurally unable to remove a waiter: a turn abandoned or aborted
28287
+ * while queued stayed in the chain forever, and if its gate was never resolved
28288
+ * every later waiter wedged with it (the 2026-10-16 cardboard-empire zombie
28289
+ * turn: queued 30+ minutes past the slot's release, immune to Stop, because
28290
+ * nothing observed the wait). This rewrite keeps an explicit queue so a
28291
+ * cancelled or timed-out waiter is REMOVED and the next waiter is woken in its
28292
+ * place, and the wait is guarded by a short poll that re-checks liveness — a
28293
+ * lost wakeup can no longer park a turn silently.
28263
28294
  */
28264
28295
  var AsyncMutex = class {
28265
- tail = Promise.resolve();
28266
- depth = 0;
28296
+ holders = 0;
28297
+ queue = [];
28267
28298
  /** Number of holders plus waiters; 0 means the mutex is free. */
28268
28299
  get pending() {
28269
- return this.depth;
28300
+ return this.holders + this.queue.length;
28301
+ }
28302
+ /** Waiters currently queued behind the holder(s). */
28303
+ get waiting() {
28304
+ return this.queue.length;
28270
28305
  }
28271
- async acquire() {
28272
- this.depth += 1;
28273
- let release;
28274
- const gate = new Promise((resolve) => {
28275
- release = resolve;
28306
+ /**
28307
+ * Acquire the mutex.
28308
+ *
28309
+ * @param opts.signal cancel the wait (the waiter is removed and the next
28310
+ * one is woken); rejects with `AbortedWaitError`
28311
+ * @param opts.timeoutMs bound the wait; rejects with `WaitTimeoutError`
28312
+ * @param opts.pollMs liveness self-check interval while queued (0 = off).
28313
+ * The poll re-checks `shouldProceed` — a hook that lets the caller verify
28314
+ * the wait is still legitimate (e.g. the queue actually advanced). If it
28315
+ * returns true while the mutex appears free, the waiter takes the mutex
28316
+ * directly, healing a lost wakeup within one poll interval instead of
28317
+ * hanging forever.
28318
+ */
28319
+ async acquire(opts = {}) {
28320
+ const signal = opts.signal;
28321
+ if (signal?.aborted) throw new AbortedWaitError("aborted before mutex acquisition");
28322
+ if (this.holders < 1) {
28323
+ this.holders += 1;
28324
+ return () => this.releaseOne();
28325
+ }
28326
+ const pollMs = opts.pollMs ?? 0;
28327
+ const timeoutMs = opts.timeoutMs;
28328
+ let onAbort = null;
28329
+ let timer = null;
28330
+ let pollTimer = null;
28331
+ return new Promise((resolve, reject) => {
28332
+ let settled = false;
28333
+ let entry = null;
28334
+ let takenDirectly = false;
28335
+ const cleanup = () => {
28336
+ if (onAbort) {
28337
+ signal?.removeEventListener("abort", onAbort);
28338
+ onAbort = null;
28339
+ }
28340
+ if (timer) {
28341
+ clearTimeout(timer);
28342
+ timer = null;
28343
+ }
28344
+ if (pollTimer) {
28345
+ clearInterval(pollTimer);
28346
+ pollTimer = null;
28347
+ }
28348
+ };
28349
+ const take = () => {
28350
+ this.holders += 1;
28351
+ return () => this.releaseOne();
28352
+ };
28353
+ const grant = () => {
28354
+ if (settled) return;
28355
+ settled = true;
28356
+ cleanup();
28357
+ resolve(take());
28358
+ };
28359
+ const fail = (err) => {
28360
+ if (settled) return;
28361
+ settled = true;
28362
+ cleanup();
28363
+ if (entry !== null) {
28364
+ const idx = this.queue.indexOf(entry);
28365
+ if (idx >= 0) this.queue.splice(idx, 1);
28366
+ entry = null;
28367
+ } else if (takenDirectly) this.releaseOne();
28368
+ reject(err);
28369
+ };
28370
+ entry = () => grant();
28371
+ this.queue.push(entry);
28372
+ onAbort = () => {
28373
+ fail(new AbortedWaitError("aborted while waiting for the mutex"));
28374
+ };
28375
+ signal?.addEventListener("abort", onAbort, { once: true });
28376
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
28377
+ timer = setTimeout(() => fail(new WaitTimeoutError("timed out waiting for the mutex")), timeoutMs);
28378
+ timer.unref?.();
28379
+ }
28380
+ if (pollMs > 0 && opts.shouldProceed !== void 0) {
28381
+ pollTimer = setInterval(() => {
28382
+ if (settled) return;
28383
+ if (this.holders < 1 && this.queue[0] === entry && opts.shouldProceed?.()) {
28384
+ const idx = this.queue.indexOf(entry);
28385
+ if (idx >= 0) this.queue.splice(idx, 1);
28386
+ entry = null;
28387
+ takenDirectly = true;
28388
+ grant();
28389
+ }
28390
+ }, pollMs);
28391
+ pollTimer.unref?.();
28392
+ }
28276
28393
  });
28277
- const previous = this.tail;
28278
- this.tail = previous.then(() => gate, () => gate);
28279
- await previous;
28280
- let released = false;
28281
- return () => {
28282
- if (released) return;
28283
- released = true;
28284
- this.depth -= 1;
28285
- release();
28286
- };
28394
+ }
28395
+ releaseOne() {
28396
+ this.holders -= 1;
28397
+ this.wakeNext();
28398
+ }
28399
+ wakeNext() {
28400
+ const next = this.queue.shift();
28401
+ if (next) queueMicrotask(next);
28402
+ }
28403
+ };
28404
+ /** The caller's abort signal fired while queued on the mutex. */
28405
+ var AbortedWaitError = class extends Error {
28406
+ constructor(message) {
28407
+ super(message);
28408
+ this.name = "AbortedWaitError";
28409
+ }
28410
+ };
28411
+ /** The bounded wait elapsed before the mutex became available. */
28412
+ var WaitTimeoutError = class extends Error {
28413
+ constructor(message) {
28414
+ super(message);
28415
+ this.name = "WaitTimeoutError";
28287
28416
  }
28288
28417
  };
28289
28418
  const credentialSlotMutex = new AsyncMutex();
@@ -28414,16 +28543,38 @@ var SlotUnavailableError = class extends Error {
28414
28543
  this.name = "SlotUnavailableError";
28415
28544
  }
28416
28545
  };
28546
+ let primaryFastRuns = 0;
28547
+ let slotSwitchPending = false;
28548
+ /** Live fast-path (same-account) run count — exported for diagnostics. */
28549
+ function fastPathRunCount() {
28550
+ return primaryFastRuns;
28551
+ }
28417
28552
  /**
28418
- * Take exclusive ownership of the credential slot for a run and arrange its
28419
- * guaranteed restoration.
28553
+ * Take ownership of the credential slot for a run and arrange its guaranteed
28554
+ * restoration.
28420
28555
  *
28421
28556
  * Non-Windows platforms have no shared slot (per-account Keychain isolation
28422
28557
  * works), so this is a no-op there.
28423
28558
  *
28559
+ * Same-account runs (the sticky primary — the common case) take a FAST PATH:
28560
+ * when the slot already provably holds this account's credential and no slot
28561
+ * switch is pending, the run starts IMMEDIATELY without the mutex, so
28562
+ * multiple sessions on one account run truly in parallel (agy is a stateless
28563
+ * CLI and handles concurrent processes natively). Only identity SWITCHES
28564
+ * serialize.
28565
+ *
28566
+ * @param options.signal cancels the wait for the slot — without it a turn
28567
+ * queued behind another session's long run ignored Stop entirely (the
28568
+ * cardboard-empire zombie turn, 2026-10-16).
28569
+ * @param options.dispatchTimeoutMs bounds the wait; 0/undefined waits
28570
+ * indefinitely (the wait stays observable and cancellable either way).
28571
+ * @param options.parallel same-account fast path (default true). false
28572
+ * restores full-run exclusivity.
28424
28573
  * @throws SlotUnavailableError when the slot cannot be handed over safely.
28425
- * The caller must abort the run instead of silently signing in as the wrong
28426
- * account (which would spend that account's quota).
28574
+ * The caller must abort the run instead of silently signing in as the wrong
28575
+ * account (which would spend that account's quota).
28576
+ * @throws AbortedWaitError when the signal aborted while queued.
28577
+ * @throws WaitTimeoutError when the bounded wait elapsed.
28427
28578
  */
28428
28579
  async function acquireCredentialSlot(account, pool, options = {}) {
28429
28580
  const io = resolveIo(options);
@@ -28431,13 +28582,46 @@ async function acquireCredentialSlot(account, pool, options = {}) {
28431
28582
  const baseDir = options.baseDir ?? defaultPoolDir();
28432
28583
  const homeDir = options.homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? "";
28433
28584
  const log = options.log ?? (() => {});
28434
- const releaseMutex = await credentialSlotMutex.acquire();
28585
+ const parallel = options.parallel !== false;
28586
+ const isPrimarySlot = Boolean(account.systemHome) || !account.dir;
28587
+ if (parallel && isPrimarySlot && !slotSwitchPending && !options.signal?.aborted) {
28588
+ const stored = readPrimaryCredential(baseDir);
28589
+ if (stored && sameCredentialGrant(io.readSlot(), stored.raw)) {
28590
+ primaryFastRuns += 1;
28591
+ log("[agy-link] same-account fast path: slot already holds the primary credential — running without exclusivity");
28592
+ return {
28593
+ identity: stored.email,
28594
+ release: () => {
28595
+ primaryFastRuns = Math.max(0, primaryFastRuns - 1);
28596
+ try {
28597
+ const afterRaw = io.readSlot();
28598
+ if (afterRaw && afterRaw !== stored.raw && sameCredentialGrant(afterRaw, stored.raw)) writePrimaryCredential({
28599
+ raw: afterRaw,
28600
+ email: stored.email,
28601
+ savedAt: Date.now()
28602
+ }, baseDir);
28603
+ } catch {}
28604
+ }
28605
+ };
28606
+ }
28607
+ }
28608
+ const releaseMutex = await credentialSlotMutex.acquire({
28609
+ signal: options.signal,
28610
+ timeoutMs: options.dispatchTimeoutMs,
28611
+ pollMs: 5e3,
28612
+ shouldProceed: () => true
28613
+ });
28435
28614
  let released = false;
28436
28615
  const done = () => {
28437
28616
  if (released) return;
28438
28617
  released = true;
28618
+ slotSwitchPending = false;
28439
28619
  releaseMutex();
28440
28620
  };
28621
+ if (options.signal?.aborted) {
28622
+ done();
28623
+ throw new AbortedWaitError("aborted after acquiring the credential slot");
28624
+ }
28441
28625
  try {
28442
28626
  const primary = await resolvePrimaryCredential(pool, {
28443
28627
  ...options,
@@ -28446,7 +28630,6 @@ async function acquireCredentialSlot(account, pool, options = {}) {
28446
28630
  });
28447
28631
  const accountFile = accountTokenFilePath(account, homeDir);
28448
28632
  const accountRaw = readTokenFileRaw(accountFile);
28449
- const isPrimarySlot = Boolean(account.systemHome) || !account.dir;
28450
28633
  if (!primary) {
28451
28634
  done();
28452
28635
  throw new SlotUnavailableError("Cannot verify the primary Antigravity credential, so the Windows credential slot cannot be handed over safely. Sign the primary account in again, then retry.");
@@ -28478,6 +28661,23 @@ async function acquireCredentialSlot(account, pool, options = {}) {
28478
28661
  }
28479
28662
  };
28480
28663
  }
28664
+ if (parallel && primaryFastRuns > 0) {
28665
+ slotSwitchPending = true;
28666
+ const deadline = options.dispatchTimeoutMs !== void 0 && options.dispatchTimeoutMs > 0 ? Date.now() + options.dispatchTimeoutMs : 0;
28667
+ log(`[agy-link] waiting for ${primaryFastRuns} same-account run(s) to finish before switching the credential slot to ${account.alias || account.id}`);
28668
+ while (primaryFastRuns > 0) {
28669
+ if (options.signal?.aborted) {
28670
+ slotSwitchPending = false;
28671
+ throw new AbortedWaitError("aborted while waiting for same-account runs to finish");
28672
+ }
28673
+ if (deadline !== 0 && Date.now() > deadline) {
28674
+ slotSwitchPending = false;
28675
+ throw new WaitTimeoutError(`timed out waiting for ${primaryFastRuns} same-account run(s) to finish before switching the credential slot`);
28676
+ }
28677
+ await new Promise((r) => setTimeout(r, 200));
28678
+ }
28679
+ slotSwitchPending = false;
28680
+ }
28481
28681
  if (!accountRaw) {
28482
28682
  done();
28483
28683
  throw new SlotUnavailableError(`Account ${account.alias || account.id} has no stored credential. Sign it in again before using it.`);
@@ -29263,11 +29463,11 @@ function isSyntheticHarnessMessage(m) {
29263
29463
  const src = m.source;
29264
29464
  const kind = src?.kind;
29265
29465
  if (kind !== void 0 && kind !== "" && kind !== "user") {
29266
- if (kind === "time-context" || kind === "skill-catalog" || kind === "runtime-context" || kind === "model-selection" || kind === "system-reminder" || kind === "dsh-session-title-llm" || kind === "synthetic") return true;
29466
+ if (kind === "time-context" || kind === "skill-catalog" || kind === "runtime-context" || kind === "model-selection" || kind === "system-reminder" || kind === "dsh-session-title-llm" || kind === "tool-goal" || kind === "synthetic") return true;
29267
29467
  if (kind === "plugin" && src.form === "snapshot") return true;
29268
29468
  }
29269
29469
  const text = textOf(m).trim();
29270
- if (text.startsWith("Time sampled while preparing") || text.startsWith("<system-reminder>") || text.startsWith("Current runtime context") || text.startsWith("[model changed:") || text.startsWith("[conversation so far]") || text.startsWith("A skill is a reusable set of task-specific instructions")) return true;
29470
+ if (text.startsWith("Time sampled while preparing") || text.startsWith("<system-reminder>") || text.startsWith("Current runtime context") || text.startsWith("[model changed:") || text.startsWith("[conversation so far]") || text.startsWith("<goal_complete>") || text.startsWith("A skill is a reusable set of task-specific instructions")) return true;
29271
29471
  return false;
29272
29472
  }
29273
29473
  /**
@@ -29725,12 +29925,18 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
29725
29925
  };
29726
29926
  return;
29727
29927
  }
29728
- const goalContext = detectGoalContext(options.messages);
29729
- if (goalContext && (!goalContext.goalId || goalContext.revision === void 0) && sessionKey && this.deps.sessionGoal) {
29928
+ let goalContext = detectGoalContext(options.messages);
29929
+ if ((!goalContext || !goalContext.goalId || goalContext.revision === void 0) && sessionKey && this.deps.sessionGoal) {
29730
29930
  const sg = this.deps.sessionGoal(sessionKey);
29731
29931
  if (sg) {
29732
- if (!goalContext.goalId) goalContext.goalId = sg.goalId;
29733
- if (goalContext.revision === void 0) goalContext.revision = sg.revision;
29932
+ if (!goalContext) goalContext = {
29933
+ goalId: sg.goalId,
29934
+ revision: sg.revision
29935
+ };
29936
+ else {
29937
+ if (!goalContext.goalId) goalContext.goalId = sg.goalId;
29938
+ if (goalContext.revision === void 0) goalContext.revision = sg.revision;
29939
+ }
29734
29940
  }
29735
29941
  }
29736
29942
  const supportsGoalUpdate = Boolean(options.tools?.some((t) => t.name === "update_goal"));
@@ -29782,7 +29988,17 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
29782
29988
  while (this.requestTimestamps.length > 0 && this.requestTimestamps[0] < windowStart) this.requestTimestamps.shift();
29783
29989
  if (this.requestTimestamps.length >= cfg.rateLimitPerMinute) {
29784
29990
  const delayMs = this.requestTimestamps[0] + 6e4 - now;
29785
- if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
29991
+ if (delayMs > 0) await new Promise((resolve, reject) => {
29992
+ const timer = setTimeout(() => {
29993
+ options.signal?.removeEventListener("abort", onAbort);
29994
+ resolve();
29995
+ }, delayMs);
29996
+ const onAbort = () => {
29997
+ clearTimeout(timer);
29998
+ reject(new LlmError("aborted during rate-limit spacing", Err.AGY_ERROR));
29999
+ };
30000
+ options.signal?.addEventListener("abort", onAbort, { once: true });
30001
+ });
29786
30002
  }
29787
30003
  this.requestTimestamps.push(Date.now());
29788
30004
  }
@@ -30084,28 +30300,89 @@ var AgyAdapter = class AgyAdapter extends LlmAdapter {
30084
30300
  promptViaStdin: true
30085
30301
  }) : argsBase;
30086
30302
  const stdinPayload = promptViaStdin ? buildStreamInputLine(prompt) : void 0;
30087
- const release = await this.deps.acquire(options.signal);
30303
+ const dispatchHardCapMs = cfg.dispatchTimeoutMinutes > 0 ? cfg.dispatchTimeoutMinutes * 6e4 : 0;
30304
+ const dispatchStartedAt = Date.now();
30305
+ const DISPATCH_HEARTBEAT_MS = 12e4;
30306
+ let dispatchHeartbeat = null;
30307
+ const stopDispatchHeartbeat = () => {
30308
+ if (dispatchHeartbeat !== null) {
30309
+ clearInterval(dispatchHeartbeat);
30310
+ dispatchHeartbeat = null;
30311
+ }
30312
+ };
30313
+ const startDispatchHeartbeat = () => {
30314
+ if (dispatchHeartbeat !== null) return;
30315
+ dispatchHeartbeat = setInterval(() => {
30316
+ const mins = Math.max(1, Math.round((Date.now() - dispatchStartedAt) / 6e4));
30317
+ const mutex = credentialSlotMutex;
30318
+ this.deps.log?.("agy dispatch still waiting: " + mins + "m — credential slot queue: " + mutex.waiting + " waiter(s), " + (mutex.pending - mutex.waiting) + " holder(s) (another session may be running a long agy tool) — the turn is waiting for the slot; press Stop to cancel");
30319
+ }, DISPATCH_HEARTBEAT_MS);
30320
+ dispatchHeartbeat.unref?.();
30321
+ };
30322
+ const dispatchSleep = async (ms) => {
30323
+ if (options.signal?.aborted) throw new LlmError("request aborted during dispatch backoff", Err.AGY_ERROR);
30324
+ await new Promise((resolve, reject) => {
30325
+ const timer = setTimeout(() => {
30326
+ options.signal?.removeEventListener("abort", onAbort);
30327
+ resolve();
30328
+ }, ms);
30329
+ const onAbort = () => {
30330
+ clearTimeout(timer);
30331
+ reject(new LlmError("request aborted during dispatch backoff", Err.AGY_ERROR));
30332
+ };
30333
+ options.signal?.addEventListener("abort", onAbort, { once: true });
30334
+ });
30335
+ };
30336
+ let release = null;
30088
30337
  let released = false;
30089
30338
  let slotLease;
30339
+ let granted = false;
30340
+ let dispatchBackoffMs = 2e3;
30341
+ const maxDispatchAttempts = 1 + Math.max(0, cfg.dispatchRetries);
30090
30342
  try {
30091
- slotLease = process.env.NODE_TEST_CONTEXT === void 0 ? await acquireCredentialSlot(account ?? void 0, this.deps.pool, {
30092
- baseDir: this.deps.pool?.dir,
30093
- proxyUrl: account?.proxyUrl || cfg.proxyUrl || detectSystemProxy() || void 0,
30094
- log: (msg) => this.deps.log?.(msg)
30095
- }) : void 0;
30343
+ for (let attempt = 1; attempt <= maxDispatchAttempts && !granted; attempt++) try {
30344
+ release = await this.deps.acquire(options.signal);
30345
+ startDispatchHeartbeat();
30346
+ slotLease = process.env.NODE_TEST_CONTEXT === void 0 ? await acquireCredentialSlot(account ?? void 0, this.deps.pool, {
30347
+ baseDir: this.deps.pool?.dir,
30348
+ proxyUrl: account?.proxyUrl || cfg.proxyUrl || detectSystemProxy() || void 0,
30349
+ log: (msg) => this.deps.log?.(msg),
30350
+ signal: options.signal,
30351
+ dispatchTimeoutMs: dispatchHardCapMs,
30352
+ parallel: cfg.parallelSameAccount
30353
+ }) : void 0;
30354
+ granted = true;
30355
+ } catch (e) {
30356
+ release?.();
30357
+ release = null;
30358
+ if (e instanceof AbortedWaitError) throw new LlmError("request aborted while waiting for the agy credential slot", Err.AGY_ERROR);
30359
+ if (e instanceof WaitTimeoutError) {
30360
+ if (attempt < maxDispatchAttempts) {
30361
+ this.deps.log?.("agy dispatch attempt " + attempt + "/" + maxDispatchAttempts + " timed out after " + cfg.dispatchTimeoutMinutes + "m waiting for the credential slot — releasing and retrying in " + Math.round(dispatchBackoffMs / 1e3) + "s");
30362
+ stopDispatchHeartbeat();
30363
+ await dispatchSleep(dispatchBackoffMs);
30364
+ dispatchBackoffMs = Math.min(dispatchBackoffMs * 2, 15e3);
30365
+ continue;
30366
+ }
30367
+ throw new LlmError("could not take the agy credential slot within " + cfg.dispatchTimeoutMinutes + "m (another run may be wedged holding it). Check the console for the dispatch heartbeat, restart DSH if a run is truly stuck.", Err.TIMEOUT);
30368
+ }
30369
+ const detail = e instanceof SlotUnavailableError ? e.message : brief(String(e));
30370
+ throw new LlmError(detail, Err.AGY_ERROR);
30371
+ }
30096
30372
  } catch (e) {
30097
- release();
30373
+ stopDispatchHeartbeat();
30098
30374
  this.clearInFlightPrompt(sessionKey, prompt);
30099
- const detail = e instanceof SlotUnavailableError ? e.message : brief(String(e));
30100
- throw new LlmError(detail, Err.AGY_ERROR);
30375
+ throw e;
30101
30376
  }
30377
+ stopDispatchHeartbeat();
30102
30378
  const releaseOnce = () => {
30103
30379
  if (released) return;
30104
30380
  released = true;
30105
30381
  try {
30106
30382
  slotLease?.release();
30107
30383
  } catch {}
30108
- release();
30384
+ release?.();
30385
+ release = null;
30109
30386
  };
30110
30387
  if (options.signal?.aborted) {
30111
30388
  releaseOnce();
@@ -31171,6 +31448,10 @@ const HELP = [
31171
31448
  "- `/agy models` — refresh and list discovered models",
31172
31449
  "- `/agy mode <skip|plan|accept-edits>` — permission mode (next turn)",
31173
31450
  "- `/agy effort <low|medium|high|default>` — default reasoning effort",
31451
+ "- `/agy dispatch [minutes|off]` — credential-slot wait cap per attempt (omit to inspect; `off` = unlimited)",
31452
+ "- `/agy dispatch-retries <n>` — extra dispatch attempts after the cap",
31453
+ "- `/agy parallel <on|off>` — same-account parallel agy runs (Windows slot fast path)",
31454
+ "- `/agy max-concurrent <n|off>` — cap on concurrent agy request streams (off = unlimited, native)",
31174
31455
  "- `/agy workspace [path]` — set the agy working directory (absolute path; omit to show)",
31175
31456
  "- `/agy clear` — drop the most recent conversation binding",
31176
31457
  "- `/agy doctor` — write a diagnostic report and return its path",
@@ -31180,7 +31461,7 @@ function agyCommandDefinition(deps) {
31180
31461
  return {
31181
31462
  name: "agy",
31182
31463
  description: "Antigravity (agy CLI) bridge: status, login, models, mode, diagnostics, goal",
31183
- input: { hint: "<status|pool|goal|session|enable-account|disable-account|models|mode|effort|workspace|clear|doctor|help>" },
31464
+ input: { hint: "<status|pool|goal|session|enable-account|disable-account|models|mode|effort|dispatch|dispatch-retries|parallel|max-concurrent|workspace|clear|doctor|help>" },
31184
31465
  handler: (invocation) => handle(deps, invocation.rawInput, invocation)
31185
31466
  };
31186
31467
  }
@@ -31463,6 +31744,49 @@ async function handle(deps, raw, invocation) {
31463
31744
  deps.setOverride("defaultEffort", arg === "default" ? "" : arg);
31464
31745
  return ok("Default effort set to **" + (arg === "default" ? "model default" : arg) + "**.");
31465
31746
  }
31747
+ if (sub === "dispatch" || sub === "dispatch-retries") {
31748
+ const cfg = deps.cfg();
31749
+ const mutex = credentialSlotMutex;
31750
+ if (arg === "") return ok([
31751
+ "**Agy 派发守护(dispatch guards)**",
31752
+ `- 硬超时:**${cfg.dispatchTimeoutMinutes > 0 ? `${cfg.dispatchTimeoutMinutes} 分钟/次` : "不限时(无限等待)"}**(重试次数 ${cfg.dispatchRetries}${cfg.dispatchTimeoutMinutes > 0 ? `,总坚持 ≈ ${(1 + cfg.dispatchRetries) * cfg.dispatchTimeoutMinutes} 分钟` : ""})`,
31753
+ "- 同账号并行:**" + (cfg.parallelSameAccount ? "开(快路径,多会话并行跑同一账号)" : "关(同一时刻仅 1 个 agy 进程)") + "**",
31754
+ `- 并发上限:**${cfg.maxConcurrent > 0 ? cfg.maxConcurrent + " 个流" : "不限制(原生并发)"}**`,
31755
+ `- 当前凭据槽位队列:**${mutex.waiting} 个等待者 / ${mutex.pending - mutex.waiting} 个持有者**,同账号快路径活跃 **${fastPathRunCount()}** 个`,
31756
+ `- 排队期间:Stop 随时可停;丢失唤醒 5 秒内自愈;每 2 分钟心跳日志`,
31757
+ `- 修改:/agy dispatch <分钟|off> /agy dispatch-retries <次数> /agy parallel <on|off>`
31758
+ ].join("\n"));
31759
+ if (sub === "dispatch") {
31760
+ if (arg === "off" || arg === "0") {
31761
+ deps.setOverride("dispatchTimeoutMinutes", 0);
31762
+ return ok("派发硬超时已关闭:排队将无限等待(仍然 Stop 可停、轮询自愈、心跳可见)。");
31763
+ }
31764
+ const minutes = Number(arg);
31765
+ if (!Number.isFinite(minutes) || minutes <= 0 || !Number.isInteger(minutes)) return err("usage: /agy dispatch <分钟数|off>");
31766
+ deps.setOverride("dispatchTimeoutMinutes", minutes);
31767
+ return ok(`派发硬超时设为 **${minutes} 分钟/次**(含 ${cfg.dispatchRetries} 次重试,总坚持 ≈ ${(1 + cfg.dispatchRetries) * minutes} 分钟)— 下一个回合生效。`);
31768
+ }
31769
+ const retries = Number(arg);
31770
+ if (!Number.isFinite(retries) || retries < 0 || !Number.isInteger(retries) || retries > 10) return err("usage: /agy dispatch-retries <0-10>");
31771
+ deps.setOverride("dispatchRetries", retries);
31772
+ return ok(`派发重试次数设为 **${retries}**${cfg.dispatchTimeoutMinutes > 0 ? `,总坚持 ≈ ${(1 + retries) * cfg.dispatchTimeoutMinutes} 分钟` : ""} — 下一个回合生效。`);
31773
+ }
31774
+ if (sub === "parallel") {
31775
+ if (arg !== "on" && arg !== "off") return err("usage: /agy parallel <on|off>");
31776
+ const on = arg === "on";
31777
+ deps.setOverride("parallelSameAccount", on);
31778
+ return ok(on ? "同账号并行已**开启**:同一账号的多个会话立即并行跑 agy(无需独占凭据槽位),仅账号切换时串行 — 下一个回合生效。" : "同账号并行已**关闭**:恢复同一时刻仅 1 个 agy 进程(全串行)— 下一个回合生效。");
31779
+ }
31780
+ if (sub === "max-concurrent") {
31781
+ if (arg === "off" || arg === "0") {
31782
+ deps.setOverride("maxConcurrent", 0);
31783
+ return ok("并发上限已移除:**不限制**(原生 agy 并发,Google 速率与账号冷却为天然调节)— 下一个回合生效。");
31784
+ }
31785
+ const n = Number(arg);
31786
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 64) return err("usage: /agy max-concurrent <1-64|off>");
31787
+ deps.setOverride("maxConcurrent", n);
31788
+ return ok(`并发上限设为 **${n}** 个请求流 — 下一个回合生效。`);
31789
+ }
31466
31790
  if (sub === "workspace") {
31467
31791
  if (arg === "") return ok("Current workspace: " + (deps.cfg().workspaceRoot !== "" ? deps.cfg().workspaceRoot : "(session cwd / process cwd)"));
31468
31792
  if (arg === "default" || arg === "clear") {
@@ -32232,7 +32556,7 @@ var QuotaService = class {
32232
32556
  body: JSON.stringify({})
32233
32557
  }, proxyUrl);
32234
32558
  if (res.ok) {
32235
- this.preferredEndpointIndex = AGY_ENDPOINTS.indexOf(endpoint);
32559
+ if (AGY_ENDPOINTS.indexOf(endpoint) === 0) this.preferredEndpointIndex = 0;
32236
32560
  return await res.json();
32237
32561
  }
32238
32562
  if (res.status === 401 || res.status === 403) break;
@@ -32339,13 +32663,18 @@ var QuotaService = class {
32339
32663
  resetTime,
32340
32664
  updatedAt: now
32341
32665
  });
32342
- else if (familyQuotas[fam].remainingFraction === void 0) {
32666
+ else {
32343
32667
  const curRemaining = familyQuotas[fam].remainingFraction ?? 1;
32344
- if (remaining < curRemaining) {
32345
- familyQuotas[fam].remainingFraction = remaining;
32346
- familyQuotas[fam].resetTime = resetTime;
32347
- } else if (remaining === curRemaining) {
32348
- if (resetTime && (!familyQuotas[fam].resetTime || Date.parse(resetTime) > Date.parse(familyQuotas[fam].resetTime))) familyQuotas[fam].resetTime = resetTime;
32668
+ const fbReset = resetTime ? Date.parse(resetTime) : NaN;
32669
+ const is5hWindow = !Number.isNaN(fbReset) && fbReset - now <= 216e5;
32670
+ if (familyQuotas[fam].remainingFraction === void 0 || is5hWindow) {
32671
+ if (remaining < curRemaining) {
32672
+ familyQuotas[fam].remainingFraction = remaining;
32673
+ familyQuotas[fam].resetTime = resetTime;
32674
+ } else if (familyQuotas[fam].remainingFraction === void 0) {
32675
+ familyQuotas[fam].remainingFraction = remaining;
32676
+ familyQuotas[fam].resetTime = resetTime;
32677
+ }
32349
32678
  }
32350
32679
  }
32351
32680
  }
@@ -32760,7 +33089,7 @@ function resolveAssetDir() {
32760
33089
  for (const c of candidates) if (existsSync(c)) return c;
32761
33090
  return join(currentDir, "assets");
32762
33091
  }
32763
- /** Cross-session concurrency limiter (ADR-12). */
33092
+ /** Cross-session concurrency limiter (ADR-12). max() <= 0 means unlimited. */
32764
33093
  var Semaphore = class {
32765
33094
  max;
32766
33095
  active = 0;
@@ -32770,7 +33099,12 @@ var Semaphore = class {
32770
33099
  }
32771
33100
  async acquire(signal) {
32772
33101
  if (signal?.aborted) throw new Error("aborted before semaphore acquisition");
32773
- if (this.active < Math.max(1, this.max())) {
33102
+ const limit = this.max();
33103
+ if (limit <= 0) {
33104
+ this.active++;
33105
+ return () => this.releaseOne();
33106
+ }
33107
+ if (this.active < limit) {
32774
33108
  this.active++;
32775
33109
  return () => this.releaseOne();
32776
33110
  }
@@ -32896,10 +33230,23 @@ function apply(ctx, entryConfig = {}) {
32896
33230
  return ctx.get("sessions")?.get(sessionId)?.header?.cwd;
32897
33231
  },
32898
33232
  sessionGoal: (sessionId) => {
33233
+ try {
33234
+ const agents = ctx.get("agents") ?? ctx.agents;
33235
+ const goals = ctx.get("goals") ?? ctx.goals;
33236
+ const agent = agents?.get(sessionId);
33237
+ if (agent && goals) {
33238
+ const g = goals.get(agent);
33239
+ if (g?.id && g.phase === "active" && (g.activation === void 0 || g.activation === "armed")) return {
33240
+ goalId: g.id,
33241
+ revision: typeof g.revision === "number" ? g.revision : 1
33242
+ };
33243
+ }
33244
+ } catch {}
32899
33245
  const session = (ctx.get("sessions") ?? ctx.sessions)?.get(sessionId);
32900
- if (!session?.events) return null;
32901
- for (let i = session.events.length - 1; i >= 0; i--) {
32902
- const ev = session.events[i];
33246
+ const evs = session?.snapshotEvents?.() ?? session?.ownEvents?.() ?? session?.log ?? session?.events;
33247
+ if (!evs) return null;
33248
+ for (let i = evs.length - 1; i >= 0; i--) {
33249
+ const ev = evs[i];
32903
33250
  if (ev?.type === "goal/change" && ev.data && typeof ev.data === "object") {
32904
33251
  const d = ev.data;
32905
33252
  if (d.operation === "clear") return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agy-link",
3
- "version": "0.4.57",
3
+ "version": "0.4.59",
4
4
  "description": "Google Antigravity (agy CLI) models for DeepSeek Harness — stream Gemini/Claude/GPT-OSS subscriptions into DSH with thinking, tool activity, token usage and in-GUI Google OAuth login.",
5
5
  "type": "module",
6
6
  "license": "MIT",