pikiclaw 0.2.41 → 0.2.42

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
@@ -2,10 +2,9 @@
2
2
 
3
3
  # pikiclaw
4
4
 
5
- **One command. Turn any computer into a world-class productivity machine.**
5
+ **Run Claude Code / Codex / Gemini on your own computer from Telegram or Feishu.**
6
6
 
7
- *一行命令,让你的老电脑变成世界顶级生产力。*
8
- *最好的 IM(Telegram / 飞书)× 最强的 Agent(Claude Code / Codex / Gemini CLI)× 你自己的电脑。*
7
+ *把 IM 变成你电脑上的远程 Agent 控制台。*
9
8
 
10
9
  ```bash
11
10
  npx pikiclaw@latest
@@ -15,43 +14,38 @@ npx pikiclaw@latest
15
14
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
16
15
  [![Node.js 18+](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org)
17
16
 
18
- <!-- TODO: 替换为实际 demo GIF -->
19
- <!-- ![demo](docs/assets/demo.gif) -->
20
-
21
17
  </div>
22
18
 
23
19
  ---
24
20
 
25
21
  ## Why pikiclaw?
26
22
 
27
- 市面上有很多 IM-to-Agent 桥接方案。它们要么自己造引擎(质量不如官方),要么什么都接(质量参差不齐)。
23
+ 很多“IM Agent”的方案,本质上还是在绕路:
28
24
 
29
- pikiclaw 的思路不同:**只挑最好的,然后把它们组合到极致。**
25
+ - 要么自己造 Agent,效果不如官方 CLI
26
+ - 要么跑在远端沙盒里,不是你的环境
27
+ - 要么只能短对话,不适合长任务
30
28
 
31
- - **最好的 Agent** — Claude Code、Codex CLI、Gemini CLI,都是各家官方出品,不造轮子
32
- - **最好的 IM** — Telegram(全球)+ 飞书(国内),不追求数量,每个都打磨到位
33
- - **最好的执行环境** — 你自己的电脑,不是云端沙盒,什么都能干
29
+ pikiclaw 的目标很直接:
34
30
 
35
- 结果就是:你在手机上发一句话,你的电脑就开始干活——小到改一行代码,大到通宵重构整个项目、整理几十份文档、跑完所有测试。
31
+ - 用官方 Agent CLI,而不是重新发明一套
32
+ - 用你自己的电脑,而不是陌生沙盒
33
+ - 用你已经在用的 IM,而不是再学一套远程控制方式
36
34
 
37
35
  ```
38
- 你(手机 IM)──→ pikiclaw ──→ 本地 Agent ──→ 你的电脑
39
-
40
- └──────── 流式进度 / 文件 / 截图 ←──────────┘
36
+ 你(Telegram / 飞书)
37
+
38
+
39
+ pikiclaw
40
+
41
+
42
+ Claude Code / Codex / Gemini
43
+
44
+
45
+ 你的电脑
41
46
  ```
42
47
 
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”更顺。
48
+ 它适合的不是“演示一次 AI”,而是你离开电脑以后,Agent 还能继续在本机把事做完。
55
49
 
56
50
  ---
57
51
 
@@ -60,251 +54,142 @@ pikiclaw 会比“守在终端里”“SSH + tmux”或“单厂商云端 agent
60
54
  ### 准备
61
55
 
62
56
  - Node.js 18+
63
- - 本机已安装 [`claude`](https://docs.anthropic.com/en/docs/claude-code)、[`codex`](https://github.com/openai/codex) 或 [`gemini`](https://github.com/google-gemini/gemini-cli) 中的任意一个
64
- - 一个 [Telegram Bot Token](https://t.me/BotFather) 或 [飞书应用](https://open.feishu.cn) 凭证
57
+ - 本机已安装并登录任意一个 Agent CLI
58
+ - [`claude`](https://docs.anthropic.com/en/docs/claude-code)
59
+ - [`codex`](https://github.com/openai/codex)
60
+ - [`gemini`](https://github.com/google-gemini/gemini-cli)
61
+ - Telegram Bot Token 或飞书应用凭证
65
62
 
66
- ### 一行启动
63
+ ### 启动
67
64
 
68
65
  ```bash
69
- cd your-workspace/
66
+ cd your-workspace
70
67
  npx pikiclaw@latest
71
68
  ```
72
69
 
73
- 启动后自动打开 **Web Dashboard**(`localhost:3939`),引导你完成全部配置。也可以用终端向导:
70
+ 默认会打开 Web Dashboard:`http://localhost:3939`
71
+
72
+ 你可以在 Dashboard 里完成:
73
+
74
+ - 渠道配置
75
+ - 默认 Agent / 模型设置
76
+ - 工作目录切换
77
+ - 会话和运行状态查看
78
+
79
+ 如果你更喜欢终端向导:
74
80
 
75
81
  ```bash
76
82
  npx pikiclaw@latest --setup
77
83
  ```
78
84
 
79
- ### 开始派活
80
-
81
- 给你的 bot 发消息:
85
+ 如果只是检查环境:
82
86
 
83
- > "把 docs/ 目录下所有零散文档整理汇总,提取核心指标,输出一份报告。"
84
-
85
- **就这样。你的电脑现在是一个随时待命的远程执行中枢。**
87
+ ```bash
88
+ npx pikiclaw@latest --doctor
89
+ ```
86
90
 
87
91
  ---
88
92
 
89
- ## Features
90
-
91
- ### Agent Engines
93
+ ## What Exists Today
92
94
 
93
- | Agent | 特点 |
94
- |-------|------|
95
- | **Claude Code** | Anthropic 官方 CLI · Thinking 展示 · 多模态 · 缓存优化 |
96
- | **Codex CLI** | OpenAI 官方 CLI · Reasoning 展示 · 计划步骤追踪 · 实时用量 |
97
- | **Gemini CLI** | Google 官方 CLI · 工具调用 · 流式输出 |
95
+ ### Channels
98
96
 
99
- 通过 `/agents` 随时切换引擎,`/models` 切换模型。
97
+ - Telegram 已可用
98
+ - 飞书已可用
99
+ - 两个渠道可以同时启动
100
100
 
101
- ### IM Channels
101
+ ### Agents
102
102
 
103
- | 渠道 | 消息编辑 | 文件收发 | 回调按钮 | 命令菜单 | 场景 |
104
- |------|---------|---------|---------|---------|------|
105
- | **Telegram** | ✅ | ✅ | ✅ | ✅ | 全球 / 个人 |
106
- | **飞书** | ✅ | ✅ | ✅ | ✅ | 国内 / 团队 |
103
+ - Claude Code
104
+ - Codex CLI
105
+ - Gemini CLI
107
106
 
108
- 两个渠道可以**同时启动**。
107
+ Agent 通过 driver registry 接入,模型列表、会话列表、usage 展示都走统一接口。
109
108
 
110
- ### Core Capabilities
109
+ ### Runtime
111
110
 
112
- | 能力 | 说明 |
113
- |------|------|
114
- | 实时流式输出 | Agent 工作时消息持续更新 |
115
- | Thinking / Reasoning / Plan | 实时查看 Agent 的思考、推理和计划步骤 |
116
- | Token 追踪 | 输入/输出/缓存统计,上下文使用率实时显示 |
117
- | 产物回传 | 截图、日志、生成文件自动发回聊天 |
118
- | 长程任务保障 | 系统级防休眠 + 守护进程 + 异常自愈 |
119
- | 长文本处理 | 超长输出自动拆分或打包为 `.md` |
120
- | 多会话管理 | 随时切换、恢复历史会话 |
121
- | 图片/文件输入 | 截图、PDF、文档直接发给 Agent |
122
- | 项目 Skills | `.pikiclaw/skills/` 自定义技能,兼容 `.claude/commands/` |
123
- | 安全模式 | 白名单访问控制,支持切换更安全的 agent 权限模式 |
124
- | Web Dashboard | 可视化配置、会话浏览、主机监控 |
111
+ - 流式预览和持续消息更新
112
+ - 会话切换、恢复和多轮续聊
113
+ - 工作目录浏览与切换
114
+ - 长任务防休眠
115
+ - watchdog 守护和自动重启
116
+ - 长文本自动拆分,文件和图片自动回传
125
117
 
126
- ---
118
+ ### Skills
127
119
 
128
- ## Comparison
120
+ - 支持项目级 `.pikiclaw/skills/*/SKILL.md`
121
+ - 兼容 `.claude/commands/*.md`
122
+ - IM 内可通过 `/skills` 和 `/sk_<name>` 触发
129
123
 
130
- ### pikiclaw vs. 其他方案
124
+ ### MCP Session Bridge
131
125
 
132
- ```
133
- 在你的环境里执行
134
-
135
- 终端 CLI │ pikiclaw
136
- (人要守着) │ (人可以走)
137
-
138
- ─────────────┼─────────────
139
- 不方便控制 │ 随时随地控制
140
-
141
- SSH+tmux │ 云端 Agent
142
- (手机上很痛苦) │ (不是你的环境)
143
-
144
- 在沙盒/远端执行
145
- ```
126
+ 每次 Agent stream 会启动一个会话级 MCP server,把 IM 能力暴露给 Agent。
146
127
 
147
- | | 终端直接跑 | SSH + tmux | 云端 Agent | **pikiclaw** |
148
- |---|---|---|---|---|
149
- | 执行环境 | ✅ 本地 | ✅ 本地 | ⚠️ 通常是远端或沙盒 | ✅ 本地 |
150
- | 走开后还能跑 | ❌ 合盖就断 | ⚠️ 要配 tmux | ✅ | ✅ 防休眠 + 守护进程 |
151
- | 手机可控 | ❌ | ⚠️ 打字痛苦 | ✅ | ✅ IM 原生 |
152
- | 实时看进度 | ✅ 终端 | ⚠️ 得连上去看 | ⚠️ 依平台而定 | ✅ 流式推到聊天 |
153
- | 结果自动回传 | ❌ | ❌ | ⚠️ 看平台 | ✅ 截图/文件/长文本 |
154
- | 配置门槛 | 无 | SSH/穿透/tmux | 注册并适应平台工作流 | `npx` 一行 |
155
-
156
- ### pikiclaw vs. OpenClaw / 官方入口
157
-
158
- | 维度 | **pikiclaw** | OpenClaw | 官方入口(Claude / Codex / Gemini) |
159
- |---|---|---|---|
160
- | 产品层 | IM 驱动的本地 agent 控制平面 | 通用个人 AI 助手 / 多渠道生态 | 单一厂商的原生 agent 入口 |
161
- | Agent 策略 | 复用官方 CLI,吃到各家最新能力 | 自带 runtime / agent stack | 只服务自家模型 |
162
- | 执行环境 | 你的电脑 | 个人设备网络 / 本地节点 | 本地 CLI 或厂商云 |
163
- | 渠道策略 | Telegram + 飞书深度打磨 | 广覆盖 | Web / App / Slack / CLI 为主 |
164
- | 锁定程度 | 低,可随时切换引擎 | 中 | 高 |
165
- | 最强场景 | 远程 coding、长任务、本地自动化 | 全能个人助手 | 原生模型体验、企业集成 |
128
+ 当前已接入的工具:
166
129
 
167
- ---
130
+ - `im_list_files`:列出 session workspace 文件
131
+ - `im_send_file`:把文件实时发回 IM
132
+ - `take_screenshot`:跨平台截图并返回路径
168
133
 
169
- ## Use Cases
170
-
171
- pikiclaw 不限于编程。你的 Agent 能做什么,pikiclaw 就能远程调度什么。
172
-
173
- 它尤其适合那些**必须在你自己的环境里执行**、同时又希望**进度和结果直接回到 IM** 的任务。
174
-
175
- **工程重构** — "把整个项目从 JS 迁移到 TS,跑测试直到全部通过。搞定告诉我。"
176
-
177
- **文档处理** — "把 docs/ 下所有零散文档整理汇总,提取核心指标,输出一份报告。"
178
-
179
- **研究分析** — "下载这 5 篇论文的 PDF,逐篇阅读,写一份 3000 字综述。"
180
-
181
- **批量任务** — "把 data/ 下所有旧版报表转换成新格式,汇总成一份,确认数据条数。"
182
-
183
- **巡检自愈** — "跑一下数据同步任务,把报错自动修好,直到全部通过。"
184
-
185
- **竞品分析** — "分析竞品网站的落地页,把我们的页面改成类似风格,改完截图发我。"
134
+ 当前 `guiTools` 模块已经预留,但点击、输入、窗口控制等顶级 GUI 工具还没接上。
186
135
 
187
136
  ---
188
137
 
189
138
  ## Commands
190
139
 
191
140
  | 命令 | 说明 |
192
- |------|------|
193
- | `/start` | 菜单、当前 Agent 和工作目录 |
141
+ |---|---|
142
+ | `/start` | 显示入口信息、当前 Agent、工作目录 |
143
+ | `/sessions` | 查看、切换或新建会话 |
194
144
  | `/agents` | 切换 Agent |
195
- | `/models` | 查看并切换模型 |
196
- | `/sessions` | 查看并切换历史会话 |
145
+ | `/models` | 查看并切换模型 / reasoning effort |
197
146
  | `/switch` | 浏览并切换工作目录 |
198
- | `/status` | 状态、会话信息、Token 统计 |
199
- | `/host` | 主机 CPU / 内存 / 磁盘 / 电量 |
147
+ | `/status` | 查看运行状态、tokens、usage、会话信息 |
148
+ | `/host` | 查看主机 CPU / 内存 / 磁盘 / 电量 |
200
149
  | `/skills` | 浏览项目 skills |
201
- | `/restart` | 拉取最新版本并重启 |
202
- | `/sk_<name>` | 执行项目技能 |
150
+ | `/restart` | 重启并重新拉起 bot |
151
+ | `/sk_<name>` | 运行项目 skill |
203
152
 
204
- > 普通文本直接发给当前 Agent。
153
+ 普通文本消息会直接转给当前 Agent。
205
154
 
206
155
  ---
207
156
 
208
- ## Configuration
157
+ ## Skills And MCP
209
158
 
210
- ### 常见用法
159
+ 项目里现在有两条能力扩展线:
211
160
 
212
- ```bash
213
- npx pikiclaw@latest # 自动检测,打开 Dashboard
214
- npx pikiclaw@latest -a claude # 指定 Agent
215
- npx pikiclaw@latest -a gemini # 使用 Gemini
216
- npx pikiclaw@latest -w ~/workspace # 指定工作目录
217
- npx pikiclaw@latest -m claude-sonnet-4-6 # 指定模型
218
- npx pikiclaw@latest --safe-mode # 安全模式
219
- npx pikiclaw@latest --allowed-ids ID # 白名单
220
- npx pikiclaw@latest --doctor # 检查环境
221
- ```
161
+ - Skills:偏“高层工作流提示词”,来源于 `.pikiclaw/skills` 和 `.claude/commands`
162
+ - MCP tools:偏“可执行工具能力”,目前是会话级 bridge,由 pikiclaw 在每次 stream 时注入
222
163
 
223
- ### CLI 参数
224
-
225
- | 参数 | 默认值 | 说明 |
226
- |------|--------|------|
227
- | `-t, --token` | — | Bot Token |
228
- | `-a, --agent` | `codex` | 默认 Agent |
229
- | `-m, --model` | Agent 默认 | 覆盖模型 |
230
- | `-w, --workdir` | 已保存或当前目录 | 工作目录 |
231
- | `--safe-mode` | `false` | Agent 自身权限模型 |
232
- | `--full-access` | `true` | 无确认执行 |
233
- | `--allowed-ids` | — | 限制 chat/user ID |
234
- | `--timeout` | `1800` | 单次请求最大秒数 |
235
- | `--no-daemon` | — | 禁用守护进程 |
236
- | `--no-dashboard` | — | 不启动 Dashboard |
237
- | `--dashboard-port` | `3939` | Dashboard 端口 |
238
- | `--doctor` | — | 检查环境 |
239
- | `--setup` | — | Setup Wizard |
240
-
241
- <details>
242
- <summary>环境变量</summary>
243
-
244
- **通用:**
245
-
246
- | 变量 | 说明 |
247
- |------|------|
248
- | `DEFAULT_AGENT` | 默认 Agent |
249
- | `PIKICLAW_WORKDIR` | 默认工作目录 |
250
- | `PIKICLAW_TIMEOUT` | 请求超时(秒) |
251
- | `PIKICLAW_ALLOWED_IDS` | 白名单 |
252
- | `PIKICLAW_FULL_ACCESS` | 完全访问模式 |
253
- | `PIKICLAW_RESTART_CMD` | 自定义重启命令 |
254
-
255
- **Telegram:**
256
-
257
- | 变量 | 说明 |
258
- |------|------|
259
- | `TELEGRAM_BOT_TOKEN` | Bot Token |
260
- | `TELEGRAM_ALLOWED_CHAT_IDS` | 白名单 |
261
-
262
- **飞书:**
263
-
264
- | 变量 | 说明 |
265
- |------|------|
266
- | `FEISHU_APP_ID` | App ID |
267
- | `FEISHU_APP_SECRET` | App Secret |
268
- | `FEISHU_DOMAIN` | API 域名(默认 `https://open.feishu.cn`) |
269
- | `FEISHU_ALLOWED_CHAT_IDS` | 白名单 |
270
-
271
- **Claude:**
272
-
273
- | 变量 | 说明 |
274
- |------|------|
275
- | `CLAUDE_MODEL` | 模型 |
276
- | `CLAUDE_PERMISSION_MODE` | 权限模式 |
277
- | `CLAUDE_EXTRA_ARGS` | 额外参数 |
278
-
279
- **Codex:**
280
-
281
- | 变量 | 说明 |
282
- |------|------|
283
- | `CODEX_MODEL` | 模型 |
284
- | `CODEX_REASONING_EFFORT` | 推理强度 |
285
- | `CODEX_FULL_ACCESS` | 完全访问 |
286
- | `CODEX_EXTRA_ARGS` | 额外参数 |
287
-
288
- **Gemini:**
289
-
290
- | 变量 | 说明 |
291
- |------|------|
292
- | `GEMINI_MODEL` | 模型 |
293
- | `GEMINI_EXTRA_ARGS` | 额外参数 |
294
-
295
- </details>
164
+ 这两条线已经能工作,但都还是偏“session / project 内部接入”,还不是仓库级统一入口。
296
165
 
297
166
  ---
298
167
 
299
168
  ## Status
300
169
 
301
- | 维度 | 状态 |
302
- |------|------|
303
- | IM | Telegram ✅ · 飞书 ✅ · WhatsApp(规划中) |
304
- | Agent | Claude Code ✅ · Codex CLI ✅ · Gemini CLI ✅ |
305
- | 面板 | Web Dashboard ✅ |
306
- | 国际化 | 中文 · English ✅ |
307
- | 平台 | macOS · Linux ✅ |
170
+ ### 已完成
171
+
172
+ | 项目 | 状态 |
173
+ |---|---|
174
+ | Telegram 渠道 | ✅ |
175
+ | 飞书渠道 | ✅ |
176
+ | Claude Code driver | ✅ |
177
+ | Codex CLI driver | ✅ |
178
+ | Gemini CLI driver | ✅ |
179
+ | Web Dashboard | ✅ |
180
+ | 项目级 Skills | ✅ |
181
+ | 会话级 MCP bridge | ✅ |
182
+ | 文件回传 / 截图回传 | ✅ |
183
+ | 守护重启 / 防休眠 | ✅ |
184
+
185
+ ### 待办
186
+
187
+ | 项目 | 说明 |
188
+ |---|---|
189
+ | 顶级 Skills 接入 | 把 skills 从当前项目级入口提升为更统一的顶级接入能力 |
190
+ | 顶级 MCP 工具接入 | 把当前会话级 MCP bridge 扩展成更完整的顶级工具接入层 |
191
+ | GUI 自动化工具补全 | 在 `src/tools/gui.ts` 上接入点击、输入、聚焦、窗口控制等工具 |
192
+ | 更多渠道 | WhatsApp 仍在规划中 |
308
193
 
309
194
  ---
310
195
 
@@ -314,12 +199,26 @@ npx pikiclaw@latest --doctor # 检查环境
314
199
  git clone https://github.com/xiaotonng/pikiclaw.git
315
200
  cd pikiclaw
316
201
  npm install
317
- echo "TELEGRAM_BOT_TOKEN=your_token" > .env
318
- set -a && source .env && npx tsx src/cli.ts
202
+ npm run build
203
+ npm test
204
+ ```
205
+
206
+ 常用命令:
207
+
208
+ ```bash
209
+ npm run dev
210
+ npm run build
319
211
  npm test
212
+ npm run test:e2e
213
+ npx vitest run test/channel-feishu.unit.test.ts
214
+ npx pikiclaw@latest --doctor
320
215
  ```
321
216
 
322
- 架构详情见 [ARCHITECTURE.md](ARCHITECTURE.md)。
217
+ 更多实现细节见:
218
+
219
+ - [ARCHITECTURE.md](ARCHITECTURE.md)
220
+ - [INTEGRATION.md](INTEGRATION.md)
221
+ - [TESTING.md](TESTING.md)
323
222
 
324
223
  ---
325
224
 
@@ -63,6 +63,22 @@ function isRetryableUploadError(err) {
63
63
  'gateway timeout',
64
64
  ].some(token => text.includes(token));
65
65
  }
66
+ function requireMessageId(resp, action) {
67
+ const messageId = resp?.data?.message_id;
68
+ if (messageId)
69
+ return String(messageId);
70
+ const code = resp?.code;
71
+ const msg = resp?.msg || resp?.message || 'no message_id returned';
72
+ throw new Error(`${action} failed: code=${code ?? '?'} msg=${msg}`);
73
+ }
74
+ function buildPostContent(paragraphs, title = '') {
75
+ return JSON.stringify({
76
+ zh_cn: {
77
+ title,
78
+ content: paragraphs,
79
+ },
80
+ });
81
+ }
66
82
  // ---------------------------------------------------------------------------
67
83
  // Card builder helper
68
84
  // ---------------------------------------------------------------------------
@@ -520,7 +536,7 @@ class FeishuChannel extends Channel {
520
536
  content: JSON.stringify(card),
521
537
  },
522
538
  });
523
- return resp?.data?.message_id ?? null;
539
+ return requireMessageId(resp, 'send interactive card');
524
540
  }
525
541
  async send(chatId, text, opts = {}) {
526
542
  const rows = keyboardToRows(opts.keyboard);
@@ -541,7 +557,7 @@ class FeishuChannel extends Channel {
541
557
  content: JSON.stringify(card),
542
558
  },
543
559
  });
544
- return resp?.data?.message_id ?? null;
560
+ return requireMessageId(resp, 'reply interactive card');
545
561
  }
546
562
  async editCard(chatId, msgId, view) {
547
563
  if (!view.markdown.trim())
@@ -730,7 +746,21 @@ class FeishuChannel extends Channel {
730
746
  content: JSON.stringify({ text }),
731
747
  },
732
748
  });
733
- return resp?.data?.message_id ?? null;
749
+ return requireMessageId(resp, 'send text');
750
+ }
751
+ async sendPost(chatId, content, opts = {}) {
752
+ const replyTo = opts.replyTo ? String(opts.replyTo) : undefined;
753
+ this._logOutgoing('sendPost', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} chars=${content.length}`);
754
+ const resp = replyTo
755
+ ? await this.client.im.message.reply({
756
+ path: { message_id: replyTo },
757
+ data: { msg_type: 'post', content },
758
+ })
759
+ : await this.client.im.message.create({
760
+ params: { receive_id_type: 'chat_id' },
761
+ data: { receive_id: chatId, msg_type: 'post', content },
762
+ });
763
+ return requireMessageId(resp, 'send post');
734
764
  }
735
765
  /** Upload an image and return the image_key. */
736
766
  async uploadImage(imageBuffer) {
@@ -772,15 +802,23 @@ class FeishuChannel extends Channel {
772
802
  const content = fs.readFileSync(filePath);
773
803
  const filename = path.basename(filePath);
774
804
  const isPhoto = opts.asPhoto ?? PHOTO_EXTS.has(path.extname(filename).toLowerCase());
805
+ const caption = typeof opts.caption === 'string' ? opts.caption.trim() : '';
775
806
  const replyTo = opts.replyTo ? String(opts.replyTo) : undefined;
776
807
  if (isPhoto) {
777
808
  try {
778
809
  const imageKey = await this.uploadImage(content);
810
+ if (caption) {
811
+ return await this.sendPost(String(chatId), buildPostContent([
812
+ [{ tag: 'img', image_key: imageKey }],
813
+ [{ tag: 'text', text: caption }],
814
+ ]), { replyTo });
815
+ }
779
816
  const msgContent = JSON.stringify({ image_key: imageKey });
817
+ this._logOutgoing('sendImage', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} file=${filename}`);
780
818
  const resp = replyTo
781
819
  ? await this.client.im.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'image', content: msgContent } })
782
820
  : await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: String(chatId), msg_type: 'image', content: msgContent } });
783
- return resp?.data?.message_id ?? null;
821
+ return requireMessageId(resp, 'send image');
784
822
  }
785
823
  catch (err) {
786
824
  if (isRetryableUploadError(err))
@@ -790,10 +828,11 @@ class FeishuChannel extends Channel {
790
828
  }
791
829
  const fileKey = await this.uploadFile(content, filename);
792
830
  const msgContent = JSON.stringify({ file_key: fileKey });
831
+ this._logOutgoing('sendFile', `${replyTo ? `reply_to=${replyTo}` : `chat=${chatId}`} file=${filename}`);
793
832
  const resp = replyTo
794
833
  ? await this.client.im.message.reply({ path: { message_id: replyTo }, data: { msg_type: 'file', content: msgContent } })
795
834
  : await this.client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: { receive_id: String(chatId), msg_type: 'file', content: msgContent } });
796
- return resp?.data?.message_id ?? null;
835
+ return requireMessageId(resp, 'send file');
797
836
  }
798
837
  // ========================================================================
799
838
  // Download resources from received messages
@@ -802,12 +802,10 @@ export async function doStream(opts) {
802
802
  }
803
803
  // Start MCP bridge if sendFile callback is provided
804
804
  let bridge = null;
805
- let mcpLogPath = null;
806
805
  if (opts.mcpSendFile) {
807
806
  try {
808
807
  const { startMcpBridge } = await import('./mcp-bridge.js');
809
808
  const sessionDir = path.dirname(session.workspacePath);
810
- mcpLogPath = path.join(sessionDir, 'mcp-server.log');
811
809
  bridge = await startMcpBridge({
812
810
  sessionDir,
813
811
  workspacePath: session.workspacePath,
@@ -815,10 +813,13 @@ export async function doStream(opts) {
815
813
  stagedFiles,
816
814
  sendFile: opts.mcpSendFile,
817
815
  agent: opts.agent,
816
+ onLog: (message) => agentLog(`[mcp] ${message}`),
818
817
  });
819
818
  prepared.mcpConfigPath = bridge.configPath;
820
- agentLog(`[mcp] bridge started on ${bridge.configPath}`);
821
- agentLog(`[mcp] server log file: ${mcpLogPath}`);
819
+ if (bridge.configPath)
820
+ agentLog(`[mcp] bridge started on ${bridge.configPath}`);
821
+ else
822
+ agentLog('[mcp] bridge registered with codex');
822
823
  try {
823
824
  agentLog(`[mcp] config content:\n${fs.readFileSync(bridge.configPath, 'utf-8')}`);
824
825
  }
@@ -837,12 +838,8 @@ export async function doStream(opts) {
837
838
  finally {
838
839
  if (bridge) {
839
840
  await bridge.stop().catch(() => { });
840
- if (mcpLogPath && fs.existsSync(mcpLogPath)) {
841
- const tail = readTailLines(mcpLogPath, 16 * 1024).slice(-20).join('\n').trim();
842
- if (tail)
843
- agentLog(`[mcp] server log tail:\n${tail}`);
844
- }
845
- agentLog('[mcp] bridge stopped');
841
+ if (bridge.hadActivity())
842
+ agentLog('[mcp] bridge stopped');
846
843
  }
847
844
  }
848
845
  }
@@ -76,29 +76,86 @@ function isInsideAllowedRoot(realFile, allowedRoots) {
76
76
  }
77
77
  return false;
78
78
  }
79
- export function resolveSendFilePath(inputPath, workspacePath, workdir) {
79
+ export function resolveSendFilePath(inputPath, workspacePath, stagedFiles = [], workdir) {
80
80
  const requested = String(inputPath || '').trim();
81
81
  if (!requested)
82
- return null;
82
+ return { path: null, error: 'path is required' };
83
83
  if (path.isAbsolute(requested))
84
- return requested;
84
+ return { path: requested };
85
+ const roots = {
86
+ workspace: path.resolve(workspacePath),
87
+ workdir: workdir ? path.resolve(workdir) : '',
88
+ tmp: path.resolve(os.tmpdir()),
89
+ };
90
+ const aliasPrefixes = [
91
+ { prefix: '@workspace/', root: roots.workspace },
92
+ { prefix: 'workspace:', root: roots.workspace },
93
+ { prefix: 'ws:', root: roots.workspace },
94
+ ...(roots.workdir ? [
95
+ { prefix: '@workdir/', root: roots.workdir },
96
+ { prefix: 'workdir:', root: roots.workdir },
97
+ { prefix: 'wd:', root: roots.workdir },
98
+ ] : []),
99
+ { prefix: '@tmp/', root: roots.tmp },
100
+ { prefix: 'tmp:', root: roots.tmp },
101
+ ];
102
+ for (const { prefix, root } of aliasPrefixes) {
103
+ if (!requested.startsWith(prefix))
104
+ continue;
105
+ const suffix = requested.slice(prefix.length).trim();
106
+ return { path: suffix ? path.resolve(root, suffix) : root };
107
+ }
85
108
  const candidates = [
86
- path.resolve(workspacePath, requested),
87
- ...(workdir ? [path.resolve(workdir, requested)] : []),
109
+ path.resolve(roots.workspace, requested),
110
+ ...(roots.workdir ? [path.resolve(roots.workdir, requested)] : []),
88
111
  ];
89
112
  for (const candidate of candidates) {
90
113
  try {
91
114
  fs.realpathSync(candidate);
92
- return candidate;
115
+ return { path: candidate };
93
116
  }
94
117
  catch {
95
118
  // Try next candidate.
96
119
  }
97
120
  }
98
- return candidates[0] || null;
121
+ if (!requested.includes('/') && !requested.includes(path.sep)) {
122
+ const basenameMatches = new Map();
123
+ const dedupedMatches = [];
124
+ const addMatch = (candidate) => {
125
+ const key = path.resolve(candidate);
126
+ if (basenameMatches.has(key))
127
+ return;
128
+ basenameMatches.set(key, key);
129
+ dedupedMatches.push(key);
130
+ };
131
+ try {
132
+ const tmpCandidate = path.join(roots.tmp, requested);
133
+ if (fs.existsSync(tmpCandidate))
134
+ addMatch(tmpCandidate);
135
+ }
136
+ catch { }
137
+ for (const relPath of stagedFiles) {
138
+ if (path.basename(relPath) !== requested)
139
+ continue;
140
+ addMatch(path.join(roots.workspace, relPath));
141
+ }
142
+ if (dedupedMatches.length === 1)
143
+ return { path: dedupedMatches[0] };
144
+ if (dedupedMatches.length > 1) {
145
+ return {
146
+ path: null,
147
+ error: `ambiguous file name "${requested}"; use @workspace/..., @workdir/..., or @tmp/...`,
148
+ };
149
+ }
150
+ }
151
+ return {
152
+ path: candidates[0] || null,
153
+ error: `file not found: ${requested}; try @workspace/..., @workdir/..., @tmp/..., or a unique filename`,
154
+ };
99
155
  }
100
156
  export async function startMcpBridge(opts) {
101
157
  const { sessionDir, workspacePath, stagedFiles, sendFile } = opts;
158
+ let hadActivity = false;
102
159
  // Build allowed roots: workspace + workdir + /tmp
103
160
  const allowedRoots = [workspacePath];
104
161
  if (opts.workdir)
@@ -106,7 +163,7 @@ export async function startMcpBridge(opts) {
106
163
  allowedRoots.push('/tmp', os.tmpdir());
107
164
  // ── HTTP callback server ──
108
165
  const server = http.createServer((req, res) => {
109
- if (req.method !== 'POST' || req.url !== '/send-file') {
166
+ if (req.method !== 'POST' || (req.url !== '/send-file' && req.url !== '/log')) {
110
167
  res.writeHead(404);
111
168
  res.end();
112
169
  return;
@@ -120,6 +177,17 @@ export async function startMcpBridge(opts) {
120
177
  req.on('end', async () => {
121
178
  clearTimeout(bodyTimer);
122
179
  try {
180
+ if (req.url === '/log') {
181
+ const data = JSON.parse(body || '{}');
182
+ const message = typeof data.message === 'string' ? data.message.trim() : '';
183
+ if (message) {
184
+ hadActivity = true;
185
+ opts.onLog?.(message);
186
+ }
187
+ res.writeHead(200, { 'Content-Type': 'application/json' });
188
+ res.end(JSON.stringify({ ok: true }));
189
+ return;
190
+ }
123
191
  const data = JSON.parse(body);
124
192
  const relPath = String(data.path || '').trim();
125
193
  if (!relPath) {
@@ -128,14 +196,15 @@ export async function startMcpBridge(opts) {
128
196
  return;
129
197
  }
130
198
  // Resolve and validate path
131
- const absPath = resolveSendFilePath(relPath, workspacePath, opts.workdir);
199
+ const resolved = resolveSendFilePath(relPath, workspacePath, stagedFiles, opts.workdir);
200
+ const absPath = resolved.path;
132
201
  let realFile;
133
202
  try {
134
203
  realFile = fs.realpathSync(String(absPath || ''));
135
204
  }
136
205
  catch {
137
206
  res.writeHead(400, { 'Content-Type': 'application/json' });
138
- res.end(JSON.stringify({ ok: false, error: `file not found: ${relPath}` }));
207
+ res.end(JSON.stringify({ ok: false, error: resolved.error || `file not found: ${relPath}` }));
139
208
  return;
140
209
  }
141
210
  if (!isInsideAllowedRoot(realFile, allowedRoots)) {
@@ -161,6 +230,7 @@ export async function startMcpBridge(opts) {
161
230
  : isPhotoFile(realFile) ? 'photo'
162
231
  : 'document';
163
232
  const caption = typeof data.caption === 'string' ? data.caption.trim().slice(0, 1024) || undefined : undefined;
233
+ hadActivity = true;
164
234
  const result = await Promise.race([
165
235
  sendFile(realFile, { caption, kind }),
166
236
  new Promise((_, reject) => setTimeout(() => reject(new Error(`sendFile timed out after ${SEND_FILE_TIMEOUT_MS / 1000}s`)), SEND_FILE_TIMEOUT_MS)),
@@ -186,8 +256,10 @@ export async function startMcpBridge(opts) {
186
256
  const { command, args } = resolveMcpServerCommand();
187
257
  const envVars = {
188
258
  MCP_WORKSPACE_PATH: workspacePath,
259
+ MCP_WORKDIR: opts.workdir || '',
189
260
  MCP_STAGED_FILES: JSON.stringify(stagedFiles),
190
261
  MCP_CALLBACK_URL: `http://127.0.0.1:${port}`,
262
+ MCP_LOG_URL: `http://127.0.0.1:${port}/log`,
191
263
  };
192
264
  let configPath = '';
193
265
  let codexRegistered = false;
@@ -224,6 +296,7 @@ export async function startMcpBridge(opts) {
224
296
  }
225
297
  return {
226
298
  configPath,
299
+ hadActivity: () => hadActivity,
227
300
  stop: async () => {
228
301
  await new Promise(resolve => server.close(() => resolve()));
229
302
  if (codexRegistered) {
@@ -7,6 +7,7 @@
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import http from 'node:http';
10
+ import os from 'node:os';
10
11
  import { toolResult, toolLog } from './types.js';
11
12
  // ---------------------------------------------------------------------------
12
13
  // Tool definitions
@@ -33,11 +34,11 @@ const tools = [
33
34
  properties: {
34
35
  path: {
35
36
  type: 'string',
36
- description: 'Absolute, workspace-relative, or workdir-relative path.',
37
+ description: 'Path to send. Supports absolute paths, @workspace/..., @workdir/..., @tmp/..., workspace-relative paths, and unique bare filenames.',
37
38
  },
38
39
  caption: {
39
40
  type: 'string',
40
- description: 'Optional caption.',
41
+ description: 'Caption.',
41
42
  },
42
43
  kind: {
43
44
  type: 'string',
@@ -45,7 +46,7 @@ const tools = [
45
46
  description: 'Optional file kind.',
46
47
  },
47
48
  },
48
- required: ['path'],
49
+ required: ['path', 'caption'],
49
50
  },
50
51
  },
51
52
  ];
@@ -65,8 +66,14 @@ function handleListFiles(args, ctx) {
65
66
  }
66
67
  try {
67
68
  const entries = fs.readdirSync(dir, { withFileTypes: true });
69
+ const workspaceRelDir = path.relative(ctx.workspace, dir);
68
70
  const files = entries.map(e => {
69
71
  const entry = { name: e.name, type: e.isDirectory() ? 'directory' : 'file' };
72
+ const relPath = workspaceRelDir && workspaceRelDir !== '' && workspaceRelDir !== '.'
73
+ ? path.posix.join(toPosix(workspaceRelDir), e.name)
74
+ : e.name;
75
+ entry.path = relPath;
76
+ entry.alias = `@workspace/${relPath}`;
70
77
  if (e.isFile()) {
71
78
  try {
72
79
  entry.size = fs.statSync(path.join(dir, e.name)).size;
@@ -78,7 +85,24 @@ function handleListFiles(args, ctx) {
78
85
  toolLog('im_list_files', `OK ${files.length} entries`);
79
86
  return toolResult(JSON.stringify({
80
87
  workspacePath: ctx.workspace,
81
- stagedFiles: ctx.stagedFiles,
88
+ workdirPath: ctx.workdir || null,
89
+ tempPath: os.tmpdir(),
90
+ pathAliases: {
91
+ workspaceRoot: '@workspace',
92
+ workdirRoot: ctx.workdir ? '@workdir' : null,
93
+ tempRoot: '@tmp',
94
+ notes: [
95
+ 'Use @workspace/... for files in the session workspace.',
96
+ ctx.workdir ? 'Use @workdir/... for files in the agent workdir.' : null,
97
+ 'Use @tmp/... for screenshots and other temp files.',
98
+ 'A bare filename also works if it uniquely matches a staged file or /tmp file.',
99
+ ].filter(Boolean),
100
+ },
101
+ stagedFiles: ctx.stagedFiles.map(relPath => ({
102
+ path: relPath,
103
+ alias: `@workspace/${toPosix(relPath)}`,
104
+ basename: path.basename(relPath),
105
+ })),
82
106
  files,
83
107
  }, null, 2));
84
108
  }
@@ -89,20 +113,24 @@ function handleListFiles(args, ctx) {
89
113
  }
90
114
  async function handleSendFile(args, ctx) {
91
115
  const filePath = typeof args?.path === 'string' ? args.path.trim() : '';
116
+ const caption = typeof args?.caption === 'string' ? args.caption.trim() : '';
92
117
  const kind = typeof args?.kind === 'string' ? args.kind : undefined;
118
+ toolLog('im_send_file', `path=${filePath} kind=${kind || 'auto'}`);
93
119
  if (!filePath) {
94
120
  toolLog('im_send_file', 'ERROR missing path');
95
121
  return toolResult('Error: "path" is required', true);
96
122
  }
123
+ if (!caption) {
124
+ toolLog('im_send_file', 'ERROR missing caption');
125
+ return toolResult('Error: "caption" is required', true);
126
+ }
97
127
  if (!ctx.callbackUrl) {
98
128
  toolLog('im_send_file', 'ERROR no callback URL');
99
129
  return toolResult('Error: MCP callback URL is not configured', true);
100
130
  }
101
- const callbackTarget = describeSendFileTarget(ctx.callbackUrl);
102
- toolLog('im_send_file', `path=${filePath} kind=${kind || 'auto'} callback=${callbackTarget}`);
103
131
  try {
104
132
  const result = await callbackSendFile(ctx.callbackUrl, filePath, {
105
- caption: typeof args?.caption === 'string' ? args.caption : undefined,
133
+ caption,
106
134
  kind,
107
135
  });
108
136
  if (result.ok) {
@@ -110,15 +138,13 @@ async function handleSendFile(args, ctx) {
110
138
  return toolResult(`File sent successfully: ${filePath}`);
111
139
  }
112
140
  else {
113
- const detail = formatSendFileFailure(result);
114
- toolLog('im_send_file', `FAILED ${detail}`);
115
- return toolResult(`Failed to send file: ${detail}`, true);
141
+ toolLog('im_send_file', `FAILED ${result.error || 'unknown error'}`);
142
+ return toolResult(`Failed to send file: ${result.error || 'unknown error'}`, true);
116
143
  }
117
144
  }
118
145
  catch (e) {
119
- const message = e instanceof Error ? e.message : String(e);
120
- toolLog('im_send_file', `ERROR callback=${callbackTarget} ${message}`);
121
- return toolResult(`Error sending file: ${message}`, true);
146
+ toolLog('im_send_file', `ERROR ${e.message}`);
147
+ return toolResult(`Error sending file: ${e.message}`, true);
122
148
  }
123
149
  }
124
150
  // ---------------------------------------------------------------------------
@@ -135,45 +161,14 @@ function callbackSendFile(callbackUrl, filePath, opts) {
135
161
  let data = '';
136
162
  res.on('data', (chunk) => { data += chunk; });
137
163
  res.on('end', () => {
138
- const statusCode = res.statusCode;
139
- const statusMessage = res.statusMessage || undefined;
140
- const bodyPreview = data ? previewText(data) : undefined;
141
- let parsed = null;
142
164
  try {
143
- parsed = data ? JSON.parse(data) : null;
144
- }
145
- catch { }
146
- if (statusCode && statusCode >= 400) {
147
- const parsedError = typeof parsed?.error === 'string' ? parsed.error : null;
148
- resolve({
149
- ok: false,
150
- error: parsedError || describeHttpFailure(statusCode, statusMessage, bodyPreview),
151
- statusCode,
152
- statusMessage,
153
- bodyPreview,
154
- });
155
- return;
165
+ resolve(JSON.parse(data));
156
166
  }
157
- if (parsed && typeof parsed.ok === 'boolean') {
158
- resolve({
159
- ok: parsed.ok,
160
- error: typeof parsed.error === 'string' ? parsed.error : undefined,
161
- statusCode,
162
- statusMessage,
163
- bodyPreview,
164
- });
165
- return;
167
+ catch {
168
+ resolve({ ok: false, error: 'invalid callback response' });
166
169
  }
167
- resolve({
168
- ok: false,
169
- error: describeHttpFailure(statusCode, statusMessage, bodyPreview, 'invalid callback response'),
170
- statusCode,
171
- statusMessage,
172
- bodyPreview,
173
- });
174
170
  });
175
171
  });
176
- req.setTimeout(30_000, () => req.destroy(new Error('send-file callback timed out after 30s')));
177
172
  req.on('error', e => reject(e));
178
173
  req.write(body);
179
174
  req.end();
@@ -190,31 +185,8 @@ function safeRealpath(p) {
190
185
  return null;
191
186
  }
192
187
  }
193
- function previewText(text, max = 400) {
194
- const normalized = String(text || '').replace(/\s+/g, ' ').trim();
195
- if (!normalized)
196
- return '';
197
- return normalized.length <= max ? normalized : `${normalized.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
198
- }
199
- function describeSendFileTarget(callbackUrl) {
200
- try {
201
- const url = new URL('/send-file', callbackUrl);
202
- return `${url.origin}${url.pathname}`;
203
- }
204
- catch {
205
- return callbackUrl;
206
- }
207
- }
208
- function describeHttpFailure(statusCode, statusMessage, bodyPreview, fallback = 'callback request failed') {
209
- const status = statusCode ? `HTTP ${statusCode}${statusMessage ? ` ${statusMessage}` : ''}` : fallback;
210
- return bodyPreview ? `${status}; body=${bodyPreview}` : status;
211
- }
212
- function formatSendFileFailure(result) {
213
- const base = result.error?.trim() || describeHttpFailure(result.statusCode, result.statusMessage, result.bodyPreview, 'unknown error');
214
- if (result.bodyPreview && !base.includes(result.bodyPreview)) {
215
- return `${base}; body=${result.bodyPreview}`;
216
- }
217
- return base;
188
+ function toPosix(p) {
189
+ return p.split(path.sep).join(path.posix.sep);
218
190
  }
219
191
  // ---------------------------------------------------------------------------
220
192
  // Module export
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiclaw",
3
- "version": "0.2.41",
3
+ "version": "0.2.42",
4
4
  "description": "The best IM-driven remote coding experience. Bridge AI coding agents to any IM.",
5
5
  "type": "module",
6
6
  "bin": {