codeclaw 0.2.3 → 0.2.5
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/README.md +11 -5
- package/dist/bot-telegram.js +201 -10
- package/dist/bot.js +7 -4
- package/dist/channel-telegram.js +29 -2
- package/dist/code-agent.js +296 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,10 +48,12 @@ No server. No Docker. No config files. Just one process bridging Telegram to you
|
|
|
48
48
|
- **Keep-alive** — OS-level sleep prevention (macOS `caffeinate`, Linux `systemd-inhibit`)
|
|
49
49
|
- **Directory browser** — switch working directory interactively via `/switch` with inline navigation
|
|
50
50
|
- **Image input** — send photos to the bot for visual context (screenshots, diagrams)
|
|
51
|
+
- **Artifact return** — agent can write screenshots/files to a per-turn manifest and codeclaw uploads them back to Telegram
|
|
51
52
|
- **Quick replies** — auto-detects yes/no questions and numbered options, shows inline buttons
|
|
52
53
|
- **Long output** — responses exceeding Telegram limits are split with a full `.md` file attachment
|
|
53
54
|
- **Thinking display** — shows agent thinking/reasoning process in collapsible blocks
|
|
54
55
|
- **Token tracking** — per-turn and cumulative input/output/cached token counts
|
|
56
|
+
- **Provider usage** — `/status` shows recent Codex/Claude usage windows and reset timing when local telemetry is available
|
|
55
57
|
- **Access control** — restrict by chat/user ID whitelist
|
|
56
58
|
- **Startup notice** — sends online status to all known chats on startup
|
|
57
59
|
- **Full access / safe mode** — let the agent run freely, or require confirmation before destructive actions
|
|
@@ -144,8 +146,9 @@ Once running, these commands are available in Telegram:
|
|
|
144
146
|
| `/sessions` | List, switch, or create sessions (paginated inline keyboard) |
|
|
145
147
|
| `/agents` | List installed agents, switch between them |
|
|
146
148
|
| `/switch` | Browse and change working directory (interactive file browser) |
|
|
147
|
-
| `/status` | Bot status: uptime, memory, agent, session, token usage |
|
|
149
|
+
| `/status` | Bot status: uptime, memory, agent, session, provider usage, token usage |
|
|
148
150
|
| `/host` | Host machine info: CPU, memory, disk, top processes |
|
|
151
|
+
| `/restart` | Restart with latest version via `npx --yes codeclaw@latest` |
|
|
149
152
|
| `/start` | Welcome message with command list |
|
|
150
153
|
|
|
151
154
|
> In private chats, just send text directly — no command prefix needed. Any unrecognized `/command` is forwarded to the agent as a prompt.
|
|
@@ -177,9 +180,9 @@ cli.ts → bot-telegram.ts → bot.ts → code-agent.ts
|
|
|
177
180
|
```
|
|
178
181
|
|
|
179
182
|
- **bot.ts** — channel-agnostic business logic, state, streaming bridge
|
|
180
|
-
- **bot-telegram.ts** — Telegram-specific rendering, keyboards, callbacks
|
|
181
|
-
- **channel-telegram.ts** — pure Telegram API transport (polling, sending, file download)
|
|
182
|
-
- **code-agent.ts** — AI agent abstraction (spawn CLI, parse JSONL stream)
|
|
183
|
+
- **bot-telegram.ts** — Telegram-specific rendering, keyboards, callbacks, artifact upload flow
|
|
184
|
+
- **channel-telegram.ts** — pure Telegram API transport (polling, sending, file download/upload routing)
|
|
185
|
+
- **code-agent.ts** — AI agent abstraction (spawn CLI, parse JSONL stream, inspect local usage telemetry)
|
|
183
186
|
|
|
184
187
|
Adding a new IM channel means creating `channel-xxx.ts` + `bot-xxx.ts` without touching shared logic.
|
|
185
188
|
|
|
@@ -229,10 +232,12 @@ claude / codex CLI
|
|
|
229
232
|
- **系统保活** — 操作系统级防休眠(macOS `caffeinate`、Linux `systemd-inhibit`)
|
|
230
233
|
- **目录浏览器** — 通过 `/switch` 交互式切换工作目录,内联导航
|
|
231
234
|
- **图片输入** — 向机器人发送图片提供视觉上下文(截图、设计图)
|
|
235
|
+
- **产物回传** — Agent 可按每轮 manifest 写出截图/文件,codeclaw 会自动回传到 Telegram
|
|
232
236
|
- **快捷回复** — 自动检测是/否问题和编号选项,显示内联按钮
|
|
233
237
|
- **长文本处理** — 超出 Telegram 限制的回复自动拆分,并附带完整 `.md` 文件
|
|
234
238
|
- **思考展示** — 在可折叠区块中显示 Agent 的思考/推理过程
|
|
235
239
|
- **Token 统计** — 每轮和累计的输入/输出/缓存 token 计数
|
|
240
|
+
- **Provider 用量** — `/status` 可展示最近的 Codex/Claude 用量窗口和重置时间(本地遥测可用时)
|
|
236
241
|
- **访问控制** — 按聊天/用户 ID 白名单限制
|
|
237
242
|
- **启动通知** — 启动时向所有已知聊天发送在线状态
|
|
238
243
|
- **完全访问 / 安全模式** — 让 AI 自由运行,或限制危险操作需确认
|
|
@@ -325,8 +330,9 @@ TELEGRAM_BOT_TOKEN=xxx CODEX_MODEL=o3 npx codeclaw -a codex
|
|
|
325
330
|
| `/sessions` | 列出、切换或创建会话(分页内联键盘) |
|
|
326
331
|
| `/agents` | 列出已安装的 Agent,切换使用 |
|
|
327
332
|
| `/switch` | 浏览和切换工作目录(交互式文件浏览器) |
|
|
328
|
-
| `/status` | 机器人状态:运行时间、内存、Agent、会话、Token 用量 |
|
|
333
|
+
| `/status` | 机器人状态:运行时间、内存、Agent、会话、Provider 用量、Token 用量 |
|
|
329
334
|
| `/host` | 宿主机信息:CPU、内存、磁盘、进程排行 |
|
|
335
|
+
| `/restart` | 通过 `npx --yes codeclaw@latest` 拉取最新版本并重启 |
|
|
330
336
|
| `/start` | 欢迎消息和命令列表 |
|
|
331
337
|
|
|
332
338
|
> 在私聊中直接发送文字即可,无需命令前缀。未识别的 `/命令` 会作为 prompt 转发给 Agent。
|
package/dist/bot-telegram.js
CHANGED
|
@@ -87,6 +87,155 @@ function detectQuickReplies(text) {
|
|
|
87
87
|
return numbered.map(m => `${m[1]}. ${m[2].trim().slice(0, 30)}`);
|
|
88
88
|
return [];
|
|
89
89
|
}
|
|
90
|
+
function isNpxBinary(bin) {
|
|
91
|
+
return path.basename(bin, path.extname(bin)).toLowerCase() === 'npx';
|
|
92
|
+
}
|
|
93
|
+
function ensureNonInteractiveRestartArgs(bin, args) {
|
|
94
|
+
if (!isNpxBinary(bin))
|
|
95
|
+
return args;
|
|
96
|
+
if (args.includes('--yes') || args.includes('-y'))
|
|
97
|
+
return args;
|
|
98
|
+
return ['--yes', ...args];
|
|
99
|
+
}
|
|
100
|
+
const ARTIFACT_MANIFEST = 'manifest.json';
|
|
101
|
+
const ARTIFACT_ROOT = path.join(os.tmpdir(), 'codeclaw-artifacts');
|
|
102
|
+
const ARTIFACT_MAX_FILES = 8;
|
|
103
|
+
const ARTIFACT_MAX_BYTES = 20 * 1024 * 1024;
|
|
104
|
+
const ARTIFACT_PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
|
105
|
+
function isPhotoFilename(filename) {
|
|
106
|
+
return ARTIFACT_PHOTO_EXTS.has(path.extname(filename).toLowerCase());
|
|
107
|
+
}
|
|
108
|
+
export function collectArtifacts(dirPath, manifestPath, log) {
|
|
109
|
+
const _log = log || (() => { });
|
|
110
|
+
if (!fs.existsSync(manifestPath))
|
|
111
|
+
return [];
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
_log(`artifact manifest parse error: ${e}`);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.files) ? parsed.files : [];
|
|
121
|
+
if (!entries.length)
|
|
122
|
+
return [];
|
|
123
|
+
const realDir = fs.realpathSync(dirPath);
|
|
124
|
+
const artifacts = [];
|
|
125
|
+
for (const entry of entries.slice(0, ARTIFACT_MAX_FILES)) {
|
|
126
|
+
const rawPath = typeof entry?.path === 'string' ? entry.path
|
|
127
|
+
: typeof entry?.name === 'string' ? entry.name
|
|
128
|
+
: '';
|
|
129
|
+
const relPath = rawPath.trim();
|
|
130
|
+
if (!relPath || path.isAbsolute(relPath)) {
|
|
131
|
+
_log(`artifact skipped: invalid path "${rawPath}"`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const resolved = path.resolve(dirPath, relPath);
|
|
135
|
+
const relative = path.relative(dirPath, resolved);
|
|
136
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
137
|
+
_log(`artifact skipped: outside turn dir "${relPath}"`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!fs.existsSync(resolved)) {
|
|
141
|
+
_log(`artifact skipped: missing file "${relPath}"`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const realFile = fs.realpathSync(resolved);
|
|
145
|
+
const realRelative = path.relative(realDir, realFile);
|
|
146
|
+
if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) {
|
|
147
|
+
_log(`artifact skipped: symlink outside turn dir "${relPath}"`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const stat = fs.statSync(realFile);
|
|
151
|
+
if (!stat.isFile()) {
|
|
152
|
+
_log(`artifact skipped: not a file "${relPath}"`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (stat.size > ARTIFACT_MAX_BYTES) {
|
|
156
|
+
_log(`artifact skipped: too large "${relPath}" (${stat.size} bytes)`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const filename = path.basename(realFile);
|
|
160
|
+
const requestedKind = typeof entry?.kind === 'string' ? entry.kind.toLowerCase()
|
|
161
|
+
: typeof entry?.type === 'string' ? entry.type.toLowerCase()
|
|
162
|
+
: '';
|
|
163
|
+
let kind = requestedKind === 'document' ? 'document'
|
|
164
|
+
: requestedKind === 'photo' ? 'photo'
|
|
165
|
+
: isPhotoFilename(filename) ? 'photo' : 'document';
|
|
166
|
+
if (kind === 'photo' && !isPhotoFilename(filename))
|
|
167
|
+
kind = 'document';
|
|
168
|
+
const caption = typeof entry?.caption === 'string' ? entry.caption.trim().slice(0, 1024) || undefined : undefined;
|
|
169
|
+
artifacts.push({ filePath: realFile, filename, kind, caption });
|
|
170
|
+
}
|
|
171
|
+
return artifacts;
|
|
172
|
+
}
|
|
173
|
+
export function buildArtifactPrompt(prompt, artifactDir, manifestPath) {
|
|
174
|
+
const base = prompt.trim() || 'Please help with this request.';
|
|
175
|
+
return [
|
|
176
|
+
base,
|
|
177
|
+
'',
|
|
178
|
+
'[Telegram Artifact Return]',
|
|
179
|
+
'If you create screenshots, images, logs, or other files that should be sent back to the Telegram user, write them only inside this directory:',
|
|
180
|
+
artifactDir,
|
|
181
|
+
'',
|
|
182
|
+
`When you want a file returned, also write this JSON manifest: ${manifestPath}`,
|
|
183
|
+
'Format:',
|
|
184
|
+
'{"files":[{"path":"screenshot.png","kind":"photo","caption":"optional caption"}]}',
|
|
185
|
+
'Rules:',
|
|
186
|
+
'- Use relative paths in "path". Never use absolute paths.',
|
|
187
|
+
'- Use "photo" for png/jpg/jpeg/webp images. Use "document" for everything else.',
|
|
188
|
+
'- Omit the manifest entirely if there is nothing to send back.',
|
|
189
|
+
].join('\n');
|
|
190
|
+
}
|
|
191
|
+
function humanizeUsageStatus(status) {
|
|
192
|
+
return (status || '').replace(/_/g, ' ').trim();
|
|
193
|
+
}
|
|
194
|
+
function usageRemainingSeconds(capturedAt, resetAfterSeconds) {
|
|
195
|
+
if (resetAfterSeconds == null)
|
|
196
|
+
return null;
|
|
197
|
+
const capturedAtMs = capturedAt ? Date.parse(capturedAt) : Number.NaN;
|
|
198
|
+
if (Number.isFinite(capturedAtMs)) {
|
|
199
|
+
return Math.round((capturedAtMs + resetAfterSeconds * 1000 - Date.now()) / 1000);
|
|
200
|
+
}
|
|
201
|
+
return resetAfterSeconds;
|
|
202
|
+
}
|
|
203
|
+
function formatProviderUsageLines(usage) {
|
|
204
|
+
const lines = ['', '<b>Provider Usage</b>'];
|
|
205
|
+
if (!usage.ok) {
|
|
206
|
+
lines.push(` Unavailable: ${escapeHtml(usage.error || 'No recent usage data found.')}`);
|
|
207
|
+
return lines;
|
|
208
|
+
}
|
|
209
|
+
if (usage.capturedAt) {
|
|
210
|
+
const capturedAtMs = Date.parse(usage.capturedAt);
|
|
211
|
+
if (Number.isFinite(capturedAtMs)) {
|
|
212
|
+
lines.push(` Updated: ${fmtUptime(Math.max(0, Date.now() - capturedAtMs))} ago`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (!usage.windows.length) {
|
|
216
|
+
const status = humanizeUsageStatus(usage.status);
|
|
217
|
+
lines.push(` ${escapeHtml(status || 'No window data')}`);
|
|
218
|
+
return lines;
|
|
219
|
+
}
|
|
220
|
+
for (const window of usage.windows) {
|
|
221
|
+
const parts = [];
|
|
222
|
+
if (window.usedPercent != null && window.remainingPercent != null) {
|
|
223
|
+
parts.push(`${window.usedPercent}% used / ${window.remainingPercent}% left`);
|
|
224
|
+
}
|
|
225
|
+
else if (window.usedPercent != null) {
|
|
226
|
+
parts.push(`${window.usedPercent}% used`);
|
|
227
|
+
}
|
|
228
|
+
const status = humanizeUsageStatus(window.status);
|
|
229
|
+
if (status)
|
|
230
|
+
parts.push(status);
|
|
231
|
+
const remainingSeconds = usageRemainingSeconds(usage.capturedAt, window.resetAfterSeconds);
|
|
232
|
+
if (remainingSeconds != null) {
|
|
233
|
+
parts.push(remainingSeconds > 0 ? `resets in ${fmtUptime(remainingSeconds * 1000)}` : 'reset passed');
|
|
234
|
+
}
|
|
235
|
+
lines.push(` ${escapeHtml(window.label)}: ${escapeHtml(parts.join(' | ') || 'No details')}`);
|
|
236
|
+
}
|
|
237
|
+
return lines;
|
|
238
|
+
}
|
|
90
239
|
// ---------------------------------------------------------------------------
|
|
91
240
|
// Directory browser (Telegram callback_data 64-byte limit)
|
|
92
241
|
// ---------------------------------------------------------------------------
|
|
@@ -252,7 +401,7 @@ export class TelegramBot extends Bot {
|
|
|
252
401
|
if (d.running) {
|
|
253
402
|
lines.push(`<b>Running:</b> ${fmtUptime(Date.now() - d.running.startedAt)} - ${escapeHtml(d.running.prompt.slice(0, 50))}`);
|
|
254
403
|
}
|
|
255
|
-
lines.push('', '<b>Usage</b>', ` Turns: ${d.stats.totalTurns}`);
|
|
404
|
+
lines.push(...formatProviderUsageLines(d.usage), '', '<b>Bot Usage</b>', ` Turns: ${d.stats.totalTurns}`);
|
|
256
405
|
if (d.stats.totalInputTokens || d.stats.totalOutputTokens) {
|
|
257
406
|
lines.push(` In: ${fmtTokens(d.stats.totalInputTokens)} Out: ${fmtTokens(d.stats.totalOutputTokens)}`);
|
|
258
407
|
if (d.stats.totalCachedTokens)
|
|
@@ -311,7 +460,7 @@ export class TelegramBot extends Bot {
|
|
|
311
460
|
return;
|
|
312
461
|
}
|
|
313
462
|
await ctx.reply(`<b>Restarting codeclaw...</b>\n\n` +
|
|
314
|
-
`Pulling latest version via <code>npx codeclaw@latest</code>.\n` +
|
|
463
|
+
`Pulling latest version via <code>npx --yes codeclaw@latest</code>.\n` +
|
|
315
464
|
`The bot will be back shortly.`, { parseMode: 'HTML' });
|
|
316
465
|
this.performRestart();
|
|
317
466
|
}
|
|
@@ -320,14 +469,23 @@ export class TelegramBot extends Bot {
|
|
|
320
469
|
this.log('restart: disconnecting...');
|
|
321
470
|
this.channel.disconnect();
|
|
322
471
|
this.stopKeepAlive();
|
|
323
|
-
const restartCmd = process.env.CODECLAW_RESTART_CMD || 'npx codeclaw@latest';
|
|
324
|
-
const [bin, ...
|
|
472
|
+
const restartCmd = process.env.CODECLAW_RESTART_CMD || 'npx --yes codeclaw@latest';
|
|
473
|
+
const [bin, ...rawArgs] = shellSplit(restartCmd);
|
|
474
|
+
const baseArgs = ensureNonInteractiveRestartArgs(bin, rawArgs);
|
|
325
475
|
const allArgs = [...baseArgs, ...process.argv.slice(2)];
|
|
326
476
|
this.log(`restart: spawning \`${bin} ${allArgs.join(' ')}\``);
|
|
477
|
+
// Collect all known chat IDs so the new process can send startup notices
|
|
478
|
+
const knownIds = new Set(this.allowedChatIds);
|
|
479
|
+
for (const cid of this.channel.knownChats)
|
|
480
|
+
knownIds.add(cid);
|
|
327
481
|
const child = spawn(bin, allArgs, {
|
|
328
482
|
stdio: 'inherit',
|
|
329
483
|
detached: true,
|
|
330
|
-
env:
|
|
484
|
+
env: {
|
|
485
|
+
...process.env,
|
|
486
|
+
npm_config_yes: process.env.npm_config_yes || 'true',
|
|
487
|
+
...(knownIds.size ? { TELEGRAM_ALLOWED_CHAT_IDS: [...knownIds].join(',') } : {}),
|
|
488
|
+
},
|
|
331
489
|
});
|
|
332
490
|
child.unref();
|
|
333
491
|
this.log(`restart: new process spawned (PID ${child.pid}), exiting...`);
|
|
@@ -339,7 +497,8 @@ export class TelegramBot extends Bot {
|
|
|
339
497
|
if (!text && !msg.files.length)
|
|
340
498
|
return;
|
|
341
499
|
const cs = this.chat(ctx.chatId);
|
|
342
|
-
const
|
|
500
|
+
const artifactTurn = this.createArtifactTurn(ctx.chatId);
|
|
501
|
+
const prompt = buildArtifactPrompt(buildPrompt(text, msg.files), artifactTurn.dir, artifactTurn.manifestPath);
|
|
343
502
|
this.log(`[handleMessage] chat=${ctx.chatId} agent=${cs.agent} session=${cs.sessionId || '(new)'} prompt="${prompt.slice(0, 100)}" files=${msg.files.length}`);
|
|
344
503
|
const phId = await ctx.reply(`<code>${escapeHtml(cs.agent)} | thinking ...</code>`, { parseMode: 'HTML' });
|
|
345
504
|
if (!phId) {
|
|
@@ -382,14 +541,44 @@ export class TelegramBot extends Bot {
|
|
|
382
541
|
editCount++;
|
|
383
542
|
};
|
|
384
543
|
const result = await this.runStream(prompt, cs, msg.files, onText);
|
|
544
|
+
const artifacts = this.collectArtifacts(artifactTurn.dir, artifactTurn.manifestPath);
|
|
385
545
|
this.log(`[handleMessage] done agent=${cs.agent} ok=${result.ok} session=${result.sessionId || '?'} elapsed=${result.elapsedS.toFixed(1)}s edits=${editCount} ` +
|
|
386
546
|
`tokens=in:${fmtTokens(result.inputTokens)}/cached:${fmtTokens(result.cachedInputTokens)}/out:${fmtTokens(result.outputTokens)}`);
|
|
387
547
|
this.log(`[handleMessage] response preview: "${result.message.slice(0, 150)}"`);
|
|
388
|
-
await this.sendFinalReply(ctx, phId, cs.agent, result);
|
|
548
|
+
const finalMsgId = await this.sendFinalReply(ctx, phId, cs.agent, result);
|
|
549
|
+
await this.sendArtifacts(ctx, finalMsgId ?? phId, artifacts);
|
|
389
550
|
this.log(`[handleMessage] final reply sent to chat=${ctx.chatId}`);
|
|
390
551
|
}
|
|
391
552
|
finally {
|
|
392
553
|
this.activeTasks.delete(ctx.chatId);
|
|
554
|
+
this.cleanupArtifactTurn(artifactTurn.dir);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
createArtifactTurn(chatId) {
|
|
558
|
+
const turnId = `${chatId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
559
|
+
const dir = path.join(ARTIFACT_ROOT, String(chatId), turnId);
|
|
560
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
561
|
+
return { dir, manifestPath: path.join(dir, ARTIFACT_MANIFEST) };
|
|
562
|
+
}
|
|
563
|
+
cleanupArtifactTurn(dirPath) {
|
|
564
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
565
|
+
}
|
|
566
|
+
collectArtifacts(dirPath, manifestPath) {
|
|
567
|
+
return collectArtifacts(dirPath, manifestPath, msg => this.log(msg));
|
|
568
|
+
}
|
|
569
|
+
async sendArtifacts(ctx, replyTo, artifacts) {
|
|
570
|
+
for (const artifact of artifacts) {
|
|
571
|
+
try {
|
|
572
|
+
await this.channel.sendFile(ctx.chatId, artifact.filePath, {
|
|
573
|
+
caption: artifact.caption,
|
|
574
|
+
replyTo,
|
|
575
|
+
asPhoto: artifact.kind === 'photo',
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
this.log(`artifact upload failed for ${artifact.filename}: ${e}`);
|
|
580
|
+
await this.channel.send(ctx.chatId, `Artifact upload failed: <code>${escapeHtml(artifact.filename)}</code>`, { parseMode: 'HTML', replyTo }).catch(() => { });
|
|
581
|
+
}
|
|
393
582
|
}
|
|
394
583
|
}
|
|
395
584
|
async sendFinalReply(ctx, phId, agent, result) {
|
|
@@ -458,12 +647,13 @@ export class TelegramBot extends Bot {
|
|
|
458
647
|
}
|
|
459
648
|
const bodyHtml = mdToTgHtml(result.message);
|
|
460
649
|
const fullHtml = `${statusHtml}${thinkingHtml}${bodyHtml}\n\n${meta}${tokenBlock}`;
|
|
650
|
+
let finalMsgId = phId;
|
|
461
651
|
if (fullHtml.length <= 3900) {
|
|
462
652
|
try {
|
|
463
653
|
await this.channel.editMessage(ctx.chatId, phId, fullHtml, { parseMode: 'HTML', keyboard });
|
|
464
654
|
}
|
|
465
655
|
catch {
|
|
466
|
-
await this.channel.send(ctx.chatId, fullHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
|
|
656
|
+
finalMsgId = await this.channel.send(ctx.chatId, fullHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
|
|
467
657
|
}
|
|
468
658
|
}
|
|
469
659
|
else {
|
|
@@ -475,13 +665,14 @@ export class TelegramBot extends Bot {
|
|
|
475
665
|
await this.channel.editMessage(ctx.chatId, phId, previewHtml, { parseMode: 'HTML', keyboard });
|
|
476
666
|
}
|
|
477
667
|
catch {
|
|
478
|
-
await this.channel.send(ctx.chatId, previewHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
|
|
668
|
+
finalMsgId = await this.channel.send(ctx.chatId, previewHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
|
|
479
669
|
}
|
|
480
670
|
const thinkingMd = result.thinking
|
|
481
671
|
? `> **${thinkLabel(agent)}**\n${result.thinking.split('\n').map(l => `> ${l}`).join('\n')}\n\n---\n\n`
|
|
482
672
|
: '';
|
|
483
|
-
await this.channel.sendDocument(ctx.chatId, thinkingMd + result.message, `response_${phId}.md`, { caption: `Full response (${result.message.length} chars)`, replyTo: phId });
|
|
673
|
+
await this.channel.sendDocument(ctx.chatId, thinkingMd + result.message, `response_${phId}.md`, { caption: `Full response (${result.message.length} chars)`, replyTo: finalMsgId ?? phId });
|
|
484
674
|
}
|
|
675
|
+
return finalMsgId;
|
|
485
676
|
}
|
|
486
677
|
// ---- callbacks ------------------------------------------------------------
|
|
487
678
|
async handleCallback(data, ctx) {
|
package/dist/bot.js
CHANGED
|
@@ -7,8 +7,8 @@ import os from 'node:os';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { execSync, spawn } from 'node:child_process';
|
|
10
|
-
import { doStream, getSessions, listAgents, } from './code-agent.js';
|
|
11
|
-
export const VERSION = '0.2.
|
|
10
|
+
import { doStream, getSessions, getUsage, listAgents, } from './code-agent.js';
|
|
11
|
+
export const VERSION = '0.2.5';
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Helpers
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
@@ -194,6 +194,7 @@ export class Bot {
|
|
|
194
194
|
memRss: mem.rss, memHeap: mem.heapUsed, pid: process.pid,
|
|
195
195
|
workdir: this.workdir, agent: cs.agent, model: this.modelForAgent(cs.agent), sessionId: cs.sessionId,
|
|
196
196
|
running: this.activeTasks.get(chatId) ?? null, stats: this.stats,
|
|
197
|
+
usage: getUsage({ agent: cs.agent, model: this.modelForAgent(cs.agent) }),
|
|
197
198
|
};
|
|
198
199
|
}
|
|
199
200
|
getHostData() {
|
|
@@ -234,9 +235,10 @@ export class Bot {
|
|
|
234
235
|
else if (cs.agent === 'codex') {
|
|
235
236
|
this.log(`[runStream] codex config: model=${this.codexModel} reasoning=${this.codexReasoningEffort} fullAccess=${this.codexFullAccess} extraArgs=[${this.codexExtraArgs.join(' ')}]`);
|
|
236
237
|
}
|
|
238
|
+
const snapshotSessionId = cs.sessionId;
|
|
237
239
|
const opts = {
|
|
238
240
|
agent: cs.agent, prompt, workdir: this.workdir, timeout: this.runTimeout,
|
|
239
|
-
sessionId:
|
|
241
|
+
sessionId: snapshotSessionId, model: null, thinkingEffort: this.codexReasoningEffort, onText,
|
|
240
242
|
attachments: attachments.length ? attachments : undefined,
|
|
241
243
|
codexModel: this.codexModel, codexFullAccess: this.codexFullAccess,
|
|
242
244
|
codexExtraArgs: this.codexExtraArgs.length ? this.codexExtraArgs : undefined,
|
|
@@ -251,7 +253,8 @@ export class Bot {
|
|
|
251
253
|
this.stats.totalOutputTokens += result.outputTokens;
|
|
252
254
|
if (result.cachedInputTokens)
|
|
253
255
|
this.stats.totalCachedTokens += result.cachedInputTokens;
|
|
254
|
-
if (
|
|
256
|
+
// Only update sessionId if it hasn't been changed externally (e.g. user switched session during run)
|
|
257
|
+
if (result.sessionId && cs.sessionId === snapshotSessionId)
|
|
255
258
|
cs.sessionId = result.sessionId;
|
|
256
259
|
this.log(`[runStream] completed turn=${this.stats.totalTurns} cumulative: in=${fmtTokens(this.stats.totalInputTokens)} out=${fmtTokens(this.stats.totalOutputTokens)} cached=${fmtTokens(this.stats.totalCachedTokens)}`);
|
|
257
260
|
return result;
|
package/dist/channel-telegram.js
CHANGED
|
@@ -56,6 +56,17 @@ import path from 'node:path';
|
|
|
56
56
|
import { Channel, splitText, sleep } from './channel-base.js';
|
|
57
57
|
export { TelegramChannel };
|
|
58
58
|
const TG_MAX = 4096;
|
|
59
|
+
const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
|
60
|
+
function mimeTypeForFilename(filename) {
|
|
61
|
+
switch (path.extname(filename).toLowerCase()) {
|
|
62
|
+
case '.png': return 'image/png';
|
|
63
|
+
case '.webp': return 'image/webp';
|
|
64
|
+
case '.jpg':
|
|
65
|
+
case '.jpeg':
|
|
66
|
+
default:
|
|
67
|
+
return 'image/jpeg';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
59
70
|
// ---------------------------------------------------------------------------
|
|
60
71
|
// TelegramChannel
|
|
61
72
|
// ---------------------------------------------------------------------------
|
|
@@ -203,12 +214,14 @@ class TelegramChannel extends Channel {
|
|
|
203
214
|
const boundary = `----codeclaw${hash}`;
|
|
204
215
|
const parts = [];
|
|
205
216
|
const add = (s) => parts.push(Buffer.from(s, 'utf-8'));
|
|
217
|
+
const filename = opts.filename || 'photo.jpg';
|
|
218
|
+
const mimeType = opts.mimeType || mimeTypeForFilename(filename);
|
|
206
219
|
add(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`);
|
|
207
|
-
if (opts.replyTo)
|
|
220
|
+
if (opts.replyTo != null)
|
|
208
221
|
add(`--${boundary}\r\nContent-Disposition: form-data; name="reply_to_message_id"\r\n\r\n${opts.replyTo}\r\n`);
|
|
209
222
|
if (opts.caption)
|
|
210
223
|
add(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${opts.caption.slice(0, 1024)}\r\n`);
|
|
211
|
-
add(`--${boundary}\r\nContent-Disposition: form-data; name="photo"; filename="
|
|
224
|
+
add(`--${boundary}\r\nContent-Disposition: form-data; name="photo"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`);
|
|
212
225
|
parts.push(photo);
|
|
213
226
|
add(`\r\n--${boundary}--\r\n`);
|
|
214
227
|
try {
|
|
@@ -251,6 +264,20 @@ class TelegramChannel extends Channel {
|
|
|
251
264
|
throw e;
|
|
252
265
|
}
|
|
253
266
|
}
|
|
267
|
+
async sendFile(chatId, filePath, opts = {}) {
|
|
268
|
+
const content = fs.readFileSync(filePath);
|
|
269
|
+
const filename = path.basename(filePath);
|
|
270
|
+
const wantsPhoto = opts.asPhoto ?? PHOTO_EXTS.has(path.extname(filename).toLowerCase());
|
|
271
|
+
if (wantsPhoto) {
|
|
272
|
+
return this.sendPhoto(chatId, content, {
|
|
273
|
+
caption: opts.caption,
|
|
274
|
+
replyTo: opts.replyTo,
|
|
275
|
+
filename,
|
|
276
|
+
mimeType: mimeTypeForFilename(filename),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return this.sendDocument(chatId, content, filename, { caption: opts.caption, replyTo: opts.replyTo });
|
|
280
|
+
}
|
|
254
281
|
/** Set bottom menu commands and ensure the menu button is visible.
|
|
255
282
|
* Automatically applies to all known chats (from incoming updates). */
|
|
256
283
|
async setMenu(commands) {
|
package/dist/code-agent.js
CHANGED
|
@@ -485,3 +485,299 @@ export function listAgents() {
|
|
|
485
485
|
],
|
|
486
486
|
};
|
|
487
487
|
}
|
|
488
|
+
function toIsoFromEpochSeconds(value) {
|
|
489
|
+
const n = Number(value);
|
|
490
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
491
|
+
return null;
|
|
492
|
+
return new Date(n * 1000).toISOString();
|
|
493
|
+
}
|
|
494
|
+
function roundPercent(value) {
|
|
495
|
+
const n = Number(value);
|
|
496
|
+
if (!Number.isFinite(n))
|
|
497
|
+
return null;
|
|
498
|
+
return Math.max(0, Math.min(100, Math.round(n * 10) / 10));
|
|
499
|
+
}
|
|
500
|
+
function labelFromWindowMinutes(value, fallback) {
|
|
501
|
+
const minutes = Number(value);
|
|
502
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
503
|
+
return fallback;
|
|
504
|
+
if (minutes === 300)
|
|
505
|
+
return '5h';
|
|
506
|
+
if (minutes === 10080)
|
|
507
|
+
return '7d';
|
|
508
|
+
if (minutes % 1440 === 0)
|
|
509
|
+
return `${minutes / 1440}d`;
|
|
510
|
+
if (minutes % 60 === 0)
|
|
511
|
+
return `${minutes / 60}h`;
|
|
512
|
+
return `${minutes}m`;
|
|
513
|
+
}
|
|
514
|
+
function usageWindowFromRateLimit(fallback, limit) {
|
|
515
|
+
if (!limit || typeof limit !== 'object')
|
|
516
|
+
return null;
|
|
517
|
+
const usedPercent = roundPercent(limit.used_percent);
|
|
518
|
+
const remainingPercent = usedPercent == null ? null : Math.max(0, Math.round((100 - usedPercent) * 10) / 10);
|
|
519
|
+
const resetAt = toIsoFromEpochSeconds(limit.reset_at ?? limit.resets_at);
|
|
520
|
+
let resetAfterSeconds = null;
|
|
521
|
+
const directResetAfter = Number(limit.reset_after_seconds);
|
|
522
|
+
if (Number.isFinite(directResetAfter) && directResetAfter >= 0) {
|
|
523
|
+
resetAfterSeconds = Math.round(directResetAfter);
|
|
524
|
+
}
|
|
525
|
+
else if (resetAt) {
|
|
526
|
+
const resetAtMs = Date.parse(resetAt);
|
|
527
|
+
if (Number.isFinite(resetAtMs)) {
|
|
528
|
+
resetAfterSeconds = Math.max(0, Math.round((resetAtMs - Date.now()) / 1000));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
label: labelFromWindowMinutes(limit.window_minutes, fallback),
|
|
533
|
+
usedPercent,
|
|
534
|
+
remainingPercent,
|
|
535
|
+
resetAt,
|
|
536
|
+
resetAfterSeconds,
|
|
537
|
+
status: typeof limit.status === 'string' ? limit.status : null,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function parseJsonTail(raw) {
|
|
541
|
+
const start = raw.indexOf('{');
|
|
542
|
+
if (start < 0)
|
|
543
|
+
return null;
|
|
544
|
+
try {
|
|
545
|
+
return JSON.parse(raw.slice(start));
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function modelFamily(model) {
|
|
552
|
+
const lower = model?.toLowerCase() || '';
|
|
553
|
+
if (!lower)
|
|
554
|
+
return null;
|
|
555
|
+
if (lower.includes('opus'))
|
|
556
|
+
return 'opus';
|
|
557
|
+
if (lower.includes('sonnet'))
|
|
558
|
+
return 'sonnet';
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
function emptyUsage(agent, error) {
|
|
562
|
+
return {
|
|
563
|
+
ok: false,
|
|
564
|
+
agent,
|
|
565
|
+
source: null,
|
|
566
|
+
capturedAt: null,
|
|
567
|
+
status: null,
|
|
568
|
+
windows: [],
|
|
569
|
+
error,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function getCodexStateDbPath(home) {
|
|
573
|
+
const root = path.join(home, '.codex');
|
|
574
|
+
if (!fs.existsSync(root))
|
|
575
|
+
return null;
|
|
576
|
+
try {
|
|
577
|
+
const files = fs.readdirSync(root)
|
|
578
|
+
.filter(name => /^state.*\.sqlite$/i.test(name))
|
|
579
|
+
.map(name => ({ name, full: path.join(root, name), mtime: fs.statSync(path.join(root, name)).mtimeMs }))
|
|
580
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
581
|
+
return files[0]?.full || null;
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function codexUsageFromRateLimits(rateLimits, capturedAt, source) {
|
|
588
|
+
if (!rateLimits || typeof rateLimits !== 'object')
|
|
589
|
+
return null;
|
|
590
|
+
const windows = [
|
|
591
|
+
usageWindowFromRateLimit('Primary', rateLimits.primary),
|
|
592
|
+
usageWindowFromRateLimit('Secondary', rateLimits.secondary),
|
|
593
|
+
].filter((v) => !!v);
|
|
594
|
+
if (!windows.length)
|
|
595
|
+
return null;
|
|
596
|
+
let status = null;
|
|
597
|
+
if (rateLimits.limit_reached === true)
|
|
598
|
+
status = 'limit_reached';
|
|
599
|
+
else if (rateLimits.allowed === true)
|
|
600
|
+
status = 'allowed';
|
|
601
|
+
return {
|
|
602
|
+
ok: true,
|
|
603
|
+
agent: 'codex',
|
|
604
|
+
source,
|
|
605
|
+
capturedAt,
|
|
606
|
+
status,
|
|
607
|
+
windows,
|
|
608
|
+
error: null,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
function getCodexUsageFromStateDb(home) {
|
|
612
|
+
const dbPath = getCodexStateDbPath(home);
|
|
613
|
+
if (!dbPath)
|
|
614
|
+
return null;
|
|
615
|
+
try {
|
|
616
|
+
const query = "SELECT ts || '|' || message FROM logs WHERE message LIKE '%codex.rate_limits%' ORDER BY ts DESC LIMIT 1;";
|
|
617
|
+
const out = execSync(`sqlite3 -noheader ${Q(dbPath)} ${Q(query)}`, { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
618
|
+
if (!out)
|
|
619
|
+
return null;
|
|
620
|
+
const sep = out.indexOf('|');
|
|
621
|
+
const rawTs = sep >= 0 ? out.slice(0, sep) : '';
|
|
622
|
+
const rawMessage = sep >= 0 ? out.slice(sep + 1) : out;
|
|
623
|
+
const payload = parseJsonTail(rawMessage);
|
|
624
|
+
const capturedAt = toIsoFromEpochSeconds(rawTs);
|
|
625
|
+
return codexUsageFromRateLimits(payload?.rate_limits, capturedAt, 'state-db');
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function getCodexUsageFromSessions(home) {
|
|
632
|
+
const sessionsRoot = path.join(home, '.codex', 'sessions');
|
|
633
|
+
if (!fs.existsSync(sessionsRoot))
|
|
634
|
+
return null;
|
|
635
|
+
const all = [];
|
|
636
|
+
try {
|
|
637
|
+
for (const year of fs.readdirSync(sessionsRoot)) {
|
|
638
|
+
const yp = path.join(sessionsRoot, year);
|
|
639
|
+
if (!fs.statSync(yp).isDirectory())
|
|
640
|
+
continue;
|
|
641
|
+
for (const month of fs.readdirSync(yp)) {
|
|
642
|
+
const mp = path.join(yp, month);
|
|
643
|
+
if (!fs.statSync(mp).isDirectory())
|
|
644
|
+
continue;
|
|
645
|
+
for (const day of fs.readdirSync(mp)) {
|
|
646
|
+
const dp = path.join(mp, day);
|
|
647
|
+
if (!fs.statSync(dp).isDirectory())
|
|
648
|
+
continue;
|
|
649
|
+
for (const f of fs.readdirSync(dp)) {
|
|
650
|
+
if (!f.endsWith('.jsonl'))
|
|
651
|
+
continue;
|
|
652
|
+
const full = path.join(dp, f);
|
|
653
|
+
all.push({ path: full, mtime: fs.statSync(full).mtimeMs });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
all.sort((a, b) => b.mtime - a.mtime);
|
|
663
|
+
for (const entry of all.slice(0, 30)) {
|
|
664
|
+
try {
|
|
665
|
+
const lines = fs.readFileSync(entry.path, 'utf-8').trim().split('\n');
|
|
666
|
+
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 200; i--) {
|
|
667
|
+
const raw = lines[i];
|
|
668
|
+
if (!raw || raw[0] !== '{' || !raw.includes('rate_limits'))
|
|
669
|
+
continue;
|
|
670
|
+
let ev;
|
|
671
|
+
try {
|
|
672
|
+
ev = JSON.parse(raw);
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const result = codexUsageFromRateLimits(ev?.payload?.rate_limits, typeof ev?.timestamp === 'string' ? ev.timestamp : null, 'session-history');
|
|
678
|
+
if (result)
|
|
679
|
+
return result;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
// ignore malformed or unreadable session files
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
function getClaudeUsageFromTelemetry(home, model) {
|
|
689
|
+
const telemetryRoot = path.join(home, '.claude', 'telemetry');
|
|
690
|
+
if (!fs.existsSync(telemetryRoot))
|
|
691
|
+
return null;
|
|
692
|
+
const preferredFamily = modelFamily(model);
|
|
693
|
+
let bestAny = null;
|
|
694
|
+
let bestMatch = null;
|
|
695
|
+
try {
|
|
696
|
+
const files = fs.readdirSync(telemetryRoot)
|
|
697
|
+
.filter(name => name.endsWith('.json'))
|
|
698
|
+
.map(name => ({ full: path.join(telemetryRoot, name), mtime: fs.statSync(path.join(telemetryRoot, name)).mtimeMs }))
|
|
699
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
700
|
+
.slice(0, 50);
|
|
701
|
+
for (const file of files) {
|
|
702
|
+
const lines = fs.readFileSync(file.full, 'utf-8').trim().split('\n');
|
|
703
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
704
|
+
const raw = lines[i];
|
|
705
|
+
if (!raw || raw[0] !== '{' || !raw.includes('tengu_claudeai_limits_status_changed'))
|
|
706
|
+
continue;
|
|
707
|
+
let parsed;
|
|
708
|
+
try {
|
|
709
|
+
parsed = JSON.parse(raw);
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const data = parsed?.event_data;
|
|
715
|
+
if (data?.event_name !== 'tengu_claudeai_limits_status_changed')
|
|
716
|
+
continue;
|
|
717
|
+
const capturedAtMs = Date.parse(data.client_timestamp || '');
|
|
718
|
+
if (!Number.isFinite(capturedAtMs))
|
|
719
|
+
continue;
|
|
720
|
+
let meta = data.additional_metadata;
|
|
721
|
+
if (typeof meta === 'string') {
|
|
722
|
+
try {
|
|
723
|
+
meta = JSON.parse(meta);
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
meta = null;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const hoursTillReset = Number(meta?.hoursTillReset);
|
|
730
|
+
const candidate = {
|
|
731
|
+
capturedAtMs,
|
|
732
|
+
capturedAt: new Date(capturedAtMs).toISOString(),
|
|
733
|
+
status: typeof meta?.status === 'string' ? meta.status : null,
|
|
734
|
+
hoursTillReset: Number.isFinite(hoursTillReset) ? hoursTillReset : null,
|
|
735
|
+
model: typeof data.model === 'string' ? data.model : null,
|
|
736
|
+
};
|
|
737
|
+
if (!bestAny || candidate.capturedAtMs > bestAny.capturedAtMs)
|
|
738
|
+
bestAny = candidate;
|
|
739
|
+
if (preferredFamily && candidate.model?.toLowerCase().includes(preferredFamily)) {
|
|
740
|
+
if (!bestMatch || candidate.capturedAtMs > bestMatch.capturedAtMs)
|
|
741
|
+
bestMatch = candidate;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
const chosen = bestMatch || bestAny;
|
|
750
|
+
if (!chosen)
|
|
751
|
+
return null;
|
|
752
|
+
const resetAfterSeconds = chosen.hoursTillReset == null ? null : Math.max(0, Math.round(chosen.hoursTillReset * 3600));
|
|
753
|
+
const resetAt = resetAfterSeconds == null ? null : new Date(chosen.capturedAtMs + resetAfterSeconds * 1000).toISOString();
|
|
754
|
+
const windows = [{
|
|
755
|
+
label: 'Current',
|
|
756
|
+
usedPercent: null,
|
|
757
|
+
remainingPercent: null,
|
|
758
|
+
resetAt,
|
|
759
|
+
resetAfterSeconds,
|
|
760
|
+
status: chosen.status,
|
|
761
|
+
}];
|
|
762
|
+
return {
|
|
763
|
+
ok: true,
|
|
764
|
+
agent: 'claude',
|
|
765
|
+
source: 'telemetry',
|
|
766
|
+
capturedAt: chosen.capturedAt,
|
|
767
|
+
status: chosen.status,
|
|
768
|
+
windows,
|
|
769
|
+
error: null,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
export function getUsage(opts) {
|
|
773
|
+
const home = process.env.HOME || '';
|
|
774
|
+
if (!home)
|
|
775
|
+
return emptyUsage(opts.agent, 'HOME is not set.');
|
|
776
|
+
if (opts.agent === 'codex') {
|
|
777
|
+
return getCodexUsageFromStateDb(home)
|
|
778
|
+
|| getCodexUsageFromSessions(home)
|
|
779
|
+
|| emptyUsage('codex', 'No recent Codex usage data found.');
|
|
780
|
+
}
|
|
781
|
+
return getClaudeUsageFromTelemetry(home, opts.model)
|
|
782
|
+
|| emptyUsage('claude', 'No recent Claude usage data found.');
|
|
783
|
+
}
|