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.
- package/CHANGELOG.md +16 -0
- package/dist/assets/{BossLogsModal-Cs4kWugD.js → BossLogsModal-DvczRUvP.js} +1 -1
- package/dist/assets/{BossSpawnModal-BudMgJqj.js → BossSpawnModal-B2autFKk.js} +1 -1
- package/dist/assets/{ControlsModal-Bh6_3qtH.js → ControlsModal-BaREMQxc.js} +1 -1
- package/dist/assets/{DockerLogsModal-Nf4wQFfz.js → DockerLogsModal-DZ0udMCo.js} +1 -1
- package/dist/assets/{EmbeddedEditor-BfRXRX50.js → EmbeddedEditor-CEjr0ZSZ.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-CXUtjA0r.js → GmailOAuthSetup-76BP8J8C.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-D-_rwicD.js → GoogleOAuthSetup-D45GImPI.js} +1 -1
- package/dist/assets/{IframeModal-CdaXsQK0.js → IframeModal-De63RfJZ.js} +1 -1
- package/dist/assets/{IntegrationsPanel-ZsSVxcWr.js → IntegrationsPanel-DYrw1Sqk.js} +2 -2
- package/dist/assets/{LogViewerModal-BAf-klB2.js → LogViewerModal-DW-2lpln.js} +1 -1
- package/dist/assets/{MonitoringModal-BjRbh5z4.js → MonitoringModal-B0-XXyd3.js} +1 -1
- package/dist/assets/{PM2LogsModal-SaywDZVL.js → PM2LogsModal-DOfOhkCY.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-jru2Ez9a.js → RestoreArchivedAreaModal-DNGmIJmM.js} +1 -1
- package/dist/assets/{Scene2DCanvas-NJHoe2Qg.js → Scene2DCanvas-BW1E76CQ.js} +1 -1
- package/dist/assets/{SceneManager-i9wVMa_V.js → SceneManager-DAvc9-Wp.js} +1 -1
- package/dist/assets/{SkillsPanel-BHfKLzWe.js → SkillsPanel-Br-gLCLA.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-BfVevCSo.js → SlackMultiInstanceSetup-DG_bIfDu.js} +1 -1
- package/dist/assets/{SpawnModal-29x5z53B.js → SpawnModal-lWM8OuZW.js} +1 -1
- package/dist/assets/{StatisticsModal-MbIByjWD.js → StatisticsModal-LvA0Fcvn.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-DF0vYaM6.js → SubordinateAssignmentModal-8YxNXcR3.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-EGcKS1Pt.js → TriggerManagerPanel-DftIuKUG.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-BLmejfqe.js → WorkflowEditorPanel-oLwyO_C6.js} +1 -1
- package/dist/assets/{area-logos-C4fgpLgO.js → area-logos-mcpQqUMZ.js} +1 -1
- package/dist/assets/{index-BiiLWTXJ.js → index-Ay7dKyeR.js} +1 -1
- package/dist/assets/{index-DThK5_yS.js → index-BMw7AAAK.js} +3 -3
- package/dist/assets/{index-C8CGfmXP.js → index-BQpd34Mc.js} +1 -1
- package/dist/assets/{index-BZ4wZwRu.js → index-C6-68CXL.js} +3 -3
- package/dist/assets/index-CIon0DLf.js +2 -0
- package/dist/assets/{index-C6Kf4ewu.js → index-Cwt-E2td.js} +1 -1
- package/dist/assets/{index-B9xpLAcH.js → index-DFVroqA5.js} +1 -1
- package/dist/assets/index-DR6_WeF_.js +1 -0
- package/dist/assets/{index-B-2H7O_Y.js → index-O0t07OB_.js} +2 -2
- package/dist/assets/{main-B0UlZnfk.js → main-D5sqeuwB.js} +151 -125
- package/dist/assets/main-JBkwGXfE.css +1 -0
- package/dist/assets/{web-CXC8P25z.js → web-BkrshujV.js} +1 -1
- package/dist/assets/{web-LGEDa25c.js → web-DPETNqOf.js} +1 -1
- package/dist/assets/{web-NM4dUqWg.js → web-vG1Smf_N.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/locales/de/config.json +3 -1
- package/dist/locales/en/config.json +2 -0
- package/dist/locales/en/terminal.json +6 -0
- package/dist/locales/es/config.json +2 -0
- package/dist/locales/fr/config.json +3 -1
- package/dist/locales/hi/config.json +3 -1
- package/dist/locales/it/config.json +3 -1
- package/dist/locales/ja/config.json +3 -1
- package/dist/locales/pt/config.json +3 -1
- package/dist/locales/ru/config.json +3 -1
- package/dist/locales/zh-CN/config.json +3 -1
- package/dist/src/packages/server/claude/backend.js +114 -10
- package/dist/src/packages/server/claude/runner/process-lifecycle.js +9 -0
- package/dist/src/packages/server/claude/runner/stdout-pipeline.js +24 -4
- package/dist/src/packages/server/claude/session-loader.js +20 -5
- package/dist/src/packages/server/codex/json-event-parser.js +149 -4
- package/dist/src/packages/server/grok/backend.js +4 -0
- package/dist/src/packages/server/grok/json-event-parser.js +29 -14
- package/dist/src/packages/server/grok/session-watcher.js +67 -34
- package/dist/src/packages/server/opencode/json-event-parser.js +184 -15
- package/dist/src/packages/server/routes/agents.js +12 -9
- package/dist/src/packages/server/services/grok-usage-service.js +374 -0
- package/dist/src/packages/server/websocket/listeners/runtime-listeners.js +10 -7
- package/package.json +1 -1
- package/dist/assets/index-DYgVP0G3.js +0 -2
- package/dist/assets/index-DhD3m2Qx.js +0 -1
- 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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
184
|
+
const part = event.part;
|
|
185
|
+
const text = part?.text;
|
|
80
186
|
if (!text)
|
|
81
187
|
return [];
|
|
82
|
-
|
|
83
|
-
this.
|
|
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:
|
|
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
|
-
//
|
|
954
|
-
//
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
+
}
|