codeclaw 0.2.2 → 0.2.4

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,90 @@ 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
+ function buildArtifactPrompt(prompt, artifactDir, manifestPath) {
109
+ const base = prompt.trim() || 'Please help with this request.';
110
+ return [
111
+ base,
112
+ '',
113
+ '[Telegram Artifact Return]',
114
+ 'If you create screenshots, images, logs, or other files that should be sent back to the Telegram user, write them only inside this directory:',
115
+ artifactDir,
116
+ '',
117
+ `When you want a file returned, also write this JSON manifest: ${manifestPath}`,
118
+ 'Format:',
119
+ '{"files":[{"path":"screenshot.png","kind":"photo","caption":"optional caption"}]}',
120
+ 'Rules:',
121
+ '- Use relative paths in "path". Never use absolute paths.',
122
+ '- Use "photo" for png/jpg/jpeg/webp images. Use "document" for everything else.',
123
+ '- Omit the manifest entirely if there is nothing to send back.',
124
+ ].join('\n');
125
+ }
126
+ function humanizeUsageStatus(status) {
127
+ return (status || '').replace(/_/g, ' ').trim();
128
+ }
129
+ function usageRemainingSeconds(capturedAt, resetAfterSeconds) {
130
+ if (resetAfterSeconds == null)
131
+ return null;
132
+ const capturedAtMs = capturedAt ? Date.parse(capturedAt) : Number.NaN;
133
+ if (Number.isFinite(capturedAtMs)) {
134
+ return Math.round((capturedAtMs + resetAfterSeconds * 1000 - Date.now()) / 1000);
135
+ }
136
+ return resetAfterSeconds;
137
+ }
138
+ function formatProviderUsageLines(usage) {
139
+ const lines = ['', '<b>Provider Usage</b>'];
140
+ if (!usage.ok) {
141
+ lines.push(` Unavailable: ${escapeHtml(usage.error || 'No recent usage data found.')}`);
142
+ return lines;
143
+ }
144
+ if (usage.capturedAt) {
145
+ const capturedAtMs = Date.parse(usage.capturedAt);
146
+ if (Number.isFinite(capturedAtMs)) {
147
+ lines.push(` Updated: ${fmtUptime(Math.max(0, Date.now() - capturedAtMs))} ago`);
148
+ }
149
+ }
150
+ if (!usage.windows.length) {
151
+ const status = humanizeUsageStatus(usage.status);
152
+ lines.push(` ${escapeHtml(status || 'No window data')}`);
153
+ return lines;
154
+ }
155
+ for (const window of usage.windows) {
156
+ const parts = [];
157
+ if (window.usedPercent != null && window.remainingPercent != null) {
158
+ parts.push(`${window.usedPercent}% used / ${window.remainingPercent}% left`);
159
+ }
160
+ else if (window.usedPercent != null) {
161
+ parts.push(`${window.usedPercent}% used`);
162
+ }
163
+ const status = humanizeUsageStatus(window.status);
164
+ if (status)
165
+ parts.push(status);
166
+ const remainingSeconds = usageRemainingSeconds(usage.capturedAt, window.resetAfterSeconds);
167
+ if (remainingSeconds != null) {
168
+ parts.push(remainingSeconds > 0 ? `resets in ${fmtUptime(remainingSeconds * 1000)}` : 'reset passed');
169
+ }
170
+ lines.push(` ${escapeHtml(window.label)}: ${escapeHtml(parts.join(' | ') || 'No details')}`);
171
+ }
172
+ return lines;
173
+ }
90
174
  // ---------------------------------------------------------------------------
91
175
  // Directory browser (Telegram callback_data 64-byte limit)
92
176
  // ---------------------------------------------------------------------------
@@ -252,7 +336,7 @@ export class TelegramBot extends Bot {
252
336
  if (d.running) {
253
337
  lines.push(`<b>Running:</b> ${fmtUptime(Date.now() - d.running.startedAt)} - ${escapeHtml(d.running.prompt.slice(0, 50))}`);
254
338
  }
255
- lines.push('', '<b>Usage</b>', ` Turns: ${d.stats.totalTurns}`);
339
+ lines.push(...formatProviderUsageLines(d.usage), '', '<b>Bot Usage</b>', ` Turns: ${d.stats.totalTurns}`);
256
340
  if (d.stats.totalInputTokens || d.stats.totalOutputTokens) {
257
341
  lines.push(` In: ${fmtTokens(d.stats.totalInputTokens)} Out: ${fmtTokens(d.stats.totalOutputTokens)}`);
258
342
  if (d.stats.totalCachedTokens)
@@ -311,7 +395,7 @@ export class TelegramBot extends Bot {
311
395
  return;
312
396
  }
313
397
  await ctx.reply(`<b>Restarting codeclaw...</b>\n\n` +
314
- `Pulling latest version via <code>npx codeclaw@latest</code>.\n` +
398
+ `Pulling latest version via <code>npx --yes codeclaw@latest</code>.\n` +
315
399
  `The bot will be back shortly.`, { parseMode: 'HTML' });
316
400
  this.performRestart();
317
401
  }
@@ -320,14 +404,18 @@ export class TelegramBot extends Bot {
320
404
  this.log('restart: disconnecting...');
321
405
  this.channel.disconnect();
322
406
  this.stopKeepAlive();
323
- const restartCmd = process.env.CODECLAW_RESTART_CMD || 'npx codeclaw@latest';
324
- const [bin, ...baseArgs] = shellSplit(restartCmd);
407
+ const restartCmd = process.env.CODECLAW_RESTART_CMD || 'npx --yes codeclaw@latest';
408
+ const [bin, ...rawArgs] = shellSplit(restartCmd);
409
+ const baseArgs = ensureNonInteractiveRestartArgs(bin, rawArgs);
325
410
  const allArgs = [...baseArgs, ...process.argv.slice(2)];
326
411
  this.log(`restart: spawning \`${bin} ${allArgs.join(' ')}\``);
327
412
  const child = spawn(bin, allArgs, {
328
413
  stdio: 'inherit',
329
414
  detached: true,
330
- env: process.env,
415
+ env: {
416
+ ...process.env,
417
+ npm_config_yes: process.env.npm_config_yes || 'true',
418
+ },
331
419
  });
332
420
  child.unref();
333
421
  this.log(`restart: new process spawned (PID ${child.pid}), exiting...`);
@@ -339,7 +427,8 @@ export class TelegramBot extends Bot {
339
427
  if (!text && !msg.files.length)
340
428
  return;
341
429
  const cs = this.chat(ctx.chatId);
342
- const prompt = buildPrompt(text, msg.files);
430
+ const artifactTurn = this.createArtifactTurn(ctx.chatId);
431
+ const prompt = buildArtifactPrompt(buildPrompt(text, msg.files), artifactTurn.dir, artifactTurn.manifestPath);
343
432
  this.log(`[handleMessage] chat=${ctx.chatId} agent=${cs.agent} session=${cs.sessionId || '(new)'} prompt="${prompt.slice(0, 100)}" files=${msg.files.length}`);
344
433
  const phId = await ctx.reply(`<code>${escapeHtml(cs.agent)} | thinking ...</code>`, { parseMode: 'HTML' });
345
434
  if (!phId) {
@@ -382,14 +471,105 @@ export class TelegramBot extends Bot {
382
471
  editCount++;
383
472
  };
384
473
  const result = await this.runStream(prompt, cs, msg.files, onText);
474
+ const artifacts = this.collectArtifacts(artifactTurn.dir, artifactTurn.manifestPath);
385
475
  this.log(`[handleMessage] done agent=${cs.agent} ok=${result.ok} session=${result.sessionId || '?'} elapsed=${result.elapsedS.toFixed(1)}s edits=${editCount} ` +
386
476
  `tokens=in:${fmtTokens(result.inputTokens)}/cached:${fmtTokens(result.cachedInputTokens)}/out:${fmtTokens(result.outputTokens)}`);
387
477
  this.log(`[handleMessage] response preview: "${result.message.slice(0, 150)}"`);
388
- await this.sendFinalReply(ctx, phId, cs.agent, result);
478
+ const finalMsgId = await this.sendFinalReply(ctx, phId, cs.agent, result);
479
+ await this.sendArtifacts(ctx, finalMsgId ?? phId, artifacts);
389
480
  this.log(`[handleMessage] final reply sent to chat=${ctx.chatId}`);
390
481
  }
391
482
  finally {
392
483
  this.activeTasks.delete(ctx.chatId);
484
+ this.cleanupArtifactTurn(artifactTurn.dir);
485
+ }
486
+ }
487
+ createArtifactTurn(chatId) {
488
+ const turnId = `${chatId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
489
+ const dir = path.join(ARTIFACT_ROOT, String(chatId), turnId);
490
+ fs.mkdirSync(dir, { recursive: true });
491
+ return { dir, manifestPath: path.join(dir, ARTIFACT_MANIFEST) };
492
+ }
493
+ cleanupArtifactTurn(dirPath) {
494
+ fs.rmSync(dirPath, { recursive: true, force: true });
495
+ }
496
+ collectArtifacts(dirPath, manifestPath) {
497
+ if (!fs.existsSync(manifestPath))
498
+ return [];
499
+ let parsed;
500
+ try {
501
+ parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
502
+ }
503
+ catch (e) {
504
+ this.log(`artifact manifest parse error: ${e}`);
505
+ return [];
506
+ }
507
+ const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.files) ? parsed.files : [];
508
+ if (!entries.length)
509
+ return [];
510
+ const realDir = fs.realpathSync(dirPath);
511
+ const artifacts = [];
512
+ for (const entry of entries.slice(0, ARTIFACT_MAX_FILES)) {
513
+ const rawPath = typeof entry?.path === 'string' ? entry.path
514
+ : typeof entry?.name === 'string' ? entry.name
515
+ : '';
516
+ const relPath = rawPath.trim();
517
+ if (!relPath || path.isAbsolute(relPath)) {
518
+ this.log(`artifact skipped: invalid path "${rawPath}"`);
519
+ continue;
520
+ }
521
+ const resolved = path.resolve(dirPath, relPath);
522
+ const relative = path.relative(dirPath, resolved);
523
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
524
+ this.log(`artifact skipped: outside turn dir "${relPath}"`);
525
+ continue;
526
+ }
527
+ if (!fs.existsSync(resolved)) {
528
+ this.log(`artifact skipped: missing file "${relPath}"`);
529
+ continue;
530
+ }
531
+ const realFile = fs.realpathSync(resolved);
532
+ const realRelative = path.relative(realDir, realFile);
533
+ if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) {
534
+ this.log(`artifact skipped: symlink outside turn dir "${relPath}"`);
535
+ continue;
536
+ }
537
+ const stat = fs.statSync(realFile);
538
+ if (!stat.isFile()) {
539
+ this.log(`artifact skipped: not a file "${relPath}"`);
540
+ continue;
541
+ }
542
+ if (stat.size > ARTIFACT_MAX_BYTES) {
543
+ this.log(`artifact skipped: too large "${relPath}" (${stat.size} bytes)`);
544
+ continue;
545
+ }
546
+ const filename = path.basename(realFile);
547
+ const requestedKind = typeof entry?.kind === 'string' ? entry.kind.toLowerCase()
548
+ : typeof entry?.type === 'string' ? entry.type.toLowerCase()
549
+ : '';
550
+ let kind = requestedKind === 'document' ? 'document'
551
+ : requestedKind === 'photo' ? 'photo'
552
+ : isPhotoFilename(filename) ? 'photo' : 'document';
553
+ if (kind === 'photo' && !isPhotoFilename(filename))
554
+ kind = 'document';
555
+ const caption = typeof entry?.caption === 'string' ? entry.caption.trim().slice(0, 1024) || undefined : undefined;
556
+ artifacts.push({ filePath: realFile, filename, kind, caption });
557
+ }
558
+ return artifacts;
559
+ }
560
+ async sendArtifacts(ctx, replyTo, artifacts) {
561
+ for (const artifact of artifacts) {
562
+ try {
563
+ await this.channel.sendFile(ctx.chatId, artifact.filePath, {
564
+ caption: artifact.caption,
565
+ replyTo,
566
+ asPhoto: artifact.kind === 'photo',
567
+ });
568
+ }
569
+ catch (e) {
570
+ this.log(`artifact upload failed for ${artifact.filename}: ${e}`);
571
+ await this.channel.send(ctx.chatId, `Artifact upload failed: <code>${escapeHtml(artifact.filename)}</code>`, { parseMode: 'HTML', replyTo }).catch(() => { });
572
+ }
393
573
  }
394
574
  }
395
575
  async sendFinalReply(ctx, phId, agent, result) {
@@ -410,7 +590,7 @@ export class TelegramBot extends Bot {
410
590
  tp.push(`out: ${fmtTokens(result.outputTokens)}`);
411
591
  tokenBlock = `\n<blockquote expandable>${tp.join(' ')}</blockquote>`;
412
592
  }
413
- const quickReplies = detectQuickReplies(result.message);
593
+ const quickReplies = result.incomplete ? [] : detectQuickReplies(result.message);
414
594
  let keyboard = undefined;
415
595
  if (quickReplies.length) {
416
596
  const rows = [];
@@ -442,32 +622,48 @@ export class TelegramBot extends Bot {
442
622
  display = '...\n' + display.slice(-800);
443
623
  thinkingHtml = `<blockquote><b>${label}</b>\n${escapeHtml(display)}</blockquote>\n\n`;
444
624
  }
625
+ let statusHtml = '';
626
+ if (result.incomplete) {
627
+ const statusLines = [];
628
+ if (result.stopReason === 'max_tokens')
629
+ statusLines.push('Output limit reached. Response may be truncated.');
630
+ if (!result.ok) {
631
+ const detail = result.error?.trim();
632
+ if (detail && detail !== result.message.trim())
633
+ statusLines.push(detail);
634
+ else
635
+ statusLines.push('Agent exited before reporting completion.');
636
+ }
637
+ statusHtml = `<blockquote expandable><b>Incomplete Response</b>\n${statusLines.map(escapeHtml).join('\n')}</blockquote>\n\n`;
638
+ }
445
639
  const bodyHtml = mdToTgHtml(result.message);
446
- const fullHtml = `${thinkingHtml}${bodyHtml}\n\n${meta}${tokenBlock}`;
640
+ const fullHtml = `${statusHtml}${thinkingHtml}${bodyHtml}\n\n${meta}${tokenBlock}`;
641
+ let finalMsgId = phId;
447
642
  if (fullHtml.length <= 3900) {
448
643
  try {
449
644
  await this.channel.editMessage(ctx.chatId, phId, fullHtml, { parseMode: 'HTML', keyboard });
450
645
  }
451
646
  catch {
452
- await this.channel.send(ctx.chatId, fullHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
647
+ finalMsgId = await this.channel.send(ctx.chatId, fullHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
453
648
  }
454
649
  }
455
650
  else {
456
651
  let preview = bodyHtml.slice(0, 3200);
457
652
  if (bodyHtml.length > 3200)
458
653
  preview += '\n<i>... (see full response below)</i>';
459
- const previewHtml = `${thinkingHtml}${preview}\n\n${meta}${tokenBlock}`;
654
+ const previewHtml = `${statusHtml}${thinkingHtml}${preview}\n\n${meta}${tokenBlock}`;
460
655
  try {
461
656
  await this.channel.editMessage(ctx.chatId, phId, previewHtml, { parseMode: 'HTML', keyboard });
462
657
  }
463
658
  catch {
464
- await this.channel.send(ctx.chatId, previewHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
659
+ finalMsgId = await this.channel.send(ctx.chatId, previewHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
465
660
  }
466
661
  const thinkingMd = result.thinking
467
662
  ? `> **${thinkLabel(agent)}**\n${result.thinking.split('\n').map(l => `> ${l}`).join('\n')}\n\n---\n\n`
468
663
  : '';
469
- await this.channel.sendDocument(ctx.chatId, thinkingMd + result.message, `response_${phId}.md`, { caption: `Full response (${result.message.length} chars)`, replyTo: phId });
664
+ await this.channel.sendDocument(ctx.chatId, thinkingMd + result.message, `response_${phId}.md`, { caption: `Full response (${result.message.length} chars)`, replyTo: finalMsgId ?? phId });
470
665
  }
666
+ return finalMsgId;
471
667
  }
472
668
  // ---- callbacks ------------------------------------------------------------
473
669
  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.2';
10
+ import { doStream, getSessions, getUsage, listAgents, } from './code-agent.js';
11
+ export const VERSION = '0.2.4';
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() {
@@ -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) {
@@ -16,6 +16,7 @@ async function run(cmd, opts, parseLine) {
16
16
  sessionId: opts.sessionId, text: '', thinking: '', msgs: [], thinkParts: [],
17
17
  model: opts.model, thinkingEffort: opts.thinkingEffort, errors: null,
18
18
  inputTokens: null, outputTokens: null, cachedInputTokens: null,
19
+ stopReason: null,
19
20
  };
20
21
  const shellCmd = cmd.map(Q).join(' ');
21
22
  agentLog(`[spawn] cmd: ${shellCmd}`);
@@ -63,7 +64,7 @@ async function run(cmd, opts, parseLine) {
63
64
  }
64
65
  catch { }
65
66
  });
66
- const [ok, code] = await new Promise(resolve => {
67
+ const [procOk, code] = await new Promise(resolve => {
67
68
  proc.on('close', code => { agentLog(`[exit] code=${code} lines_parsed=${lineCount}`); resolve([code === 0, code]); });
68
69
  proc.on('error', e => { agentLog(`[error] ${e.message}`); stderr += e.message; resolve([false, -1]); });
69
70
  });
@@ -71,18 +72,26 @@ async function run(cmd, opts, parseLine) {
71
72
  s.text = s.msgs.join('\n\n');
72
73
  if (!s.thinking.trim() && s.thinkParts.length)
73
74
  s.thinking = s.thinkParts.join('\n\n');
75
+ const ok = procOk && !s.errors;
76
+ const error = s.errors?.map(e => e.trim()).filter(Boolean).join('; ') || (!procOk ? (stderr.trim() || `Failed (exit=${code}).`) : null);
77
+ const incomplete = !ok || s.stopReason === 'max_tokens';
74
78
  const elapsed = ((Date.now() - start) / 1000).toFixed(1);
75
79
  agentLog(`[result] ok=${ok && !s.errors} elapsed=${elapsed}s text=${s.text.length}chars thinking=${s.thinking.length}chars session=${s.sessionId || '?'}`);
76
80
  if (s.errors)
77
81
  agentLog(`[result] errors: ${s.errors.join('; ')}`);
78
- if (stderr.trim() && !ok)
82
+ if (s.stopReason)
83
+ agentLog(`[result] stop_reason=${s.stopReason}`);
84
+ if (stderr.trim() && !procOk)
79
85
  agentLog(`[result] stderr: ${stderr.trim().slice(0, 300)}`);
80
86
  return {
81
- ok: ok && !s.errors, sessionId: s.sessionId, model: s.model, thinkingEffort: s.thinkingEffort,
82
- message: s.errors?.join('; ') || s.text.trim() || (ok ? '(no textual response)' : `Failed (exit=${code}).\n\n${stderr.trim() || '(no output)'}`),
87
+ ok, sessionId: s.sessionId, model: s.model, thinkingEffort: s.thinkingEffort,
88
+ message: s.text.trim() || s.errors?.join('; ') || (procOk ? '(no textual response)' : `Failed (exit=${code}).\n\n${stderr.trim() || '(no output)'}`),
83
89
  thinking: s.thinking.trim() || null,
84
90
  elapsedS: (Date.now() - start) / 1000,
85
91
  inputTokens: s.inputTokens, outputTokens: s.outputTokens, cachedInputTokens: s.cachedInputTokens,
92
+ error,
93
+ stopReason: s.stopReason,
94
+ incomplete,
86
95
  };
87
96
  }
88
97
  // --- codex ---
@@ -171,6 +180,8 @@ function claudeParse(ev, s) {
171
180
  s.text += d.text || '';
172
181
  }
173
182
  if (inner.type === 'message_delta') {
183
+ const d = inner.delta || {};
184
+ s.stopReason = d.stop_reason ?? s.stopReason;
174
185
  const u = inner.usage;
175
186
  if (u) {
176
187
  s.inputTokens = u.input_tokens ?? s.inputTokens;
@@ -182,13 +193,15 @@ function claudeParse(ev, s) {
182
193
  s.model = ev.model ?? s.model;
183
194
  }
184
195
  if (t === 'assistant') {
185
- const contents = (ev.message || {}).content || [];
196
+ const msg = ev.message || {};
197
+ const contents = msg.content || [];
186
198
  const th = contents.filter((b) => b?.type === 'thinking').map((b) => b.thinking || '').join('');
187
199
  const tx = contents.filter((b) => b?.type === 'text').map((b) => b.text || '').join('');
188
200
  if (th && !s.thinking.trim())
189
201
  s.thinking = th;
190
202
  if (tx && !s.text.trim())
191
203
  s.text = tx;
204
+ s.stopReason = msg.stop_reason ?? s.stopReason;
192
205
  }
193
206
  if (t === 'result') {
194
207
  s.sessionId = ev.session_id ?? s.sessionId;
@@ -197,6 +210,7 @@ function claudeParse(ev, s) {
197
210
  s.errors = ev.errors;
198
211
  if (ev.result && !s.text.trim())
199
212
  s.text = ev.result;
213
+ s.stopReason = ev.stop_reason ?? s.stopReason;
200
214
  const u = ev.usage;
201
215
  if (u) {
202
216
  s.inputTokens = u.input_tokens ?? s.inputTokens;
@@ -208,7 +222,8 @@ function claudeParse(ev, s) {
208
222
  export async function doClaudeStream(opts) {
209
223
  const result = await run(claudeCmd(opts), opts, claudeParse);
210
224
  // session not found → retry as new conversation
211
- if (!result.ok && opts.sessionId && /no conversation found/i.test(result.message)) {
225
+ const retryText = `${result.error || ''}\n${result.message}`;
226
+ if (!result.ok && opts.sessionId && /no conversation found/i.test(retryText)) {
212
227
  return run(claudeCmd({ ...opts, sessionId: null }), { ...opts, sessionId: null }, claudeParse);
213
228
  }
214
229
  return result;
@@ -470,3 +485,299 @@ export function listAgents() {
470
485
  ],
471
486
  };
472
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.2",
3
+ "version": "0.2.4",
4
4
  "description": "The best IM-driven remote coding experience. Bridge AI coding agents to any IM.",
5
5
  "type": "module",
6
6
  "bin": {