wechatbridge-cli 1.3.3__tar.gz → 1.3.4__tar.gz

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.
Files changed (21) hide show
  1. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/PKG-INFO +3 -3
  2. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/README.md +2 -2
  3. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/tests/test_hardening.py +2 -2
  4. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge/__init__.py +1 -1
  5. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge/agy.py +34 -28
  6. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge/config.py +2 -1
  7. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge/grok.py +26 -21
  8. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge/main.py +94 -49
  9. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge/runner_common.py +39 -38
  10. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/PKG-INFO +3 -3
  11. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/LICENSE +0 -0
  12. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/pyproject.toml +0 -0
  13. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/setup.cfg +0 -0
  14. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge/__main__.py +0 -0
  15. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge/ilink.py +0 -0
  16. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge/update_check.py +0 -0
  17. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/SOURCES.txt +0 -0
  18. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
  19. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
  20. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/requires.txt +0 -0
  21. {wechatbridge_cli-1.3.3 → wechatbridge_cli-1.3.4}/wechatbridge_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.3.3
3
+ Version: 1.3.4
4
4
  Summary: Bridge WeChat messages to agy or Grok Build CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -155,7 +155,7 @@ Key variables (all have defaults):
155
155
  | `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (both backends) |
156
156
  | `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
157
157
  | `WECHATBRIDGE_MAX_INBOUND_BYTES` | `20971520` | max inbound image/file after download (20 MB) |
158
- | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent handlers; extras get a busy reply |
158
+ | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply |
159
159
  | `WECHATBRIDGE_CONFIRM_TOKEN` | `y` | reply this token to approve a gated dangerous prompt |
160
160
  | `WECHATBRIDGE_ENABLE_MCP` | `true` | enable the `/mcp` help text command |
161
161
  | `WECHATBRIDGE_ENABLE_SUBAGENT` | `true` | enable the `/agent` prompt-rewrite command |
@@ -262,7 +262,7 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
262
262
  - **Danger gate is keyword-based**, not full intent understanding. Defaults target concrete patterns (`rm -rf /`, pipe-to-shell, `mkfs`, `format c:`, a few heavy Chinese phrases, …). Everyday wording like bare “delete” is **not** gated. Override list via `WECHATBRIDGE_CONFIRM_KEYWORDS`; approve with `WECHATBRIDGE_CONFIRM_TOKEN` (default `y`), TTL `WECHATBRIDGE_PENDING_TTL`.
263
263
  - **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
264
264
  - **Outbound artifacts** only leave the allowed per-user tree (agy: session scratch; grok: under session dir), after `realpath` checks, and only if under `WECHATBRIDGE_MAX_OUTBOUND_BYTES`.
265
- - **Concurrency:** global cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4); same user is serialized, different users can run in parallel.
265
+ - **Concurrency:** global process-slot cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4). Same user is serialized and does **not** hold a global slot while waiting on their previous message; different users can run in parallel up to the cap.
266
266
  - **Long replies** are split into chunks (`WECHATBRIDGE_MESSAGE_CHUNK`, default 2000 characters).
267
267
  - **Data layout:** instance data under `~/.local/share/wechatbridge/<instance>/` (override with env). Runtime dirs prefer `0700`; token/QR files prefer `0600` (Unix; Windows relies on NTFS ACLs).
268
268
  - **Retention:** session temps vs dialogue history use separate TTLs (`WECHATBRIDGE_SESSION_RETENTION_DAYS`, `WECHATBRIDGE_HISTORY_RETENTION_DAYS`). Prefs/auth are kept.
@@ -124,7 +124,7 @@ Key variables (all have defaults):
124
124
  | `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (both backends) |
125
125
  | `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
126
126
  | `WECHATBRIDGE_MAX_INBOUND_BYTES` | `20971520` | max inbound image/file after download (20 MB) |
127
- | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent handlers; extras get a busy reply |
127
+ | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply |
128
128
  | `WECHATBRIDGE_CONFIRM_TOKEN` | `y` | reply this token to approve a gated dangerous prompt |
129
129
  | `WECHATBRIDGE_ENABLE_MCP` | `true` | enable the `/mcp` help text command |
130
130
  | `WECHATBRIDGE_ENABLE_SUBAGENT` | `true` | enable the `/agent` prompt-rewrite command |
@@ -231,7 +231,7 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
231
231
  - **Danger gate is keyword-based**, not full intent understanding. Defaults target concrete patterns (`rm -rf /`, pipe-to-shell, `mkfs`, `format c:`, a few heavy Chinese phrases, …). Everyday wording like bare “delete” is **not** gated. Override list via `WECHATBRIDGE_CONFIRM_KEYWORDS`; approve with `WECHATBRIDGE_CONFIRM_TOKEN` (default `y`), TTL `WECHATBRIDGE_PENDING_TTL`.
232
232
  - **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
233
233
  - **Outbound artifacts** only leave the allowed per-user tree (agy: session scratch; grok: under session dir), after `realpath` checks, and only if under `WECHATBRIDGE_MAX_OUTBOUND_BYTES`.
234
- - **Concurrency:** global cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4); same user is serialized, different users can run in parallel.
234
+ - **Concurrency:** global process-slot cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4). Same user is serialized and does **not** hold a global slot while waiting on their previous message; different users can run in parallel up to the cap.
235
235
  - **Long replies** are split into chunks (`WECHATBRIDGE_MESSAGE_CHUNK`, default 2000 characters).
236
236
  - **Data layout:** instance data under `~/.local/share/wechatbridge/<instance>/` (override with env). Runtime dirs prefer `0700`; token/QR files prefer `0600` (Unix; Windows relies on NTFS ACLs).
237
237
  - **Retention:** session temps vs dialogue history use separate TTLs (`WECHATBRIDGE_SESSION_RETENTION_DAYS`, `WECHATBRIDGE_HISTORY_RETENTION_DAYS`). Prefs/auth are kept.
@@ -145,7 +145,7 @@ class TestOversizedArtifactNotice(unittest.TestCase):
145
145
  self.assertNotIn("/root/", text)
146
146
  self.assertNotIn("sessions/", text)
147
147
  self.assertIn("report.pdf", text)
148
- self.assertIn("未回传", text)
148
+ self.assertIn("无法发到微信", text)
149
149
 
150
150
  def test_send_artifacts_back_uses_safe_notice(self):
151
151
  """Call the real async helper; assert user text has no server path."""
@@ -187,7 +187,7 @@ class TestOversizedArtifactNotice(unittest.TestCase):
187
187
  self.assertNotIn(big_path, text)
188
188
  self.assertNotIn(td, text)
189
189
  self.assertIn("huge.bin", text)
190
- self.assertIn("未回传", text)
190
+ self.assertIn("无法发到微信", text)
191
191
 
192
192
  asyncio.run(_run())
193
193
 
@@ -1,2 +1,2 @@
1
1
  """WeChatBridge — bridge WeChat messages to agy or Grok Build CLIs."""
2
- __version__ = "1.3.3"
2
+ __version__ = "1.3.4"
@@ -117,7 +117,7 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
117
117
  logger.warning("Prompt too large for argv from user %s", user_id)
118
118
  return format_error(
119
119
  "消息过长",
120
- f"单条消息超过 {_MAX_ARG_BYTES // 1024}KB 无法传给 CLI,请精简或分段发送。",
120
+ f"这条消息太长了(超过 {_MAX_ARG_BYTES // 1024}KB),请精简或分段发送。",
121
121
  ), []
122
122
 
123
123
  t0 = time.time()
@@ -252,8 +252,8 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
252
252
  )
253
253
  await terminate_process(retry_process, graceful=True)
254
254
  return format_error(
255
- "级联超时",
256
- "模型 API 级联推理超时,自动重试仍超时。请稍后重试或简化指令。",
255
+ "模型响应超时",
256
+ "模型响应超时,自动重试仍超时。请稍后重试或简化指令。",
257
257
  ), []
258
258
  except (asyncio.CancelledError, Exception):
259
259
  await terminate_process(retry_process, graceful=False)
@@ -269,12 +269,12 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
269
269
  mark_initialized(session_dir)
270
270
  return r_display, r_artifacts
271
271
  return format_error(
272
- "级联超时",
273
- "模型 API 级联推理超时,自动重试仍失败。请稍后重试或简化指令。",
272
+ "模型响应超时",
273
+ "模型响应超时,自动重试仍失败。请稍后重试或简化指令。",
274
274
  ), []
275
275
  return format_cli_error(stderr_text, backend="agy"), []
276
276
  # Non-zero exit: never treat raw stdout as a normal success reply
277
- raw = stderr_text or stdout_text or display or "agy 进程异常退出"
277
+ raw = stderr_text or stdout_text or display or "process exited abnormally"
278
278
  return format_cli_error(raw, backend="agy"), []
279
279
 
280
280
  # Success path only
@@ -305,7 +305,10 @@ async def run_agy(prompt: str, user_id: str, timeout: int = None) -> tuple[str,
305
305
  except Exception as e:
306
306
  logger.exception("Unexpected error running agy: %s", e)
307
307
  await terminate_process(process, graceful=False)
308
- return format_error("执行出错", "内部错误,详情见服务端日志。"), []
308
+ return format_error(
309
+ "执行出错",
310
+ "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
311
+ ), []
309
312
 
310
313
 
311
314
  # ---------------------------------------------------------------------------
@@ -352,16 +355,19 @@ async def _run_agy_subcommand(subcmd_args: list, user_id: str) -> str:
352
355
  return clean_output(stdout_text) or EMPTY_REPLY
353
356
 
354
357
  except asyncio.TimeoutError:
355
- # 超时必须回收子进程,否则挂死的子命令成为孤儿进程
358
+ # 超时必须回收子进程,否则挂死的查询进程成为孤儿
356
359
  await terminate_process(process, graceful=True)
357
- return format_error("指令超时", "子命令 30 秒内未完成。")
360
+ return format_error("查询超时", "查询超时,请稍后再试。")
358
361
  except asyncio.CancelledError:
359
362
  await terminate_process(process, graceful=False)
360
363
  raise
361
364
  except Exception as e:
362
365
  logger.exception("Subcommand error: %s", e)
363
366
  await terminate_process(process, graceful=False)
364
- return format_error("执行出错", "内部错误,详情见服务端日志。")
367
+ return format_error(
368
+ "执行出错",
369
+ "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
370
+ )
365
371
 
366
372
 
367
373
  def _cmd_help() -> str:
@@ -372,28 +378,28 @@ def _cmd_help() -> str:
372
378
  "**模型控制**",
373
379
  "- `/model <名称>` — 切换模型(用 `/models` 查看可用列表)",
374
380
  "- `/models` — 查看可用模型列表",
375
- "- `/backend <agy|grok>` — 切换后端 CLI",
381
+ "- `/backend <agy|grok>` — 切换助手引擎",
376
382
  "",
377
383
  "**对话控制**",
378
384
  "- `/clear` 或 `/new` — 重置对话(开始新会话)",
379
- "- `/fast` — 开启**快速模式**(低推理开销)",
380
- "- `/planning` — 开启 **planning 模式**",
385
+ "- `/fast` — 开启**快速模式**(回答更快,思考更少)",
386
+ "- `/planning` — 开启**规划模式**(先想清楚再动手)",
381
387
  "",
382
388
  "**工具**",
383
389
  "- `/add-dir <路径>` — 添加工作目录",
384
- "- `/agents` — 查看可用 agent",
390
+ "- `/agents` — 查看可用助手",
385
391
  "",
386
- "**MCP & 子代理**",
387
- "- `/mcp` — MCP 工具使用引导",
388
- "- `/agent <名称> <任务>` — 调用子代理执行任务",
392
+ "**扩展工具 & 子助手**",
393
+ "- `/mcp` — 扩展工具使用说明",
394
+ "- `/agent <名称> <任务>` — 调用子助手执行任务",
389
395
  "",
390
396
  "**人格**",
391
- "- `/persona <内容>` — 设置你专属的人格文档(支持 show / clear / reset 子命令)",
397
+ "- `/persona <内容>` — 设置你专属的人格文档(另有 show / clear / reset",
392
398
  "",
393
399
  "**其他**",
394
400
  "- `/help` — 显示本帮助",
395
401
  "",
396
- "提示:其他 `/` 指令(如 `/goal`、`/grill-me`、`/schedule` 等)会直接交给 agy 处理。",
402
+ "提示:其他 `/` 指令(如 `/goal`、`/grill-me`、`/schedule` 等)会直接交给助手处理。",
397
403
  ]
398
404
  return "\n".join(lines)
399
405
 
@@ -496,13 +502,13 @@ def _cmd_clear(user_id: str) -> str:
496
502
  def _cmd_fast(user_id: str) -> str:
497
503
  """Handle /fast: set effort=low (scoped to current backend)."""
498
504
  update_active_prefs(user_id, effort="low")
499
- return "✅ **已开启 fast 模式** ✅"
505
+ return "✅ **已开启快速模式** ✅"
500
506
 
501
507
 
502
508
  def _cmd_planning(user_id: str) -> str:
503
509
  """Handle /planning: set mode=plan (scoped to current backend)."""
504
510
  update_active_prefs(user_id, mode="plan")
505
- return "✅ **已开启 planning 模式** ✅"
511
+ return "✅ **已开启规划模式** ✅"
506
512
 
507
513
 
508
514
  def _cmd_add_dir(args: str, user_id: str) -> str:
@@ -627,8 +633,9 @@ async def handle_slash_command(text: str, user_id: str) -> str | None:
627
633
 
628
634
  if cmd == "/agents":
629
635
  output = await _run_agy_subcommand(["agents"], user_id)
630
- if not output or output == "Available agents:":
631
- output = "**Available agents**\n\n(当前没有自定义 agent。)"
636
+ # Match CLI empty output; rewrite user-facing wording only
637
+ if not output or output.strip() in ("Available agents:", "Available agents"):
638
+ output = "**可用助手**\n\n(当前没有自定义助手。)"
632
639
  return output
633
640
 
634
641
  if cmd == "/models":
@@ -642,12 +649,11 @@ async def handle_slash_command(text: str, user_id: str) -> str | None:
642
649
  if not config.enable_mcp:
643
650
  return "ℹ️ **该功能已禁用** ℹ️"
644
651
  return (
645
- "ℹ️ **MCP 工具使用引导** ℹ️\n\n"
646
- "agy 已配置 MCP server(ctxmode / codegraph)。\n\n"
647
- "使用方法:用自然语言描述调用,格式为:\n"
648
- "> 用 call_mcp_tool 调用 `<工具名>`,参数 `<json>`\n\n"
652
+ "ℹ️ **扩展工具说明** ℹ️\n\n"
653
+ "可以直接用自然语言让助手调用已配置的扩展工具(如代码检索等)。\n\n"
649
654
  "示例:\n"
650
- "> 用 codegraph 的 search 工具搜 ctxmode"
655
+ "> 用 codegraph 的 search 搜一下 ctxmode\n"
656
+ "> 帮我查一下这个项目里 xxx 怎么实现的"
651
657
  )
652
658
 
653
659
  # /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)
@@ -191,7 +191,8 @@ class AppConfig:
191
191
  # Maximum inbound image/file size after download (bytes) — default 20 MB
192
192
  max_inbound_file_bytes: int = _env_int("WECHATBRIDGE_MAX_INBOUND_BYTES", 20 * 1024 * 1024)
193
193
 
194
- # Max concurrent message handlers (global). Extra messages get a busy reply.
194
+ # Global concurrent process_message slots. Same user serializes first and
195
+ # does not hold a slot while waiting on their previous message; extras get a busy reply.
195
196
  max_concurrent_tasks: int = _env_int("WECHATBRIDGE_MAX_CONCURRENT", 4)
196
197
 
197
198
  # WeChat text chunk size (characters) when splitting long replies
@@ -372,7 +372,7 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
372
372
  logger.warning("Prompt too large for argv from user %s", user_id)
373
373
  return format_error(
374
374
  "消息过长",
375
- f"单条消息超过 {_MAX_ARG_BYTES // 1024}KB 无法传给 CLI,请精简或分段发送。",
375
+ f"这条消息太长了(超过 {_MAX_ARG_BYTES // 1024}KB),请精简或分段发送。",
376
376
  ), []
377
377
 
378
378
  t0 = time.time()
@@ -432,7 +432,7 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
432
432
  )
433
433
  artifacts = []
434
434
  if not (isinstance(display, str) and display.startswith("❌")):
435
- raw_err = stderr_text or ("" if display == EMPTY_REPLY else display) or "grok 进程异常退出"
435
+ raw_err = stderr_text or ("" if display == EMPTY_REPLY else display) or "process exited abnormally"
436
436
  display = format_cli_error(raw_err, backend="grok")
437
437
 
438
438
  # Only mark session initialized on a real successful reply
@@ -459,7 +459,10 @@ async def run_grok(prompt: str, user_id: str, timeout: int = None) -> tuple:
459
459
  except Exception as e:
460
460
  logger.exception("Unexpected error running grok: %s", e)
461
461
  await terminate_process(process, graceful=False)
462
- return format_error("执行出错", "内部错误,详情见服务端日志。"), []
462
+ return format_error(
463
+ "执行出错",
464
+ "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
465
+ ), []
463
466
 
464
467
 
465
468
  async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
@@ -502,16 +505,19 @@ async def _run_grok_subcommand(subcmd_args: list, user_id: str) -> str:
502
505
  return clean_output(stdout_text) or EMPTY_REPLY
503
506
 
504
507
  except asyncio.TimeoutError:
505
- # 超时必须回收子进程,否则挂死的子命令成为孤儿进程
508
+ # 超时必须回收子进程,否则挂死的查询进程成为孤儿
506
509
  await terminate_process(process, graceful=True)
507
- return format_error("指令超时", "子命令 30 秒内未完成。")
510
+ return format_error("查询超时", "查询超时,请稍后再试。")
508
511
  except asyncio.CancelledError:
509
512
  await terminate_process(process, graceful=False)
510
513
  raise
511
514
  except Exception as e:
512
515
  logger.exception("Subcommand error: %s", e)
513
516
  await terminate_process(process, graceful=False)
514
- return format_error("执行出错", "内部错误,详情见服务端日志。")
517
+ return format_error(
518
+ "执行出错",
519
+ "这次没处理好,请稍后再试。若一直失败,请联系管理员。",
520
+ )
515
521
 
516
522
 
517
523
  # ---------------------------------------------------------------------------
@@ -582,24 +588,24 @@ def _cmd_help() -> str:
582
588
  "**模型控制**",
583
589
  "- `/model <名称>` — 切换模型(用 `/models` 查看可用列表)",
584
590
  "- `/models` — 查看可用模型列表",
585
- "- `/backend <agy|grok>` — 切换后端 CLI",
591
+ "- `/backend <agy|grok>` — 切换助手引擎",
586
592
  "",
587
593
  "**对话控制**",
588
594
  "- `/clear` 或 `/new` — 重置对话(开始新会话)",
589
- "- `/fast` — 开启**快速模式**(低推理开销)",
590
- "- `/planning` — 开启 **planning 模式**",
595
+ "- `/fast` — 开启**快速模式**(回答更快,思考更少)",
596
+ "- `/planning` — 开启**规划模式**(先想清楚再动手)",
591
597
  "",
592
598
  "**工具**",
593
- "- `/add-dir <路径>` — 添加工作目录(grok 后端暂不支持,仅记录)",
594
- "- `/agents` — 查看可用 agent",
599
+ "- `/add-dir <路径>` — 添加工作目录(当前 grok 引擎暂不支持,仅记录)",
600
+ "- `/agents` — 查看可用助手",
595
601
  "",
596
602
  "**人格**",
597
- "- `/persona <内容>` — 设置你专属的人格文档(支持 show / clear / reset 子命令)",
603
+ "- `/persona <内容>` — 设置你专属的人格文档(另有 show / clear / reset",
598
604
  "",
599
605
  "**其他**",
600
606
  "- `/help` — 显示本帮助",
601
607
  "",
602
- "提示:其他 `/` 指令会直接交给 grok 处理。",
608
+ "提示:其他 `/` 指令会直接交给助手处理。",
603
609
  ]
604
610
  return "\n".join(lines)
605
611
 
@@ -646,11 +652,11 @@ async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
646
652
 
647
653
  if cmd == "/fast":
648
654
  update_active_prefs(user_id, effort="low")
649
- return "✅ **已开启 fast 模式** ✅"
655
+ return "✅ **已开启快速模式** ✅"
650
656
 
651
657
  if cmd == "/planning":
652
658
  update_active_prefs(user_id, mode="plan")
653
- return "✅ **已开启 planning 模式** ✅"
659
+ return "✅ **已开启规划模式** ✅"
654
660
 
655
661
  if cmd == "/model":
656
662
  return await _cmd_model(args, user_id)
@@ -671,7 +677,7 @@ async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
671
677
  save_prefs(user_id, prefs)
672
678
  return (
673
679
  f"✅ **已记录工作目录** ✅\n\n```\n{resolved}\n```\n\n"
674
- "ℹ️ grok 后端暂不支持通过命令行传递额外目录。"
680
+ "ℹ️ 当前引擎暂不支持额外工作目录,路径已记下备用。"
675
681
  )
676
682
 
677
683
  if cmd == "/agents":
@@ -689,12 +695,11 @@ async def handle_grok_slash_command(text: str, user_id: str) -> str | None:
689
695
  if not config.enable_mcp:
690
696
  return "ℹ️ **该功能已禁用** ℹ️"
691
697
  return (
692
- "ℹ️ **MCP 工具使用引导** ℹ️\n\n"
693
- "grok 已配置 MCP server。\n\n"
694
- "使用方法:用自然语言描述调用,格式为:\n"
695
- "> 用 `<工具名>` 调用,参数 `<json>`\n\n"
698
+ "ℹ️ **扩展工具说明** ℹ️\n\n"
699
+ "可以直接用自然语言让助手调用已配置的扩展工具。\n\n"
696
700
  "示例:\n"
697
- "> 用 codegraph 的 search 工具搜 ctxmode"
701
+ "> 用 codegraph 的 search 搜一下 ctxmode\n"
702
+ "> 帮我查一下这个项目里 xxx 怎么实现的"
698
703
  )
699
704
 
700
705
  # /agent 已上移到 main.py 统一处理(必须经过危险确认门,不能再绕过)
@@ -158,7 +158,7 @@ async def _handle_slash(client: ILinkClient, text: str, user_id: str, context_to
158
158
  if cmd == "/version":
159
159
  return (
160
160
  f"📦 **版本信息** 📦\n\n当前版本: `{__version__}`\n"
161
- f"实例: `{config.instance}` 后端: `{_get_backend(user_id)}`"
161
+ f"实例: `{config.instance}` 引擎: `{_get_backend(user_id)}`"
162
162
  ) + format_update_hint()
163
163
 
164
164
  backend = _get_backend(user_id)
@@ -183,12 +183,12 @@ def _cmd_backend(args: str, user_id: str) -> str:
183
183
  current = prefs.get("backend", config.backend)
184
184
  model_label = format_model_label(prefs.get("model", ""))
185
185
  return (
186
- f"📋 **当前后端** 📋\n\n`{current}`\n"
186
+ f"📋 **当前助手引擎** 📋\n\n`{current}`\n"
187
187
  f"模型: `{model_label}`\n\n"
188
188
  "用法: `/backend agy` 或 `/backend grok`"
189
189
  )
190
190
  if name not in ("agy", "grok"):
191
- return "❌ **未知后端** ❌\n\n支持: `agy` / `grok`\n\n`/backend agy` 或 `/backend grok`"
191
+ return "❌ **未知引擎** ❌\n\n支持: `agy` / `grok`\n\n`/backend agy` 或 `/backend grok`"
192
192
  prefs = load_prefs(user_id)
193
193
  old, new = switch_backend_prefs(prefs, name)
194
194
  save_prefs(user_id, prefs)
@@ -200,19 +200,19 @@ def _cmd_backend(args: str, user_id: str) -> str:
200
200
  if os.path.exists(flag):
201
201
  os.remove(flag)
202
202
  return (
203
- f"✅ **后端已切换** ✅\n\n"
203
+ f"✅ **助手引擎已切换** ✅\n\n"
204
204
  f"`{old}` → `{new}`\n"
205
205
  f"模型: `{model_label}`\n\n"
206
- "⚠️ 对话已重置,新后端将开始新会话。"
206
+ "⚠️ 对话已重置,新引擎将开始新会话。"
207
207
  )
208
208
  return (
209
- f"📋 **当前后端** 📋\n\n`{name}`(未变化)\n"
209
+ f"📋 **当前助手引擎** 📋\n\n`{name}`(未变化)\n"
210
210
  f"模型: `{model_label}`"
211
211
  )
212
212
 
213
213
 
214
214
  async def _cmd_agent(client: ILinkClient, args: str, user_id: str, context_token: str):
215
- """Handle /agent <名称> <任务> — 调用子代理执行任务。
215
+ """Handle /agent <名称> <任务> — 调用子助手执行任务。
216
216
 
217
217
  必须经过 gate_and_run 的危险确认门(历史上后端各自实现时绕过了该检查)。
218
218
  """
@@ -223,9 +223,18 @@ async def _cmd_agent(client: ILinkClient, args: str, user_id: str, context_token
223
223
  agent_parts = args.split(maxsplit=1)
224
224
  agent_name = agent_parts[0]
225
225
  agent_task = agent_parts[1] if len(agent_parts) > 1 else ""
226
+ # Execution path still uses the invoke_subagent wording for the backend;
227
+ # confirmation UI shows the user's original agent/task only.
226
228
  crafted = f"请用 invoke_subagent 调用 agent {agent_name} 执行任务:{agent_task}"
229
+ display = (
230
+ f"调用助手「{agent_name}」执行:{agent_task}"
231
+ if agent_task
232
+ else f"调用助手「{agent_name}」"
233
+ )
227
234
  logger.info("Agent subcmd: user=%s agent=%s task=%.100s", user_id, agent_name, agent_task)
228
- result = await gate_and_run(client, user_id, context_token, crafted)
235
+ result = await gate_and_run(
236
+ client, user_id, context_token, crafted, display_prompt=display,
237
+ )
229
238
  if result is None:
230
239
  return _HANDLED # 已进入危险确认流程
231
240
  return result # (reply, artifacts)
@@ -326,10 +335,20 @@ async def login_flow(client: ILinkClient) -> bool:
326
335
  return False
327
336
 
328
337
 
329
- async def gate_and_run(client, from_user, context_token, prompt) -> tuple[str, list] | None:
338
+ async def gate_and_run(
339
+ client,
340
+ from_user,
341
+ context_token,
342
+ prompt,
343
+ *,
344
+ display_prompt: str | None = None,
345
+ ) -> tuple[str, list] | None:
330
346
  """Check prompt with is_dangerous; if dangerous, ask for confirmation.
331
347
 
332
348
  Returns (reply, artifacts) on safe prompt, None if confirmation asked.
349
+
350
+ ``display_prompt`` (optional) is what the user sees in the confirm bubble;
351
+ the stored/executed prompt remains ``prompt`` (execution path unchanged).
333
352
  """
334
353
  if is_dangerous(prompt):
335
354
  expire_at = time.time() + config.pending_confirm_ttl
@@ -338,10 +357,11 @@ async def gate_and_run(client, from_user, context_token, prompt) -> tuple[str, l
338
357
  "expire_at": expire_at,
339
358
  "context_token": context_token,
340
359
  }
360
+ shown = display_prompt if display_prompt is not None else prompt
341
361
  await client.send_message(
342
362
  to_user_id=from_user,
343
363
  text=(
344
- f"⚠️ **危险操作确认** ⚠️\n\n```\n{prompt}\n```\n\n"
364
+ f"⚠️ **危险操作确认** ⚠️\n\n```\n{shown}\n```\n\n"
345
365
  f"- 回复 **{config.confirm_token}** → 执行\n"
346
366
  f"- 回复其他 → 取消"
347
367
  ),
@@ -537,7 +557,7 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
537
557
  reply = ""
538
558
  if image_media:
539
559
  if not image_media.get("aes_key"):
540
- reply = format_error("图片无法处理", "缺少解密密钥,请重新发送图片。")
560
+ reply = format_error("图片处理失败", "图片信息不完整,请重新发。")
541
561
  logger.warning("图片缺少 aes_key from=%s", from_user)
542
562
  else:
543
563
  try:
@@ -572,15 +592,21 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
572
592
  reply, artifacts = result
573
593
 
574
594
  except Exception as e:
595
+ # ilink 细节(aes_key / CDN / 字节数等)只写日志,不直出给用户
575
596
  logger.exception("图片下载/解密失败: %s", e)
576
- # ValueError 是自定义的中文可读原因;其他异常(httpx 等)含内部 URL,不外发
577
- detail = str(e) if isinstance(e, ValueError) else "请重新发送图片。"
578
- reply = format_error("图片下载或解密失败", detail)
597
+ err_s = str(e)
598
+ if isinstance(e, ValueError) and "过大" in err_s:
599
+ reply = format_error(
600
+ "文件太大了",
601
+ "图片太大,发不进来。请压缩后再发,或换成更小的图。",
602
+ )
603
+ else:
604
+ reply = format_error("图片处理失败", "图片没处理好,请重新发。")
579
605
 
580
606
  # ---- Case 1.5: Message contains a file (non-image) ----
581
607
  elif file_media:
582
608
  if not file_media.get("aes_key"):
583
- reply = format_error("文件无法处理", "缺少解密密钥,请重新发送文件。")
609
+ reply = format_error("文件处理失败", "文件信息不完整,请重新发。")
584
610
  logger.warning("文件缺少 aes_key from=%s", from_user)
585
611
  else:
586
612
  try:
@@ -612,10 +638,16 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
612
638
  reply, artifacts = result
613
639
 
614
640
  except Exception as e:
641
+ # ilink 细节(aes_key / CDN / 字节数等)只写日志,不直出给用户
615
642
  logger.exception("文件下载/解密失败: %s", e)
616
- # ValueError 是自定义的中文可读原因;其他异常(httpx 等)含内部 URL,不外发
617
- detail = str(e) if isinstance(e, ValueError) else "请重新发送文件。"
618
- reply = format_error("文件下载或解密失败", detail)
643
+ err_s = str(e)
644
+ if isinstance(e, ValueError) and "过大" in err_s:
645
+ reply = format_error(
646
+ "文件太大了",
647
+ "文件太大,发不进来。请压缩后再发,或换成更小的文件。",
648
+ )
649
+ else:
650
+ reply = format_error("文件处理失败", "文件没处理好,请重新发。")
619
651
 
620
652
  # ---- Case 1.6: Voice message (text transcription passthrough) ----
621
653
  elif has_voice:
@@ -627,7 +659,7 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
627
659
  reply, artifacts = result
628
660
  else:
629
661
  # WeChat failed to transcribe the voice → ask user to type.
630
- reply = "🤔 **听不清,请打字** 🤔"
662
+ reply = "🤔 **没听清语音** 🤔\n\n请改成打字发。"
631
663
  logger.info("语音未识别出文字 from=%s", from_user)
632
664
 
633
665
  # ---- Case 2: Text-only message (original logic) ----
@@ -849,43 +881,56 @@ def _prune_user_locks() -> None:
849
881
 
850
882
 
851
883
  async def _safe_process_message(client: ILinkClient, msg: dict) -> None:
852
- """Run process_message inside global concurrency + per-user locks.
884
+ """Run process_message with per-user serial queue then global concurrency.
885
+
886
+ Order matters:
887
+ 1) Take per-user lock first — same-user messages queue here and do NOT
888
+ hold a global slot while waiting for the previous one.
889
+ 2) Acquire global semaphore only immediately before process_message.
890
+ 3) Fail-fast (short timeout) if global slots are full; release only if held.
853
891
 
854
- This ensures the main get_updates long-polling loop is NEVER blocked,
855
- keeping WeChat heartbeats 100% active while ensuring per-user message ordering.
892
+ Thus one user can occupy at most one global slot at a time; cross-user
893
+ parallelism is still capped by WECHATBRIDGE_MAX_CONCURRENT.
856
894
  """
857
895
  from_user = msg.get("from_user_id", "")
858
896
  context_token = msg.get("context_token", "")
859
897
  sem = _get_global_sem()
898
+ sem_held = False
860
899
 
861
- # Fail fast when all slots are busy (do not queue unbounded work)
862
- try:
863
- await asyncio.wait_for(sem.acquire(), timeout=0.05)
864
- except asyncio.TimeoutError:
865
- logger.warning("并发已满,拒绝处理 from=%s", from_user)
866
- if context_token and from_user:
867
- try:
868
- await client.send_message(
869
- to_user_id=from_user,
870
- text="⏳ **系统繁忙** ⏳\n\n当前处理人数已满,请稍后再试。",
871
- context_token=context_token,
872
- baseurl=client.state.baseurl,
873
- bot_token=client.state.bot_token,
874
- )
875
- except Exception as e:
876
- logger.warning("发送繁忙提示失败: %s", e)
877
- return
900
+ # Serialize same user first (empty from_user still gets a dedicated lock key)
901
+ if from_user not in user_locks:
902
+ user_locks[from_user] = asyncio.Lock()
878
903
 
879
- try:
880
- if from_user not in user_locks:
881
- user_locks[from_user] = asyncio.Lock()
882
- async with user_locks[from_user]:
883
- try:
884
- await process_message(client, msg)
885
- except Exception as e:
886
- logger.exception("处理消息异常 (from=%s): %s", from_user, e)
887
- finally:
888
- sem.release()
904
+ async with user_locks[from_user]:
905
+ # Global slot only after we own the user lock
906
+ try:
907
+ await asyncio.wait_for(sem.acquire(), timeout=0.05)
908
+ sem_held = True
909
+ except asyncio.TimeoutError:
910
+ logger.warning("并发已满,拒绝处理 from=%s", from_user)
911
+ if context_token and from_user:
912
+ try:
913
+ await client.send_message(
914
+ to_user_id=from_user,
915
+ text=(
916
+ "⏳ **现在有点忙** ⏳\n\n"
917
+ "同时处理的消息太多,请过几秒再发。"
918
+ ),
919
+ context_token=context_token,
920
+ baseurl=client.state.baseurl,
921
+ bot_token=client.state.bot_token,
922
+ )
923
+ except Exception as e:
924
+ logger.warning("发送繁忙提示失败: %s", e)
925
+ return
926
+
927
+ try:
928
+ await process_message(client, msg)
929
+ except Exception as e:
930
+ logger.exception("处理消息异常 (from=%s): %s", from_user, e)
931
+ finally:
932
+ if sem_held:
933
+ sem.release()
889
934
 
890
935
 
891
936
  # ---------------------------------------------------------------------------
@@ -113,8 +113,8 @@ def validate_add_dir(path: str, user_id: str) -> tuple[bool, str]:
113
113
  if not any(path_is_under(resolved, root) for root in allowed_roots):
114
114
  return False, (
115
115
  "路径不在允许范围内。"
116
- "仅允许会话目录"
117
- + (" 或 WECHATBRIDGE_ADD_DIR_ROOTS 内路径" if config.add_dir_roots else "")
116
+ "仅允许你的工作区"
117
+ + (" 或管理员已开放的目录" if config.add_dir_roots else "")
118
118
  + "。"
119
119
  )
120
120
  return True, resolved
@@ -151,8 +151,8 @@ def clean_output(text: str) -> str:
151
151
  # WeChat error reply format (fixed header + body)
152
152
  # ---------------------------------------------------------------------------
153
153
 
154
- # Shown when CLI returns successfully but with no display text
155
- EMPTY_REPLY = "(无回复内容)"
154
+ # Shown when backend returns successfully but with no display text
155
+ EMPTY_REPLY = "(这次没有文字回复)"
156
156
 
157
157
 
158
158
  def format_error(title: str, detail: str = "") -> str:
@@ -161,7 +161,7 @@ def format_error(title: str, detail: str = "") -> str:
161
161
  Example:
162
162
  ❌ **未登录** ❌
163
163
 
164
- Grok 凭证不可用。
164
+ 助手尚未登录,请联系管理员。
165
165
  """
166
166
  title = (title or "错误").strip().replace("\n", " ")
167
167
  detail = (detail or "").strip()
@@ -179,29 +179,26 @@ def format_oversized_artifact_notice(art_name: str, size_mb: float) -> str:
179
179
  return (
180
180
  f"⚠️ **文件过大** ⚠️\n\n"
181
181
  f"`{name}` {float(size_mb):.1f} MB\n"
182
- f"已保存在服务器会话目录,未回传微信。"
182
+ f"文件已生成,但太大无法发到微信。请联系管理员获取,或让我改小后再试。"
183
183
  )
184
184
 
185
185
 
186
186
  def format_cli_error(raw_message: str, *, backend: str = "") -> str:
187
- """Map CLI stderr/JSON error text into Chinese title + fixed header.
187
+ """Map backend stderr/JSON error text into a short Chinese user reply.
188
188
 
189
- Never put the whole raw English blob into the **...** title line.
190
- Known cases get a Chinese title + short Chinese explanation; raw text
191
- is kept under「原始信息」only when it is not already Chinese-heavy.
189
+ Never put English raw blobs or internal path/env names into user text.
190
+ Details stay in the server log only.
192
191
  """
193
192
  raw = clean_output(raw_message or "") or "未知错误"
194
193
  lower = raw.lower()
195
194
  backend = (backend or "").strip().lower()
196
- name = "Grok" if backend == "grok" else ("agy" if backend == "agy" else "CLI")
197
-
198
- def _with_raw(title: str, zh: str) -> str:
199
- # Avoid duplicating if raw is already the zh line
200
- if raw.strip() == zh.strip():
201
- return format_error(title, zh)
202
- return format_error(title, f"{zh}\n\n原始信息:\n{raw}")
195
+ logger.info(
196
+ "format_cli_error backend=%s raw=%.300s",
197
+ backend or "?",
198
+ raw,
199
+ )
203
200
 
204
- # Auth / login
201
+ # Auth / login — ops details stay in logs; users contact admin
205
202
  if (
206
203
  "not signed in" in lower
207
204
  or "authenticate" in lower
@@ -213,11 +210,11 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
213
210
  or "unauthorized" in lower
214
211
  or "401" in lower and ("auth" in lower or "token" in lower or "login" in lower)
215
212
  or ("xai_api_key" in lower and ("sign" in lower or "login" in lower or "auth" in lower))
213
+ or "api_key" in lower and ("missing" in lower or "invalid" in lower or "required" in lower)
216
214
  ):
217
- return _with_raw(
215
+ return format_error(
218
216
  "未登录",
219
- f"{name} 未登录,或读不到有效凭证。"
220
- + (" 请在本机执行 `grok login --device-code`,或设置 `XAI_API_KEY`。" if backend == "grok" else " 请检查本机登录/凭证是否有效。"),
217
+ "助手尚未登录或凭证失效,请联系管理员处理。",
221
218
  )
222
219
 
223
220
  # Rate limit / quota
@@ -229,7 +226,10 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
229
226
  or "resource exhausted" in lower
230
227
  or "429" in lower
231
228
  ):
232
- return _with_raw("请求过于频繁", "触发限流或额度不足,请稍后再试。")
229
+ return format_error(
230
+ "请求过于频繁",
231
+ "用得有点多,暂时被限制了,请稍后再试。",
232
+ )
233
233
 
234
234
  # Network
235
235
  if (
@@ -244,17 +244,20 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
244
244
  or "fetch failed" in lower
245
245
  or "socket hang up" in lower
246
246
  ):
247
- return _with_raw("网络错误", "连不上服务,请检查网络后重试。")
247
+ return format_error("网络错误", "连不上服务,请检查网络后重试。")
248
248
 
249
- # Cascade / API hang (agy)
249
+ # Cascade / API hang (agy) — plain language for users
250
250
  if "timeout waiting for cascade" in lower or "timeout waiting for response" in lower:
251
- return _with_raw("级联超时", "模型 API 级联推理超时,请稍后重试或简化指令。")
251
+ return format_error(
252
+ "模型响应超时",
253
+ "模型响应超时,请稍后重试或简化指令。",
254
+ )
252
255
 
253
256
  if "permission" in lower and ("denied" in lower or "refuse" in lower or "rejected" in lower):
254
- return _with_raw("权限不足", "没有执行该操作的权限。")
257
+ return format_error("权限不足", "没有执行该操作的权限。")
255
258
 
256
259
  if "timeout" in lower or "timed out" in lower or "deadline exceeded" in lower:
257
- return _with_raw("超时", "等待响应超时,请稍后重试。")
260
+ return format_error("超时", "等待响应超时,请稍后重试。")
258
261
 
259
262
  if "model" in lower and (
260
263
  "not found" in lower
@@ -265,21 +268,19 @@ def format_cli_error(raw_message: str, *, backend: str = "") -> str:
265
268
  or "not supported" in lower
266
269
  or "no such" in lower
267
270
  ):
268
- return _with_raw("模型无效", "指定的模型不可用,请用 `/models` 查看后重选。")
271
+ return format_error("模型无效", "指定的模型不可用,请用 `/models` 查看后重选。")
269
272
 
270
273
  if "command not found" in lower or "not a command" in lower:
271
- return _with_raw("命令不可用", f"{name} 可执行文件可能未安装或不在 PATH 中。")
274
+ return format_error(
275
+ "助手不可用",
276
+ "助手程序未正确安装或配置,请联系管理员。",
277
+ )
272
278
 
273
279
  if "not found" in lower or "no such file" in lower or "enoent" in lower:
274
- return _with_raw("未找到", "请求的资源或文件不存在。")
280
+ return format_error("未找到", "请求的资源或文件不存在。")
275
281
 
276
- # Generic CLI failureshort Chinese title, body is raw (may still be English)
277
- title = "执行失败"
278
- if backend == "grok":
279
- title = "Grok 执行失败"
280
- elif backend == "agy":
281
- title = "agy 执行失败"
282
- return format_error(title, raw)
282
+ # Unknown: fixed Chinese only never echo English raw to WeChat users
283
+ return format_error("执行失败", "这次没处理好,请稍后再试。")
283
284
 
284
285
 
285
286
  # ---------------------------------------------------------------------------
@@ -454,7 +455,7 @@ def format_model_label(model: str) -> str:
454
455
  """Human-readable model for switch replies."""
455
456
  model = (model or "").strip()
456
457
  if not model:
457
- return "后端默认(未指定)"
458
+ return "默认(未指定)"
458
459
  return model
459
460
 
460
461
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wechatbridge-cli
3
- Version: 1.3.3
3
+ Version: 1.3.4
4
4
  Summary: Bridge WeChat messages to agy or Grok Build CLIs — text/image/file/voice in, CLI replies and generated files back.
5
5
  Author: WeChatBridge contributors
6
6
  License: MIT
@@ -155,7 +155,7 @@ Key variables (all have defaults):
155
155
  | `AGY_TIMEOUT` | `600` | CLI run timeout in seconds (both backends) |
156
156
  | `WECHATBRIDGE_MAX_OUTBOUND_BYTES` | `104857600` | max file size sent back to WeChat (100 MB) |
157
157
  | `WECHATBRIDGE_MAX_INBOUND_BYTES` | `20971520` | max inbound image/file after download (20 MB) |
158
- | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent handlers; extras get a busy reply |
158
+ | `WECHATBRIDGE_MAX_CONCURRENT` | `4` | global concurrent process slots; same user serial (queue does not hold a slot); extras get a busy reply |
159
159
  | `WECHATBRIDGE_CONFIRM_TOKEN` | `y` | reply this token to approve a gated dangerous prompt |
160
160
  | `WECHATBRIDGE_ENABLE_MCP` | `true` | enable the `/mcp` help text command |
161
161
  | `WECHATBRIDGE_ENABLE_SUBAGENT` | `true` | enable the `/agent` prompt-rewrite command |
@@ -262,7 +262,7 @@ Other `/…` commands are either rejected (e.g. `/exit`), reported as unsupporte
262
262
  - **Danger gate is keyword-based**, not full intent understanding. Defaults target concrete patterns (`rm -rf /`, pipe-to-shell, `mkfs`, `format c:`, a few heavy Chinese phrases, …). Everyday wording like bare “delete” is **not** gated. Override list via `WECHATBRIDGE_CONFIRM_KEYWORDS`; approve with `WECHATBRIDGE_CONFIRM_TOKEN` (default `y`), TTL `WECHATBRIDGE_PENDING_TTL`.
263
263
  - **Inbound media** is size-capped (default 20 MB), streamed, and CDN hosts are allowlisted. Missing `aes_key` returns a clear error.
264
264
  - **Outbound artifacts** only leave the allowed per-user tree (agy: session scratch; grok: under session dir), after `realpath` checks, and only if under `WECHATBRIDGE_MAX_OUTBOUND_BYTES`.
265
- - **Concurrency:** global cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4); same user is serialized, different users can run in parallel.
265
+ - **Concurrency:** global process-slot cap (`WECHATBRIDGE_MAX_CONCURRENT`, default 4). Same user is serialized and does **not** hold a global slot while waiting on their previous message; different users can run in parallel up to the cap.
266
266
  - **Long replies** are split into chunks (`WECHATBRIDGE_MESSAGE_CHUNK`, default 2000 characters).
267
267
  - **Data layout:** instance data under `~/.local/share/wechatbridge/<instance>/` (override with env). Runtime dirs prefer `0700`; token/QR files prefer `0600` (Unix; Windows relies on NTFS ACLs).
268
268
  - **Retention:** session temps vs dialogue history use separate TTLs (`WECHATBRIDGE_SESSION_RETENTION_DAYS`, `WECHATBRIDGE_HISTORY_RETENTION_DAYS`). Prefs/auth are kept.