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 +63 -0
- package/README.md +34 -20
- package/dist/client.js +25 -6
- package/dist/index.js +425 -78
- package/package.json +1 -1
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)** ——
|
|
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
|
-
-
|
|
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
|
|
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
|
-
|
|
|
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.
|
|
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
|
|
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
|
-
-
|
|
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,
|
|
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
|
-
|
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
28266
|
-
|
|
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.
|
|
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
|
-
|
|
28272
|
-
|
|
28273
|
-
|
|
28274
|
-
|
|
28275
|
-
|
|
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
|
-
|
|
28278
|
-
|
|
28279
|
-
|
|
28280
|
-
|
|
28281
|
-
|
|
28282
|
-
|
|
28283
|
-
|
|
28284
|
-
|
|
28285
|
-
|
|
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
|
|
28419
|
-
*
|
|
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
|
-
*
|
|
28426
|
-
*
|
|
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
|
|
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
|
-
|
|
29729
|
-
if (goalContext
|
|
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
|
|
29733
|
-
|
|
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((
|
|
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
|
|
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
|
-
|
|
30092
|
-
|
|
30093
|
-
|
|
30094
|
-
|
|
30095
|
-
|
|
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
|
-
|
|
30373
|
+
stopDispatchHeartbeat();
|
|
30098
30374
|
this.clearInFlightPrompt(sessionKey, prompt);
|
|
30099
|
-
|
|
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
|
-
|
|
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
|
|
32666
|
+
else {
|
|
32343
32667
|
const curRemaining = familyQuotas[fam].remainingFraction ?? 1;
|
|
32344
|
-
|
|
32345
|
-
|
|
32346
|
-
|
|
32347
|
-
|
|
32348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32901
|
-
|
|
32902
|
-
|
|
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.
|
|
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",
|