codeclaw 0.2.4 → 0.2.6

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.
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
  import { spawn } from 'node:child_process';
11
11
  import { Bot, VERSION, fmtTokens, fmtUptime, fmtBytes, whichSync, listSubdirs, buildPrompt, thinkLabel, parseAllowedChatIds, shellSplit, } from './bot.js';
12
12
  import { TelegramChannel } from './channel-telegram.js';
13
+ import { splitText } from './channel-base.js';
13
14
  // ---------------------------------------------------------------------------
14
15
  // Telegram HTML formatting
15
16
  // ---------------------------------------------------------------------------
@@ -105,7 +106,72 @@ const ARTIFACT_PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
105
106
  function isPhotoFilename(filename) {
106
107
  return ARTIFACT_PHOTO_EXTS.has(path.extname(filename).toLowerCase());
107
108
  }
108
- function buildArtifactPrompt(prompt, artifactDir, manifestPath) {
109
+ export function collectArtifacts(dirPath, manifestPath, log) {
110
+ const _log = log || (() => { });
111
+ if (!fs.existsSync(manifestPath))
112
+ return [];
113
+ let parsed;
114
+ try {
115
+ parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
116
+ }
117
+ catch (e) {
118
+ _log(`artifact manifest parse error: ${e}`);
119
+ return [];
120
+ }
121
+ const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.files) ? parsed.files : [];
122
+ if (!entries.length)
123
+ return [];
124
+ const realDir = fs.realpathSync(dirPath);
125
+ const artifacts = [];
126
+ for (const entry of entries.slice(0, ARTIFACT_MAX_FILES)) {
127
+ const rawPath = typeof entry?.path === 'string' ? entry.path
128
+ : typeof entry?.name === 'string' ? entry.name
129
+ : '';
130
+ const relPath = rawPath.trim();
131
+ if (!relPath || path.isAbsolute(relPath)) {
132
+ _log(`artifact skipped: invalid path "${rawPath}"`);
133
+ continue;
134
+ }
135
+ const resolved = path.resolve(dirPath, relPath);
136
+ const relative = path.relative(dirPath, resolved);
137
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
138
+ _log(`artifact skipped: outside turn dir "${relPath}"`);
139
+ continue;
140
+ }
141
+ if (!fs.existsSync(resolved)) {
142
+ _log(`artifact skipped: missing file "${relPath}"`);
143
+ continue;
144
+ }
145
+ const realFile = fs.realpathSync(resolved);
146
+ const realRelative = path.relative(realDir, realFile);
147
+ if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) {
148
+ _log(`artifact skipped: symlink outside turn dir "${relPath}"`);
149
+ continue;
150
+ }
151
+ const stat = fs.statSync(realFile);
152
+ if (!stat.isFile()) {
153
+ _log(`artifact skipped: not a file "${relPath}"`);
154
+ continue;
155
+ }
156
+ if (stat.size > ARTIFACT_MAX_BYTES) {
157
+ _log(`artifact skipped: too large "${relPath}" (${stat.size} bytes)`);
158
+ continue;
159
+ }
160
+ const filename = path.basename(realFile);
161
+ const requestedKind = typeof entry?.kind === 'string' ? entry.kind.toLowerCase()
162
+ : typeof entry?.type === 'string' ? entry.type.toLowerCase()
163
+ : '';
164
+ let kind = requestedKind === 'document' ? 'document'
165
+ : requestedKind === 'photo' ? 'photo'
166
+ : isPhotoFilename(filename) ? 'photo' : 'document';
167
+ if (kind === 'photo' && !isPhotoFilename(filename))
168
+ kind = 'document';
169
+ const caption = typeof entry?.caption === 'string' ? entry.caption.trim().slice(0, 1024) || undefined : undefined;
170
+ artifacts.push({ filePath: realFile, filename, kind, caption });
171
+ }
172
+ return artifacts;
173
+ }
174
+ export function buildArtifactPrompt(prompt, artifactDir, manifestPath) {
109
175
  const base = prompt.trim() || 'Please help with this request.';
110
176
  return [
111
177
  base,
@@ -409,12 +475,17 @@ export class TelegramBot extends Bot {
409
475
  const baseArgs = ensureNonInteractiveRestartArgs(bin, rawArgs);
410
476
  const allArgs = [...baseArgs, ...process.argv.slice(2)];
411
477
  this.log(`restart: spawning \`${bin} ${allArgs.join(' ')}\``);
478
+ // Collect all known chat IDs so the new process can send startup notices
479
+ const knownIds = new Set(this.allowedChatIds);
480
+ for (const cid of this.channel.knownChats)
481
+ knownIds.add(cid);
412
482
  const child = spawn(bin, allArgs, {
413
483
  stdio: 'inherit',
414
484
  detached: true,
415
485
  env: {
416
486
  ...process.env,
417
487
  npm_config_yes: process.env.npm_config_yes || 'true',
488
+ ...(knownIds.size ? { TELEGRAM_ALLOWED_CHAT_IDS: [...knownIds].join(',') } : {}),
418
489
  },
419
490
  });
420
491
  child.unref();
@@ -494,68 +565,7 @@ export class TelegramBot extends Bot {
494
565
  fs.rmSync(dirPath, { recursive: true, force: true });
495
566
  }
496
567
  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;
568
+ return collectArtifacts(dirPath, manifestPath, msg => this.log(msg));
559
569
  }
560
570
  async sendArtifacts(ctx, replyTo, artifacts) {
561
571
  for (const artifact of artifacts) {
@@ -648,20 +658,39 @@ export class TelegramBot extends Bot {
648
658
  }
649
659
  }
650
660
  else {
651
- let preview = bodyHtml.slice(0, 3200);
652
- if (bodyHtml.length > 3200)
653
- preview += '\n<i>... (see full response below)</i>';
654
- const previewHtml = `${statusHtml}${thinkingHtml}${preview}\n\n${meta}${tokenBlock}`;
661
+ // Send full content as split plain-text messages instead of a file.
662
+ // First message: edit placeholder with meta + thinking + beginning of body.
663
+ const headerHtml = `${statusHtml}${thinkingHtml}`;
664
+ const footerHtml = `\n\n${meta}${tokenBlock}`;
665
+ const maxFirst = 3900 - headerHtml.length - footerHtml.length;
666
+ let firstBody;
667
+ let remaining;
668
+ if (maxFirst > 200) {
669
+ // find a newline-friendly cut in the HTML body
670
+ let cut = bodyHtml.lastIndexOf('\n', maxFirst);
671
+ if (cut < maxFirst * 0.3)
672
+ cut = maxFirst;
673
+ firstBody = bodyHtml.slice(0, cut);
674
+ remaining = bodyHtml.slice(cut);
675
+ }
676
+ else {
677
+ firstBody = '';
678
+ remaining = bodyHtml;
679
+ }
680
+ const firstHtml = `${headerHtml}${firstBody}${footerHtml}`;
655
681
  try {
656
- await this.channel.editMessage(ctx.chatId, phId, previewHtml, { parseMode: 'HTML', keyboard });
682
+ await this.channel.editMessage(ctx.chatId, phId, firstHtml, { parseMode: 'HTML', keyboard });
657
683
  }
658
684
  catch {
659
- finalMsgId = await this.channel.send(ctx.chatId, previewHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
685
+ finalMsgId = await this.channel.send(ctx.chatId, firstHtml, { parseMode: 'HTML', replyTo: ctx.messageId, keyboard });
686
+ }
687
+ // Send remaining body as continuation messages (split at ~3800 chars)
688
+ if (remaining.trim()) {
689
+ const chunks = splitText(remaining, 3800);
690
+ for (const chunk of chunks) {
691
+ await this.channel.send(ctx.chatId, chunk, { parseMode: 'HTML', replyTo: finalMsgId ?? phId });
692
+ }
660
693
  }
661
- const thinkingMd = result.thinking
662
- ? `> **${thinkLabel(agent)}**\n${result.thinking.split('\n').map(l => `> ${l}`).join('\n')}\n\n---\n\n`
663
- : '';
664
- await this.channel.sendDocument(ctx.chatId, thinkingMd + result.message, `response_${phId}.md`, { caption: `Full response (${result.message.length} chars)`, replyTo: finalMsgId ?? phId });
665
694
  }
666
695
  return finalMsgId;
667
696
  }
package/dist/bot.js CHANGED
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { execSync, spawn } from 'node:child_process';
10
10
  import { doStream, getSessions, getUsage, listAgents, } from './code-agent.js';
11
- export const VERSION = '0.2.4';
11
+ export const VERSION = '0.2.6';
12
12
  // ---------------------------------------------------------------------------
13
13
  // Helpers
14
14
  // ---------------------------------------------------------------------------
@@ -235,9 +235,10 @@ export class Bot {
235
235
  else if (cs.agent === 'codex') {
236
236
  this.log(`[runStream] codex config: model=${this.codexModel} reasoning=${this.codexReasoningEffort} fullAccess=${this.codexFullAccess} extraArgs=[${this.codexExtraArgs.join(' ')}]`);
237
237
  }
238
+ const snapshotSessionId = cs.sessionId;
238
239
  const opts = {
239
240
  agent: cs.agent, prompt, workdir: this.workdir, timeout: this.runTimeout,
240
- sessionId: cs.sessionId, model: null, thinkingEffort: this.codexReasoningEffort, onText,
241
+ sessionId: snapshotSessionId, model: null, thinkingEffort: this.codexReasoningEffort, onText,
241
242
  attachments: attachments.length ? attachments : undefined,
242
243
  codexModel: this.codexModel, codexFullAccess: this.codexFullAccess,
243
244
  codexExtraArgs: this.codexExtraArgs.length ? this.codexExtraArgs : undefined,
@@ -252,7 +253,8 @@ export class Bot {
252
253
  this.stats.totalOutputTokens += result.outputTokens;
253
254
  if (result.cachedInputTokens)
254
255
  this.stats.totalCachedTokens += result.cachedInputTokens;
255
- 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)
256
258
  cs.sessionId = result.sessionId;
257
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)}`);
258
260
  return result;
@@ -25,7 +25,7 @@ async function run(cmd, opts, parseLine) {
25
25
  const proc = spawn(shellCmd, { cwd: opts.workdir, stdio: ['pipe', 'pipe', 'pipe'], shell: true });
26
26
  agentLog(`[spawn] pid=${proc.pid}`);
27
27
  try {
28
- proc.stdin.write(opts.prompt);
28
+ proc.stdin.write(opts._stdinOverride ?? opts.prompt);
29
29
  proc.stdin.end();
30
30
  }
31
31
  catch { }
@@ -147,6 +147,49 @@ export function doCodexStream(opts) {
147
147
  return run(codexCmd(opts), opts, codexParse);
148
148
  }
149
149
  // --- claude ---
150
+ const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
151
+ function mimeForExt(ext) {
152
+ switch (ext) {
153
+ case '.jpg':
154
+ case '.jpeg': return 'image/jpeg';
155
+ case '.png': return 'image/png';
156
+ case '.gif': return 'image/gif';
157
+ case '.webp': return 'image/webp';
158
+ default: return 'application/octet-stream';
159
+ }
160
+ }
161
+ /**
162
+ * Build a stream-json stdin payload that includes images as base64 content
163
+ * blocks alongside the text prompt.
164
+ */
165
+ function buildClaudeMultimodalStdin(prompt, attachments) {
166
+ const content = [];
167
+ for (const filePath of attachments) {
168
+ const ext = path.extname(filePath).toLowerCase();
169
+ if (IMAGE_EXTS.has(ext)) {
170
+ try {
171
+ const data = fs.readFileSync(filePath);
172
+ content.push({
173
+ type: 'image',
174
+ source: { type: 'base64', media_type: mimeForExt(ext), data: data.toString('base64') },
175
+ });
176
+ }
177
+ catch (e) {
178
+ agentLog(`[attach] failed to read image ${filePath}: ${e.message}`);
179
+ }
180
+ }
181
+ else {
182
+ // For non-image files, tell Claude the path so it can Read it
183
+ content.push({ type: 'text', text: `[Attached file: ${filePath}]` });
184
+ }
185
+ }
186
+ content.push({ type: 'text', text: prompt });
187
+ const msg = {
188
+ type: 'user',
189
+ message: { role: 'user', content },
190
+ };
191
+ return JSON.stringify(msg) + '\n';
192
+ }
150
193
  function claudeCmd(o) {
151
194
  const args = ['claude', '-p', '--verbose', '--output-format', 'stream-json', '--include-partial-messages'];
152
195
  if (o.claudeModel)
@@ -156,8 +199,8 @@ function claudeCmd(o) {
156
199
  if (o.sessionId)
157
200
  args.push('--resume', o.sessionId);
158
201
  if (o.attachments?.length) {
159
- for (const f of o.attachments)
160
- args.push('--input-file', f);
202
+ args.push('--input-format', 'stream-json');
203
+ o._stdinOverride = buildClaudeMultimodalStdin(o.prompt, o.attachments);
161
204
  }
162
205
  if (o.claudeExtraArgs?.length)
163
206
  args.push(...o.claudeExtraArgs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeclaw",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "The best IM-driven remote coding experience. Bridge AI coding agents to any IM.",
5
5
  "type": "module",
6
6
  "bin": {