pikiclaw 0.2.35 → 0.2.37

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
@@ -40,6 +40,19 @@ pikiclaw 的思路不同:**只挑最好的,然后把它们组合到极致。
40
40
  └──────── 流式进度 / 文件 / 截图 ←──────────┘
41
41
  ```
42
42
 
43
+ pikiclaw 不是另一个终端包装器,也不是另一个云端 IDE。
44
+
45
+ 它更像一个让官方 coding agent 变得**可远程调度、可持续运行、可回传结果**的本地执行中枢。
46
+
47
+ 当你需要:
48
+
49
+ - 在手机上发一句话就能派活
50
+ - 任务必须跑在你自己的电脑、现有代码库和本地工具链里
51
+ - 想在 Claude Code / Codex CLI / Gemini CLI 之间自由切换
52
+ - 希望进度、截图、文件自动回到聊天
53
+
54
+ pikiclaw 会比“守在终端里”“SSH + tmux”或“单厂商云端 agent”更顺。
55
+
43
56
  ---
44
57
 
45
58
  ## Quick Start
@@ -48,7 +61,7 @@ pikiclaw 的思路不同:**只挑最好的,然后把它们组合到极致。
48
61
 
49
62
  - Node.js 18+
50
63
  - 本机已安装 [`claude`](https://docs.anthropic.com/en/docs/claude-code)、[`codex`](https://github.com/openai/codex) 或 [`gemini`](https://github.com/google-gemini/gemini-cli) 中的任意一个
51
- - 一个 [Telegram Bot Token](https://t.me/BotFather) 或[飞书应用](https://open.feishu.cn)凭证
64
+ - 一个 [Telegram Bot Token](https://t.me/BotFather) 或 [飞书应用](https://open.feishu.cn) 凭证
52
65
 
53
66
  ### 一行启动
54
67
 
@@ -87,10 +100,10 @@ npx pikiclaw@latest --setup
87
100
 
88
101
  ### IM Channels
89
102
 
90
- | 渠道 | 消息编辑 | 文件上传 | 回调按钮 | 表情回应 | 消息线程 |
91
- |------|---------|---------|---------|---------|---------|
92
- | **Telegram** | ✅ | ✅ | ✅ | | |
93
- | **飞书** | ✅ | ✅ | ✅ | ✅ | |
103
+ | 渠道 | 消息编辑 | 文件收发 | 回调按钮 | 命令菜单 | 场景 |
104
+ |------|---------|---------|---------|---------|------|
105
+ | **Telegram** | ✅ | ✅ | ✅ | | 全球 / 个人 |
106
+ | **飞书** | ✅ | ✅ | ✅ | ✅ | 国内 / 团队 |
94
107
 
95
108
  两个渠道可以**同时启动**。
96
109
 
@@ -99,16 +112,15 @@ npx pikiclaw@latest --setup
99
112
  | 能力 | 说明 |
100
113
  |------|------|
101
114
  | 实时流式输出 | Agent 工作时消息持续更新 |
102
- | Thinking / Reasoning | 实时查看 Agent 的思考和推理过程 |
115
+ | Thinking / Reasoning / Plan | 实时查看 Agent 的思考、推理和计划步骤 |
103
116
  | Token 追踪 | 输入/输出/缓存统计,上下文使用率实时显示 |
104
- | 产物回传 | 截图、日志、生成文件自动发回 |
105
- | 长程防休眠 | 系统级防休眠,小时级任务不中断 |
106
- | 守护进程 | 崩溃自动重启,指数退避(3s → 60s) |
117
+ | 产物回传 | 截图、日志、生成文件自动发回聊天 |
118
+ | 长程任务保障 | 系统级防休眠 + 守护进程 + 异常自愈 |
107
119
  | 长文本处理 | 超长输出自动拆分或打包为 `.md` |
108
120
  | 多会话管理 | 随时切换、恢复历史会话 |
109
121
  | 图片/文件输入 | 截图、PDF、文档直接发给 Agent |
110
122
  | 项目 Skills | `.pikiclaw/skills/` 自定义技能,兼容 `.claude/commands/` |
111
- | 安全模式 | 危险操作推送确认卡片,白名单访问控制 |
123
+ | 安全模式 | 白名单访问控制,支持切换更安全的 agent 权限模式 |
112
124
  | Web Dashboard | 可视化配置、会话浏览、主机监控 |
113
125
 
114
126
  ---
@@ -134,24 +146,23 @@ npx pikiclaw@latest --setup
134
146
 
135
147
  | | 终端直接跑 | SSH + tmux | 云端 Agent | **pikiclaw** |
136
148
  |---|---|---|---|---|
137
- | 执行环境 | ✅ 本地 | ✅ 本地 | 沙盒 | ✅ 本地 |
149
+ | 执行环境 | ✅ 本地 | ✅ 本地 | ⚠️ 通常是远端或沙盒 | ✅ 本地 |
138
150
  | 走开后还能跑 | ❌ 合盖就断 | ⚠️ 要配 tmux | ✅ | ✅ 防休眠 + 守护进程 |
139
151
  | 手机可控 | ❌ | ⚠️ 打字痛苦 | ✅ | ✅ IM 原生 |
140
- | 实时看进度 | ✅ 终端 | ⚠️ 得连上去看 | 多数是黑盒 | ✅ 流式推到聊天 |
152
+ | 实时看进度 | ✅ 终端 | ⚠️ 得连上去看 | ⚠️ 依平台而定 | ✅ 流式推到聊天 |
141
153
  | 结果自动回传 | ❌ | ❌ | ⚠️ 看平台 | ✅ 截图/文件/长文本 |
142
- | 配置门槛 | 无 | SSH/穿透/tmux | 注册/付费 | `npx` 一行 |
154
+ | 配置门槛 | 无 | SSH/穿透/tmux | 注册并适应平台工作流 | `npx` 一行 |
143
155
 
144
- ### pikiclaw vs. 同类项目
156
+ ### pikiclaw vs. OpenClaw / 官方入口
145
157
 
146
- | | **pikiclaw** | OpenClaw | cc-connect |
158
+ | 维度 | **pikiclaw** | OpenClaw | 官方入口(Claude / Codex / Gemini) |
147
159
  |---|---|---|---|
148
- | **理念** | **精选最好的工具,组合到极致** | 开源自主 AI 智能体生态 | 多渠道多端连接器 |
149
- | **Agent** | Claude Code / Codex / Gemini CLI(官方出品) | 内置 Agent(自接模型) | 多种本地 CLI |
150
- | **IM** | Telegram + 飞书(深度打磨) | Web / 移动端 | Slack / Discord / LINE 等 |
151
- | **长程任务** | 防休眠 · 守护进程 · 异常自愈 | 偏即时任务 | 偏短对话 |
152
- | **产物回传** | 截图 · 文件 · 长文本打包 | ⚠️ 依赖客户端 | ⚠️ 基础附件 |
153
- | **流式体验** | IM 内实时流式 | | ⚠️ 看桥接能力 |
154
- | **上手成本** | **一行 `npx`** | 需部署后端 | 需安装服务端 |
160
+ | 产品层 | IM 驱动的本地 agent 控制平面 | 通用个人 AI 助手 / 多渠道生态 | 单一厂商的原生 agent 入口 |
161
+ | Agent 策略 | 复用官方 CLI,吃到各家最新能力 | 自带 runtime / agent stack | 只服务自家模型 |
162
+ | 执行环境 | 你的电脑 | 个人设备网络 / 本地节点 | 本地 CLI 或厂商云 |
163
+ | 渠道策略 | Telegram + 飞书深度打磨 | 广覆盖 | Web / App / Slack / CLI 为主 |
164
+ | 锁定程度 | 低,可随时切换引擎 | | |
165
+ | 最强场景 | 远程 coding、长任务、本地自动化 | 全能个人助手 | 原生模型体验、企业集成 |
155
166
 
156
167
  ---
157
168
 
@@ -159,6 +170,8 @@ npx pikiclaw@latest --setup
159
170
 
160
171
  pikiclaw 不限于编程。你的 Agent 能做什么,pikiclaw 就能远程调度什么。
161
172
 
173
+ 它尤其适合那些**必须在你自己的环境里执行**、同时又希望**进度和结果直接回到 IM** 的任务。
174
+
162
175
  **工程重构** — "把整个项目从 JS 迁移到 TS,跑测试直到全部通过。搞定告诉我。"
163
176
 
164
177
  **文档处理** — "把 docs/ 下所有零散文档整理汇总,提取核心指标,输出一份报告。"
@@ -298,7 +311,7 @@ npx pikiclaw@latest --doctor # 检查环境
298
311
  ## Development
299
312
 
300
313
  ```bash
301
- git clone https://github.com/nicepkg/pikiclaw.git
314
+ git clone https://github.com/xiaotonng/pikiclaw.git
302
315
  cd pikiclaw
303
316
  npm install
304
317
  echo "TELEGRAM_BOT_TOKEN=your_token" > .env
@@ -176,7 +176,7 @@ export function buildStreamPreviewMarkdown(input) {
176
176
  const thinkDisplay = formatThinkingForDisplay(input.thinking, maxBody);
177
177
  const planDisplay = renderPlanForPreview(input.plan ?? null);
178
178
  const activityDisplay = summarizeActivityForPreview(input.activity);
179
- const maxActivity = !display && !thinkDisplay && !planDisplay ? 1800 : 900;
179
+ const maxActivity = !display && !thinkDisplay && !planDisplay ? 2400 : 1400;
180
180
  const parts = [];
181
181
  const label = thinkLabel(input.agent);
182
182
  if (planDisplay) {
@@ -189,8 +189,10 @@ export function buildStreamPreviewMarkdown(input) {
189
189
  parts.push(`**${label}**\n${thinkDisplay}`);
190
190
  }
191
191
  else if (display) {
192
- if (rawThinking)
193
- parts.push(`*${label} (${rawThinking.length} chars)*`);
192
+ if (rawThinking) {
193
+ const thinkSnippet = formatThinkingForDisplay(input.thinking, 600);
194
+ parts.push(`**${label}**\n${thinkSnippet}`);
195
+ }
194
196
  const preview = display.length > maxBody ? '(...truncated)\n' + display.slice(-maxBody) : display;
195
197
  parts.push(preview);
196
198
  }
@@ -211,8 +213,8 @@ export function buildFinalReplyRender(agent, result) {
211
213
  const narrative = summary.narrative.join('\n');
212
214
  if (narrative) {
213
215
  let display = narrative;
214
- if (display.length > 800)
215
- display = '...\n' + display.slice(-800);
216
+ if (display.length > 1600)
217
+ display = '...\n' + display.slice(-1600);
216
218
  activityText = `**Activity**\n${display}\n\n`;
217
219
  }
218
220
  const commandSummary = formatActivityCommandSummary(summary.completedCommands, summary.activeCommands, summary.failedCommands);
@@ -221,7 +223,7 @@ export function buildFinalReplyRender(agent, result) {
221
223
  }
222
224
  let thinkingText = '';
223
225
  if (result.thinking) {
224
- thinkingText = `**${thinkLabel(agent)}**\n${formatThinkingForDisplay(result.thinking, 800)}\n\n`;
226
+ thinkingText = `**${thinkLabel(agent)}**\n${formatThinkingForDisplay(result.thinking, 1600)}\n\n`;
225
227
  }
226
228
  let statusText = '';
227
229
  if (result.incomplete) {
@@ -437,14 +437,19 @@ export class FeishuBot extends Bot {
437
437
  livePreview?.update(nextText, nextThinking, nextActivity, meta, plan);
438
438
  }, undefined, mcpSendFile);
439
439
  await livePreview?.settle();
440
- // End streaming mode — finalize the card before sending final reply
441
- if (placeholderId) {
442
- const summary = result.message.slice(0, 80).replace(/\s+/g, ' ').trim() || 'Response complete.';
443
- await this.channel.endStreaming(placeholderId, summary);
444
- }
445
440
  this.log(`[handleMessage] done agent=${session.agent} ok=${result.ok} elapsed=${result.elapsedS.toFixed(1)}s ` +
446
441
  `tokens=in:${fmtTokens(result.inputTokens)}/out:${fmtTokens(result.outputTokens)}`);
447
- const finalReplyIds = await this.sendFinalReply(ctx, placeholderId, session.agent, result);
442
+ // For streaming cards: push final content via CardKit before ending stream,
443
+ // then send any overflow chunks as new messages.
444
+ // Regular cards: edit placeholder with final content as before.
445
+ const wasStreaming = placeholderId && this.channel.isStreamingCard(placeholderId);
446
+ let finalReplyIds;
447
+ if (wasStreaming) {
448
+ finalReplyIds = await this.finalizeStreamingCard(ctx, placeholderId, session.agent, result);
449
+ }
450
+ else {
451
+ finalReplyIds = await this.sendFinalReply(ctx, placeholderId, session.agent, result);
452
+ }
448
453
  this.registerSessionMessages(ctx.chatId, finalReplyIds, session);
449
454
  this.log(`[handleMessage] final reply sent to chat=${ctx.chatId}`);
450
455
  }
@@ -455,6 +460,10 @@ export class FeishuBot extends Bot {
455
460
  if (placeholderId) {
456
461
  try {
457
462
  await this.channel.editMessage(ctx.chatId, placeholderId, errorText);
463
+ // End streaming if this was a streaming card
464
+ if (this.channel.isStreamingCard(placeholderId)) {
465
+ await this.channel.endStreaming(placeholderId, 'Error');
466
+ }
458
467
  }
459
468
  catch {
460
469
  await this.channel.send(ctx.chatId, errorText).catch(() => null);
@@ -474,6 +483,51 @@ export class FeishuBot extends Bot {
474
483
  this.finishTask(taskId);
475
484
  });
476
485
  }
486
+ /**
487
+ * Finalize a streaming card: push final content via CardKit, end streaming,
488
+ * then send any overflow chunks as new messages.
489
+ * This avoids the "schemaV2 card can not change schemaV1" error that occurs
490
+ * when trying to PATCH a CardKit v2 card with a v1 interactive card.
491
+ */
492
+ async finalizeStreamingCard(ctx, placeholderId, agent, result) {
493
+ const rendered = buildFinalReplyRender(agent, result);
494
+ const messageIds = [placeholderId];
495
+ const MAX_CARD = 25_000;
496
+ let firstText;
497
+ let remaining = '';
498
+ if (rendered.fullText.length <= MAX_CARD) {
499
+ firstText = rendered.fullText;
500
+ }
501
+ else {
502
+ const maxFirst = MAX_CARD - rendered.headerText.length - rendered.footerText.length;
503
+ if (maxFirst > 200) {
504
+ let cut = rendered.bodyText.lastIndexOf('\n', maxFirst);
505
+ if (cut < maxFirst * 0.3)
506
+ cut = maxFirst;
507
+ firstText = `${rendered.headerText}${rendered.bodyText.slice(0, cut)}${rendered.footerText}`;
508
+ remaining = rendered.bodyText.slice(cut);
509
+ }
510
+ else {
511
+ firstText = `${rendered.headerText}${rendered.footerText}`;
512
+ remaining = rendered.bodyText;
513
+ }
514
+ }
515
+ // Push final content while card is still in streaming mode (CardKit v2 API)
516
+ await this.channel.editMessage(ctx.chatId, placeholderId, firstText);
517
+ // Finalize the streaming card
518
+ const summary = result.message.slice(0, 80).replace(/\s+/g, ' ').trim() || 'Response complete.';
519
+ await this.channel.endStreaming(placeholderId, summary);
520
+ // Send overflow chunks as new messages
521
+ if (remaining.trim()) {
522
+ const chunks = splitText(remaining, MAX_CARD);
523
+ for (const chunk of chunks) {
524
+ const sent = await this.channel.send(ctx.chatId, chunk);
525
+ if (sent)
526
+ messageIds.push(sent);
527
+ }
528
+ }
529
+ return messageIds;
530
+ }
477
531
  async sendFinalReply(ctx, placeholderId, agent, result) {
478
532
  const rendered = buildFinalReplyRender(agent, result);
479
533
  const messageIds = [];
@@ -542,6 +596,7 @@ export class FeishuBot extends Bot {
542
596
  try {
543
597
  await this.channel.sendFile(ctx.chatId, filePath, {
544
598
  caption: opts?.caption,
599
+ replyTo: ctx.messageId,
545
600
  asPhoto: opts?.kind === 'photo',
546
601
  });
547
602
  return { ok: true };
@@ -298,7 +298,7 @@ export function buildStreamPreviewHtml(input) {
298
298
  const thinkDisplay = formatThinkingForDisplay(input.thinking, maxBody);
299
299
  const planDisplay = renderPlanForPreview(input.plan ?? null);
300
300
  const activityDisplay = summarizeActivityForPreview(input.activity);
301
- const maxActivity = !display && !thinkDisplay && !planDisplay ? 1800 : 900;
301
+ const maxActivity = !display && !thinkDisplay && !planDisplay ? 2400 : 1400;
302
302
  const parts = [];
303
303
  const label = thinkLabel(input.agent);
304
304
  if (planDisplay) {
@@ -311,8 +311,10 @@ export function buildStreamPreviewHtml(input) {
311
311
  parts.push(`<blockquote><b>${escapeHtml(label)}</b>\n${escapeHtml(thinkDisplay)}</blockquote>`);
312
312
  }
313
313
  else if (display) {
314
- if (rawThinking)
315
- parts.push(`<i>${escapeHtml(`${label} (${rawThinking.length} chars)`)}</i>`);
314
+ if (rawThinking) {
315
+ const thinkSnippet = formatThinkingForDisplay(input.thinking, 600);
316
+ parts.push(`<blockquote><b>${escapeHtml(label)}</b>\n${escapeHtml(thinkSnippet)}</blockquote>`);
317
+ }
316
318
  const preview = display.length > maxBody ? '(...truncated)\n' + display.slice(-maxBody) : display;
317
319
  parts.push(mdToTgHtml(preview));
318
320
  }
@@ -329,8 +331,8 @@ export function buildFinalReplyRender(agent, result) {
329
331
  const narrative = summary.narrative.join('\n');
330
332
  if (narrative) {
331
333
  let display = narrative;
332
- if (display.length > 800)
333
- display = '...\n' + display.slice(-800);
334
+ if (display.length > 1600)
335
+ display = '...\n' + display.slice(-1600);
334
336
  activityHtml = `<blockquote><b>Activity</b>\n${escapeHtml(display)}</blockquote>\n\n`;
335
337
  }
336
338
  const commandSummary = formatActivityCommandSummary(summary.completedCommands, summary.activeCommands, summary.failedCommands);
@@ -339,7 +341,7 @@ export function buildFinalReplyRender(agent, result) {
339
341
  }
340
342
  let thinkingHtml = '';
341
343
  if (result.thinking) {
342
- thinkingHtml = `<blockquote><b>${thinkLabel(agent)}</b>\n${escapeHtml(formatThinkingForDisplay(result.thinking, 800))}</blockquote>\n\n`;
344
+ thinkingHtml = `<blockquote><b>${thinkLabel(agent)}</b>\n${escapeHtml(formatThinkingForDisplay(result.thinking, 1600))}</blockquote>\n\n`;
343
345
  }
344
346
  let statusHtml = '';
345
347
  if (result.incomplete) {
package/dist/bot.js CHANGED
@@ -8,20 +8,20 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { execSync, spawn } from 'node:child_process';
10
10
  import { getActiveUserConfig, onUserConfigChange, resolveUserWorkdir, setUserWorkdir } from './user-config.js';
11
- import { doStream, getSessions, getSessionTail, getUsage, initializeProjectSkills, listAgents, listModels, listSkills, } from './code-agent.js';
11
+ import { doStream, getSessions, getSessionTail, getUsage, initializeProjectSkills, listAgents, listModels, listSkills, isPendingSessionId, } from './code-agent.js';
12
12
  import { getDriver, hasDriver, allDriverIds } from './agent-driver.js';
13
13
  import { terminateProcessTree } from './process-control.js';
14
- export const VERSION = '0.2.35';
14
+ export const VERSION = '0.2.37';
15
15
  const MACOS_USER_ACTIVITY_PULSE_INTERVAL_MS = 20_000;
16
16
  const MACOS_USER_ACTIVITY_PULSE_TIMEOUT_S = 30;
17
17
  // ---------------------------------------------------------------------------
18
18
  // Helpers
19
19
  // ---------------------------------------------------------------------------
20
20
  /**
21
- * If `dir` has a .gitignore, ensure runtime state is ignored while `.pikiclaw/skills`
22
- * stays trackable as the canonical project skill location.
21
+ * If `dir` has a .gitignore, ignore managed `.pikiclaw` state without hiding
22
+ * `.pikiclaw/skills`, which may be committed as project skills.
23
23
  */
24
- function ensureGitignore(dir) {
24
+ export function ensureGitignore(dir) {
25
25
  try {
26
26
  const gi = path.join(dir, '.gitignore');
27
27
  if (!fs.existsSync(gi))
@@ -30,10 +30,12 @@ function ensureGitignore(dir) {
30
30
  '.pikiclaw/*',
31
31
  '!.pikiclaw/skills/',
32
32
  '!.pikiclaw/skills/**',
33
+ ];
34
+ const legacyLines = new Set([
35
+ '.pikiclaw/',
33
36
  '.claude/skills/',
34
37
  '.agents/skills/',
35
- ];
36
- const legacyLines = new Set(['.pikiclaw/']);
38
+ ]);
37
39
  const rawLines = fs.readFileSync(gi, 'utf8').split(/\r?\n/);
38
40
  const normalized = rawLines.filter(line => {
39
41
  const trimmed = line.trim();
@@ -171,25 +173,19 @@ export function thinkLabel(agent) {
171
173
  return 'Thinking';
172
174
  }
173
175
  }
174
- export function extractThinkingTail(text, maxLines = 3) {
176
+ export function extractThinkingTail(text, maxLines = 10) {
175
177
  const normalized = text.replace(/\r\n?/g, '\n').trim();
176
178
  if (!normalized)
177
179
  return '';
178
- const blocks = normalized
179
- .split(/\n\s*\n+/)
180
- .map(block => block.trim())
181
- .filter(Boolean);
182
- if (blocks.length > 1)
183
- return blocks[blocks.length - 1];
184
180
  const lines = normalized
185
181
  .split('\n')
186
182
  .map(line => line.trimEnd())
187
183
  .filter(line => line.trim());
188
- if (lines.length > 1)
189
- return lines.slice(-Math.min(maxLines, lines.length)).join('\n').trim();
184
+ if (lines.length > maxLines)
185
+ return lines.slice(-maxLines).join('\n').trim();
190
186
  return normalized;
191
187
  }
192
- export function formatThinkingForDisplay(text, maxChars = 800) {
188
+ export function formatThinkingForDisplay(text, maxChars = 1600) {
193
189
  let display = extractThinkingTail(text);
194
190
  if (display.length > maxChars)
195
191
  display = '...\n' + display.slice(-maxChars);
@@ -200,6 +196,21 @@ export function buildPrompt(text, files) {
200
196
  return text;
201
197
  return `${text || 'Please analyze this.'}\n\n[Files: ${files.map(f => path.basename(f)).join(', ')}]`;
202
198
  }
199
+ function appendExtraPrompt(base, extra) {
200
+ const lhs = String(base || '').trim();
201
+ const rhs = String(extra || '').trim();
202
+ if (!lhs)
203
+ return rhs;
204
+ if (!rhs)
205
+ return lhs;
206
+ return `${lhs}\n\n${rhs}`;
207
+ }
208
+ function buildMcpDeliveryPrompt() {
209
+ return [
210
+ '[Artifact Return]',
211
+ 'This is an IM conversation, so pay attention to the IM tools.',
212
+ ].join('\n');
213
+ }
203
214
  function configModelValue(config, agent) {
204
215
  switch (agent) {
205
216
  case 'claude': return String(config.claudeModel || process.env.CLAUDE_MODEL || 'claude-opus-4-6').trim();
@@ -791,6 +802,10 @@ export class Bot {
791
802
  const extraArgs = agentConfig.extraArgs || [];
792
803
  this.log(`[runStream] agent=${cs.agent} session=${cs.sessionId || '(new)'} workdir=${this.workdir} timeout=${this.runTimeout}s attachments=${attachments.length}`);
793
804
  this.log(`[runStream] ${cs.agent} config: model=${resolvedModel} extraArgs=[${extraArgs.join(' ')}]`);
805
+ const isFirstTurnOfSession = !cs.sessionId || isPendingSessionId(cs.sessionId);
806
+ const effectiveSystemPrompt = isFirstTurnOfSession
807
+ ? (mcpSendFile ? appendExtraPrompt(systemPrompt, buildMcpDeliveryPrompt()) : systemPrompt)
808
+ : undefined;
794
809
  const opts = {
795
810
  agent: cs.agent, prompt, workdir: this.workdir, timeout: this.runTimeout,
796
811
  sessionId: cs.sessionId, model: null,
@@ -799,13 +814,13 @@ export class Bot {
799
814
  // codex-specific
800
815
  codexModel: cs.agent === 'codex' ? resolvedModel : this.codexModel,
801
816
  codexFullAccess: this.codexFullAccess,
802
- codexDeveloperInstructions: systemPrompt || undefined,
817
+ codexDeveloperInstructions: effectiveSystemPrompt || undefined,
803
818
  codexExtraArgs: this.codexExtraArgs.length ? this.codexExtraArgs : undefined,
804
819
  codexPrevCumulative: cs.codexCumulative,
805
820
  // claude-specific
806
821
  claudeModel: cs.agent === 'claude' ? resolvedModel : this.claudeModel,
807
822
  claudePermissionMode: this.claudePermissionMode,
808
- claudeAppendSystemPrompt: systemPrompt || undefined,
823
+ claudeAppendSystemPrompt: effectiveSystemPrompt || undefined,
809
824
  claudeExtraArgs: this.claudeExtraArgs.length ? this.claudeExtraArgs : undefined,
810
825
  // gemini-specific
811
826
  geminiModel: cs.agent === 'gemini' ? resolvedModel : (this.agentConfigs.gemini?.model || ''),
@@ -15,6 +15,7 @@ import path from 'node:path';
15
15
  import { Channel, DEFAULT_CHANNEL_CAPABILITIES, sleep, } from './channel-base.js';
16
16
  export { FeishuChannel };
17
17
  const FEISHU_CARD_MAX = 28_000; // card markdown budget (card JSON limit ~30KB)
18
+ const FILE_MAX_BYTES = 20 * 1024 * 1024; // 20MB max for file send/receive
18
19
  const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
19
20
  const FEISHU_WS_START_RETRY_MAX_DELAY_MS = 60_000;
20
21
  function describeError(err) {
@@ -647,7 +648,8 @@ class FeishuChannel extends Channel {
647
648
  throw new Error('no card_id returned');
648
649
  }
649
650
  catch (e) {
650
- this._log(`[streaming] CardKit create failed: ${e?.message || e}, falling back to regular card`);
651
+ const detail = e?.response?.data ? ` resp=${JSON.stringify(e.response.data)}` : '';
652
+ this._log(`[streaming] CardKit create failed: ${e?.message || e}${detail}, falling back to regular card`);
651
653
  return this.send(chatId, initialContent);
652
654
  }
653
655
  // Step 2: Send card as message (reply to user's message if replyTo is set)
@@ -679,6 +681,10 @@ class FeishuChannel extends Channel {
679
681
  return this.send(chatId, initialContent);
680
682
  }
681
683
  }
684
+ /** Check if a message is currently a streaming card (CardKit v2). */
685
+ isStreamingCard(messageId) {
686
+ return this.cardStates.has(messageId);
687
+ }
682
688
  /**
683
689
  * End streaming mode on a card and finalize it.
684
690
  * After this, `editMessage()` falls through to the regular PATCH path.
@@ -759,20 +765,21 @@ class FeishuChannel extends Channel {
759
765
  }
760
766
  /** Upload and send a local file. */
761
767
  async sendFile(chatId, filePath, opts = {}) {
768
+ const stat = fs.statSync(filePath);
769
+ if (stat.size > FILE_MAX_BYTES) {
770
+ throw new Error(`file too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${FILE_MAX_BYTES / 1024 / 1024}MB)`);
771
+ }
762
772
  const content = fs.readFileSync(filePath);
763
773
  const filename = path.basename(filePath);
764
774
  const isPhoto = opts.asPhoto ?? PHOTO_EXTS.has(path.extname(filename).toLowerCase());
775
+ const replyTo = opts.replyTo ? String(opts.replyTo) : undefined;
765
776
  if (isPhoto) {
766
777
  try {
767
778
  const imageKey = await this.uploadImage(content);
768
- const resp = await this.client.im.message.create({
769
- params: { receive_id_type: 'chat_id' },
770
- data: {
771
- receive_id: String(chatId),
772
- msg_type: 'image',
773
- content: JSON.stringify({ image_key: imageKey }),
774
- },
775
- });
779
+ const msgContent = JSON.stringify({ image_key: imageKey });
780
+ const resp = replyTo
781
+ ? await this.client.im.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'image', content: msgContent } })
782
+ : await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: String(chatId), msg_type: 'image', content: msgContent } });
776
783
  return resp?.data?.message_id ?? null;
777
784
  }
778
785
  catch (err) {
@@ -782,14 +789,10 @@ class FeishuChannel extends Channel {
782
789
  }
783
790
  }
784
791
  const fileKey = await this.uploadFile(content, filename);
785
- const resp = await this.client.im.message.create({
786
- params: { receive_id_type: 'chat_id' },
787
- data: {
788
- receive_id: String(chatId),
789
- msg_type: 'file',
790
- content: JSON.stringify({ file_key: fileKey }),
791
- },
792
- });
792
+ const msgContent = JSON.stringify({ file_key: fileKey });
793
+ const resp = replyTo
794
+ ? await this.client.im.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'file', content: msgContent } })
795
+ : await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: String(chatId), msg_type: 'file', content: msgContent } });
793
796
  return resp?.data?.message_id ?? null;
794
797
  }
795
798
  // ========================================================================
@@ -805,6 +808,12 @@ class FeishuChannel extends Channel {
805
808
  const localPath = path.join(this.workdir, `_feishu_${name}`);
806
809
  fs.mkdirSync(this.workdir, { recursive: true });
807
810
  await resp.writeFile(localPath);
811
+ // Check downloaded file size
812
+ const stat = fs.statSync(localPath);
813
+ if (stat.size > FILE_MAX_BYTES) {
814
+ fs.rmSync(localPath, { force: true });
815
+ throw new Error(`file too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${FILE_MAX_BYTES / 1024 / 1024}MB)`);
816
+ }
808
817
  return localPath;
809
818
  }
810
819
  // ========================================================================
@@ -63,6 +63,7 @@ import { Channel, DEFAULT_CHANNEL_CAPABILITIES, splitText, sleep, } from './chan
63
63
  setGlobalDispatcher(new EnvHttpProxyAgent());
64
64
  export { TelegramChannel };
65
65
  const TG_MAX = 4096;
66
+ const FILE_MAX_BYTES = 20 * 1024 * 1024; // 20MB max for file send/receive
66
67
  const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
67
68
  function previewText(value, max = 280) {
68
69
  const normalized = value.replace(/\s+/g, ' ').trim();
@@ -489,6 +490,10 @@ class TelegramChannel extends Channel {
489
490
  return data?.result?.message_id ?? null;
490
491
  }
491
492
  async sendFile(chatId, filePath, opts = {}) {
493
+ const stat = fs.statSync(filePath);
494
+ if (stat.size > FILE_MAX_BYTES) {
495
+ throw new Error(`file too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${FILE_MAX_BYTES / 1024 / 1024}MB)`);
496
+ }
492
497
  const content = fs.readFileSync(filePath);
493
498
  const filename = path.basename(filePath);
494
499
  const wantsPhoto = opts.asPhoto ?? PHOTO_EXTS.has(path.extname(filename).toLowerCase());
@@ -585,6 +590,12 @@ class TelegramChannel extends Channel {
585
590
  const buf = Buffer.from(await resp.arrayBuffer());
586
591
  fs.writeFileSync(localPath, buf);
587
592
  }
593
+ // Check downloaded file size
594
+ const stat = fs.statSync(localPath);
595
+ if (stat.size > FILE_MAX_BYTES) {
596
+ fs.rmSync(localPath, { force: true });
597
+ throw new Error(`file too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max ${FILE_MAX_BYTES / 1024 / 1024}MB)`);
598
+ }
588
599
  return localPath;
589
600
  }
590
601
  // ========================================================================