pikiclaw 0.2.41 → 0.2.43

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,21 +2,19 @@
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
12
11
  ```
13
12
 
14
- [![npm](https://img.shields.io/npm/v/pikiclaw)](https://www.npmjs.com/package/pikiclaw)
15
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
16
- [![Node.js 18+](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org)
17
-
18
- <!-- TODO: 替换为实际 demo GIF -->
19
- <!-- ![demo](docs/assets/demo.gif) -->
13
+ <p align="center">
14
+ <a href="https://www.npmjs.com/package/pikiclaw"><img src="https://img.shields.io/npm/v/pikiclaw" alt="npm"></a>
15
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
16
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node.js-18+-green.svg" alt="Node.js 18+"></a>
17
+ </p>
20
18
 
21
19
  </div>
22
20
 
@@ -24,34 +22,32 @@ npx pikiclaw@latest
24
22
 
25
23
  ## Why pikiclaw?
26
24
 
27
- 市面上有很多 IM-to-Agent 桥接方案。它们要么自己造引擎(质量不如官方),要么什么都接(质量参差不齐)。
25
+ 很多“IM Agent”的方案,本质上还是在绕路:
28
26
 
29
- pikiclaw 的思路不同:**只挑最好的,然后把它们组合到极致。**
27
+ - 要么自己造 Agent,效果不如官方 CLI
28
+ - 要么跑在远端沙盒里,不是你的环境
29
+ - 要么只能短对话,不适合长任务
30
30
 
31
- - **最好的 Agent** — Claude Code、Codex CLI、Gemini CLI,都是各家官方出品,不造轮子
32
- - **最好的 IM** — Telegram(全球)+ 飞书(国内),不追求数量,每个都打磨到位
33
- - **最好的执行环境** — 你自己的电脑,不是云端沙盒,什么都能干
31
+ pikiclaw 的目标很直接:
34
32
 
35
- 结果就是:你在手机上发一句话,你的电脑就开始干活——小到改一行代码,大到通宵重构整个项目、整理几十份文档、跑完所有测试。
33
+ - 用官方 Agent CLI,而不是重新发明一套
34
+ - 用你自己的电脑,而不是陌生沙盒
35
+ - 用你已经在用的 IM,而不是再学一套远程控制方式
36
36
 
37
37
  ```
38
- 你(手机 IM)──→ pikiclaw ──→ 本地 Agent ──→ 你的电脑
39
-
40
- └──────── 流式进度 / 文件 / 截图 ←──────────┘
38
+ 你(Telegram / 飞书)
39
+
40
+
41
+ pikiclaw
42
+
43
+
44
+ Claude Code / Codex / Gemini
45
+
46
+
47
+ 你的电脑
41
48
  ```
42
49
 
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”更顺。
50
+ 它适合的不是“演示一次 AI”,而是你离开电脑以后,Agent 还能继续在本机把事做完。
55
51
 
56
52
  ---
57
53
 
@@ -60,251 +56,142 @@ pikiclaw 会比“守在终端里”“SSH + tmux”或“单厂商云端 agent
60
56
  ### 准备
61
57
 
62
58
  - 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) 凭证
59
+ - 本机已安装并登录任意一个 Agent CLI
60
+ - [`claude`](https://docs.anthropic.com/en/docs/claude-code)
61
+ - [`codex`](https://github.com/openai/codex)
62
+ - [`gemini`](https://github.com/google-gemini/gemini-cli)
63
+ - Telegram Bot Token 或飞书应用凭证
65
64
 
66
- ### 一行启动
65
+ ### 启动
67
66
 
68
67
  ```bash
69
- cd your-workspace/
68
+ cd your-workspace
70
69
  npx pikiclaw@latest
71
70
  ```
72
71
 
73
- 启动后自动打开 **Web Dashboard**(`localhost:3939`),引导你完成全部配置。也可以用终端向导:
72
+ 默认会打开 Web Dashboard:`http://localhost:3939`
73
+
74
+ 你可以在 Dashboard 里完成:
75
+
76
+ - 渠道配置
77
+ - 默认 Agent / 模型设置
78
+ - 工作目录切换
79
+ - 会话和运行状态查看
80
+
81
+ 如果你更喜欢终端向导:
74
82
 
75
83
  ```bash
76
84
  npx pikiclaw@latest --setup
77
85
  ```
78
86
 
79
- ### 开始派活
80
-
81
- 给你的 bot 发消息:
87
+ 如果只是检查环境:
82
88
 
83
- > "把 docs/ 目录下所有零散文档整理汇总,提取核心指标,输出一份报告。"
84
-
85
- **就这样。你的电脑现在是一个随时待命的远程执行中枢。**
89
+ ```bash
90
+ npx pikiclaw@latest --doctor
91
+ ```
86
92
 
87
93
  ---
88
94
 
89
- ## Features
90
-
91
- ### Agent Engines
95
+ ## What Exists Today
92
96
 
93
- | Agent | 特点 |
94
- |-------|------|
95
- | **Claude Code** | Anthropic 官方 CLI · Thinking 展示 · 多模态 · 缓存优化 |
96
- | **Codex CLI** | OpenAI 官方 CLI · Reasoning 展示 · 计划步骤追踪 · 实时用量 |
97
- | **Gemini CLI** | Google 官方 CLI · 工具调用 · 流式输出 |
97
+ ### Channels
98
98
 
99
- 通过 `/agents` 随时切换引擎,`/models` 切换模型。
99
+ - Telegram 已可用
100
+ - 飞书已可用
101
+ - 两个渠道可以同时启动
100
102
 
101
- ### IM Channels
103
+ ### Agents
102
104
 
103
- | 渠道 | 消息编辑 | 文件收发 | 回调按钮 | 命令菜单 | 场景 |
104
- |------|---------|---------|---------|---------|------|
105
- | **Telegram** | ✅ | ✅ | ✅ | ✅ | 全球 / 个人 |
106
- | **飞书** | ✅ | ✅ | ✅ | ✅ | 国内 / 团队 |
105
+ - Claude Code
106
+ - Codex CLI
107
+ - Gemini CLI
107
108
 
108
- 两个渠道可以**同时启动**。
109
+ Agent 通过 driver registry 接入,模型列表、会话列表、usage 展示都走统一接口。
109
110
 
110
- ### Core Capabilities
111
+ ### Runtime
111
112
 
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 | 可视化配置、会话浏览、主机监控 |
113
+ - 流式预览和持续消息更新
114
+ - 会话切换、恢复和多轮续聊
115
+ - 工作目录浏览与切换
116
+ - 长任务防休眠
117
+ - watchdog 守护和自动重启
118
+ - 长文本自动拆分,文件和图片自动回传
125
119
 
126
- ---
120
+ ### Skills
127
121
 
128
- ## Comparison
122
+ - 支持项目级 `.pikiclaw/skills/*/SKILL.md`
123
+ - 兼容 `.claude/commands/*.md`
124
+ - IM 内可通过 `/skills` 和 `/sk_<name>` 触发
129
125
 
130
- ### pikiclaw vs. 其他方案
126
+ ### MCP Session Bridge
131
127
 
132
- ```
133
- 在你的环境里执行
134
-
135
- 终端 CLI │ pikiclaw
136
- (人要守着) │ (人可以走)
137
-
138
- ─────────────┼─────────────
139
- 不方便控制 │ 随时随地控制
140
-
141
- SSH+tmux │ 云端 Agent
142
- (手机上很痛苦) │ (不是你的环境)
143
-
144
- 在沙盒/远端执行
145
- ```
128
+ 每次 Agent stream 会启动一个会话级 MCP server,把 IM 能力暴露给 Agent。
146
129
 
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、长任务、本地自动化 | 全能个人助手 | 原生模型体验、企业集成 |
130
+ 当前已接入的工具:
166
131
 
167
- ---
132
+ - `im_list_files`:列出 session workspace 文件
133
+ - `im_send_file`:把文件实时发回 IM
134
+ - `take_screenshot`:跨平台截图并返回路径
168
135
 
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
- **竞品分析** — "分析竞品网站的落地页,把我们的页面改成类似风格,改完截图发我。"
136
+ 当前 `guiTools` 模块已经预留,但点击、输入、窗口控制等顶级 GUI 工具还没接上。
186
137
 
187
138
  ---
188
139
 
189
140
  ## Commands
190
141
 
191
142
  | 命令 | 说明 |
192
- |------|------|
193
- | `/start` | 菜单、当前 Agent 和工作目录 |
143
+ |---|---|
144
+ | `/start` | 显示入口信息、当前 Agent、工作目录 |
145
+ | `/sessions` | 查看、切换或新建会话 |
194
146
  | `/agents` | 切换 Agent |
195
- | `/models` | 查看并切换模型 |
196
- | `/sessions` | 查看并切换历史会话 |
147
+ | `/models` | 查看并切换模型 / reasoning effort |
197
148
  | `/switch` | 浏览并切换工作目录 |
198
- | `/status` | 状态、会话信息、Token 统计 |
199
- | `/host` | 主机 CPU / 内存 / 磁盘 / 电量 |
149
+ | `/status` | 查看运行状态、tokens、usage、会话信息 |
150
+ | `/host` | 查看主机 CPU / 内存 / 磁盘 / 电量 |
200
151
  | `/skills` | 浏览项目 skills |
201
- | `/restart` | 拉取最新版本并重启 |
202
- | `/sk_<name>` | 执行项目技能 |
152
+ | `/restart` | 重启并重新拉起 bot |
153
+ | `/sk_<name>` | 运行项目 skill |
203
154
 
204
- > 普通文本直接发给当前 Agent。
155
+ 普通文本消息会直接转给当前 Agent。
205
156
 
206
157
  ---
207
158
 
208
- ## Configuration
159
+ ## Skills And MCP
209
160
 
210
- ### 常见用法
161
+ 项目里现在有两条能力扩展线:
211
162
 
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
- ```
163
+ - Skills:偏“高层工作流提示词”,来源于 `.pikiclaw/skills` 和 `.claude/commands`
164
+ - MCP tools:偏“可执行工具能力”,目前是会话级 bridge,由 pikiclaw 在每次 stream 时注入
222
165
 
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>
166
+ 这两条线已经能工作,但都还是偏“session / project 内部接入”,还不是仓库级统一入口。
296
167
 
297
168
  ---
298
169
 
299
170
  ## Status
300
171
 
301
- | 维度 | 状态 |
302
- |------|------|
303
- | IM | Telegram ✅ · 飞书 ✅ · WhatsApp(规划中) |
304
- | Agent | Claude Code ✅ · Codex CLI ✅ · Gemini CLI ✅ |
305
- | 面板 | Web Dashboard ✅ |
306
- | 国际化 | 中文 · English ✅ |
307
- | 平台 | macOS · Linux ✅ |
172
+ ### 已完成
173
+
174
+ | 项目 | 状态 |
175
+ |---|---|
176
+ | Telegram 渠道 | ✅ |
177
+ | 飞书渠道 | ✅ |
178
+ | Claude Code driver | ✅ |
179
+ | Codex CLI driver | ✅ |
180
+ | Gemini CLI driver | ✅ |
181
+ | Web Dashboard | ✅ |
182
+ | 项目级 Skills | ✅ |
183
+ | 会话级 MCP bridge | ✅ |
184
+ | 文件回传 / 截图回传 | ✅ |
185
+ | 守护重启 / 防休眠 | ✅ |
186
+
187
+ ### 待办
188
+
189
+ | 项目 | 说明 |
190
+ |---|---|
191
+ | 顶级 Skills 接入 | 把 skills 从当前项目级入口提升为更统一的顶级接入能力 |
192
+ | 顶级 MCP 工具接入 | 把当前会话级 MCP bridge 扩展成更完整的顶级工具接入层 |
193
+ | GUI 自动化工具补全 | 在 `src/tools/gui.ts` 上接入点击、输入、聚焦、窗口控制等工具 |
194
+ | 更多渠道 | WhatsApp 仍在规划中 |
308
195
 
309
196
  ---
310
197
 
@@ -314,12 +201,26 @@ npx pikiclaw@latest --doctor # 检查环境
314
201
  git clone https://github.com/xiaotonng/pikiclaw.git
315
202
  cd pikiclaw
316
203
  npm install
317
- echo "TELEGRAM_BOT_TOKEN=your_token" > .env
318
- set -a && source .env && npx tsx src/cli.ts
204
+ npm run build
205
+ npm test
206
+ ```
207
+
208
+ 常用命令:
209
+
210
+ ```bash
211
+ npm run dev
212
+ npm run build
319
213
  npm test
214
+ npm run test:e2e
215
+ npx vitest run test/channel-feishu.unit.test.ts
216
+ npx pikiclaw@latest --doctor
320
217
  ```
321
218
 
322
- 架构详情见 [ARCHITECTURE.md](ARCHITECTURE.md)。
219
+ 更多实现细节见:
220
+
221
+ - [ARCHITECTURE.md](ARCHITECTURE.md)
222
+ - [INTEGRATION.md](INTEGRATION.md)
223
+ - [TESTING.md](TESTING.md)
323
224
 
324
225
  ---
325
226
 
@@ -23,10 +23,18 @@ export function getStartData(bot, chatId) {
23
23
  const installedCount = res.agents.filter(a => a.installed).length;
24
24
  const skillRes = bot.fetchSkills();
25
25
  const commands = buildDefaultMenuCommands(installedCount, skillRes.skills);
26
+ const agentDetails = res.agents
27
+ .filter(a => a.installed)
28
+ .map(a => ({
29
+ agent: a.agent,
30
+ model: bot.modelForAgent(a.agent) || '(default)',
31
+ effort: bot.effortForAgent(a.agent),
32
+ }));
26
33
  return {
27
34
  ...intro,
28
35
  agent: cs.agent,
29
36
  workdir: bot.workdir,
37
+ agentDetails,
30
38
  commands,
31
39
  };
32
40
  }
@@ -262,6 +262,14 @@ export function renderStart(d) {
262
262
  `**Agent:** ${d.agent}`,
263
263
  `**Workdir:** \`${d.workdir}\``,
264
264
  '',
265
+ '**Agents**',
266
+ ...d.agentDetails.map(a => {
267
+ let line = ` **${a.agent}**: ${a.model}`;
268
+ if (a.effort)
269
+ line += ` (effort: ${a.effort})`;
270
+ return line;
271
+ }),
272
+ '',
265
273
  '**Commands**',
266
274
  ...d.commands.map(c => `/${c.command} — ${c.description}`),
267
275
  ];
@@ -21,7 +21,6 @@ import { feishuPreviewRenderer, buildInitialPreviewMarkdown, buildFinalReplyRend
21
21
  import { FeishuChannel } from './channel-feishu.js';
22
22
  import { splitText, supportsChannelCapability } from './channel-base.js';
23
23
  import { getActiveUserConfig } from './user-config.js';
24
- import { VERSION } from './version.js';
25
24
  const SHUTDOWN_EXIT_CODE = {
26
25
  SIGINT: 130,
27
26
  SIGTERM: 143,
@@ -794,9 +793,10 @@ export class FeishuBot extends Bot {
794
793
  this.log('no known chats for startup notice');
795
794
  return;
796
795
  }
797
- const text = `**${VERSION}** pikiclaw is online.\nSend /start to get started.`;
798
796
  for (const cid of targets) {
799
797
  try {
798
+ const d = getStartData(this, cid);
799
+ const text = renderStart(d);
800
800
  await this.channel.send(cid, text);
801
801
  this.log(`startup notice sent to chat=${cid}`);
802
802
  }
@@ -12,7 +12,7 @@ import { spawn } from 'node:child_process';
12
12
  import { Bot, fmtTokens, fmtUptime, fmtBytes, buildPrompt, parseAllowedChatIds, } from './bot.js';
13
13
  import { stageSessionFiles, } from './code-agent.js';
14
14
  import { shutdownAllDrivers } from './agent-driver.js';
15
- import { buildDefaultMenuCommands, buildWelcomeIntro, SKILL_CMD_PREFIX, } from './bot-menu.js';
15
+ import { buildDefaultMenuCommands, SKILL_CMD_PREFIX, } from './bot-menu.js';
16
16
  import { getStartData, getStatusDataAsync, getHostDataSync, getSessionTurnPreviewData, resolveSkillPrompt, summarizePromptForStatus, } from './bot-commands.js';
17
17
  import { buildAgentsCommandView, buildModelsCommandView, buildSessionsCommandView, buildSkillsCommandView, decodeCommandAction, executeCommandAction, } from './bot-command-ui.js';
18
18
  import { buildSwitchWorkdirView, resolveRegisteredPath } from './bot-telegram-directory.js';
@@ -22,7 +22,6 @@ import { buildInitialPreviewHtml, buildStreamPreviewHtml, buildFinalReplyRender,
22
22
  import { TelegramChannel } from './channel-telegram.js';
23
23
  import { splitText, supportsChannelCapability } from './channel-base.js';
24
24
  import { getActiveUserConfig } from './user-config.js';
25
- import { VERSION } from './version.js';
26
25
  /** Telegram HTML renderer for LivePreview. */
27
26
  const telegramPreviewRenderer = {
28
27
  renderInitial: buildInitialPreviewHtml,
@@ -164,13 +163,6 @@ export class TelegramBot extends Bot {
164
163
  const commands = buildDefaultMenuCommands(installedCount, skillRes.skills);
165
164
  return { commands, skillCount: skillRes.skills.length, skills: skillRes.skills };
166
165
  }
167
- welcomeIntroLines() {
168
- const intro = buildWelcomeIntro(VERSION);
169
- return [
170
- `<b>${escapeHtml(intro.title)}</b> v${escapeHtml(intro.version)}`,
171
- escapeHtml(intro.subtitle),
172
- ];
173
- }
174
166
  createTaskId(session) {
175
167
  const seq = this.nextTaskId++;
176
168
  return `${session.key}:${Date.now().toString(36)}:${seq.toString(36)}`;
@@ -242,6 +234,9 @@ export class TelegramBot extends Bot {
242
234
  // ---- commands -------------------------------------------------------------
243
235
  async cmdStart(ctx) {
244
236
  const d = getStartData(this, ctx.chatId);
237
+ await ctx.reply(this.renderStartHtml(d), { parseMode: 'HTML' });
238
+ }
239
+ renderStartHtml(d) {
245
240
  const lines = [
246
241
  `<b>${escapeHtml(d.title)}</b> v${escapeHtml(d.version)}`,
247
242
  escapeHtml(d.subtitle),
@@ -249,10 +244,18 @@ export class TelegramBot extends Bot {
249
244
  `<b>Agent:</b> ${escapeHtml(d.agent)}`,
250
245
  `<b>Workdir:</b> <code>${escapeHtml(d.workdir)}</code>`,
251
246
  '',
247
+ '<b>Agents</b>',
248
+ ...d.agentDetails.map(a => {
249
+ const parts = [` <b>${escapeHtml(a.agent)}</b>: ${escapeHtml(a.model)}`];
250
+ if (a.effort)
251
+ parts[0] += ` (effort: ${escapeHtml(a.effort)})`;
252
+ return parts[0];
253
+ }),
254
+ '',
252
255
  '<b>Commands</b>',
253
256
  ...formatMenuLines(d.commands),
254
257
  ];
255
- await ctx.reply(lines.join('\n'), { parseMode: 'HTML' });
258
+ return lines.join('\n');
256
259
  }
257
260
  async cmdSkills(ctx) {
258
261
  await this.sendCommandView(ctx, buildSkillsCommandView(this, ctx.chatId));
@@ -776,9 +779,10 @@ export class TelegramBot extends Bot {
776
779
  this.log('no known chats for startup notice');
777
780
  return;
778
781
  }
779
- const text = this.welcomeIntroLines().join('\n');
780
782
  for (const cid of targets) {
781
783
  try {
784
+ const d = getStartData(this, cid);
785
+ const text = this.renderStartHtml(d);
782
786
  await this.channel.send(cid, text, { parseMode: 'HTML' });
783
787
  this.log(`startup notice sent to chat=${cid}`);
784
788
  }
@@ -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
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ import { startDashboard } from './dashboard.js';
12
12
  import { buildSetupGuide, collectSetupState, hasReadyAgent, isSetupReady } from './onboarding.js';
13
13
  import { buildRestartCommand, clearRestartStateFile, consumeRestartStateFile, createRestartStateFilePath, PROCESS_RESTART_EXIT_CODE, requestProcessRestart, } from './process-control.js';
14
14
  import { runSetupWizard } from './setup-wizard.js';
15
- import { applyUserConfig, loadUserConfig, startUserConfigSync } from './user-config.js';
15
+ import { applyUserConfig, loadUserConfig, startUserConfigSync, updateUserConfig } from './user-config.js';
16
16
  import { VERSION } from './version.js';
17
17
  /* ── Daemon (watchdog) mode ─────────────────────────────────────────── */
18
18
  const DAEMON_RESTART_DELAY_MS = 3_000;
@@ -191,6 +191,16 @@ export async function main() {
191
191
  process.stdout.write(`pikiclaw ${VERSION}\n`);
192
192
  process.exit(0);
193
193
  }
194
+ // Fresh CLI launch (not a daemon-managed child): persist the current working
195
+ // directory (or explicit -w) into setting.json so the bot session — and any
196
+ // subsequent daemon-managed restarts — starts in the right place.
197
+ if (!process.env.PIKICLAW_DAEMON_CHILD) {
198
+ const cliWorkdir = path.resolve(args.workdir || '.');
199
+ if (userConfig.workdir !== cliWorkdir) {
200
+ updateUserConfig({ workdir: cliWorkdir });
201
+ userConfig = loadUserConfig();
202
+ }
203
+ }
194
204
  // Daemon mode (default): become a watchdog that supervises the real bot process.
195
205
  // The child is spawned via `npx pikiclaw@latest` so restarts always pull latest code.
196
206
  // Use --no-daemon to disable.
@@ -212,8 +222,6 @@ export async function main() {
212
222
  const configOverrides = {};
213
223
  if (args.agent)
214
224
  configOverrides.defaultAgent = args.agent;
215
- if (args.workdir)
216
- process.env.PIKICLAW_WORKDIR = path.resolve(args.workdir);
217
225
  // Apply config early so managed env vars are populated from setting.json.
218
226
  applyUserConfig({ ...userConfig, ...configOverrides }, undefined, { overwrite: true, clearMissing: true });
219
227
  const effectiveConfig = () => ({ ...userConfig, ...configOverrides });
@@ -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
  }
@@ -210,10 +210,27 @@ export async function validateFeishuConfig(appId, appSecret, options = {}) {
210
210
  app: null,
211
211
  };
212
212
  }
213
- const app = { appId: trimmedAppId, displayName: null };
214
- feishuValidationLog(appLabel, `verified elapsedMs=${Date.now() - startedAt}`);
213
+ // Try to fetch bot display name using the tenant access token
214
+ let botDisplayName = null;
215
+ try {
216
+ const botResp = await withTimeout(fetch(`${apiDomain}/open-apis/bot/v3/info`, {
217
+ method: 'GET',
218
+ headers: { Authorization: `Bearer ${parsed.tenant_access_token}` },
219
+ }).then(r => r.json()), 5_000, 'Feishu bot info');
220
+ if (botResp?.bot?.app_name) {
221
+ botDisplayName = botResp.bot.app_name;
222
+ }
223
+ }
224
+ catch {
225
+ // Non-critical — proceed without bot name
226
+ }
227
+ const app = { appId: trimmedAppId, displayName: botDisplayName };
228
+ const identity = botDisplayName
229
+ ? `${botDisplayName} (${appLabel})`
230
+ : `App ${appLabel} verified.`;
231
+ feishuValidationLog(appLabel, `verified botName=${botDisplayName ?? '(unknown)'} elapsedMs=${Date.now() - startedAt}`);
215
232
  return {
216
- state: readyChannelState('feishu', `App ${trimmedAppId} verified.`),
233
+ state: readyChannelState('feishu', identity),
217
234
  app,
218
235
  };
219
236
  }
@@ -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.43",
4
4
  "description": "The best IM-driven remote coding experience. Bridge AI coding agents to any IM.",
5
5
  "type": "module",
6
6
  "bin": {