tide-commander 1.149.0 → 1.150.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 (66) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/assets/{BossLogsModal-Cs4kWugD.js → BossLogsModal-DvczRUvP.js} +1 -1
  3. package/dist/assets/{BossSpawnModal-BudMgJqj.js → BossSpawnModal-B2autFKk.js} +1 -1
  4. package/dist/assets/{ControlsModal-Bh6_3qtH.js → ControlsModal-BaREMQxc.js} +1 -1
  5. package/dist/assets/{DockerLogsModal-Nf4wQFfz.js → DockerLogsModal-DZ0udMCo.js} +1 -1
  6. package/dist/assets/{EmbeddedEditor-BfRXRX50.js → EmbeddedEditor-CEjr0ZSZ.js} +1 -1
  7. package/dist/assets/{GmailOAuthSetup-CXUtjA0r.js → GmailOAuthSetup-76BP8J8C.js} +1 -1
  8. package/dist/assets/{GoogleOAuthSetup-D-_rwicD.js → GoogleOAuthSetup-D45GImPI.js} +1 -1
  9. package/dist/assets/{IframeModal-CdaXsQK0.js → IframeModal-De63RfJZ.js} +1 -1
  10. package/dist/assets/{IntegrationsPanel-ZsSVxcWr.js → IntegrationsPanel-DYrw1Sqk.js} +2 -2
  11. package/dist/assets/{LogViewerModal-BAf-klB2.js → LogViewerModal-DW-2lpln.js} +1 -1
  12. package/dist/assets/{MonitoringModal-BjRbh5z4.js → MonitoringModal-B0-XXyd3.js} +1 -1
  13. package/dist/assets/{PM2LogsModal-SaywDZVL.js → PM2LogsModal-DOfOhkCY.js} +1 -1
  14. package/dist/assets/{RestoreArchivedAreaModal-jru2Ez9a.js → RestoreArchivedAreaModal-DNGmIJmM.js} +1 -1
  15. package/dist/assets/{Scene2DCanvas-NJHoe2Qg.js → Scene2DCanvas-BW1E76CQ.js} +1 -1
  16. package/dist/assets/{SceneManager-i9wVMa_V.js → SceneManager-DAvc9-Wp.js} +1 -1
  17. package/dist/assets/{SkillsPanel-BHfKLzWe.js → SkillsPanel-Br-gLCLA.js} +1 -1
  18. package/dist/assets/{SlackMultiInstanceSetup-BfVevCSo.js → SlackMultiInstanceSetup-DG_bIfDu.js} +1 -1
  19. package/dist/assets/{SpawnModal-29x5z53B.js → SpawnModal-lWM8OuZW.js} +1 -1
  20. package/dist/assets/{StatisticsModal-MbIByjWD.js → StatisticsModal-LvA0Fcvn.js} +1 -1
  21. package/dist/assets/{SubordinateAssignmentModal-DF0vYaM6.js → SubordinateAssignmentModal-8YxNXcR3.js} +1 -1
  22. package/dist/assets/{TriggerManagerPanel-EGcKS1Pt.js → TriggerManagerPanel-DftIuKUG.js} +1 -1
  23. package/dist/assets/{WorkflowEditorPanel-BLmejfqe.js → WorkflowEditorPanel-oLwyO_C6.js} +1 -1
  24. package/dist/assets/{area-logos-C4fgpLgO.js → area-logos-mcpQqUMZ.js} +1 -1
  25. package/dist/assets/{index-BiiLWTXJ.js → index-Ay7dKyeR.js} +1 -1
  26. package/dist/assets/{index-DThK5_yS.js → index-BMw7AAAK.js} +3 -3
  27. package/dist/assets/{index-C8CGfmXP.js → index-BQpd34Mc.js} +1 -1
  28. package/dist/assets/{index-BZ4wZwRu.js → index-C6-68CXL.js} +3 -3
  29. package/dist/assets/index-CIon0DLf.js +2 -0
  30. package/dist/assets/{index-C6Kf4ewu.js → index-Cwt-E2td.js} +1 -1
  31. package/dist/assets/{index-B9xpLAcH.js → index-DFVroqA5.js} +1 -1
  32. package/dist/assets/index-DR6_WeF_.js +1 -0
  33. package/dist/assets/{index-B-2H7O_Y.js → index-O0t07OB_.js} +2 -2
  34. package/dist/assets/{main-B0UlZnfk.js → main-D5sqeuwB.js} +151 -125
  35. package/dist/assets/main-JBkwGXfE.css +1 -0
  36. package/dist/assets/{web-CXC8P25z.js → web-BkrshujV.js} +1 -1
  37. package/dist/assets/{web-LGEDa25c.js → web-DPETNqOf.js} +1 -1
  38. package/dist/assets/{web-NM4dUqWg.js → web-vG1Smf_N.js} +1 -1
  39. package/dist/index.html +2 -2
  40. package/dist/locales/de/config.json +3 -1
  41. package/dist/locales/en/config.json +2 -0
  42. package/dist/locales/en/terminal.json +6 -0
  43. package/dist/locales/es/config.json +2 -0
  44. package/dist/locales/fr/config.json +3 -1
  45. package/dist/locales/hi/config.json +3 -1
  46. package/dist/locales/it/config.json +3 -1
  47. package/dist/locales/ja/config.json +3 -1
  48. package/dist/locales/pt/config.json +3 -1
  49. package/dist/locales/ru/config.json +3 -1
  50. package/dist/locales/zh-CN/config.json +3 -1
  51. package/dist/src/packages/server/claude/backend.js +114 -10
  52. package/dist/src/packages/server/claude/runner/process-lifecycle.js +9 -0
  53. package/dist/src/packages/server/claude/runner/stdout-pipeline.js +24 -4
  54. package/dist/src/packages/server/claude/session-loader.js +20 -5
  55. package/dist/src/packages/server/codex/json-event-parser.js +149 -4
  56. package/dist/src/packages/server/grok/backend.js +4 -0
  57. package/dist/src/packages/server/grok/json-event-parser.js +29 -14
  58. package/dist/src/packages/server/grok/session-watcher.js +67 -34
  59. package/dist/src/packages/server/opencode/json-event-parser.js +184 -15
  60. package/dist/src/packages/server/routes/agents.js +12 -9
  61. package/dist/src/packages/server/services/grok-usage-service.js +374 -0
  62. package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +10 -7
  63. package/package.json +1 -1
  64. package/dist/assets/index-DYgVP0G3.js +0 -2
  65. package/dist/assets/index-DhD3m2Qx.js +0 -1
  66. package/dist/assets/main-DeiAIdzy.css +0 -1
@@ -1,6 +1,16 @@
1
1
  /**
2
2
  * OpenCode JSON Event Parser
3
- * Maps OpenCode NDJSON events to StandardEvent format
3
+ * Maps OpenCode NDJSON events to StandardEvent format.
4
+ *
5
+ * Streaming model (mirrors Claude/Grok):
6
+ * - `message.part.delta` → append delta with isStreaming:true + stable partID uuid
7
+ * - progressive `text` / `reasoning` snapshots (same part.id, growing text) →
8
+ * emit only the new suffix as a streaming chunk
9
+ * - final text (part.time.end set, or step_finish) → isStreaming:false same uuid
10
+ *
11
+ * Note: current `opencode run --format json` often emits a single complete `text`
12
+ * event per turn (no token deltas). This parser still assigns stable uuids and is
13
+ * ready for delta / progressive events when the CLI surfaces them.
4
14
  */
5
15
  import { createLogger } from '../utils/logger.js';
6
16
  const log = createLogger('OpencodeParser');
@@ -34,6 +44,12 @@ export class OpencodeJsonEventParser {
34
44
  textEventEmittedInTurn = false;
35
45
  lastThinkingContent;
36
46
  thinkingEventEmittedInTurn = false;
47
+ /** Full text seen so far per part id — used to derive deltas from snapshots. */
48
+ streamedTextByPart = new Map();
49
+ streamedThinkingByPart = new Map();
50
+ /** Open text/thinking stream uuids awaiting isStreaming:false finalize. */
51
+ openTextStreamUuids = new Set();
52
+ openThinkingStreamUuids = new Set();
37
53
  parseEvent(rawEvent) {
38
54
  const event = rawEvent;
39
55
  if (!event || typeof event !== 'object' || !event.type) {
@@ -51,42 +67,166 @@ export class OpencodeJsonEventParser {
51
67
  return this.parseToolUse(event);
52
68
  case 'step_finish':
53
69
  return this.parseStepFinish(event);
70
+ // Server/SDK event names — surface token deltas when present.
71
+ case 'message.part.delta':
72
+ return this.parseMessagePartDelta(event);
73
+ case 'message.part.updated':
74
+ return this.parseMessagePartUpdated(event);
54
75
  default:
55
76
  log.log(`parseEvent: unknown event type '${event.type}' - skipping`);
56
77
  return [];
57
78
  }
58
79
  }
59
80
  parseStepStart(event) {
81
+ // New step — allow streams again.
82
+ this.openTextStreamUuids.clear();
83
+ this.openThinkingStreamUuids.clear();
84
+ this.streamedTextByPart.clear();
85
+ this.streamedThinkingByPart.clear();
60
86
  return [{
61
87
  type: 'init',
62
88
  sessionId: event.sessionID,
63
89
  }];
64
90
  }
65
- parseText(event) {
66
- const text = event.part?.text;
67
- if (!text)
91
+ streamUuid(kind, partId, messageId) {
92
+ if (partId)
93
+ return `opencode-${kind}-${partId}`;
94
+ if (messageId)
95
+ return `opencode-${kind}-msg-${messageId}`;
96
+ return `opencode-${kind}-anon`;
97
+ }
98
+ /**
99
+ * Emit streaming text from a full snapshot: only the new suffix is sent as a
100
+ * delta so the client can append. Returns [] when the snapshot is unchanged.
101
+ */
102
+ emitProgressive(kind, fullText, partId, messageId, isFinal) {
103
+ const map = kind === 'text' ? this.streamedTextByPart : this.streamedThinkingByPart;
104
+ const openSet = kind === 'text' ? this.openTextStreamUuids : this.openThinkingStreamUuids;
105
+ const key = partId || messageId || 'anon';
106
+ const prev = map.get(key) || '';
107
+ const uuid = this.streamUuid(kind, partId, messageId);
108
+ if (kind === 'text') {
109
+ this.lastTextContent = fullText;
110
+ this.textEventEmittedInTurn = true;
111
+ }
112
+ else {
113
+ this.lastThinkingContent = fullText;
114
+ this.thinkingEventEmittedInTurn = true;
115
+ }
116
+ // Unchanged snapshot (common re-broadcast) — only finalize if needed.
117
+ if (fullText === prev) {
118
+ if (isFinal && openSet.has(uuid)) {
119
+ openSet.delete(uuid);
120
+ return [{
121
+ type: kind === 'text' ? 'text' : 'thinking',
122
+ text: fullText,
123
+ isStreaming: false,
124
+ uuid,
125
+ }];
126
+ }
68
127
  return [];
69
- // Track last text for resultText in step_complete (boss delegation parsing)
70
- this.lastTextContent = text;
71
- this.textEventEmittedInTurn = true;
128
+ }
129
+ map.set(key, fullText);
130
+ // Growing prefix → emit suffix as streaming chunk.
131
+ if (fullText.startsWith(prev) && !isFinal) {
132
+ const delta = fullText.slice(prev.length);
133
+ if (!delta)
134
+ return [];
135
+ openSet.add(uuid);
136
+ return [{
137
+ type: kind === 'text' ? 'text' : 'thinking',
138
+ text: delta,
139
+ isStreaming: true,
140
+ uuid,
141
+ }];
142
+ }
143
+ // Non-prefix rewrite or final: replace with full text.
144
+ if (isFinal) {
145
+ openSet.delete(uuid);
146
+ return [{
147
+ type: kind === 'text' ? 'text' : 'thinking',
148
+ text: fullText,
149
+ isStreaming: false,
150
+ uuid,
151
+ }];
152
+ }
153
+ openSet.add(uuid);
154
+ // Client merge appends when isStreaming; for rewrites send full text with
155
+ // isStreaming:false then... no, that finalizes early. Prefer replace path:
156
+ // emit as streaming full text only if prev empty; else finalize+restart.
157
+ if (!prev) {
158
+ return [{
159
+ type: kind === 'text' ? 'text' : 'thinking',
160
+ text: fullText,
161
+ isStreaming: true,
162
+ uuid,
163
+ }];
164
+ }
165
+ // Rare rewrite: finalize with new full content.
166
+ openSet.delete(uuid);
72
167
  return [{
73
- type: 'text',
74
- text,
168
+ type: kind === 'text' ? 'text' : 'thinking',
169
+ text: fullText,
75
170
  isStreaming: false,
171
+ uuid,
76
172
  }];
77
173
  }
174
+ parseText(event) {
175
+ const part = event.part;
176
+ const text = part?.text;
177
+ if (!text)
178
+ return [];
179
+ // CLI format marks completion with time.end; absence → treat as progressive.
180
+ const isFinal = typeof part?.time?.end === 'number';
181
+ return this.emitProgressive('text', text, part?.id, part?.messageID, isFinal);
182
+ }
78
183
  parseReasoning(event) {
79
- const text = event.part?.text;
184
+ const part = event.part;
185
+ const text = part?.text;
80
186
  if (!text)
81
187
  return [];
82
- this.lastThinkingContent = text;
83
- this.thinkingEventEmittedInTurn = true;
188
+ const isFinal = typeof part?.time?.end === 'number';
189
+ return this.emitProgressive('thinking', text, part?.id, part?.messageID, isFinal);
190
+ }
191
+ parseMessagePartDelta(event) {
192
+ const props = event.properties || {};
193
+ const delta = props.delta ?? event.part?.delta;
194
+ const field = props.field || event.part?.field || 'text';
195
+ const partId = props.partID || event.part?.id;
196
+ const messageId = props.messageID || event.part?.messageID;
197
+ if (typeof delta !== 'string' || !delta)
198
+ return [];
199
+ const kind = field === 'reasoning' || field === 'thinking' ? 'thinking' : 'text';
200
+ const map = kind === 'text' ? this.streamedTextByPart : this.streamedThinkingByPart;
201
+ const openSet = kind === 'text' ? this.openTextStreamUuids : this.openThinkingStreamUuids;
202
+ const key = partId || messageId || 'anon';
203
+ const next = (map.get(key) || '') + delta;
204
+ map.set(key, next);
205
+ if (kind === 'text') {
206
+ this.lastTextContent = next;
207
+ this.textEventEmittedInTurn = true;
208
+ }
209
+ else {
210
+ this.lastThinkingContent = next;
211
+ this.thinkingEventEmittedInTurn = true;
212
+ }
213
+ const uuid = this.streamUuid(kind, partId, messageId);
214
+ openSet.add(uuid);
84
215
  return [{
85
- type: 'thinking',
86
- text,
87
- isStreaming: false,
216
+ type: kind === 'text' ? 'text' : 'thinking',
217
+ text: delta,
218
+ isStreaming: true,
219
+ uuid,
88
220
  }];
89
221
  }
222
+ parseMessagePartUpdated(event) {
223
+ const part = event.properties?.part || event.part;
224
+ if (!part?.text)
225
+ return [];
226
+ const kind = part.type === 'reasoning' || part.type === 'thinking' ? 'thinking' : 'text';
227
+ const isFinal = typeof part.time?.end === 'number';
228
+ return this.emitProgressive(kind, part.text, part.id, part.messageID, isFinal);
229
+ }
90
230
  parseToolUse(event) {
91
231
  const part = event.part;
92
232
  if (!part)
@@ -103,6 +243,7 @@ export class OpencodeJsonEventParser {
103
243
  };
104
244
  if (callId) {
105
245
  toolStart.toolUseId = callId;
246
+ toolStart.uuid = callId;
106
247
  }
107
248
  events.push(toolStart);
108
249
  // Emit tool_result if state is present (completed tool)
@@ -114,6 +255,7 @@ export class OpencodeJsonEventParser {
114
255
  };
115
256
  if (callId) {
116
257
  toolResult.toolUseId = callId;
258
+ toolResult.uuid = callId;
117
259
  }
118
260
  events.push(toolResult);
119
261
  }
@@ -125,6 +267,31 @@ export class OpencodeJsonEventParser {
125
267
  return [];
126
268
  const events = [];
127
269
  const tokens = part.tokens;
270
+ // Finalize any open streaming rows so the UI stops the "live" cursor.
271
+ for (const uuid of this.openTextStreamUuids) {
272
+ const text = this.lastTextContent || '';
273
+ if (text) {
274
+ events.push({
275
+ type: 'text',
276
+ text,
277
+ isStreaming: false,
278
+ uuid,
279
+ });
280
+ }
281
+ }
282
+ this.openTextStreamUuids.clear();
283
+ for (const uuid of this.openThinkingStreamUuids) {
284
+ const text = this.lastThinkingContent || '';
285
+ if (text) {
286
+ events.push({
287
+ type: 'thinking',
288
+ text,
289
+ isStreaming: false,
290
+ uuid,
291
+ });
292
+ }
293
+ }
294
+ this.openThinkingStreamUuids.clear();
128
295
  // Emit usage_snapshot if token info is available
129
296
  if (tokens) {
130
297
  events.push({
@@ -174,6 +341,8 @@ export class OpencodeJsonEventParser {
174
341
  this.textEventEmittedInTurn = false;
175
342
  this.lastThinkingContent = undefined;
176
343
  this.thinkingEventEmittedInTurn = false;
344
+ this.streamedTextByPart.clear();
345
+ this.streamedThinkingByPart.clear();
177
346
  return events;
178
347
  }
179
348
  }
@@ -22,6 +22,7 @@ import { getSystemPrompt, setSystemPrompt, clearSystemPrompt, isEchoPromptEnable
22
22
  import { markInstructionsDirtyForAll } from '../services/instruction-refresh.js';
23
23
  import { startAgentTerminal, stopAgentTerminal } from '../services/agent-terminal-service.js';
24
24
  import { buildClaudeUsageByAgentSummary, buildClaudeUsageByDaySummary, buildClaudeUsageSnapshot } from '../services/claude-usage-service.js';
25
+ import { buildGrokUsageSnapshot } from '../services/grok-usage-service.js';
25
26
  import { getBackupStatus, setBackupEnabled } from '../services/backup-service.js';
26
27
  const log = createLogger('Routes');
27
28
  const router = Router();
@@ -948,30 +949,32 @@ router.delete('/:id', (req, res) => {
948
949
  }
949
950
  res.status(204).end();
950
951
  });
951
- // GET /api/agents/:id/usage - Claude usage snapshot for a single agent
952
+ // GET /api/agents/:id/usage - Claude or Grok usage snapshot for a single agent
952
953
  //
953
- // Mirrors what the Claude CLI's `/usage` slash command surfaces: local agent
954
- // stats plus the live rate-limit gauges fetched with the CLI's own OAuth
955
- // credentials. See services/claude-usage-service.ts for the source list.
954
+ // Claude: local agent stats + Anthropic OAuth rate-limit gauges (CLI `/usage`).
955
+ // Grok: local agent stats + CLI chat-proxy billing/credit gauges (CLI `/usage`).
956
956
  router.get('/:id/usage', async (req, res) => {
957
957
  const agent = agentService.getAgent(req.params.id);
958
958
  if (!agent) {
959
959
  res.status(404).json({ error: 'Agent not found' });
960
960
  return;
961
961
  }
962
- if (agent.provider !== 'claude') {
962
+ const provider = agent.provider ?? 'claude';
963
+ if (provider !== 'claude' && provider !== 'grok') {
963
964
  res.status(400).json({
964
- error: 'Usage data is only available for Claude agents',
965
- provider: agent.provider,
965
+ error: 'Usage data is only available for Claude and Grok agents',
966
+ provider,
966
967
  });
967
968
  return;
968
969
  }
969
970
  try {
970
- const snapshot = await buildClaudeUsageSnapshot(agent);
971
+ const snapshot = provider === 'grok'
972
+ ? await buildGrokUsageSnapshot(agent)
973
+ : await buildClaudeUsageSnapshot(agent);
971
974
  res.json(snapshot);
972
975
  }
973
976
  catch (err) {
974
- log.error('Failed to build Claude usage snapshot:', err);
977
+ log.error(`Failed to build ${provider} usage snapshot:`, err);
975
978
  res.status(500).json({ error: err?.message ?? 'Failed to build usage snapshot' });
976
979
  }
977
980
  });
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Grok Usage Service
3
+ *
4
+ * Surfaces the same plan-limit gauges the Grok CLI's `/usage` command shows,
5
+ * using the CLI chat-proxy billing endpoints with the OAuth token in
6
+ * `~/.grok/auth.json`:
7
+ *
8
+ * - `GET /v1/billing?format=credits` — subscription usage percent +
9
+ * current weekly/monthly period (what the CLI logs as
10
+ * "billing: fetched credits config")
11
+ * - `GET /v1/billing` — calendar-month spend/credit allotment
12
+ * (`used` / `monthlyLimit`) when the credits payload is weekly-only
13
+ *
14
+ * When both are available the snapshot exposes **weekly** and **monthly**
15
+ * gauges side-by-side, matching the terminal `/usage` panel.
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as os from 'os';
19
+ import * as path from 'path';
20
+ import { createLogger } from '../utils/logger.js';
21
+ const log = createLogger('GrokUsage');
22
+ const AUTH_PATH = path.join(os.homedir(), '.grok', 'auth.json');
23
+ const DEFAULT_BILLING_BASE = 'https://cli-chat-proxy.grok.com/v1/billing';
24
+ const BILLING_TIMEOUT_MS = 5_000;
25
+ // Account-wide gauges — one cache line serves every Grok agent/client poll.
26
+ const RATE_LIMIT_CACHE_TTL_MS = 60_000;
27
+ const RATE_LIMIT_429_BACKOFF_MS = 5 * 60_000;
28
+ const RATE_LIMIT_ERROR_BACKOFF_MS = 30_000;
29
+ function billingBaseUrl() {
30
+ const base = process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim();
31
+ if (base) {
32
+ return `${base.replace(/\/+$/, '')}/billing`;
33
+ }
34
+ return DEFAULT_BILLING_BASE;
35
+ }
36
+ function readGrokAccessToken() {
37
+ try {
38
+ if (!fs.existsSync(AUTH_PATH)) {
39
+ return { error: 'No Grok CLI credentials found — run `grok login`' };
40
+ }
41
+ const raw = JSON.parse(fs.readFileSync(AUTH_PATH, 'utf-8'));
42
+ // auth.json is keyed by issuer::client_id; pick the first entry with a token.
43
+ for (const entry of Object.values(raw)) {
44
+ if (!entry || typeof entry !== 'object')
45
+ continue;
46
+ const token = entry.key;
47
+ if (typeof token !== 'string' || token === '')
48
+ continue;
49
+ const expiresRaw = entry.expires_at ?? entry.expiresAt;
50
+ if (typeof expiresRaw === 'string' || typeof expiresRaw === 'number') {
51
+ const expiresMs = typeof expiresRaw === 'number' ? expiresRaw : Date.parse(expiresRaw);
52
+ if (Number.isFinite(expiresMs) && expiresMs <= Date.now()) {
53
+ return {
54
+ error: 'Grok CLI OAuth token has expired — run `grok login` or any Grok session to refresh it',
55
+ };
56
+ }
57
+ }
58
+ return { token };
59
+ }
60
+ return { error: 'Grok CLI credentials are missing an OAuth token' };
61
+ }
62
+ catch (err) {
63
+ log.warn(`Failed to read Grok credentials at ${AUTH_PATH}: ${err}`);
64
+ return { error: 'Failed to read Grok CLI credentials' };
65
+ }
66
+ }
67
+ function unwrapVal(value) {
68
+ if (typeof value === 'number' && Number.isFinite(value))
69
+ return value;
70
+ if (value && typeof value === 'object' && 'val' in value) {
71
+ const inner = value.val;
72
+ if (typeof inner === 'number' && Number.isFinite(inner))
73
+ return inner;
74
+ }
75
+ return null;
76
+ }
77
+ function parseIso(value) {
78
+ if (typeof value !== 'string' || value.trim() === '')
79
+ return null;
80
+ const ms = Date.parse(value);
81
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
82
+ }
83
+ function clampPercent(value) {
84
+ return Math.max(0, Math.min(100, value));
85
+ }
86
+ /** Map API period type strings / date span → weekly | monthly | period. */
87
+ export function classifyPeriodType(typeRaw, startIso, endIso) {
88
+ if (typeof typeRaw === 'string') {
89
+ const t = typeRaw.toUpperCase();
90
+ if (t.includes('WEEK'))
91
+ return 'weekly';
92
+ if (t.includes('MONTH'))
93
+ return 'monthly';
94
+ }
95
+ if (!startIso || !endIso)
96
+ return 'period';
97
+ const start = Date.parse(startIso);
98
+ const end = Date.parse(endIso);
99
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start)
100
+ return 'period';
101
+ const days = (end - start) / (24 * 60 * 60 * 1000);
102
+ if (days >= 5 && days <= 10)
103
+ return 'weekly';
104
+ if (days >= 25 && days <= 35)
105
+ return 'monthly';
106
+ return 'period';
107
+ }
108
+ /** @deprecated use classifyPeriodType — kept for existing tests. */
109
+ export function classifyBillingPeriod(startIso, endIso) {
110
+ return classifyPeriodType(null, startIso, endIso);
111
+ }
112
+ function parseRetryAfterMs(headerValue) {
113
+ if (!headerValue)
114
+ return undefined;
115
+ const seconds = Number(headerValue);
116
+ if (Number.isFinite(seconds))
117
+ return Math.max(0, seconds * 1000);
118
+ const dateMs = Date.parse(headerValue);
119
+ if (Number.isFinite(dateMs))
120
+ return Math.max(0, dateMs - Date.now());
121
+ return undefined;
122
+ }
123
+ async function fetchBillingJson(url, token) {
124
+ const response = await fetch(url, {
125
+ headers: {
126
+ Authorization: `Bearer ${token}`,
127
+ Accept: 'application/json',
128
+ 'x-grok-client-version': '0.2.93',
129
+ },
130
+ signal: AbortSignal.timeout(BILLING_TIMEOUT_MS),
131
+ });
132
+ if (!response.ok) {
133
+ return {
134
+ ok: false,
135
+ status: response.status,
136
+ error: `Grok billing endpoint returned ${response.status}`,
137
+ retryAfterMs: parseRetryAfterMs(response.headers.get('retry-after')),
138
+ };
139
+ }
140
+ const body = (await response.json());
141
+ return {
142
+ ok: true,
143
+ status: response.status,
144
+ config: body?.config ?? {},
145
+ retryAfterMs: parseRetryAfterMs(response.headers.get('retry-after')),
146
+ };
147
+ }
148
+ /**
149
+ * Parse `/billing?format=credits` — the payload the CLI uses for the
150
+ * Weekly/Monthly percent gauges in `/usage`.
151
+ */
152
+ export function parseCreditsConfig(config) {
153
+ let weekly = null;
154
+ let monthly = null;
155
+ let onDemand = null;
156
+ const percentRaw = config.creditUsagePercent;
157
+ const percent = typeof percentRaw === 'number' && Number.isFinite(percentRaw)
158
+ ? clampPercent(percentRaw)
159
+ : null;
160
+ const currentPeriod = config.currentPeriod && typeof config.currentPeriod === 'object'
161
+ ? config.currentPeriod
162
+ : null;
163
+ const periodStart = parseIso(currentPeriod?.start) ?? parseIso(config.billingPeriodStart);
164
+ const periodEnd = parseIso(currentPeriod?.end) ?? parseIso(config.billingPeriodEnd);
165
+ const kind = classifyPeriodType(currentPeriod?.type, periodStart, periodEnd);
166
+ if (percent != null && periodEnd) {
167
+ const window = {
168
+ utilization: percent,
169
+ resetsAt: periodEnd,
170
+ };
171
+ if (kind === 'weekly')
172
+ weekly = window;
173
+ else if (kind === 'monthly')
174
+ monthly = window;
175
+ else {
176
+ // Unknown period type — prefer weekly label when span is short-ish.
177
+ if (kind === 'period' && periodStart && periodEnd) {
178
+ const days = (Date.parse(periodEnd) - Date.parse(periodStart)) / (24 * 60 * 60 * 1000);
179
+ if (days <= 14)
180
+ weekly = window;
181
+ else
182
+ monthly = window;
183
+ }
184
+ else {
185
+ weekly = window;
186
+ }
187
+ }
188
+ }
189
+ // productUsage[] can carry per-product percents (e.g. GrokBuild) — prefer the
190
+ // primary creditUsagePercent above, but fall back if it was missing.
191
+ if (percent == null && Array.isArray(config.productUsage)) {
192
+ for (const entry of config.productUsage) {
193
+ if (!entry || typeof entry !== 'object')
194
+ continue;
195
+ const p = entry.usagePercent;
196
+ if (typeof p === 'number' && Number.isFinite(p) && periodEnd) {
197
+ const window = {
198
+ utilization: clampPercent(p),
199
+ resetsAt: periodEnd,
200
+ };
201
+ if (kind === 'monthly')
202
+ monthly = window;
203
+ else
204
+ weekly = window;
205
+ break;
206
+ }
207
+ }
208
+ }
209
+ const onDemandCap = unwrapVal(config.onDemandCap);
210
+ const onDemandUsed = unwrapVal(config.onDemandUsed) ?? 0;
211
+ if (onDemandCap != null && onDemandCap > 0) {
212
+ onDemand = {
213
+ utilization: clampPercent((onDemandUsed / onDemandCap) * 100),
214
+ resetsAt: periodEnd ?? parseIso(config.billingPeriodEnd) ?? new Date().toISOString(),
215
+ used: onDemandUsed,
216
+ limit: onDemandCap,
217
+ };
218
+ }
219
+ return { weekly, monthly, onDemand };
220
+ }
221
+ /**
222
+ * Parse default `/billing` — absolute used/monthlyLimit spend-style cap.
223
+ * Used to fill the monthly gauge when the credits payload only has weekly.
224
+ */
225
+ export function parseSpendConfig(config) {
226
+ const used = unwrapVal(config.used) ?? unwrapVal(config.includedUsed);
227
+ const limit = unwrapVal(config.monthlyLimit);
228
+ const periodEnd = parseIso(config.billingPeriodEnd);
229
+ if (used == null || limit == null || limit <= 0 || !periodEnd)
230
+ return null;
231
+ return {
232
+ utilization: clampPercent((used / limit) * 100),
233
+ resetsAt: periodEnd,
234
+ used,
235
+ limit,
236
+ };
237
+ }
238
+ /**
239
+ * Raw network calls to the CLI chat-proxy billing endpoints. Prefer
240
+ * `getGrokRateLimits()` which caches + single-flights.
241
+ */
242
+ async function fetchGrokBillingFromApi() {
243
+ const creds = readGrokAccessToken();
244
+ if ('error' in creds) {
245
+ return { rateLimits: null, error: creds.error };
246
+ }
247
+ const base = billingBaseUrl();
248
+ try {
249
+ // Parallel: credits format (CLI /usage) + default spend allotment.
250
+ const [creditsResult, spendResult] = await Promise.all([
251
+ fetchBillingJson(`${base}?format=credits`, creds.token),
252
+ fetchBillingJson(base, creds.token),
253
+ ]);
254
+ // Prefer status/retry from credits (primary); fall back to spend.
255
+ if (!creditsResult.ok && !spendResult.ok) {
256
+ return {
257
+ rateLimits: null,
258
+ error: creditsResult.error,
259
+ status: creditsResult.status,
260
+ retryAfterMs: creditsResult.retryAfterMs ?? spendResult.retryAfterMs,
261
+ };
262
+ }
263
+ let weekly = null;
264
+ let monthly = null;
265
+ let onDemand = null;
266
+ if (creditsResult.ok) {
267
+ const parsed = parseCreditsConfig(creditsResult.config);
268
+ weekly = parsed.weekly;
269
+ monthly = parsed.monthly;
270
+ onDemand = parsed.onDemand;
271
+ }
272
+ // Fill monthly from spend allotment when credits only gave weekly (the
273
+ // SuperGrok dual-gauge layout in the CLI /usage panel).
274
+ if (spendResult.ok) {
275
+ const spendMonthly = parseSpendConfig(spendResult.config);
276
+ if (spendMonthly && !monthly) {
277
+ monthly = spendMonthly;
278
+ }
279
+ // On-demand may only appear on the default payload.
280
+ if (!onDemand) {
281
+ const onDemandCap = unwrapVal(spendResult.config.onDemandCap);
282
+ const onDemandUsed = unwrapVal(spendResult.config.onDemandUsed) ?? 0;
283
+ const periodEnd = parseIso(spendResult.config.billingPeriodEnd);
284
+ if (onDemandCap != null && onDemandCap > 0 && periodEnd) {
285
+ onDemand = {
286
+ utilization: clampPercent((onDemandUsed / onDemandCap) * 100),
287
+ resetsAt: periodEnd,
288
+ used: onDemandUsed,
289
+ limit: onDemandCap,
290
+ };
291
+ }
292
+ }
293
+ }
294
+ if (!weekly && !monthly && !onDemand) {
295
+ return {
296
+ rateLimits: null,
297
+ error: creditsResult.ok
298
+ ? 'Grok billing response had no usable usage limits'
299
+ : creditsResult.error,
300
+ status: creditsResult.ok ? creditsResult.status : creditsResult.status,
301
+ };
302
+ }
303
+ return {
304
+ rateLimits: { weekly, monthly, onDemand },
305
+ error: null,
306
+ status: creditsResult.ok ? creditsResult.status : spendResult.status,
307
+ };
308
+ }
309
+ catch (err) {
310
+ log.warn(`Failed to fetch Grok billing: ${err}`);
311
+ const reason = err?.name === 'TimeoutError' ? 'request timed out' : (err?.message ?? 'request failed');
312
+ return { rateLimits: null, error: `Could not reach Grok billing endpoint (${reason})` };
313
+ }
314
+ }
315
+ let rateLimitCache = null;
316
+ let lastGoodRateLimits = null;
317
+ let rateLimitInFlight = null;
318
+ async function getGrokRateLimits() {
319
+ const now = Date.now();
320
+ if (rateLimitCache && now < rateLimitCache.validUntil) {
321
+ return { rateLimits: rateLimitCache.rateLimits, error: rateLimitCache.error };
322
+ }
323
+ if (rateLimitInFlight)
324
+ return rateLimitInFlight;
325
+ rateLimitInFlight = (async () => {
326
+ const result = await fetchGrokBillingFromApi();
327
+ let ttl = RATE_LIMIT_CACHE_TTL_MS;
328
+ if (result.rateLimits) {
329
+ lastGoodRateLimits = result.rateLimits;
330
+ }
331
+ else if (result.status === 429) {
332
+ ttl = Math.max(result.retryAfterMs ?? RATE_LIMIT_429_BACKOFF_MS, RATE_LIMIT_CACHE_TTL_MS);
333
+ }
334
+ else {
335
+ ttl = RATE_LIMIT_ERROR_BACKOFF_MS;
336
+ }
337
+ const entry = {
338
+ rateLimits: result.rateLimits ?? lastGoodRateLimits,
339
+ error: result.error,
340
+ validUntil: Date.now() + ttl,
341
+ };
342
+ rateLimitCache = entry;
343
+ return { rateLimits: entry.rateLimits, error: entry.error };
344
+ })();
345
+ try {
346
+ return await rateLimitInFlight;
347
+ }
348
+ finally {
349
+ rateLimitInFlight = null;
350
+ }
351
+ }
352
+ /** Test helper: drop the cached billing result and backoff state. */
353
+ export function resetGrokRateLimitCache() {
354
+ rateLimitCache = null;
355
+ lastGoodRateLimits = null;
356
+ rateLimitInFlight = null;
357
+ }
358
+ export async function buildGrokUsageSnapshot(agent) {
359
+ const { rateLimits, error: rateLimitsError } = await getGrokRateLimits();
360
+ return {
361
+ provider: 'grok',
362
+ fetchedAt: Date.now(),
363
+ session: {
364
+ tokensUsed: agent.tokensUsed ?? 0,
365
+ contextUsed: agent.contextUsed ?? 0,
366
+ contextLimit: agent.contextLimit ?? 500_000,
367
+ taskCount: agent.taskCount ?? 0,
368
+ lastActivity: agent.lastActivity ?? 0,
369
+ },
370
+ rateLimits,
371
+ rateLimitsError,
372
+ cliHint: "Run /usage inside this agent's terminal to see live weekly and monthly usage limits.",
373
+ };
374
+ }