pikiclaw 0.2.35
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/LICENSE +21 -0
- package/README.md +315 -0
- package/dist/agent-driver.js +24 -0
- package/dist/bot-command-ui.js +299 -0
- package/dist/bot-commands.js +236 -0
- package/dist/bot-feishu-render.js +527 -0
- package/dist/bot-feishu.js +752 -0
- package/dist/bot-handler.js +115 -0
- package/dist/bot-menu.js +44 -0
- package/dist/bot-streaming.js +165 -0
- package/dist/bot-telegram-directory.js +74 -0
- package/dist/bot-telegram-live-preview.js +192 -0
- package/dist/bot-telegram-render.js +369 -0
- package/dist/bot-telegram.js +789 -0
- package/dist/bot.js +897 -0
- package/dist/channel-base.js +46 -0
- package/dist/channel-feishu.js +873 -0
- package/dist/channel-states.js +3 -0
- package/dist/channel-telegram.js +773 -0
- package/dist/cli-channels.js +24 -0
- package/dist/cli.js +484 -0
- package/dist/code-agent.js +1080 -0
- package/dist/config-validation.js +244 -0
- package/dist/dashboard-ui.js +31 -0
- package/dist/dashboard.js +840 -0
- package/dist/driver-claude.js +520 -0
- package/dist/driver-codex.js +1055 -0
- package/dist/driver-gemini.js +230 -0
- package/dist/mcp-bridge.js +192 -0
- package/dist/mcp-session-server.js +321 -0
- package/dist/onboarding.js +138 -0
- package/dist/process-control.js +259 -0
- package/dist/run.js +275 -0
- package/dist/session-status.js +43 -0
- package/dist/setup-wizard.js +231 -0
- package/dist/user-config.js +195 -0
- package/package.json +60 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* driver-claude.ts — Claude CLI agent driver.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { registerDriver } from './agent-driver.js';
|
|
8
|
+
import {
|
|
9
|
+
// shared helpers
|
|
10
|
+
run, agentLog, detectAgentBin, pushRecentActivity, summarizeClaudeToolUse, summarizeClaudeToolResult, IMAGE_EXTS, mimeForExt, listPikiclawSessions, isPendingSessionId, readTailLines, stripInjectedPrompts, roundPercent, modelFamily, emptyUsage, normalizeUsageStatus, } from './code-agent.js';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Multimodal stdin
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
function buildClaudeMultimodalStdin(prompt, attachments) {
|
|
15
|
+
const content = [];
|
|
16
|
+
for (const filePath of attachments) {
|
|
17
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
18
|
+
if (IMAGE_EXTS.has(ext)) {
|
|
19
|
+
try {
|
|
20
|
+
const data = fs.readFileSync(filePath);
|
|
21
|
+
content.push({
|
|
22
|
+
type: 'image',
|
|
23
|
+
source: { type: 'base64', media_type: mimeForExt(ext), data: data.toString('base64') },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
agentLog(`[attach] failed to read image ${filePath}: ${e.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
content.push({ type: 'text', text: `[Attached file: ${filePath}]` });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
content.push({ type: 'text', text: prompt });
|
|
35
|
+
return JSON.stringify({ type: 'user', message: { role: 'user', content } }) + '\n';
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Command & parser
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
function claudeCmd(o) {
|
|
41
|
+
const args = ['claude', '-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
|
|
42
|
+
if (o.claudeModel)
|
|
43
|
+
args.push('--model', o.claudeModel);
|
|
44
|
+
if (o.claudePermissionMode)
|
|
45
|
+
args.push('--permission-mode', o.claudePermissionMode);
|
|
46
|
+
if (o.sessionId)
|
|
47
|
+
args.push('--resume', o.sessionId);
|
|
48
|
+
if (o.attachments?.length) {
|
|
49
|
+
args.push('--input-format', 'stream-json');
|
|
50
|
+
o._stdinOverride = buildClaudeMultimodalStdin(o.prompt, o.attachments);
|
|
51
|
+
}
|
|
52
|
+
if (o.thinkingEffort)
|
|
53
|
+
args.push('--effort', o.thinkingEffort);
|
|
54
|
+
if (o.claudeAppendSystemPrompt)
|
|
55
|
+
args.push('--append-system-prompt', o.claudeAppendSystemPrompt);
|
|
56
|
+
if (o.mcpConfigPath)
|
|
57
|
+
args.push('--mcp-config', o.mcpConfigPath);
|
|
58
|
+
if (o.claudeExtraArgs?.length)
|
|
59
|
+
args.push(...o.claudeExtraArgs);
|
|
60
|
+
return args;
|
|
61
|
+
}
|
|
62
|
+
function claudeParse(ev, s) {
|
|
63
|
+
const t = ev.type || '';
|
|
64
|
+
if (t === 'system') {
|
|
65
|
+
s.sessionId = ev.session_id ?? s.sessionId;
|
|
66
|
+
s.model = ev.model ?? s.model;
|
|
67
|
+
s.thinkingEffort = ev.thinking_level ?? s.thinkingEffort;
|
|
68
|
+
}
|
|
69
|
+
if (t === 'stream_event') {
|
|
70
|
+
const inner = ev.event || {};
|
|
71
|
+
if (inner.type === 'message_start') {
|
|
72
|
+
const u = inner.message?.usage;
|
|
73
|
+
s.inputTokens = u?.input_tokens ?? null;
|
|
74
|
+
s.cachedInputTokens = u?.cache_read_input_tokens ?? null;
|
|
75
|
+
s.cacheCreationInputTokens = u?.cache_creation_input_tokens ?? null;
|
|
76
|
+
s.outputTokens = null;
|
|
77
|
+
}
|
|
78
|
+
if (inner.type === 'content_block_delta') {
|
|
79
|
+
const d = inner.delta || {};
|
|
80
|
+
if (d.type === 'thinking_delta')
|
|
81
|
+
s.thinking += d.thinking || '';
|
|
82
|
+
else if (d.type === 'text_delta')
|
|
83
|
+
s.text += d.text || '';
|
|
84
|
+
}
|
|
85
|
+
if (inner.type === 'message_delta') {
|
|
86
|
+
const d = inner.delta || {};
|
|
87
|
+
s.stopReason = d.stop_reason ?? s.stopReason;
|
|
88
|
+
const u = inner.usage;
|
|
89
|
+
if (u) {
|
|
90
|
+
if (u.input_tokens != null)
|
|
91
|
+
s.inputTokens = u.input_tokens;
|
|
92
|
+
if (u.cache_read_input_tokens != null)
|
|
93
|
+
s.cachedInputTokens = u.cache_read_input_tokens;
|
|
94
|
+
if (u.cache_creation_input_tokens != null)
|
|
95
|
+
s.cacheCreationInputTokens = u.cache_creation_input_tokens;
|
|
96
|
+
if (u.output_tokens != null)
|
|
97
|
+
s.outputTokens = u.output_tokens;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
s.sessionId = ev.session_id ?? s.sessionId;
|
|
101
|
+
s.model = ev.model ?? s.model;
|
|
102
|
+
}
|
|
103
|
+
if (t === 'assistant') {
|
|
104
|
+
const msg = ev.message || {};
|
|
105
|
+
const contents = msg.content || [];
|
|
106
|
+
const th = contents.filter((b) => b?.type === 'thinking').map((b) => b.thinking || '').join('');
|
|
107
|
+
const tx = contents.filter((b) => b?.type === 'text').map((b) => b.text || '').join('');
|
|
108
|
+
const toolUses = contents.filter((b) => b?.type === 'tool_use');
|
|
109
|
+
if (th && !s.thinking.trim())
|
|
110
|
+
s.thinking = th;
|
|
111
|
+
if (tx && !s.text.trim())
|
|
112
|
+
s.text = tx;
|
|
113
|
+
for (const block of toolUses) {
|
|
114
|
+
const toolId = String(block?.id || '').trim();
|
|
115
|
+
if (!toolId || s.seenClaudeToolIds.has(toolId))
|
|
116
|
+
continue;
|
|
117
|
+
const tool = {
|
|
118
|
+
name: String(block?.name || 'Tool').trim() || 'Tool',
|
|
119
|
+
summary: summarizeClaudeToolUse(block?.name, block?.input || {}),
|
|
120
|
+
};
|
|
121
|
+
s.seenClaudeToolIds.add(toolId);
|
|
122
|
+
s.claudeToolsById.set(toolId, tool);
|
|
123
|
+
pushRecentActivity(s.recentActivity, tool.summary);
|
|
124
|
+
}
|
|
125
|
+
s.activity = s.recentActivity.join('\n');
|
|
126
|
+
s.stopReason = msg.stop_reason ?? s.stopReason;
|
|
127
|
+
}
|
|
128
|
+
if (t === 'user') {
|
|
129
|
+
const msg = ev.message || {};
|
|
130
|
+
const contents = Array.isArray(msg.content) ? msg.content : [];
|
|
131
|
+
const toolResults = contents.filter((b) => b?.type === 'tool_result');
|
|
132
|
+
for (const block of toolResults) {
|
|
133
|
+
const toolId = String(block?.tool_use_id || '').trim();
|
|
134
|
+
const tool = toolId ? s.claudeToolsById.get(toolId) : undefined;
|
|
135
|
+
pushRecentActivity(s.recentActivity, summarizeClaudeToolResult(tool, block, ev.tool_use_result));
|
|
136
|
+
}
|
|
137
|
+
s.activity = s.recentActivity.join('\n');
|
|
138
|
+
}
|
|
139
|
+
if (t === 'result') {
|
|
140
|
+
s.sessionId = ev.session_id ?? s.sessionId;
|
|
141
|
+
s.model = ev.model ?? s.model;
|
|
142
|
+
if (ev.is_error && ev.errors?.length)
|
|
143
|
+
s.errors = ev.errors;
|
|
144
|
+
if (ev.result && !s.text.trim())
|
|
145
|
+
s.text = ev.result;
|
|
146
|
+
s.stopReason = ev.stop_reason ?? s.stopReason;
|
|
147
|
+
const u = ev.usage;
|
|
148
|
+
if (u) {
|
|
149
|
+
s.inputTokens = u.input_tokens ?? s.inputTokens;
|
|
150
|
+
s.cachedInputTokens = (u.cache_read_input_tokens ?? u.cached_input_tokens) ?? s.cachedInputTokens;
|
|
151
|
+
s.cacheCreationInputTokens = u.cache_creation_input_tokens ?? s.cacheCreationInputTokens;
|
|
152
|
+
s.outputTokens = u.output_tokens ?? s.outputTokens;
|
|
153
|
+
}
|
|
154
|
+
const mu = ev.modelUsage;
|
|
155
|
+
if (mu && typeof mu === 'object') {
|
|
156
|
+
for (const info of Object.values(mu)) {
|
|
157
|
+
if (info?.contextWindow > 0) {
|
|
158
|
+
s.contextWindow = info.contextWindow;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Stream
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
export async function doClaudeStream(opts) {
|
|
169
|
+
const result = await run(claudeCmd(opts), opts, claudeParse);
|
|
170
|
+
const retryText = `${result.error || ''}\n${result.message}`;
|
|
171
|
+
if (!result.ok && opts.sessionId && /no conversation found/i.test(retryText)) {
|
|
172
|
+
return run(claudeCmd({ ...opts, sessionId: null }), { ...opts, sessionId: null }, claudeParse);
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Sessions
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
function claudeProjectDirName(workdir) {
|
|
180
|
+
return workdir.replace(/\//g, '-');
|
|
181
|
+
}
|
|
182
|
+
/** Read native Claude Code sessions from ~/.claude/projects/{dirName}/*.jsonl */
|
|
183
|
+
function getNativeClaudeSessions(workdir) {
|
|
184
|
+
const home = process.env.HOME || '';
|
|
185
|
+
if (!home)
|
|
186
|
+
return [];
|
|
187
|
+
const projectDir = path.join(home, '.claude', 'projects', claudeProjectDirName(workdir));
|
|
188
|
+
if (!fs.existsSync(projectDir))
|
|
189
|
+
return [];
|
|
190
|
+
const sessions = [];
|
|
191
|
+
let entries;
|
|
192
|
+
try {
|
|
193
|
+
entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
200
|
+
continue;
|
|
201
|
+
const sessionId = entry.name.slice(0, -6); // strip .jsonl
|
|
202
|
+
const filePath = path.join(projectDir, entry.name);
|
|
203
|
+
try {
|
|
204
|
+
const stat = fs.statSync(filePath);
|
|
205
|
+
// Read first few KB to extract title and model from first user/assistant messages
|
|
206
|
+
const fd = fs.openSync(filePath, 'r');
|
|
207
|
+
const buf = Buffer.alloc(8192);
|
|
208
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
209
|
+
fs.closeSync(fd);
|
|
210
|
+
const head = buf.toString('utf8', 0, bytesRead);
|
|
211
|
+
const lines = head.split('\n');
|
|
212
|
+
let title = null;
|
|
213
|
+
let model = null;
|
|
214
|
+
for (const line of lines) {
|
|
215
|
+
if (!line || line[0] !== '{')
|
|
216
|
+
continue;
|
|
217
|
+
try {
|
|
218
|
+
const ev = JSON.parse(line);
|
|
219
|
+
if (!title && ev.type === 'user') {
|
|
220
|
+
const text = extractClaudeText(ev.message?.content, true).replace(/\s+/g, ' ').trim();
|
|
221
|
+
if (text)
|
|
222
|
+
title = text.length <= 120 ? text : `${text.slice(0, 117).trimEnd()}...`;
|
|
223
|
+
}
|
|
224
|
+
if (!model && ev.type === 'assistant' && ev.message?.model) {
|
|
225
|
+
model = ev.message.model;
|
|
226
|
+
}
|
|
227
|
+
if (title && model)
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
catch { /* skip */ }
|
|
231
|
+
}
|
|
232
|
+
sessions.push({
|
|
233
|
+
sessionId,
|
|
234
|
+
agent: 'claude',
|
|
235
|
+
workdir,
|
|
236
|
+
workspacePath: null,
|
|
237
|
+
model,
|
|
238
|
+
createdAt: stat.birthtime.toISOString(),
|
|
239
|
+
title,
|
|
240
|
+
running: Date.now() - stat.mtimeMs < 10_000,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch { /* skip unreadable files */ }
|
|
244
|
+
}
|
|
245
|
+
return sessions;
|
|
246
|
+
}
|
|
247
|
+
function getClaudeSessions(workdir, limit) {
|
|
248
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
249
|
+
// Merge pikiclaw-tracked sessions with native Claude sessions
|
|
250
|
+
const pikiclawSessions = listPikiclawSessions(resolvedWorkdir, 'claude').map(record => ({
|
|
251
|
+
sessionId: record.sessionId,
|
|
252
|
+
agent: 'claude',
|
|
253
|
+
workdir: record.workdir,
|
|
254
|
+
workspacePath: record.workspacePath,
|
|
255
|
+
model: record.model,
|
|
256
|
+
createdAt: record.createdAt,
|
|
257
|
+
title: record.title,
|
|
258
|
+
running: Date.now() - Date.parse(record.updatedAt) < 10_000,
|
|
259
|
+
}));
|
|
260
|
+
const nativeSessions = getNativeClaudeSessions(resolvedWorkdir);
|
|
261
|
+
// Merge: pikiclaw records take precedence (they have workspacePath etc.)
|
|
262
|
+
// Filter out pending sessions — they haven't been confirmed by the agent yet
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
const merged = [];
|
|
265
|
+
for (const s of pikiclawSessions) {
|
|
266
|
+
if (isPendingSessionId(s.sessionId))
|
|
267
|
+
continue;
|
|
268
|
+
if (s.sessionId)
|
|
269
|
+
seen.add(s.sessionId);
|
|
270
|
+
merged.push(s);
|
|
271
|
+
}
|
|
272
|
+
for (const s of nativeSessions) {
|
|
273
|
+
if (s.sessionId && !seen.has(s.sessionId))
|
|
274
|
+
merged.push(s);
|
|
275
|
+
}
|
|
276
|
+
// Sort by createdAt descending
|
|
277
|
+
merged.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''));
|
|
278
|
+
const sessions = typeof limit === 'number' ? merged.slice(0, limit) : merged;
|
|
279
|
+
const projectDir = path.join(process.env.HOME || '', '.claude', 'projects', claudeProjectDirName(resolvedWorkdir));
|
|
280
|
+
agentLog(`[sessions:claude] workdir=${resolvedWorkdir} projectDir=${projectDir} projectDirExists=${fs.existsSync(projectDir)} ` +
|
|
281
|
+
`pikiclaw=${pikiclawSessions.length} native=${nativeSessions.length} merged=${sessions.length}`);
|
|
282
|
+
return { ok: true, sessions, error: null };
|
|
283
|
+
}
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Session tail
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
function extractClaudeText(content, skipSystemBlocks = false) {
|
|
288
|
+
if (typeof content === 'string')
|
|
289
|
+
return content;
|
|
290
|
+
if (!Array.isArray(content))
|
|
291
|
+
return '';
|
|
292
|
+
const parts = [];
|
|
293
|
+
for (const block of content) {
|
|
294
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
295
|
+
if (skipSystemBlocks && block.text.startsWith('<'))
|
|
296
|
+
continue;
|
|
297
|
+
parts.push(block.text);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return parts.join('\n');
|
|
301
|
+
}
|
|
302
|
+
function getClaudeSessionTail(opts) {
|
|
303
|
+
const limit = opts.limit ?? 4;
|
|
304
|
+
const home = process.env.HOME || '';
|
|
305
|
+
const projectDir = path.join(home, '.claude', 'projects', claudeProjectDirName(opts.workdir));
|
|
306
|
+
const filePath = path.join(projectDir, `${opts.sessionId}.jsonl`);
|
|
307
|
+
if (!fs.existsSync(filePath)) {
|
|
308
|
+
return { ok: false, messages: [], error: 'Session file not found' };
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const lines = readTailLines(filePath);
|
|
312
|
+
const allMsgs = [];
|
|
313
|
+
for (const raw of lines) {
|
|
314
|
+
if (!raw || raw[0] !== '{')
|
|
315
|
+
continue;
|
|
316
|
+
try {
|
|
317
|
+
const ev = JSON.parse(raw);
|
|
318
|
+
if (ev.type === 'user') {
|
|
319
|
+
const text = stripInjectedPrompts(extractClaudeText(ev.message?.content, true));
|
|
320
|
+
if (text)
|
|
321
|
+
allMsgs.push({ role: 'user', text });
|
|
322
|
+
}
|
|
323
|
+
else if (ev.type === 'assistant') {
|
|
324
|
+
const text = extractClaudeText(ev.message?.content, true);
|
|
325
|
+
if (text)
|
|
326
|
+
allMsgs.push({ role: 'assistant', text });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch { /* skip */ }
|
|
330
|
+
}
|
|
331
|
+
return { ok: true, messages: allMsgs.slice(-limit), error: null };
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
return { ok: false, messages: [], error: e.message };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Models
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
const CLAUDE_MODELS = [
|
|
341
|
+
{ id: 'claude-opus-4-6', alias: 'opus' },
|
|
342
|
+
{ id: 'claude-opus-4-6[1m]', alias: 'opus-1m' },
|
|
343
|
+
{ id: 'claude-sonnet-4-6', alias: 'sonnet' },
|
|
344
|
+
{ id: 'claude-sonnet-4-6[1m]', alias: 'sonnet-1m' },
|
|
345
|
+
{ id: 'claude-haiku-4-5-20251001', alias: 'haiku' },
|
|
346
|
+
];
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Usage
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
function getClaudeOAuthToken() {
|
|
351
|
+
try {
|
|
352
|
+
const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', {
|
|
353
|
+
encoding: 'utf-8', timeout: 3000,
|
|
354
|
+
}).trim();
|
|
355
|
+
if (!raw)
|
|
356
|
+
return null;
|
|
357
|
+
const parsed = JSON.parse(raw);
|
|
358
|
+
return parsed?.claudeAiOauth?.accessToken || null;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function getClaudeUsageFromOAuth() {
|
|
365
|
+
const token = getClaudeOAuthToken();
|
|
366
|
+
if (!token)
|
|
367
|
+
return null;
|
|
368
|
+
try {
|
|
369
|
+
const raw = execSync(`curl -s --max-time 5 -H "Authorization: Bearer ${token}" -H "anthropic-beta: oauth-2025-04-20" -H "Content-Type: application/json" "https://api.anthropic.com/api/oauth/usage"`, { encoding: 'utf-8', timeout: 8000 }).trim();
|
|
370
|
+
if (!raw || raw[0] !== '{')
|
|
371
|
+
return null;
|
|
372
|
+
const data = JSON.parse(raw);
|
|
373
|
+
const capturedAt = new Date().toISOString();
|
|
374
|
+
const apiError = data?.error;
|
|
375
|
+
if (apiError && typeof apiError === 'object') {
|
|
376
|
+
// The usage query endpoint itself returned an error (e.g. 429 rate
|
|
377
|
+
// limit on the query API). This does NOT reflect the user's actual
|
|
378
|
+
// Claude usage status, so fall through to telemetry instead of
|
|
379
|
+
// reporting a misleading "limit_reached".
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
const makeWindow = (label, entry) => {
|
|
383
|
+
if (!entry || typeof entry !== 'object')
|
|
384
|
+
return null;
|
|
385
|
+
const usedPercent = roundPercent(entry.utilization);
|
|
386
|
+
if (usedPercent == null)
|
|
387
|
+
return null;
|
|
388
|
+
const remainingPercent = Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
|
|
389
|
+
const resetAt = typeof entry.resets_at === 'string' ? entry.resets_at : null;
|
|
390
|
+
let resetAfterSeconds = null;
|
|
391
|
+
if (resetAt) {
|
|
392
|
+
const resetAtMs = Date.parse(resetAt);
|
|
393
|
+
if (Number.isFinite(resetAtMs))
|
|
394
|
+
resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
label, usedPercent, remainingPercent, resetAt, resetAfterSeconds,
|
|
398
|
+
status: usedPercent >= 100 ? 'limit_reached' : usedPercent >= 80 ? 'warning' : 'allowed',
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
const windows = [];
|
|
402
|
+
for (const [label, key] of [['5h', 'five_hour'], ['7d', 'seven_day'], ['7d Opus', 'seven_day_opus'], ['7d Sonnet', 'seven_day_sonnet'], ['Extra', 'extra_usage']]) {
|
|
403
|
+
const w = makeWindow(label, data[key]);
|
|
404
|
+
if (w)
|
|
405
|
+
windows.push(w);
|
|
406
|
+
}
|
|
407
|
+
if (!windows.length)
|
|
408
|
+
return null;
|
|
409
|
+
const overallStatus = windows.some(w => w.status === 'limit_reached') ? 'limit_reached'
|
|
410
|
+
: windows.some(w => w.status === 'warning') ? 'warning' : 'allowed';
|
|
411
|
+
return { ok: true, agent: 'claude', source: 'oauth-api', capturedAt, status: overallStatus, windows, error: null };
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function getClaudeUsageFromTelemetry(home, model) {
|
|
418
|
+
const telemetryRoot = path.join(home, '.claude', 'telemetry');
|
|
419
|
+
if (!fs.existsSync(telemetryRoot))
|
|
420
|
+
return null;
|
|
421
|
+
const preferredFamily = modelFamily(model);
|
|
422
|
+
let bestAny = null;
|
|
423
|
+
let bestMatch = null;
|
|
424
|
+
try {
|
|
425
|
+
const files = fs.readdirSync(telemetryRoot)
|
|
426
|
+
.filter(name => name.endsWith('.json'))
|
|
427
|
+
.map(name => ({ full: path.join(telemetryRoot, name), mtime: fs.statSync(path.join(telemetryRoot, name)).mtimeMs }))
|
|
428
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
429
|
+
.slice(0, 50);
|
|
430
|
+
for (const file of files) {
|
|
431
|
+
const lines = fs.readFileSync(file.full, 'utf-8').trim().split('\n');
|
|
432
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
433
|
+
const raw = lines[i];
|
|
434
|
+
if (!raw || raw[0] !== '{' || !raw.includes('tengu_claudeai_limits_status_changed'))
|
|
435
|
+
continue;
|
|
436
|
+
let parsed;
|
|
437
|
+
try {
|
|
438
|
+
parsed = JSON.parse(raw);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const data = parsed?.event_data;
|
|
444
|
+
if (data?.event_name !== 'tengu_claudeai_limits_status_changed')
|
|
445
|
+
continue;
|
|
446
|
+
const capturedAtMs = Date.parse(data.client_timestamp || '');
|
|
447
|
+
if (!Number.isFinite(capturedAtMs))
|
|
448
|
+
continue;
|
|
449
|
+
let meta = data.additional_metadata;
|
|
450
|
+
if (typeof meta === 'string') {
|
|
451
|
+
try {
|
|
452
|
+
meta = JSON.parse(meta);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
meta = null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const hoursTillReset = Number(meta?.hoursTillReset);
|
|
459
|
+
const candidate = {
|
|
460
|
+
capturedAtMs, capturedAt: new Date(capturedAtMs).toISOString(),
|
|
461
|
+
status: typeof meta?.status === 'string' ? meta.status : null,
|
|
462
|
+
hoursTillReset: Number.isFinite(hoursTillReset) ? hoursTillReset : null,
|
|
463
|
+
model: typeof data.model === 'string' ? data.model : null,
|
|
464
|
+
};
|
|
465
|
+
if (!bestAny || candidate.capturedAtMs > bestAny.capturedAtMs)
|
|
466
|
+
bestAny = candidate;
|
|
467
|
+
if (preferredFamily && candidate.model?.toLowerCase().includes(preferredFamily)) {
|
|
468
|
+
if (!bestMatch || candidate.capturedAtMs > bestMatch.capturedAtMs)
|
|
469
|
+
bestMatch = candidate;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
const chosen = bestMatch || bestAny;
|
|
478
|
+
if (!chosen)
|
|
479
|
+
return null;
|
|
480
|
+
const status = normalizeUsageStatus(chosen.status);
|
|
481
|
+
const resetAfterSeconds = chosen.hoursTillReset == null ? null : Math.max(0, Math.round(chosen.hoursTillReset * 3600));
|
|
482
|
+
const resetAt = resetAfterSeconds == null ? null : new Date(chosen.capturedAtMs + resetAfterSeconds * 1000).toISOString();
|
|
483
|
+
// Build a locale-neutral label from capture age (e.g. "3h ago", "2d ago")
|
|
484
|
+
const ageMs = Date.now() - chosen.capturedAtMs;
|
|
485
|
+
const ageMins = Math.round(ageMs / 60_000);
|
|
486
|
+
const ageLabel = ageMins < 1 ? '<1m ago' : ageMins < 60 ? `${ageMins}m ago` : ageMins < 1440 ? `${Math.round(ageMins / 60)}h ago` : `${Math.round(ageMins / 1440)}d ago`;
|
|
487
|
+
const windows = [{ label: ageLabel, usedPercent: null, remainingPercent: null, resetAt, resetAfterSeconds, status }];
|
|
488
|
+
return { ok: true, agent: 'claude', source: 'telemetry', capturedAt: chosen.capturedAt, status, windows, error: null };
|
|
489
|
+
}
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Driver
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
class ClaudeDriver {
|
|
494
|
+
id = 'claude';
|
|
495
|
+
cmd = 'claude';
|
|
496
|
+
thinkLabel = 'Thinking';
|
|
497
|
+
detect() { return detectAgentBin('claude', 'claude'); }
|
|
498
|
+
async doStream(opts) {
|
|
499
|
+
return doClaudeStream(opts);
|
|
500
|
+
}
|
|
501
|
+
async getSessions(workdir, limit) {
|
|
502
|
+
return getClaudeSessions(workdir, limit);
|
|
503
|
+
}
|
|
504
|
+
async getSessionTail(opts) {
|
|
505
|
+
return getClaudeSessionTail(opts);
|
|
506
|
+
}
|
|
507
|
+
async listModels(_opts) {
|
|
508
|
+
return { agent: 'claude', models: [...CLAUDE_MODELS], sources: [], note: null };
|
|
509
|
+
}
|
|
510
|
+
getUsage(opts) {
|
|
511
|
+
const home = process.env.HOME || '';
|
|
512
|
+
if (!home)
|
|
513
|
+
return emptyUsage('claude', 'HOME is not set.');
|
|
514
|
+
return getClaudeUsageFromOAuth()
|
|
515
|
+
|| getClaudeUsageFromTelemetry(home, opts.model)
|
|
516
|
+
|| emptyUsage('claude', 'No recent Claude usage data found.');
|
|
517
|
+
}
|
|
518
|
+
shutdown() { }
|
|
519
|
+
}
|
|
520
|
+
registerDriver(new ClaudeDriver());
|