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 +129 -228
- package/dist/bot-commands.js +8 -0
- package/dist/bot-feishu-render.js +8 -0
- package/dist/bot-feishu.js +2 -2
- package/dist/bot-telegram.js +15 -11
- package/dist/channel-feishu.js +44 -5
- package/dist/cli.js +11 -3
- package/dist/code-agent.js +7 -10
- package/dist/config-validation.js +20 -3
- package/dist/mcp-bridge.js +83 -10
- package/dist/tools/workspace.js +44 -72
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,21 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
# pikiclaw
|
|
4
4
|
|
|
5
|
-
**
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<!--  -->
|
|
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
|
-
|
|
25
|
+
很多“IM 接 Agent”的方案,本质上还是在绕路:
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
- 要么自己造 Agent,效果不如官方 CLI
|
|
28
|
+
- 要么跑在远端沙盒里,不是你的环境
|
|
29
|
+
- 要么只能短对话,不适合长任务
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
- **最好的 IM** — Telegram(全球)+ 飞书(国内),不追求数量,每个都打磨到位
|
|
33
|
-
- **最好的执行环境** — 你自己的电脑,不是云端沙盒,什么都能干
|
|
31
|
+
pikiclaw 的目标很直接:
|
|
34
32
|
|
|
35
|
-
|
|
33
|
+
- 用官方 Agent CLI,而不是重新发明一套
|
|
34
|
+
- 用你自己的电脑,而不是陌生沙盒
|
|
35
|
+
- 用你已经在用的 IM,而不是再学一套远程控制方式
|
|
36
36
|
|
|
37
37
|
```
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
你(Telegram / 飞书)
|
|
39
|
+
│
|
|
40
|
+
▼
|
|
41
|
+
pikiclaw
|
|
42
|
+
│
|
|
43
|
+
▼
|
|
44
|
+
Claude Code / Codex / Gemini
|
|
45
|
+
│
|
|
46
|
+
▼
|
|
47
|
+
你的电脑
|
|
41
48
|
```
|
|
42
49
|
|
|
43
|
-
|
|
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
|
-
-
|
|
64
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
```bash
|
|
90
|
+
npx pikiclaw@latest --doctor
|
|
91
|
+
```
|
|
86
92
|
|
|
87
93
|
---
|
|
88
94
|
|
|
89
|
-
##
|
|
90
|
-
|
|
91
|
-
### Agent Engines
|
|
95
|
+
## What Exists Today
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|-------|------|
|
|
95
|
-
| **Claude Code** | Anthropic 官方 CLI · Thinking 展示 · 多模态 · 缓存优化 |
|
|
96
|
-
| **Codex CLI** | OpenAI 官方 CLI · Reasoning 展示 · 计划步骤追踪 · 实时用量 |
|
|
97
|
-
| **Gemini CLI** | Google 官方 CLI · 工具调用 · 流式输出 |
|
|
97
|
+
### Channels
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
- Telegram 已可用
|
|
100
|
+
- 飞书已可用
|
|
101
|
+
- 两个渠道可以同时启动
|
|
100
102
|
|
|
101
|
-
###
|
|
103
|
+
### Agents
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
| **飞书** | ✅ | ✅ | ✅ | ✅ | 国内 / 团队 |
|
|
105
|
+
- Claude Code
|
|
106
|
+
- Codex CLI
|
|
107
|
+
- Gemini CLI
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
Agent 通过 driver registry 接入,模型列表、会话列表、usage 展示都走统一接口。
|
|
109
110
|
|
|
110
|
-
###
|
|
111
|
+
### Runtime
|
|
111
112
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
+
- 支持项目级 `.pikiclaw/skills/*/SKILL.md`
|
|
123
|
+
- 兼容 `.claude/commands/*.md`
|
|
124
|
+
- IM 内可通过 `/skills` 和 `/sk_<name>` 触发
|
|
129
125
|
|
|
130
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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` |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `/start` | 显示入口信息、当前 Agent、工作目录 |
|
|
145
|
+
| `/sessions` | 查看、切换或新建会话 |
|
|
194
146
|
| `/agents` | 切换 Agent |
|
|
195
|
-
| `/models` | 查看并切换模型 |
|
|
196
|
-
| `/sessions` | 查看并切换历史会话 |
|
|
147
|
+
| `/models` | 查看并切换模型 / reasoning effort |
|
|
197
148
|
| `/switch` | 浏览并切换工作目录 |
|
|
198
|
-
| `/status` |
|
|
199
|
-
| `/host` |
|
|
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
|
-
|
|
155
|
+
普通文本消息会直接转给当前 Agent。
|
|
205
156
|
|
|
206
157
|
---
|
|
207
158
|
|
|
208
|
-
##
|
|
159
|
+
## Skills And MCP
|
|
209
160
|
|
|
210
|
-
|
|
161
|
+
项目里现在有两条能力扩展线:
|
|
211
162
|
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
304
|
-
|
|
305
|
-
|
|
|
306
|
-
|
|
|
307
|
-
|
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
219
|
+
更多实现细节见:
|
|
220
|
+
|
|
221
|
+
- [ARCHITECTURE.md](ARCHITECTURE.md)
|
|
222
|
+
- [INTEGRATION.md](INTEGRATION.md)
|
|
223
|
+
- [TESTING.md](TESTING.md)
|
|
323
224
|
|
|
324
225
|
---
|
|
325
226
|
|
package/dist/bot-commands.js
CHANGED
|
@@ -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
|
];
|
package/dist/bot-feishu.js
CHANGED
|
@@ -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
|
}
|
package/dist/bot-telegram.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
}
|
package/dist/channel-feishu.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 });
|
package/dist/code-agent.js
CHANGED
|
@@ -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
|
-
|
|
821
|
-
|
|
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 (
|
|
841
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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',
|
|
233
|
+
state: readyChannelState('feishu', identity),
|
|
217
234
|
app,
|
|
218
235
|
};
|
|
219
236
|
}
|
package/dist/mcp-bridge.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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) {
|
package/dist/tools/workspace.js
CHANGED
|
@@ -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: '
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
194
|
-
|
|
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
|