weacpx 0.3.0 → 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 CHANGED
@@ -145,6 +145,50 @@ hello
145
145
  | `weacpx stop` | 停止后台实例 |
146
146
  | `weacpx doctor` | 运行环境诊断 |
147
147
  | `weacpx version` | 查看当前版本 |
148
+ | `weacpx workspace list` | 查看本机已注册的 workspace |
149
+ | `weacpx workspace add [name]` | 把当前目录注册成 workspace;不传 `name` 时使用当前目录名 |
150
+ | `weacpx workspace rm <name>` | 删除 workspace |
151
+
152
+ `workspace` 也可以简写为 `ws`:
153
+
154
+ ```bash
155
+ weacpx ws add
156
+ weacpx ws list
157
+ weacpx ws rm backend
158
+ ```
159
+
160
+ ### `workspace` CLI 怎么用
161
+
162
+ `weacpx workspace` 用来在电脑本机维护 `~/.weacpx/config.json` 里的 `workspaces` 配置。它适合先在终端里注册常用项目目录,然后在微信里用 `--ws <name>` 直接引用。
163
+
164
+ | 命令 | 说明 |
165
+ |------|------|
166
+ | `weacpx workspace list` | 列出已注册的 workspace 及其路径 |
167
+ | `weacpx workspace add` | 把当前目录注册为 workspace,名称默认取当前目录名 |
168
+ | `weacpx workspace add <name>` | 把当前目录注册为指定名称 |
169
+ | `weacpx workspace rm <name>` | 删除指定 workspace |
170
+
171
+ 常见用法:
172
+
173
+ ```bash
174
+ cd /absolute/path/to/backend
175
+ weacpx workspace add backend
176
+
177
+ cd /absolute/path/to/frontend
178
+ weacpx ws add frontend
179
+
180
+ weacpx ws list
181
+ weacpx ws rm frontend
182
+ ```
183
+
184
+ 注册后,你可以在微信里直接使用:
185
+
186
+ ```text
187
+ /ss codex --ws backend
188
+ /ss new claude --ws frontend
189
+ ```
190
+
191
+ 注意:`workspace add` 总是注册**当前终端所在目录**;如果不传名称,会用当前目录名作为 workspace 名称。
148
192
 
149
193
  ### `doctor` 怎么用
150
194
 
@@ -274,6 +318,55 @@ README 里只保留用户视角的最常用命令。
274
318
 
275
319
  - [docs/weacpx-group-usage-guide.md](./docs/weacpx-group-usage-guide.md)
276
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
+
277
370
  ## 常见场景
278
371
 
279
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 {
@@ -354,6 +463,7 @@ var init_streaming_prompt = __esm(() => {
354
463
 
355
464
  // src/recovery/discover-parent-package-paths.ts
356
465
  import { spawn } from "node:child_process";
466
+ import { createRequire as createRequire2 } from "node:module";
357
467
  import { access } from "node:fs/promises";
358
468
  import { homedir } from "node:os";
359
469
  import { dirname, join } from "node:path";
@@ -416,9 +526,9 @@ function isUnder(child, parent) {
416
526
  const p = parent.replace(/[\\/]+$/, "");
417
527
  return c === p || c.startsWith(p + "/") || c.startsWith(p + "\\");
418
528
  }
419
- async function defaultFsExists(path) {
529
+ async function defaultFsExists(path2) {
420
530
  try {
421
- await access(path);
531
+ await access(path2);
422
532
  return true;
423
533
  } catch {
424
534
  return false;
@@ -426,8 +536,8 @@ async function defaultFsExists(path) {
426
536
  }
427
537
  function defaultResolveFromCwd(name, cwd) {
428
538
  try {
429
- const pkgJson = __require.resolve(`${name}/package.json`, {
430
- paths: [cwd, ...__require.resolve.paths(name) ?? []]
539
+ const pkgJson = require2.resolve(`${name}/package.json`, {
540
+ paths: [cwd, ...require2.resolve.paths(name) ?? []]
431
541
  });
432
542
  return dirname(pkgJson);
433
543
  } catch {
@@ -480,11 +590,14 @@ async function defaultQueryPackageManagerRoot(tool) {
480
590
  });
481
591
  });
482
592
  }
483
- var init_discover_parent_package_paths = () => {};
593
+ var require2;
594
+ var init_discover_parent_package_paths = __esm(() => {
595
+ require2 = createRequire2(import.meta.url);
596
+ });
484
597
 
485
598
  // src/process/terminate-process-tree.ts
486
599
  import { spawn as spawn2 } from "node:child_process";
487
- async function terminateProcessTree(pid, platform = process.platform, runCommand = defaultRunProcessCommand, killProcess = (targetPid, signal) => {
600
+ async function terminateProcessTree(pid, options = {}, platform = process.platform, runCommand = defaultRunProcessCommand, killProcess = (targetPid, signal) => {
488
601
  process.kill(targetPid, signal);
489
602
  }, isProcessRunning = defaultIsProcessRunning) {
490
603
  if (pid <= 0) {
@@ -496,7 +609,7 @@ async function terminateProcessTree(pid, platform = process.platform, runCommand
496
609
  } catch {}
497
610
  return;
498
611
  }
499
- const targetPid = pid > 0 ? -pid : pid;
612
+ const targetPid = options.detachedProcessGroup ? -pid : pid;
500
613
  try {
501
614
  killProcess(targetPid, "SIGTERM");
502
615
  } catch {
@@ -692,7 +805,7 @@ async function terminateAcpxQueueOwner(sessionId) {
692
805
  return;
693
806
  }
694
807
  if (typeof owner.pid === "number" && Number.isInteger(owner.pid) && owner.pid > 0) {
695
- await terminateProcessTree(owner.pid);
808
+ await terminateProcessTree(owner.pid, { detachedProcessGroup: true });
696
809
  }
697
810
  await unlink(lockPath).catch(() => {});
698
811
  }
@@ -783,6 +896,7 @@ class BridgeRequestScheduler {
783
896
  // src/bridge/bridge-runtime.ts
784
897
  init_spawn_command();
785
898
  init_prompt_output();
899
+ init_prompt_media();
786
900
  init_streaming_prompt();
787
901
  import { copyFile, readdir } from "node:fs/promises";
788
902
  import { homedir as homedir3 } from "node:os";
@@ -929,15 +1043,22 @@ class BridgeRuntime {
929
1043
  }
930
1044
  async prompt(input, onEvent) {
931
1045
  await this.launchMcpQueueOwnerIfNeeded(input);
1046
+ const structuredPrompt = await createStructuredPromptFile(input.text, input.media);
932
1047
  const spawnSpec = resolveSpawnCommand(this.command, this.buildPromptArgs(input, [
933
1048
  "prompt",
934
1049
  "-s",
935
1050
  input.name,
936
- input.text
1051
+ ...structuredPrompt ? ["--file", structuredPrompt.filePath] : [input.text]
937
1052
  ]));
938
1053
  const formatToolCalls = input.replyMode === "verbose";
939
- const result = onEvent ? await this.runPromptCommand(spawnSpec.command, spawnSpec.args, onEvent, { formatToolCalls }) : await this.run(spawnSpec.command, spawnSpec.args);
940
- return { text: getPromptText(result) };
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
+ }
941
1062
  }
942
1063
  async launchMcpQueueOwnerIfNeeded(input) {
943
1064
  if (!input.mcpCoordinatorSession) {
@@ -1331,6 +1452,7 @@ class BridgeServer {
1331
1452
  }
1332
1453
  });
1333
1454
  case "prompt":
1455
+ const media = asOptionalPromptMedia(params.media);
1334
1456
  return await this.runtime.prompt({
1335
1457
  agent: requireString(params, "agent"),
1336
1458
  agentCommand: asOptionalString(params.agentCommand),
@@ -1338,8 +1460,9 @@ class BridgeServer {
1338
1460
  name: requireString(params, "name"),
1339
1461
  mcpCoordinatorSession: asOptionalString(params.mcpCoordinatorSession),
1340
1462
  mcpSourceHandle: asOptionalString(params.mcpSourceHandle),
1341
- text: requireString(params, "text"),
1342
- replyMode: asOptionalReplyMode(params.replyMode)
1463
+ text: requirePromptText(params, media),
1464
+ replyMode: asOptionalReplyMode(params.replyMode),
1465
+ media
1343
1466
  }, (event) => {
1344
1467
  if (event.type === "prompt.segment") {
1345
1468
  writeLine?.(encodeBridgePromptSegmentEvent({
@@ -1445,6 +1568,16 @@ function requireString(params, key) {
1445
1568
  }
1446
1569
  return value;
1447
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
+ }
1448
1581
  function requirePermissionMode(params, key) {
1449
1582
  const value = params[key];
1450
1583
  if (value === "approve-all" || value === "approve-reads" || value === "deny-all") {
@@ -1465,6 +1598,33 @@ function asOptionalString(value) {
1465
1598
  }
1466
1599
  return value;
1467
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
+ }
1468
1628
  var VALID_REPLY_MODES = new Set(["stream", "final", "verbose"]);
1469
1629
  function asOptionalReplyMode(value) {
1470
1630
  if (typeof value !== "string" || !VALID_REPLY_MODES.has(value)) {