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 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。
@@ -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, ...baseArgs] = shellSplit(restartCmd);
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: process.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 prompt = buildPrompt(text, msg.files);
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.3';
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: cs.sessionId, model: null, thinkingEffort: this.codexReasoningEffort, onText,
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 (result.sessionId)
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;
@@ -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="photo.jpg"\r\nContent-Type: image/jpeg\r\n\r\n`);
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) {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeclaw",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "The best IM-driven remote coding experience. Bridge AI coding agents to any IM.",
5
5
  "type": "module",
6
6
  "bin": {