weacpx 0.2.0 → 0.2.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
@@ -71,7 +71,9 @@ bun add -g weacpx
71
71
  hello
72
72
  ```
73
73
 
74
- 如果任务比较长,`weacpx` 会优先把 Agent 的中间回复分段发回微信,而不是一直等到最后一条结果。
74
+ 如果任务比较长,`weacpx` 在支持流式中间回复的 transport(当前主要是 `acpx-cli`)下,会优先把 Agent 的中间回复分段发回微信,而不是一直等到最后一条结果。
75
+
76
+ 如果你更想要“一次性只回最终结果”,可以配置全局默认 `wechat.replyMode`,或在当前会话里用 `/replymode final` 临时覆盖。
75
77
 
76
78
  如果你是从源码仓库直接使用:
77
79
 
@@ -96,14 +98,55 @@ bun run dev
96
98
  - `weacpx start`
97
99
  - `weacpx status`
98
100
  - `weacpx stop`
101
+ - `weacpx doctor`
102
+ - `weacpx version`
99
103
 
100
- 说明:
104
+ 其他说明:
101
105
 
102
106
  - `run` 前台运行,适合调试
103
107
  - `start` 后台启动
104
108
  - `status` 查看后台状态、PID、配置路径和日志路径
105
109
  - `stop` 停止后台实例
106
110
  - `logout` 清除本机已保存的微信登录凭证;如果当前没有已登录账号,会直接提示
111
+ - `doctor` 运行诊断,默认检查 config / runtime / daemon / wechat / acpx / bridge
112
+ - `version` 输出当前安装的 `weacpx` 版本号,可用于排查环境或确认升级是否生效
113
+
114
+ ### doctor 诊断
115
+
116
+ `weacpx doctor` 用来快速检查本机环境是否能正常运行。
117
+
118
+ ```bash
119
+ weacpx doctor
120
+ weacpx doctor --verbose
121
+ weacpx doctor --smoke
122
+ weacpx doctor --smoke --agent codex --workspace backend
123
+ ```
124
+
125
+ 说明:
126
+
127
+ - `--verbose` 会展开每个检查的技术细节,方便定位问题
128
+ - `--smoke` 会额外执行一次真实 transport 级别的最小 prompt 检查
129
+ - `--agent` / `--workspace` 只影响 `--smoke`,不会改变默认诊断检查
130
+ - `--smoke` 可能留下一个临时的底层 `acpx` session;这是为了换取真实链路验证能力
131
+ - 如果不传 `--smoke`,相关 smoke 检查会被标记为 `SKIP`
132
+
133
+ ### version 查看版本
134
+
135
+ `weacpx version` 用来输出当前安装的 CLI 版本号。
136
+
137
+ ```bash
138
+ weacpx version
139
+ weacpx --version
140
+ weacpx -v
141
+ ```
142
+
143
+ 说明:
144
+
145
+ - 三种写法都会输出同一个版本号
146
+ - 适合在升级后确认当前生效的是哪个版本
147
+ - 排查用户环境问题时,建议先附上这里输出的版本信息
148
+
149
+ ### logout 退出登录
107
150
 
108
151
  说明:
109
152
 
@@ -170,14 +213,18 @@ bun run dev
170
213
  | 命令 | 说明 |
171
214
  |------|------|
172
215
  | `/sessions` / `/session` / `/ss` | 查看当前已添加的会话 |
173
- | `/ss <agent> -d <path>` | 新建会话(自动按目录名推导并创建或复用 workspace,再创建或复用 session) |
174
- | `/ss new <agent> -d <path>` | 强制新建会话 |
216
+ | `/ss <agent> (-d <path> | --ws <name>)` | 新建会话;传 `-d` 时按目录自动创建或复用 workspace,传 `--ws` 时复用已注册 workspace |
217
+ | `/ss new <agent> (-d <path> | --ws <name>)` | 强制新建会话;`--ws` 只复用已注册 workspace |
175
218
  | `/ss new <alias> -a <name> --ws <name>` | 强制新建会话,并指定 agent 和 workspace |
176
219
  | `/ss attach <alias> -a <name> --ws <name> --name <transport-session>` | 恢复已存在的会话 |
177
220
  | `/use <alias>` | 切换当前会话 |
178
221
  | `/status` | 查看当前会话状态 |
179
222
  | `/mode` | 查看当前会话已保存的 mode |
180
223
  | `/mode <id>` | 设置当前会话 mode,例如 `/mode plan` |
224
+ | `/replymode` | 查看当前会话的回复输出模式(全局默认 / 当前覆盖 / 实际生效) |
225
+ | `/replymode stream` | 当前逻辑会话使用流式回复 |
226
+ | `/replymode final` | 当前逻辑会话只发送最终文本结果 |
227
+ | `/replymode reset` | 清除当前逻辑会话覆盖,回退到全局默认 |
181
228
  | `/session reset` | 重置当前会话上下文,保留 alias/agent/workspace,但重新绑定到一个新的后端 session |
182
229
  | `/clear` | `/session reset` 的快捷别名 |
183
230
  | `/cancel` | 取消当前会话 |
@@ -186,13 +233,45 @@ bun run dev
186
233
  说明:
187
234
 
188
235
  - `/ss <agent> -d <path>` 是最常用入口,会自动按目录名推导并创建或复用 workspace,再创建或复用 session
189
- - `/ss new <agent> -d <path>` 表示强制新建 session
236
+ - `/ss <agent> --ws <name>` 会直接复用已注册 workspace,再创建或复用 session
237
+ - `/ss new <agent> (-d <path> | --ws <name>)` 表示强制新建 session
190
238
  - `/use <alias>` 用来切换当前会话
191
239
  - `/mode` 会显示当前逻辑会话里保存的 mode;如果还没设置过,会显示“未设置”
192
240
  - `/mode <id>` 会把 mode 透传给底层 `acpx set-mode`,成功后再写回当前逻辑会话
241
+ - `/replymode` 修改的是**当前逻辑会话**的 reply mode override,不是底层 transport session 的全局属性
242
+ - `wechat.replyMode` 是全局默认值;`/replymode reset` 会回退到这个默认值
243
+ - `final` 只影响微信侧是否实时发送文本流式片段,不改变 acpx transport 本身的生成方式
193
244
  - `/session reset` 和 `/clear` 会保留当前逻辑会话名,但重新创建一个新的后端 session,从空上下文重新开始
194
245
  - 非 `/` 开头的文本会发送到当前 session
195
246
 
247
+ ### 配置命令
248
+
249
+ `/config` 用来查看和修改一小部分**受支持的配置字段**,不是任意 JSON 编辑器。
250
+
251
+ | 命令 | 说明 |
252
+ |------|------|
253
+ | `/config` | 查看当前支持通过微信修改的配置路径 |
254
+ | `/config set <path> <value>` | 修改一个受支持的配置值 |
255
+
256
+ 常见示例:
257
+
258
+ ```text
259
+ /config
260
+ /config set wechat.replyMode final
261
+ /config set logging.level debug
262
+ /config set transport.permissionMode approve-reads
263
+ /config set workspaces.backend.description backend repo
264
+ ```
265
+
266
+ 说明:
267
+
268
+ - `/config` 只允许修改白名单字段,不支持任意路径写入
269
+ - `agents.<name>.*` / `workspaces.<name>.*` 这类动态路径要求目标已经存在,不会自动创建
270
+ - `/config set wechat.replyMode final` 修改的是**全局默认回复模式**
271
+ - `/replymode final` 修改的是**当前逻辑会话覆盖**
272
+ - 成功修改后会立即写回 `~/.weacpx/config.json`
273
+ - 更完整的边界和支持字段,请参考 [docs/config-command.md](./docs/config-command.md)
274
+
196
275
  ### 权限策略
197
276
 
198
277
  `weacpx` 支持直接在微信里查看和切换 `acpx` 的权限策略。
@@ -204,7 +283,6 @@ bun run dev
204
283
  | `/pm set read` | 切到 `approve-reads` |
205
284
  | `/pm set deny` | 切到 `deny-all` |
206
285
  | `/pm auto` | 查看当前非交互策略 |
207
- | `/pm auto allow` | 切到 `allow` |
208
286
  | `/pm auto deny` | 切到 `deny` |
209
287
  | `/pm auto fail` | 切到 `fail` |
210
288
 
@@ -283,7 +361,7 @@ bun run dev
283
361
  "type": "acpx-bridge",
284
362
  "sessionInitTimeoutMs": 120000,
285
363
  "permissionMode": "approve-all",
286
- "nonInteractivePermissions": "fail"
364
+ "nonInteractivePermissions": "deny"
287
365
  }
288
366
  }
289
367
  ```
@@ -291,8 +369,8 @@ bun run dev
291
369
  说明:
292
370
 
293
371
  - `permissionMode`: `approve-all`、`approve-reads`、`deny-all`
294
- - `nonInteractivePermissions`: `allow`、`deny`、`fail`
295
- - 默认值分别是 `approve-all` 和 `fail`
372
+ - `nonInteractivePermissions`: `deny`、`fail`
373
+ - 默认值分别是 `approve-all` 和 `deny`
296
374
  - 也可以直接在微信里通过 `/pm` 和 `/pm auto` 修改
297
375
 
298
376
  ### 日志配置
@@ -44,6 +44,7 @@ var __export = (target, all) => {
44
44
  });
45
45
  };
46
46
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
47
+ var __promiseAll = (args) => Promise.all(args);
47
48
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
48
49
 
49
50
  // src/transport/acpx-bridge/acpx-bridge-protocol.ts
@@ -275,6 +276,39 @@ function normalizeBridgeNonInteractivePermissions(value) {
275
276
  // src/bridge/bridge-server.ts
276
277
  init_prompt_output();
277
278
 
279
+ // src/bridge/bridge-request-scheduler.ts
280
+ class BridgeRequestScheduler {
281
+ sessions = new Map;
282
+ run(sessionName, lane, task) {
283
+ if (lane === "control") {
284
+ return Promise.resolve().then(task);
285
+ }
286
+ const state = this.sessions.get(sessionName) ?? this.createSessionState(sessionName);
287
+ state.pendingNormals += 1;
288
+ const result = state.tail.then(() => task());
289
+ state.tail = result.then(() => {
290
+ return;
291
+ }, () => {
292
+ return;
293
+ });
294
+ return result.finally(() => {
295
+ state.pendingNormals -= 1;
296
+ if (state.pendingNormals === 0 && this.sessions.get(sessionName) === state) {
297
+ this.sessions.delete(sessionName);
298
+ }
299
+ });
300
+ }
301
+ createSessionState(sessionName) {
302
+ const state = {
303
+ pendingNormals: 0,
304
+ tail: Promise.resolve()
305
+ };
306
+ this.sessions.set(sessionName, state);
307
+ return state;
308
+ }
309
+ }
310
+
311
+ // src/bridge/bridge-server.ts
278
312
  class BridgeInvalidRequestError extends Error {
279
313
  }
280
314
  var BRIDGE_METHODS = new Set([
@@ -287,9 +321,11 @@ var BRIDGE_METHODS = new Set([
287
321
  "setMode",
288
322
  "cancel"
289
323
  ]);
324
+ var SESSION_SCOPED_METHODS = new Set(["hasSession", "ensureSession", "prompt", "setMode", "cancel"]);
290
325
 
291
326
  class BridgeServer {
292
327
  runtime;
328
+ scheduler = new BridgeRequestScheduler;
293
329
  constructor(runtime) {
294
330
  this.runtime = runtime;
295
331
  }
@@ -298,7 +334,7 @@ class BridgeServer {
298
334
  try {
299
335
  const request = parseBridgeRequest(line);
300
336
  requestId = request.id;
301
- const result = await this.dispatch(request.id, request.method, request.params, writeLine);
337
+ const result = await this.dispatchRequest(request.id, request.method, request.params, writeLine);
302
338
  return `${JSON.stringify({
303
339
  id: request.id,
304
340
  ok: true,
@@ -325,6 +361,21 @@ class BridgeServer {
325
361
  `;
326
362
  }
327
363
  }
364
+ async dispatchRequest(requestId, method, params, writeLine) {
365
+ if (!SESSION_SCOPED_METHODS.has(method)) {
366
+ return await this.dispatch(requestId, method, params, writeLine);
367
+ }
368
+ const sessionName = getSessionName(params);
369
+ if (!sessionName) {
370
+ return await this.dispatch(requestId, method, params, writeLine);
371
+ }
372
+ const sessionKey = getSessionScheduleKey(params);
373
+ if (!sessionKey) {
374
+ return await this.dispatch(requestId, method, params, writeLine);
375
+ }
376
+ const lane = method === "cancel" ? "control" : "normal";
377
+ return await this.scheduler.run(sessionKey, lane, () => this.dispatch(requestId, method, params, writeLine));
378
+ }
328
379
  async dispatch(requestId, method, params, writeLine) {
329
380
  switch (method) {
330
381
  case "ping":
@@ -430,6 +481,24 @@ function parseBridgeRequest(line) {
430
481
  params
431
482
  };
432
483
  }
484
+ function getSessionName(params) {
485
+ return asNonEmptyString(params.name);
486
+ }
487
+ function getSessionScheduleKey(params) {
488
+ const name = asNonEmptyString(params.name);
489
+ const cwd = asNonEmptyString(params.cwd);
490
+ const agentIdentity = asNonEmptyString(params.agentCommand) ?? asNonEmptyString(params.agent);
491
+ if (!name || !cwd || !agentIdentity) {
492
+ return;
493
+ }
494
+ return JSON.stringify([agentIdentity, cwd, name]);
495
+ }
496
+ function asNonEmptyString(value) {
497
+ if (typeof value !== "string" || value.length === 0) {
498
+ return;
499
+ }
500
+ return value;
501
+ }
433
502
  function requireString(params, key) {
434
503
  const value = params[key];
435
504
  if (typeof value !== "string" || value.length === 0) {
@@ -461,8 +530,10 @@ function asOptionalString(value) {
461
530
  // src/bridge/bridge-runtime.ts
462
531
  init_spawn_command();
463
532
  init_prompt_output();
533
+ import { copyFile, readdir } from "node:fs/promises";
534
+ import { homedir } from "node:os";
535
+ import { join } from "node:path";
464
536
  import { spawn } from "node:child_process";
465
- import { fileURLToPath } from "node:url";
466
537
 
467
538
  class BridgeRuntime {
468
539
  command;
@@ -508,11 +579,18 @@ class BridgeRuntime {
508
579
  return {};
509
580
  }
510
581
  const createSpec = resolveSpawnCommand(this.command, this.buildSessionArgs(input, ["sessions", "new", "--name", input.name]));
511
- const createdWithHelper = await this.runSessionCreate(createSpec.command, createSpec.args, input.cwd);
512
- if (createdWithHelper.code !== 0) {
513
- throw new Error(createdWithHelper.stderr || createdWithHelper.stdout || ensured.stderr || ensured.stdout || "failed to create session");
582
+ const created = await this.runSessionCreate(createSpec.command, createSpec.args, input.cwd);
583
+ if (created.code === 0) {
584
+ return {};
514
585
  }
515
- return {};
586
+ const output = created.stderr || created.stdout || "";
587
+ if (output.includes("EPERM") && await tryRepairAcpxSessionIndex()) {
588
+ const repaired = await this.run(existingSpec.command, existingSpec.args);
589
+ if (repaired.code === 0) {
590
+ return {};
591
+ }
592
+ }
593
+ throw new Error(output || ensured.stderr || ensured.stdout || "failed to create session");
516
594
  }
517
595
  async prompt(input, onEvent) {
518
596
  const spawnSpec = resolveSpawnCommand(this.command, this.buildPromptArgs(input, [
@@ -663,9 +741,9 @@ async function defaultPromptRunner(command, args, onEvent) {
663
741
  return await runStreamingPrompt(command, args, onEvent);
664
742
  }
665
743
  async function shellSessionCreateRunner(command, args, cwd) {
666
- const helperPath = fileURLToPath(new URL("../../scripts/acpx-session-new-helper.sh", import.meta.url));
667
744
  return await new Promise((resolve, reject) => {
668
- const child = spawn("/bin/zsh", [helperPath, command, cwd, ...args], {
745
+ const child = spawn(command, args, {
746
+ cwd,
669
747
  stdio: ["ignore", "pipe", "pipe"]
670
748
  });
671
749
  let stdout = "";
@@ -682,19 +760,94 @@ async function shellSessionCreateRunner(command, args, cwd) {
682
760
  });
683
761
  });
684
762
  }
763
+ async function tryRepairAcpxSessionIndex() {
764
+ if (process.platform !== "win32") {
765
+ return false;
766
+ }
767
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir();
768
+ if (!home) {
769
+ return false;
770
+ }
771
+ const sessionsDir = join(home, ".acpx", "sessions");
772
+ const indexPath = join(sessionsDir, "index.json");
773
+ let files;
774
+ try {
775
+ files = await readdir(sessionsDir);
776
+ } catch {
777
+ return false;
778
+ }
779
+ const tmpFiles = files.filter((f) => f.startsWith("index.json.") && f.endsWith(".tmp"));
780
+ if (tmpFiles.length === 0) {
781
+ return false;
782
+ }
783
+ let latestTmp = "";
784
+ let latestTime = 0;
785
+ for (const f of tmpFiles) {
786
+ const match = f.match(/^index\.json\.\d+\.(\d+)\.tmp$/);
787
+ if (match && Number(match[1]) > latestTime) {
788
+ latestTime = Number(match[1]);
789
+ latestTmp = f;
790
+ }
791
+ }
792
+ if (!latestTmp) {
793
+ return false;
794
+ }
795
+ try {
796
+ await copyFile(join(sessionsDir, latestTmp), indexPath);
797
+ return true;
798
+ } catch {
799
+ return false;
800
+ }
801
+ }
685
802
 
686
803
  // src/bridge/bridge-main.ts
687
- var server = new BridgeServer(new BridgeRuntime(process.env.WEACPX_BRIDGE_ACPX_COMMAND ?? "acpx", undefined, undefined, {
688
- permissionMode: normalizeBridgePermissionMode(process.env.WEACPX_BRIDGE_PERMISSION_MODE),
689
- nonInteractivePermissions: normalizeBridgeNonInteractivePermissions(process.env.WEACPX_BRIDGE_NON_INTERACTIVE_PERMISSIONS)
690
- }));
691
- var input = createInterface({
692
- input: process.stdin,
693
- crlfDelay: Infinity
694
- });
695
- for await (const line of input) {
696
- const response = await server.handleLine(line, (chunk) => {
697
- process.stdout.write(chunk);
804
+ async function processBridgeInput(options) {
805
+ const pendingWrites = new Set;
806
+ let firstError;
807
+ for await (const line of options.input) {
808
+ const pendingWrite = (async () => {
809
+ const response = await options.server.handleLine(line, (chunk) => {
810
+ options.write(chunk);
811
+ });
812
+ options.write(response);
813
+ })();
814
+ const observedPendingWrite = pendingWrite.catch((error) => {
815
+ if (firstError === undefined) {
816
+ firstError = error;
817
+ options.input.close();
818
+ }
819
+ });
820
+ pendingWrites.add(pendingWrite);
821
+ observedPendingWrite.finally(() => {
822
+ pendingWrites.delete(pendingWrite);
823
+ });
824
+ }
825
+ await Promise.allSettled(pendingWrites);
826
+ if (firstError !== undefined) {
827
+ throw firstError;
828
+ }
829
+ }
830
+ async function runBridgeMain() {
831
+ const server = new BridgeServer(new BridgeRuntime(process.env.WEACPX_BRIDGE_ACPX_COMMAND ?? "acpx", undefined, undefined, {
832
+ permissionMode: normalizeBridgePermissionMode(process.env.WEACPX_BRIDGE_PERMISSION_MODE),
833
+ nonInteractivePermissions: normalizeBridgeNonInteractivePermissions(process.env.WEACPX_BRIDGE_NON_INTERACTIVE_PERMISSIONS)
834
+ }));
835
+ const input = createInterface({
836
+ input: process.stdin,
837
+ crlfDelay: Infinity
698
838
  });
699
- process.stdout.write(response);
839
+ await processBridgeInput({
840
+ input,
841
+ server,
842
+ write: (chunk) => {
843
+ process.stdout.write(chunk);
844
+ }
845
+ });
846
+ }
847
+ if (__require.main == __require.module) {
848
+ await runBridgeMain();
700
849
  }
850
+ export {
851
+ runBridgeMain,
852
+ processBridgeInput
853
+ };