clauderipple 0.9.5 → 0.10.0

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.
Files changed (112) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/providers/anthropic-usage.js +33 -1
  94. package/dist/router/src/providers/google/index.js +26 -0
  95. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  96. package/dist/router/src/providers/google/usage.js +257 -0
  97. package/dist/router/src/providers/grok/index.js +18 -0
  98. package/dist/router/src/providers/grok/usage.js +77 -0
  99. package/dist/router/src/providers/openai/translate.js +13 -2
  100. package/dist/router/src/providers/opencode-usage.js +116 -0
  101. package/dist/router/src/proxy.js +339 -258
  102. package/dist/router/src/remote-host.js +320 -0
  103. package/dist/router/src/remote-tunnel.js +320 -0
  104. package/dist/router/src/requestlog.js +48 -39
  105. package/dist/router/src/usage-history.js +227 -0
  106. package/dist/router/src/version.js +1 -1
  107. package/dist/ui/app.js +436 -59
  108. package/dist/ui/i18n.js +184 -0
  109. package/dist/ui/index.html +24 -5
  110. package/dist/ui/style.css +39 -0
  111. package/docs/ARCHITECTURE.md +108 -8
  112. package/package.json +7 -1
@@ -0,0 +1,29 @@
1
+ const SDK_PARAGRAPH = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
2
+ const GENERIC_EXHAUSTED = "Resource has been exhausted (e.g. check quota).";
3
+ export const ANTIGRAVITY_PROMPT_REFUSAL_MESSAGE = `Google Antigravity rejected this Claude Agent SDK system prompt (upstream 429: ${GENERIC_EXHAUSTED}). ` +
4
+ "This request appears incompatible with Antigravity; retrying or waiting on this account is unlikely to fix it. " +
5
+ "Choose another provider for this Claude Code request.";
6
+ /**
7
+ * Measured 2026-10-16: the same Gemini 3.8 Flash / Claude Opus 5.5 request got 429
8
+ * with this standalone SDK paragraph and 200 without it. This is response-based
9
+ * classification, never a prompt rewrite or a prediction before Google answers.
10
+ * Keep any explicit quota/reset details on the ordinary rate-limit path. This shape
11
+ * alone also occurs on real exhaustion; callers must require fresh quota evidence.
12
+ */
13
+ export function isAntigravityPromptRefusal(system, status, text, retryAfter) {
14
+ if (status !== 429 || retryAfter !== null)
15
+ return false;
16
+ if (!system?.parts.some(part => part.text?.split(/\r?\n\s*\r?\n/).some(paragraph => paragraph.trim() === SDK_PARAGRAPH)))
17
+ return false;
18
+ try {
19
+ const error = JSON.parse(text)?.error;
20
+ if (!error || typeof error !== "object" || Array.isArray(error))
21
+ return false;
22
+ const e = error;
23
+ return e.code === 429 && e.status === "RESOURCE_EXHAUSTED" && e.message === GENERIC_EXHAUSTED &&
24
+ (e.details === undefined || (Array.isArray(e.details) && e.details.length === 0));
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
@@ -0,0 +1,257 @@
1
+ // How much of each Antigravity (Cloud Code Assist) account's quota is left, for the dashboard and
2
+ // the status bar's limits row. One request per account, like ClaudeUsage (anthropic-usage.ts): the
3
+ // accounts store already refreshes the OAuth token, so this only reads.
4
+ //
5
+ // Measured 2026-10-15 against a live Antigravity subscription:
6
+ // POST {prodApi}/v1internal:retrieveUserQuotaSummary
7
+ // headers: authorization: Bearer <accessToken>, content-type: application/json, user-agent
8
+ // body: {"project": "<projectId>"}
9
+ // answer: {"groups":[{"displayName":"Gemini Models","buckets":[
10
+ // {"bucketId":"gemini-weekly","displayName":"Weekly","window":"weekly",
11
+ // "resetTime":"…","remainingFraction":0.98},
12
+ // {"bucketId":"gemini-5h","displayName":"5h","window":"5h",…}]},
13
+ // {"displayName":"Claude and GPT models","buckets":[
14
+ // {"bucketId":"3p-weekly",…},{"bucketId":"3p-5h",…}]}]}
15
+ // `remainingFraction` is what is left (1 = untouched), so used = (1 - remainingFraction) * 100. The
16
+ // older `retrieveUserQuota` returns meaningless values and is deliberately not used.
17
+ import { GOOGLE_OAUTH, antigravityUserAgent } from "./antigravity.js";
18
+ /** The five-hour and weekly window lengths in minutes, for the limits row's `window_minutes`. */
19
+ const FIVE_HOURS = 5 * 60;
20
+ const WEEK = 7 * 24 * 60;
21
+ /**
22
+ * Parse the `retrieveUserQuotaSummary` body into groups. A group or bucket missing the fields the
23
+ * row reads is dropped rather than guessed; null when the body is not the shape at all (so the
24
+ * caller keeps its last good answer instead of blanking).
25
+ */
26
+ export function parseQuotaSummary(body) {
27
+ if (!body || typeof body !== "object" || Array.isArray(body))
28
+ return null;
29
+ const groups = body.groups;
30
+ if (!Array.isArray(groups))
31
+ return null;
32
+ const out = [];
33
+ for (const g of groups) {
34
+ if (!g || typeof g !== "object" || Array.isArray(g))
35
+ continue;
36
+ const group = g;
37
+ if (typeof group.displayName !== "string" || !group.displayName)
38
+ continue;
39
+ const buckets = [];
40
+ if (Array.isArray(group.buckets)) {
41
+ for (const b of group.buckets) {
42
+ if (!b || typeof b !== "object" || Array.isArray(b))
43
+ continue;
44
+ const bucket = b;
45
+ if (typeof bucket.remainingFraction !== "number" || !Number.isFinite(bucket.remainingFraction))
46
+ continue;
47
+ if (typeof bucket.window !== "string" || !bucket.window)
48
+ continue;
49
+ buckets.push({
50
+ bucketId: typeof bucket.bucketId === "string" ? bucket.bucketId : "",
51
+ displayName: typeof bucket.displayName === "string" ? bucket.displayName : "",
52
+ window: bucket.window,
53
+ ...(typeof bucket.resetTime === "string" ? { resetTime: bucket.resetTime } : {}),
54
+ remainingFraction: bucket.remainingFraction,
55
+ });
56
+ }
57
+ }
58
+ if (buckets.length)
59
+ out.push({ displayName: group.displayName, buckets });
60
+ }
61
+ return out;
62
+ }
63
+ /** The window length in minutes, from the endpoint's own name ("5h", "weekly"). */
64
+ function windowMinutes(window) {
65
+ if (window === "5h")
66
+ return FIVE_HOURS;
67
+ if (window === "weekly")
68
+ return WEEK;
69
+ return undefined;
70
+ }
71
+ const RANK = { five_hour: 0, seven_day: 1, scoped: 2, window: 3 };
72
+ /** Five-hour, then weekly, then the rest: the same order the row shows Claude's own windows in. */
73
+ function ordered(windows) {
74
+ return windows.map((w, i) => [w, i]).sort((a, b) => (RANK[a[0].kind] - RANK[b[0].kind]) || (a[1] - b[1])).map(([w]) => w);
75
+ }
76
+ /**
77
+ * The endpoint's groups as windows the limits row reads. "Gemini Models" carries the plan's own
78
+ * five-hour and weekly windows; every other group (the measured "Claude and GPT models") is a
79
+ * third-party window, shown as a `scoped` "3P" with its length in `window_minutes`.
80
+ */
81
+ export function googleWindowsFromGroups(groups) {
82
+ const out = [];
83
+ for (const group of groups) {
84
+ const gemini = /gemini/i.test(group.displayName);
85
+ for (const bucket of group.buckets) {
86
+ const minutes = windowMinutes(bucket.window);
87
+ const used = Math.round((1 - bucket.remainingFraction) * 1000) / 10;
88
+ const at = bucket.resetTime ? Math.round(Date.parse(bucket.resetTime) / 1000) : NaN;
89
+ const reset = Number.isFinite(at) ? { reset_at: at } : {};
90
+ if (gemini) {
91
+ const kind = bucket.window === "5h" ? "five_hour" : bucket.window === "weekly" ? "seven_day" : "window";
92
+ out.push({ kind, used_percent: used, ...(minutes !== undefined ? { window_minutes: minutes } : {}), ...reset });
93
+ }
94
+ else {
95
+ out.push({ kind: "scoped", label: "3P", used_percent: used, ...(minutes !== undefined ? { window_minutes: minutes } : {}), ...reset });
96
+ }
97
+ }
98
+ }
99
+ return ordered(out);
100
+ }
101
+ /**
102
+ * The whole body as groups, or null when ANY group or bucket is malformed. The display parser drops
103
+ * what it cannot read; evidence must not, because a dropped bucket could be the exhausted one.
104
+ */
105
+ function strictQuotaGroups(body) {
106
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
107
+ if (!isObject(body) || !Array.isArray(body.groups))
108
+ return null;
109
+ const out = [];
110
+ for (const g of body.groups) {
111
+ if (!isObject(g) || typeof g.displayName !== "string" || !g.displayName || !Array.isArray(g.buckets))
112
+ return null;
113
+ const buckets = [];
114
+ for (const b of g.buckets) {
115
+ if (!isObject(b) || typeof b.bucketId !== "string" || typeof b.window !== "string" || !b.window)
116
+ return null;
117
+ if (typeof b.remainingFraction !== "number" || !Number.isFinite(b.remainingFraction))
118
+ return null;
119
+ if (b.resetTime !== undefined && typeof b.resetTime !== "string")
120
+ return null;
121
+ buckets.push({ bucketId: b.bucketId, displayName: typeof b.displayName === "string" ? b.displayName : "", window: b.window, ...(typeof b.resetTime === "string" ? { resetTime: b.resetTime } : {}), remainingFraction: b.remainingFraction });
122
+ }
123
+ out.push({ displayName: g.displayName, buckets });
124
+ }
125
+ return out;
126
+ }
127
+ /** The measured group names and their bucket ids; the endpoint reports no per-model quota. */
128
+ const GEMINI_GROUP = { name: "gemini models", ids: { "5h": "gemini-5h", weekly: "gemini-weekly" } };
129
+ const THIRD_PARTY_GROUP = { name: "claude and gpt models", ids: { "5h": "3p-5h", weekly: "3p-weekly" } };
130
+ /** gemini-* draws on "Gemini Models"; claude-* and gpt-oss* on "Claude and GPT models"; else unknown. */
131
+ function groupFor(groups, model) {
132
+ const id = model.toLowerCase();
133
+ const known = /^gemini-/.test(id) ? GEMINI_GROUP : /^claude-/.test(id) || /^gpt-oss(-|$)/.test(id) ? THIRD_PARTY_GROUP : undefined;
134
+ if (!known)
135
+ return undefined;
136
+ const found = groups.filter((g) => g.displayName.trim().toLowerCase() === known.name);
137
+ return found.length === 1 ? { group: found[0], ids: known.ids } : undefined;
138
+ }
139
+ /** True when the group is exactly its 5h and weekly buckets, each with 0 < fraction <= 1 and a reset after `now`. */
140
+ function groupHasQuota(group, ids, now) {
141
+ if (group.buckets.length !== 2)
142
+ return false;
143
+ return ["5h", "weekly"].every((window) => {
144
+ const matches = group.buckets.filter((b) => b.window === window && b.bucketId === ids[window]);
145
+ const bucket = matches[0];
146
+ if (matches.length !== 1 || !bucket)
147
+ return false;
148
+ if (bucket.remainingFraction <= 0 || bucket.remainingFraction > 1)
149
+ return false;
150
+ const reset = bucket.resetTime ? Date.parse(bucket.resetTime) : Number.NaN;
151
+ return Number.isFinite(reset) && reset > now;
152
+ });
153
+ }
154
+ /**
155
+ * Per-account quota groups, fetched at most once per `ttlMs` per account. A failed lookup keeps the
156
+ * last good groups rather than blanking them, and only the status code is logged.
157
+ */
158
+ export class GoogleUsage {
159
+ last = new Map();
160
+ /** Per account, only the last SUCCESSFUL lookup; dropped as soon as a refresh fails. */
161
+ evidence = new Map();
162
+ inFlight = null;
163
+ fetchedAt = Number.NEGATIVE_INFINITY;
164
+ source;
165
+ opts;
166
+ constructor(source, opts) {
167
+ this.source = source;
168
+ this.opts = opts;
169
+ }
170
+ /** Groups by account owner id, from the store's own account ids. */
171
+ snapshot() {
172
+ const now = (this.opts.now ?? Date.now)();
173
+ if (now - this.fetchedAt < (this.opts.ttlMs ?? 5 * 60_000))
174
+ return Promise.resolve(Object.fromEntries(this.last));
175
+ this.inFlight ??= this.fetchAll().finally(() => {
176
+ this.fetchedAt = (this.opts.now ?? Date.now)();
177
+ this.inFlight = null;
178
+ });
179
+ return this.inFlight;
180
+ }
181
+ /**
182
+ * Whether the cached quota still shows room for `model` on this account and project. Reads the
183
+ * cache only (never fetches), and is true only when the last refresh of this account succeeded
184
+ * within the TTL, the model's group is recognized, and both its 5h and weekly buckets have a
185
+ * remaining fraction in (0, 1] and a reset time in the future. Everything else is false: this
186
+ * is positive evidence, not a guess, so "false" means "unknown or spent", not "refused".
187
+ */
188
+ hasRemainingQuota(credential, model) {
189
+ const evidence = this.evidence.get(credential.ownerId);
190
+ if (!evidence || evidence.projectId !== credential.projectId)
191
+ return false;
192
+ const now = (this.opts.now ?? Date.now)();
193
+ const age = now - evidence.observedAt;
194
+ if (!(age >= 0 && age < (this.opts.ttlMs ?? 5 * 60_000)))
195
+ return false;
196
+ const match = groupFor(evidence.groups, model);
197
+ return !!match && groupHasQuota(match.group, match.ids, now);
198
+ }
199
+ async fetchAll() {
200
+ let credentials;
201
+ try {
202
+ credentials = await this.source.credentials();
203
+ }
204
+ catch (e) {
205
+ this.opts.warn?.(`google usage: no credentials: ${e.message}`);
206
+ this.evidence.clear();
207
+ return Object.fromEntries(this.last);
208
+ }
209
+ await Promise.all(credentials.map(async (credential) => {
210
+ const fetched = await this.fetchOne(credential);
211
+ if (fetched) {
212
+ this.last.set(credential.ownerId, fetched.groups);
213
+ // A body with any malformed group or bucket is shown as far as it parses, but proves nothing.
214
+ if (fetched.strict)
215
+ this.evidence.set(credential.ownerId, { projectId: credential.projectId, observedAt: (this.opts.now ?? Date.now)(), groups: fetched.strict });
216
+ else
217
+ this.evidence.delete(credential.ownerId);
218
+ }
219
+ else {
220
+ // The displayed numbers stay, but they are no longer proof of anything.
221
+ this.evidence.delete(credential.ownerId);
222
+ }
223
+ }));
224
+ // An account that was removed must not keep its old numbers on the screen.
225
+ const owners = new Set(credentials.map((c) => c.ownerId));
226
+ for (const owner of [...this.last.keys()])
227
+ if (!owners.has(owner))
228
+ this.last.delete(owner);
229
+ for (const owner of [...this.evidence.keys()])
230
+ if (!owners.has(owner))
231
+ this.evidence.delete(owner);
232
+ return Object.fromEntries(this.last);
233
+ }
234
+ async fetchOne(credential) {
235
+ const owner = credential.ownerId.slice(0, 8);
236
+ try {
237
+ const res = await (this.opts.fetch ?? fetch)(`${GOOGLE_OAUTH.prodApi}/${GOOGLE_OAUTH.apiVersion}:retrieveUserQuotaSummary`, {
238
+ method: "POST",
239
+ headers: { authorization: `Bearer ${credential.accessToken}`, "content-type": "application/json", accept: "*/*", "user-agent": antigravityUserAgent() },
240
+ body: JSON.stringify({ project: credential.projectId }),
241
+ signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5000),
242
+ });
243
+ if (!res.ok) {
244
+ // The status alone: the body of an auth failure is not worth the risk of echoing.
245
+ this.opts.warn?.(`google usage ${owner}: HTTP ${res.status}`);
246
+ return null;
247
+ }
248
+ const body = await res.json();
249
+ const groups = parseQuotaSummary(body);
250
+ return groups ? { groups, strict: strictQuotaGroups(body) } : null;
251
+ }
252
+ catch (e) {
253
+ this.opts.warn?.(`google usage ${owner}: ${e.message}`);
254
+ return null;
255
+ }
256
+ }
257
+ }
@@ -10,6 +10,7 @@
10
10
  import { OpenAiCompatibleAdapter } from "../openai/index.js";
11
11
  import { GROK_AUTH_HINT, GrokAuth, GrokAuthError, grokHome, sharedGrokAuth } from "./auth.js";
12
12
  import { GROK_DEFAULT_URL, GROK_EFFORT_LEVELS } from "./catalog.js";
13
+ import { GrokUsage } from "./usage.js";
13
14
  /**
14
15
  * Facts only, returned by the probe in a `warning` field like the Antigravity one. Nothing published
15
16
  * by xAI says either way whether a third-party client may use the CLI's session.
@@ -34,8 +35,14 @@ export class GrokAdapter {
34
35
  name;
35
36
  auth;
36
37
  inner;
38
+ url;
39
+ clientVersion;
40
+ usageReader;
37
41
  constructor(name, cfg, log, auth) {
38
42
  this.name = name;
43
+ this.url = cfg.url ?? GROK_DEFAULT_URL;
44
+ this.clientVersion = cfg.clientVersion;
45
+ this.usageReader = new GrokUsage(name, { warn: (message) => log.warn(message) });
39
46
  // Shared with the dashboard's probe and with any other provider on the same session, and kept
40
47
  // across a config change that rebuilds this adapter.
41
48
  this.auth = auth ?? sharedGrokAuth({ home: cfg.home ?? grokHome(), ...(cfg.cli ? { cli: cfg.cli } : {}), log });
@@ -63,6 +70,17 @@ export class GrokAdapter {
63
70
  unauthorized: async (_model, sent) => this.auth.rejected(bearer(sent)),
64
71
  });
65
72
  }
73
+ /**
74
+ * The subscription's usage pool, for the status bar's limits row: what the CLI's `/usage` reads,
75
+ * with this adapter's own session and headers. Empty while no session is usable.
76
+ */
77
+ usage() {
78
+ return this.usageReader.windows(this.url, async () => {
79
+ const token = await this.auth.token();
80
+ const { "x-grok-model-override": _model, ...headers } = grokHeaders(token.key, "", await this.auth.clientVersion(this.clientVersion));
81
+ return headers;
82
+ }).catch(() => []);
83
+ }
66
84
  async handle(req, res, path, json, model, effort) {
67
85
  // Counting tokens is answered locally and needs no session.
68
86
  if (!path.startsWith("/v1/messages/count_tokens")) {
@@ -0,0 +1,77 @@
1
+ // How much of a Grok subscription's usage pool is spent, for the status bar's limits row.
2
+ //
3
+ // The Grok CLI's `/usage` (alias `/cost`) shows it; the CLI reads it from the chat proxy it talks to
4
+ // with the same session (CLI 1.0.5 strings: "/billing?format=credits", "Monthly limit", "Weekly
5
+ // limit", "Next reset:", field names creditUsagePercent, currentPeriod, billingPeriodEnd).
6
+ // Measured 2026-10-15, signed in on a SuperGrok account with nothing used that week:
7
+ // GET https://cli-chat-proxy.grok.com/v1/billing?format=credits (the adapter's headers) → 200
8
+ // {"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"…","end":"2026-10-17T04:12:41Z"},
9
+ // "onDemandCap":{"val":0},"onDemandUsed":{"val":0},"isUnifiedBillingUser":true,"prepaidBalance":{"val":0},
10
+ // "billingPeriodStart":"…","billingPeriodEnd":"2026-10-17T04:12:41Z"}}
11
+ // The body is protobuf JSON, which leaves zero values out: `creditUsagePercent` was absent at 0%
12
+ // use, so absent reads as 0. Its scale (0–100, by its name) is not yet seen on a non-zero week.
13
+ const WEEK = 7 * 24 * 60;
14
+ const MONTH = 30 * 24 * 60;
15
+ /** The usage pool's current period as a window, or null when the body names none. */
16
+ export function grokWindowsFromBilling(body) {
17
+ const config = body?.config;
18
+ if (!config || typeof config !== "object" || Array.isArray(config))
19
+ return null;
20
+ const c = config;
21
+ const period = c.currentPeriod;
22
+ if (!period || typeof period !== "object")
23
+ return null;
24
+ const weekly = period.type === "USAGE_PERIOD_TYPE_WEEKLY";
25
+ const raw = c.creditUsagePercent;
26
+ const used = typeof raw === "number" && Number.isFinite(raw) ? raw : raw === undefined ? 0 : NaN;
27
+ if (!Number.isFinite(used))
28
+ return null;
29
+ const end = typeof period.end === "string" ? Date.parse(period.end) : typeof c.billingPeriodEnd === "string" ? Date.parse(c.billingPeriodEnd) : NaN;
30
+ return [{ kind: weekly ? "seven_day" : "window", used_percent: used, window_minutes: weekly ? WEEK : MONTH, ...(Number.isFinite(end) ? { reset_at: Math.round(end / 1000) } : {}) }];
31
+ }
32
+ /**
33
+ * One provider's pool, fetched at most once per `ttlMs`. A failed lookup keeps the last good
34
+ * window rather than blanking it, and only the status code is logged (never the session).
35
+ */
36
+ export class GrokUsage {
37
+ last = [];
38
+ at = Number.NEGATIVE_INFINITY;
39
+ inFlight = null;
40
+ name;
41
+ opts;
42
+ constructor(name, opts = {}) {
43
+ this.name = name;
44
+ this.opts = opts;
45
+ }
46
+ windows(url, headers) {
47
+ const now = (this.opts.now ?? Date.now)();
48
+ if (now - this.at < (this.opts.ttlMs ?? 5 * 60_000))
49
+ return Promise.resolve(this.last);
50
+ this.inFlight ??= this.fetchOne(url, headers).finally(() => {
51
+ this.at = (this.opts.now ?? Date.now)();
52
+ this.inFlight = null;
53
+ });
54
+ return this.inFlight;
55
+ }
56
+ async fetchOne(url, headers) {
57
+ try {
58
+ const res = await (this.opts.fetch ?? fetch)(`${url.replace(/\/+$/, "")}/billing?format=credits`, {
59
+ method: "GET",
60
+ headers: { accept: "application/json", ...(await headers()) },
61
+ signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5000),
62
+ });
63
+ if (!res.ok) {
64
+ this.opts.warn?.(`grok usage ${this.name}: HTTP ${res.status}`);
65
+ return this.last;
66
+ }
67
+ const windows = grokWindowsFromBilling(await res.json());
68
+ if (windows)
69
+ this.last = windows;
70
+ return this.last;
71
+ }
72
+ catch (e) {
73
+ this.opts.warn?.(`grok usage ${this.name}: ${e.message}`);
74
+ return this.last;
75
+ }
76
+ }
77
+ }
@@ -163,13 +163,23 @@ export function toChatMessages(req, opts) {
163
163
  messages.push({ role: "user", content: onlyText ? parts.map((part) => part.text).join("\n") : parts });
164
164
  parts = [];
165
165
  };
166
+ // A tool result's images (and the fallback text of an unknown call) wait until the run of tool
167
+ // results ends: every `tool` message must follow the assistant's tool_calls directly, and a user
168
+ // message between parallel results is refused (DeepSeek: "insufficient tool messages", #64).
169
+ let deferred = [];
170
+ const release = () => {
171
+ parts.push(...deferred);
172
+ deferred = [];
173
+ };
166
174
  for (const block of message.content) {
167
175
  if (block.type === "text") {
176
+ release();
168
177
  const text = String(block.text ?? "");
169
178
  if (text)
170
179
  parts.push({ type: "text", text });
171
180
  }
172
181
  else if (block.type === "image") {
182
+ release();
173
183
  const url = imageUrl(block);
174
184
  if (url)
175
185
  parts.push({ type: "image_url", image_url: { url } });
@@ -186,11 +196,12 @@ export function toChatMessages(req, opts) {
186
196
  if (knownCalls.has(result.tool_use_id))
187
197
  messages.push({ role: "tool", tool_call_id: result.tool_use_id, content: output });
188
198
  else
189
- parts.push({ type: "text", text: `[Tool result]\n${output}` });
199
+ deferred.push({ type: "text", text: `[Tool result]\n${output}` });
190
200
  for (const url of images)
191
- parts.push({ type: "image_url", image_url: { url } });
201
+ deferred.push({ type: "image_url", image_url: { url } });
192
202
  }
193
203
  }
204
+ release();
194
205
  flush();
195
206
  }
196
207
  return messages;
@@ -0,0 +1,116 @@
1
+ // How much of an OpenCode Go subscription is used, for the status bar's limits row.
2
+ //
3
+ // One request per provider: GET {provider.url}/usage (measured 2026-10-15: https://opencode.ai/zen/go/v1/usage)
4
+ // with the provider's own config headers (the bearer key) and the same default User-Agent Node's
5
+ // fetch sends elsewhere — Cloudflare answers 403 `error 1010` to a request carrying no User-Agent
6
+ // and no browser fingerprint, but accepts Node's fetch as it is. Source of the route (behavioral
7
+ // only, no code copied): anomalyco/opencode packages/console/app/src/routes/zen/go/v1/usage.ts and
8
+ // lib/inference-proxy.ts (the `GET /zen/go/v1/usage` → `/go/v1/usage` mapping).
9
+ //
10
+ // Measured answer:
11
+ // {"usage":{"rolling":{"status":"ok","percent":3,"resetsAt":"2026-10-15T05:39:48Z"},
12
+ // "weekly":{"status":"ok","percent":52,"resetsAt":"2026-10-19T00:00:00Z"},
13
+ // "monthly":{"status":"ok","percent":92,"resetsAt":"2026-10-18T05:43:30Z"}}}
14
+ // `rolling` is the five-hour window, `weekly` the week, `monthly` roughly 30 days.
15
+ /** The five-hour, weekly and ~monthly window lengths in minutes. */
16
+ const FIVE_HOURS = 5 * 60;
17
+ const WEEK = 7 * 24 * 60;
18
+ const MONTH = 30 * 24 * 60;
19
+ /** The window lengths and read order of the endpoint's three keys. */
20
+ const KEYS = [
21
+ { key: "rolling", kind: "five_hour", window_minutes: FIVE_HOURS },
22
+ { key: "weekly", kind: "seven_day", window_minutes: WEEK },
23
+ { key: "monthly", kind: "window", window_minutes: MONTH },
24
+ ];
25
+ /**
26
+ * Parse the `/usage` body into windows. A window missing a finite percent is dropped; null when the
27
+ * body carries no `usage` object at all (so the caller keeps its last good answer instead of
28
+ * blanking).
29
+ */
30
+ export function opencodeWindowsFromBody(body) {
31
+ if (!body || typeof body !== "object" || Array.isArray(body))
32
+ return null;
33
+ const usage = body.usage;
34
+ if (!usage || typeof usage !== "object" || Array.isArray(usage))
35
+ return null;
36
+ const source = usage;
37
+ const out = [];
38
+ for (const { key, kind, window_minutes } of KEYS) {
39
+ const w = source[key];
40
+ if (!w || typeof w !== "object" || Array.isArray(w))
41
+ continue;
42
+ const win = w;
43
+ if (typeof win.percent !== "number" || !Number.isFinite(win.percent))
44
+ continue;
45
+ const at = typeof win.resetsAt === "string" ? Date.parse(win.resetsAt) : NaN;
46
+ out.push({ kind, used_percent: win.percent, window_minutes, ...(Number.isFinite(at) ? { reset_at: Math.round(at / 1000) } : {}) });
47
+ }
48
+ return out.length ? out : null;
49
+ }
50
+ /** Whether a provider is an OpenCode Go subscription: its preset, or an OpenCode Go endpoint. */
51
+ export function isOpenCodeGo(p) {
52
+ if (p.type === "openai-compatible" && p.preset === "opencode-go")
53
+ return true;
54
+ if (typeof p.url !== "string")
55
+ return false;
56
+ try {
57
+ const url = new URL(p.url);
58
+ return url.hostname === "opencode.ai" && /^\/zen\/go\/v1(?:\/|$)/.test(url.pathname);
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /**
65
+ * Per-provider usage windows, fetched at most once per `ttlMs` per provider. A failed lookup keeps
66
+ * the last good windows rather than blanking them, and only the status code is logged.
67
+ */
68
+ export class OpenCodeUsage {
69
+ last = new Map();
70
+ at = new Map();
71
+ inFlight = new Map();
72
+ opts;
73
+ constructor(opts) {
74
+ this.opts = opts;
75
+ }
76
+ /** The provider's windows, cached for `ttlMs`; the last good ones survive a failed lookup. */
77
+ windows(name, url, headers) {
78
+ const now = (this.opts.now ?? Date.now)();
79
+ const ttl = this.opts.ttlMs ?? 5 * 60_000;
80
+ if (now - (this.at.get(name) ?? Number.NEGATIVE_INFINITY) < ttl)
81
+ return Promise.resolve(this.last.get(name) ?? []);
82
+ const running = this.inFlight.get(name);
83
+ if (running)
84
+ return running;
85
+ const task = this.fetchOne(name, url, headers).finally(() => {
86
+ this.at.set(name, (this.opts.now ?? Date.now)());
87
+ this.inFlight.delete(name);
88
+ });
89
+ this.inFlight.set(name, task);
90
+ return task;
91
+ }
92
+ async fetchOne(name, url, headers) {
93
+ const keep = () => this.last.get(name) ?? [];
94
+ try {
95
+ const res = await (this.opts.fetch ?? fetch)(`${url.replace(/\/+$/, "")}/usage`, {
96
+ method: "GET",
97
+ headers: { accept: "application/json", ...headers },
98
+ signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5000),
99
+ });
100
+ if (!res.ok) {
101
+ // The status alone: the body of an auth failure is not worth the risk of echoing.
102
+ this.opts.warn?.(`opencode usage ${name}: HTTP ${res.status}`);
103
+ return keep();
104
+ }
105
+ const windows = opencodeWindowsFromBody(await res.json());
106
+ if (!windows)
107
+ return keep();
108
+ this.last.set(name, windows);
109
+ return windows;
110
+ }
111
+ catch (e) {
112
+ this.opts.warn?.(`opencode usage ${name}: ${e.message}`);
113
+ return keep();
114
+ }
115
+ }
116
+ }