weacpx 0.3.1 → 0.3.2
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 +70 -19
- package/dist/bridge/bridge-main.js +163 -7
- package/dist/cli.js +2190 -869
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -11,21 +11,21 @@
|
|
|
11
11
|
|
|
12
12
|
## 这是什么
|
|
13
13
|
|
|
14
|
-
`weacpx`
|
|
14
|
+
`weacpx` 是一个可以通过微信直接控制 Codex / Claude Code / Gemini / OpenCode 的工具。它把微信消息通过 `acpx` 连接到 Agent CLI 会话上,让你直接在手机里:
|
|
15
15
|
|
|
16
|
-
-
|
|
16
|
+
- 新建和切换会话
|
|
17
17
|
- 让 Agent 继续在指定项目目录里工作
|
|
18
18
|
- 查看流式回复、最终结果和工具调用摘要
|
|
19
19
|
- 调整权限策略
|
|
20
|
-
-
|
|
20
|
+
- 在需要时做多 Agent 编排
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
如果你需要临时远程编码或办公,`weacpx` 提供的是一个方便快捷的**远程入口**,让你在微信里就能随时随地干活。
|
|
23
23
|
|
|
24
24
|
## 适合谁
|
|
25
25
|
|
|
26
|
-
`weacpx`
|
|
26
|
+
`weacpx` 适合轻量临时使用多 Agent 办公的用户。你可以用微信盯任务、发指令、看结果,并在同一个聊天里管理多个会话。
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
> `weacpx` 的会话是跟本地隔离的,它目前还不能使用 CLI 已有的会话,你在 weacpx 也无法看到本地的 CLI 会话记录。
|
|
29
29
|
|
|
30
30
|
## 5 分钟快速开始
|
|
31
31
|
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
开始前,你至少需要:
|
|
35
35
|
|
|
36
36
|
- Node.js 22+ 或 Bun
|
|
37
|
-
- 已可用的 Codex
|
|
37
|
+
- 已可用的 Codex / Claude Code / Gemini / OpenCode
|
|
38
38
|
- 一台装了微信的手机
|
|
39
39
|
|
|
40
40
|
> `weacpx` 基于 `weixin-agent-sdk` 和 `acpx` 工作。正常情况下,你不需要额外全局安装 `acpx`。
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
### 安装
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
npm install -g weacpx
|
|
45
|
+
npm install -g weacpx --registry=https://registry.npmjs.org
|
|
46
46
|
# 或
|
|
47
47
|
bun add -g weacpx
|
|
48
48
|
```
|
|
@@ -53,7 +53,7 @@ bun add -g weacpx
|
|
|
53
53
|
weacpx login
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
终端会显示二维码,请继续用微信扫码登录。
|
|
57
57
|
|
|
58
58
|
### 启动服务
|
|
59
59
|
|
|
@@ -85,7 +85,7 @@ hello
|
|
|
85
85
|
1. **启动后台服务**:`weacpx start`
|
|
86
86
|
2. **创建或切换会话**:`/ss ...`、`/use ...`
|
|
87
87
|
3. **直接发普通文本**:让当前会话继续工作
|
|
88
|
-
4.
|
|
88
|
+
4. **必要时查看状态或取消当前任务**:`/status`、`/cancel`
|
|
89
89
|
|
|
90
90
|
### 1) 创建会话
|
|
91
91
|
|
|
@@ -95,7 +95,7 @@ hello
|
|
|
95
95
|
/ss codex -d /absolute/path/to/your/repo
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
它会使用 `codex
|
|
98
|
+
它会使用 `codex`,绑定这个工作目录,并自动切换到新会话。
|
|
99
99
|
|
|
100
100
|
### 2) 发普通消息
|
|
101
101
|
|
|
@@ -109,9 +109,9 @@ hello
|
|
|
109
109
|
|
|
110
110
|
`weacpx` 支持三种常用回复模式:
|
|
111
111
|
|
|
112
|
-
- `stream
|
|
112
|
+
- `stream`:流式返回中间文本
|
|
113
113
|
- `final`:只返回最终结果
|
|
114
|
-
- `verbose
|
|
114
|
+
- `verbose`:默认,在流式文本之外,额外显示工具调用摘要
|
|
115
115
|
|
|
116
116
|
例如 `verbose` 模式下,你会看到:
|
|
117
117
|
|
|
@@ -208,17 +208,19 @@ weacpx doctor --smoke --agent codex --workspace backend
|
|
|
208
208
|
|
|
209
209
|
## 常用微信命令
|
|
210
210
|
|
|
211
|
-
下面这部分保留一份**中等长度**的日常手册。够你上手和日常使用,但不把 README 写成完整参考手册。
|
|
212
|
-
|
|
213
211
|
完整微信命令参考见:[docs/commands.md](./docs/commands.md)。
|
|
214
212
|
|
|
215
213
|
### Agent 管理
|
|
216
214
|
|
|
215
|
+
已内置常用的 Codex 与 Claude Code;
|
|
216
|
+
|
|
217
|
+
可以使用 `/agent add opencode` 添加你所需要的 agents。
|
|
218
|
+
|
|
217
219
|
| 命令 | 说明 |
|
|
218
220
|
|------|------|
|
|
219
221
|
| `/agents` | 查看 agent 列表 |
|
|
220
|
-
| `/agent add
|
|
221
|
-
| `/agent add
|
|
222
|
+
| `/agent add gemini` | 添加 `Gemini` Agent |
|
|
223
|
+
| `/agent add opencode` | 添加 `OpenCode` Agent |
|
|
222
224
|
| `/agent rm <name>` | 删除 agent |
|
|
223
225
|
|
|
224
226
|
### Workspace 管理
|
|
@@ -226,7 +228,7 @@ weacpx doctor --smoke --agent codex --workspace backend
|
|
|
226
228
|
| 命令 | 说明 |
|
|
227
229
|
|------|------|
|
|
228
230
|
| `/workspaces` / `/workspace` / `/ws` | 查看 workspace 列表 |
|
|
229
|
-
| `/ws new <name> -d <path>` | 添加 workspace,`path`
|
|
231
|
+
| `/ws new <name> -d <path>` | 添加 workspace,`path` 是电脑上的绝对路径,Windows 不用区分正反斜杠 |
|
|
230
232
|
| `/workspace rm <name>` | 删除 workspace |
|
|
231
233
|
|
|
232
234
|
### Session 会话
|
|
@@ -246,7 +248,7 @@ weacpx doctor --smoke --agent codex --workspace backend
|
|
|
246
248
|
| `/replymode reset` | 回退到全局默认 reply mode |
|
|
247
249
|
| `/session reset` | 重置当前会话上下文 |
|
|
248
250
|
| `/clear` | `/session reset` 的快捷别名 |
|
|
249
|
-
| `/cancel` / `/stop` |
|
|
251
|
+
| `/cancel` / `/stop` | 停止当前任务 |
|
|
250
252
|
|
|
251
253
|
建议你优先记住这三个:
|
|
252
254
|
|
|
@@ -316,6 +318,55 @@ README 里只保留用户视角的最常用命令。
|
|
|
316
318
|
|
|
317
319
|
- [docs/weacpx-group-usage-guide.md](./docs/weacpx-group-usage-guide.md)
|
|
318
320
|
|
|
321
|
+
|
|
322
|
+
### MCP 集成:外部 coordinator
|
|
323
|
+
|
|
324
|
+
如果你想让 Codex、Claude Code 等外部 MCP host 直接使用 weacpx 的多 Agent 编排能力,可以把 `weacpx mcp-stdio` 配成一个 stdio MCP server。
|
|
325
|
+
|
|
326
|
+
先启动 daemon:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
weacpx start
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
MCP 配置推荐保持简单,不要在启动参数里绑定 workspace:
|
|
333
|
+
|
|
334
|
+
```json
|
|
335
|
+
{
|
|
336
|
+
"mcpServers": {
|
|
337
|
+
"weacpx": {
|
|
338
|
+
"command": "weacpx",
|
|
339
|
+
"args": ["mcp-stdio"]
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
外部 host 调用 `delegate_request` 时传 `workingDirectory`,weacpx 会让被委派的 worker 在这个目录工作:
|
|
346
|
+
|
|
347
|
+
```json
|
|
348
|
+
{
|
|
349
|
+
"targetAgent": "claude",
|
|
350
|
+
"task": "审查这个改动的风险点",
|
|
351
|
+
"workingDirectory": "/absolute/path/to/your/repo"
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Windows 上如果 MCP host 不会帮你解析带参数的 `command`,把 `node.exe` 放在 `command`,把 weacpx 脚本和参数放在 `args`:
|
|
356
|
+
|
|
357
|
+
```json
|
|
358
|
+
{
|
|
359
|
+
"type": "stdio",
|
|
360
|
+
"command": "D:\\Users\\you\\.nvmd\\versions\\22.19.0\\node.exe",
|
|
361
|
+
"args": [
|
|
362
|
+
"E:\\projects\\weacpx\\dist\\cli.js",
|
|
363
|
+
"mcp-stdio"
|
|
364
|
+
]
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
更多身份规则、`workingDirectory` 语义、工具列表、流程图和故障排查见:[docs/external-mcp.md](./docs/external-mcp.md)。
|
|
369
|
+
|
|
319
370
|
## 常见场景
|
|
320
371
|
|
|
321
372
|
### 在手机上继续盯一个本地项目
|
|
@@ -212,6 +212,115 @@ var init_spawn_command = __esm(() => {
|
|
|
212
212
|
SCRIPT_FILE_PATTERN = /\.(c|m)?js$/i;
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
+
// src/transport/prompt-media.ts
|
|
216
|
+
import { mkdtemp, open, rm, writeFile } from "node:fs/promises";
|
|
217
|
+
import { tmpdir as defaultTmpdir } from "node:os";
|
|
218
|
+
import path from "node:path";
|
|
219
|
+
async function createStructuredPromptFile(text, media, deps = defaultStructuredPromptFileDeps) {
|
|
220
|
+
if (!media) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
if (media.type !== "image") {
|
|
224
|
+
throw new Error("prompt media type is not supported; only image media is supported");
|
|
225
|
+
}
|
|
226
|
+
const imageData = await deps.readImageFile(media.filePath, MAX_STRUCTURED_IMAGE_BYTES);
|
|
227
|
+
if (imageData.byteLength === 0) {
|
|
228
|
+
throw new Error("image prompt must not be empty");
|
|
229
|
+
}
|
|
230
|
+
if (imageData.byteLength > MAX_STRUCTURED_IMAGE_BYTES) {
|
|
231
|
+
throw new Error(`image prompt exceeds ${MAX_STRUCTURED_IMAGE_BYTES} bytes`);
|
|
232
|
+
}
|
|
233
|
+
const blocks = [];
|
|
234
|
+
if (text.trim().length > 0) {
|
|
235
|
+
blocks.push({ type: "text", text });
|
|
236
|
+
}
|
|
237
|
+
blocks.push({
|
|
238
|
+
type: "image",
|
|
239
|
+
mimeType: resolveImageMimeType(imageData, media.mimeType),
|
|
240
|
+
data: imageData.toString("base64")
|
|
241
|
+
});
|
|
242
|
+
let dir = "";
|
|
243
|
+
try {
|
|
244
|
+
dir = await deps.mkdtemp(path.join(deps.tmpdir(), "weacpx-acp-prompt-"));
|
|
245
|
+
const filePath = path.join(dir, "prompt.json");
|
|
246
|
+
await deps.writeFile(filePath, JSON.stringify(blocks), "utf8");
|
|
247
|
+
return {
|
|
248
|
+
filePath,
|
|
249
|
+
cleanup: async () => {
|
|
250
|
+
await deps.rm(dir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (dir) {
|
|
255
|
+
try {
|
|
256
|
+
await deps.rm(dir, { recursive: true, force: true });
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
async function readImageFileBounded(filePath, maxBytes) {
|
|
263
|
+
const handle = await open(filePath, "r");
|
|
264
|
+
try {
|
|
265
|
+
const imageStats = await handle.stat();
|
|
266
|
+
if (!imageStats.isFile()) {
|
|
267
|
+
throw new Error("image prompt path must be a regular file");
|
|
268
|
+
}
|
|
269
|
+
if (imageStats.size > maxBytes) {
|
|
270
|
+
throw new Error(`image prompt exceeds ${maxBytes} bytes`);
|
|
271
|
+
}
|
|
272
|
+
const chunks = [];
|
|
273
|
+
let total = 0;
|
|
274
|
+
let position = 0;
|
|
275
|
+
const chunkSize = 1024 * 1024;
|
|
276
|
+
while (total <= maxBytes) {
|
|
277
|
+
const buffer = Buffer.allocUnsafe(Math.min(chunkSize, maxBytes + 1 - total));
|
|
278
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
279
|
+
if (bytesRead === 0)
|
|
280
|
+
break;
|
|
281
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
282
|
+
total += bytesRead;
|
|
283
|
+
position += bytesRead;
|
|
284
|
+
}
|
|
285
|
+
return Buffer.concat(chunks, total);
|
|
286
|
+
} finally {
|
|
287
|
+
await handle.close();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function resolveImageMimeType(buffer, declaredMimeType) {
|
|
291
|
+
if (/^image\/[A-Za-z0-9.+-]+$/.test(declaredMimeType) && declaredMimeType !== "image/*") {
|
|
292
|
+
return declaredMimeType;
|
|
293
|
+
}
|
|
294
|
+
if (buffer.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) {
|
|
295
|
+
return "image/png";
|
|
296
|
+
}
|
|
297
|
+
if (buffer.length >= 3 && buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255) {
|
|
298
|
+
return "image/jpeg";
|
|
299
|
+
}
|
|
300
|
+
const header6 = buffer.subarray(0, 6).toString("ascii");
|
|
301
|
+
if (header6 === "GIF87a" || header6 === "GIF89a") {
|
|
302
|
+
return "image/gif";
|
|
303
|
+
}
|
|
304
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") {
|
|
305
|
+
return "image/webp";
|
|
306
|
+
}
|
|
307
|
+
if (buffer.length >= 2 && buffer.subarray(0, 2).toString("ascii") === "BM") {
|
|
308
|
+
return "image/bmp";
|
|
309
|
+
}
|
|
310
|
+
return "image/png";
|
|
311
|
+
}
|
|
312
|
+
var MAX_STRUCTURED_IMAGE_BYTES, defaultStructuredPromptFileDeps;
|
|
313
|
+
var init_prompt_media = __esm(() => {
|
|
314
|
+
MAX_STRUCTURED_IMAGE_BYTES = 100 * 1024 * 1024;
|
|
315
|
+
defaultStructuredPromptFileDeps = {
|
|
316
|
+
readImageFile: readImageFileBounded,
|
|
317
|
+
mkdtemp,
|
|
318
|
+
writeFile,
|
|
319
|
+
rm,
|
|
320
|
+
tmpdir: defaultTmpdir
|
|
321
|
+
};
|
|
322
|
+
});
|
|
323
|
+
|
|
215
324
|
// src/transport/streaming-prompt.ts
|
|
216
325
|
function createStreamingPromptState(formatToolCalls = false) {
|
|
217
326
|
return {
|
|
@@ -417,9 +526,9 @@ function isUnder(child, parent) {
|
|
|
417
526
|
const p = parent.replace(/[\\/]+$/, "");
|
|
418
527
|
return c === p || c.startsWith(p + "/") || c.startsWith(p + "\\");
|
|
419
528
|
}
|
|
420
|
-
async function defaultFsExists(
|
|
529
|
+
async function defaultFsExists(path2) {
|
|
421
530
|
try {
|
|
422
|
-
await access(
|
|
531
|
+
await access(path2);
|
|
423
532
|
return true;
|
|
424
533
|
} catch {
|
|
425
534
|
return false;
|
|
@@ -787,6 +896,7 @@ class BridgeRequestScheduler {
|
|
|
787
896
|
// src/bridge/bridge-runtime.ts
|
|
788
897
|
init_spawn_command();
|
|
789
898
|
init_prompt_output();
|
|
899
|
+
init_prompt_media();
|
|
790
900
|
init_streaming_prompt();
|
|
791
901
|
import { copyFile, readdir } from "node:fs/promises";
|
|
792
902
|
import { homedir as homedir3 } from "node:os";
|
|
@@ -933,15 +1043,22 @@ class BridgeRuntime {
|
|
|
933
1043
|
}
|
|
934
1044
|
async prompt(input, onEvent) {
|
|
935
1045
|
await this.launchMcpQueueOwnerIfNeeded(input);
|
|
1046
|
+
const structuredPrompt = await createStructuredPromptFile(input.text, input.media);
|
|
936
1047
|
const spawnSpec = resolveSpawnCommand(this.command, this.buildPromptArgs(input, [
|
|
937
1048
|
"prompt",
|
|
938
1049
|
"-s",
|
|
939
1050
|
input.name,
|
|
940
|
-
input.text
|
|
1051
|
+
...structuredPrompt ? ["--file", structuredPrompt.filePath] : [input.text]
|
|
941
1052
|
]));
|
|
942
1053
|
const formatToolCalls = input.replyMode === "verbose";
|
|
943
|
-
|
|
944
|
-
|
|
1054
|
+
try {
|
|
1055
|
+
const result = onEvent ? await this.runPromptCommand(spawnSpec.command, spawnSpec.args, onEvent, { formatToolCalls }) : await this.run(spawnSpec.command, spawnSpec.args);
|
|
1056
|
+
return { text: getPromptText(result) };
|
|
1057
|
+
} finally {
|
|
1058
|
+
try {
|
|
1059
|
+
await structuredPrompt?.cleanup();
|
|
1060
|
+
} catch {}
|
|
1061
|
+
}
|
|
945
1062
|
}
|
|
946
1063
|
async launchMcpQueueOwnerIfNeeded(input) {
|
|
947
1064
|
if (!input.mcpCoordinatorSession) {
|
|
@@ -1335,6 +1452,7 @@ class BridgeServer {
|
|
|
1335
1452
|
}
|
|
1336
1453
|
});
|
|
1337
1454
|
case "prompt":
|
|
1455
|
+
const media = asOptionalPromptMedia(params.media);
|
|
1338
1456
|
return await this.runtime.prompt({
|
|
1339
1457
|
agent: requireString(params, "agent"),
|
|
1340
1458
|
agentCommand: asOptionalString(params.agentCommand),
|
|
@@ -1342,8 +1460,9 @@ class BridgeServer {
|
|
|
1342
1460
|
name: requireString(params, "name"),
|
|
1343
1461
|
mcpCoordinatorSession: asOptionalString(params.mcpCoordinatorSession),
|
|
1344
1462
|
mcpSourceHandle: asOptionalString(params.mcpSourceHandle),
|
|
1345
|
-
text:
|
|
1346
|
-
replyMode: asOptionalReplyMode(params.replyMode)
|
|
1463
|
+
text: requirePromptText(params, media),
|
|
1464
|
+
replyMode: asOptionalReplyMode(params.replyMode),
|
|
1465
|
+
media
|
|
1347
1466
|
}, (event) => {
|
|
1348
1467
|
if (event.type === "prompt.segment") {
|
|
1349
1468
|
writeLine?.(encodeBridgePromptSegmentEvent({
|
|
@@ -1449,6 +1568,16 @@ function requireString(params, key) {
|
|
|
1449
1568
|
}
|
|
1450
1569
|
return value;
|
|
1451
1570
|
}
|
|
1571
|
+
function requirePromptText(params, media) {
|
|
1572
|
+
const value = params.text;
|
|
1573
|
+
if (typeof value !== "string") {
|
|
1574
|
+
throw new BridgeInvalidRequestError("text must be a non-empty string");
|
|
1575
|
+
}
|
|
1576
|
+
if (value.length === 0 && media?.type !== "image") {
|
|
1577
|
+
throw new BridgeInvalidRequestError("text must be a non-empty string unless image media is provided");
|
|
1578
|
+
}
|
|
1579
|
+
return value;
|
|
1580
|
+
}
|
|
1452
1581
|
function requirePermissionMode(params, key) {
|
|
1453
1582
|
const value = params[key];
|
|
1454
1583
|
if (value === "approve-all" || value === "approve-reads" || value === "deny-all") {
|
|
@@ -1469,6 +1598,33 @@ function asOptionalString(value) {
|
|
|
1469
1598
|
}
|
|
1470
1599
|
return value;
|
|
1471
1600
|
}
|
|
1601
|
+
function asOptionalPromptMedia(value) {
|
|
1602
|
+
if (value === undefined) {
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1606
|
+
throw new BridgeInvalidRequestError("media must be an object when provided");
|
|
1607
|
+
}
|
|
1608
|
+
const record = value;
|
|
1609
|
+
const type = record.type;
|
|
1610
|
+
const filePath = record.filePath;
|
|
1611
|
+
const mimeType = record.mimeType;
|
|
1612
|
+
if (type !== "image") {
|
|
1613
|
+
throw new BridgeInvalidRequestError("media.type must be image");
|
|
1614
|
+
}
|
|
1615
|
+
if (typeof filePath !== "string" || filePath.length === 0) {
|
|
1616
|
+
throw new BridgeInvalidRequestError("media.filePath must be a non-empty string");
|
|
1617
|
+
}
|
|
1618
|
+
if (typeof mimeType !== "string" || mimeType.length === 0) {
|
|
1619
|
+
throw new BridgeInvalidRequestError("media.mimeType must be a non-empty string");
|
|
1620
|
+
}
|
|
1621
|
+
return {
|
|
1622
|
+
type,
|
|
1623
|
+
filePath,
|
|
1624
|
+
mimeType,
|
|
1625
|
+
...typeof record.fileName === "string" && record.fileName.length > 0 ? { fileName: record.fileName } : {}
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1472
1628
|
var VALID_REPLY_MODES = new Set(["stream", "final", "verbose"]);
|
|
1473
1629
|
function asOptionalReplyMode(value) {
|
|
1474
1630
|
if (typeof value !== "string" || !VALID_REPLY_MODES.has(value)) {
|