@gavin-hu/my-pi-agent 0.17.0 → 0.19.0

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 (274) hide show
  1. package/README.md +61 -45
  2. package/README.zh-CN.md +54 -42
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +344 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +554 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
  14. package/docs/extensions/plan.md +976 -0
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
  19. package/docs/extensions/team.md +226 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
  25. package/extensions/board/commands.ts +87 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +72 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +312 -0
  32. package/extensions/board/result.ts +99 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +75 -0
  35. package/extensions/board/seat.ts +29 -0
  36. package/extensions/board/snapshot.ts +41 -0
  37. package/extensions/board/tools.ts +279 -0
  38. package/extensions/board/transport/router.ts +172 -0
  39. package/extensions/board/transport/server.ts +149 -0
  40. package/extensions/board/types.ts +54 -0
  41. package/extensions/board/view/actions.ts +306 -0
  42. package/extensions/board/view/client.ts +41 -0
  43. package/extensions/board/view/keys.ts +121 -0
  44. package/extensions/board/view/live.ts +397 -0
  45. package/extensions/board/view/page.ts +158 -0
  46. package/extensions/board/view/render.ts +496 -0
  47. package/extensions/board/view/styles.ts +211 -0
  48. package/extensions/board/view/team.ts +305 -0
  49. package/extensions/board/writes.ts +99 -0
  50. package/extensions/bytefly/notify/types.ts +1 -1
  51. package/extensions/bytefly/panel/commands.ts +40 -7
  52. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  53. package/extensions/bytefly/panel/settings.ts +11 -21
  54. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  55. package/extensions/bytefly/picker/discovery.ts +0 -2
  56. package/extensions/bytefly/picker/types.ts +0 -2
  57. package/extensions/bytefly/update/check.ts +39 -0
  58. package/extensions/bytefly/update/format.ts +15 -0
  59. package/extensions/bytefly/update/register.ts +73 -34
  60. package/extensions/bytefly/update/state.ts +6 -3
  61. package/extensions/context/tui.ts +1 -1
  62. package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
  63. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  64. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  65. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  66. package/extensions/file-browse/index.ts +4 -4
  67. package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
  68. package/extensions/file-browse/live/rev.ts +42 -0
  69. package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
  70. package/extensions/file-browse/transport/server.ts +110 -0
  71. package/extensions/file-browse/view/chrome.ts +83 -0
  72. package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
  73. package/extensions/file-browse/view/diff-html.ts +94 -0
  74. package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
  75. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  76. package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
  77. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  78. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  79. package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
  80. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  81. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  82. package/extensions/file-read/schema.ts +2 -1
  83. package/extensions/goal/plan-route.ts +8 -2
  84. package/extensions/job/index.ts +15 -7
  85. package/extensions/memory/store.ts +12 -5
  86. package/extensions/plan/blocks.ts +23 -2
  87. package/extensions/plan/commands.ts +77 -21
  88. package/extensions/plan/feedback.ts +409 -63
  89. package/extensions/plan/index.ts +7 -5
  90. package/extensions/plan/plan-index.ts +44 -10
  91. package/extensions/plan/plan-text.ts +51 -24
  92. package/extensions/plan/plans.ts +162 -57
  93. package/extensions/plan/policy.ts +20 -12
  94. package/extensions/plan/review-prompt.ts +93 -0
  95. package/extensions/plan/review.ts +51 -95
  96. package/extensions/plan/runtime.ts +22 -10
  97. package/extensions/plan/sections.ts +112 -0
  98. package/extensions/plan/steps.ts +63 -95
  99. package/extensions/plan/tools/enter.ts +8 -3
  100. package/extensions/plan/tools/exit.ts +13 -19
  101. package/extensions/plan/tools/write.ts +8 -3
  102. package/extensions/plan/tools.ts +1 -1
  103. package/extensions/plan/tui.ts +29 -15
  104. package/extensions/plan/types.ts +29 -3
  105. package/extensions/plan/web-chrome.ts +2 -1
  106. package/extensions/plan/web-client.ts +122 -178
  107. package/extensions/plan/web-collab.ts +207 -0
  108. package/extensions/plan/web-comments.ts +269 -0
  109. package/extensions/plan/web-page.ts +96 -17
  110. package/extensions/plan/web-router.ts +109 -47
  111. package/extensions/plan/web-share-session.ts +191 -0
  112. package/extensions/plan/web-share.ts +169 -0
  113. package/extensions/plan/web-streams.ts +124 -0
  114. package/extensions/plan/web-tokens.ts +63 -0
  115. package/extensions/plan/web.ts +245 -151
  116. package/extensions/question/dialogs.ts +91 -1
  117. package/extensions/question/tools.ts +2 -1
  118. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  119. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  120. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  121. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  122. package/extensions/rewind/index.ts +3 -3
  123. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  124. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  125. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  126. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  127. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  128. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  129. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  130. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  131. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  132. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  133. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  134. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  135. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  136. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  137. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  138. package/extensions/session-bus/commands.ts +113 -0
  139. package/extensions/session-bus/format.ts +101 -0
  140. package/extensions/session-bus/index.ts +75 -0
  141. package/extensions/session-bus/join-request.ts +40 -0
  142. package/extensions/session-bus/mail-watch.ts +134 -0
  143. package/extensions/session-bus/notice.ts +86 -0
  144. package/extensions/session-bus/overlap.ts +36 -0
  145. package/extensions/session-bus/protocol.ts +104 -0
  146. package/extensions/session-bus/resolve.ts +67 -0
  147. package/extensions/session-bus/result.ts +208 -0
  148. package/extensions/session-bus/rows.ts +171 -0
  149. package/extensions/session-bus/runtime.ts +466 -0
  150. package/extensions/session-bus/store.ts +365 -0
  151. package/extensions/session-bus/tools.ts +192 -0
  152. package/extensions/session-bus/tui.ts +394 -0
  153. package/extensions/session-bus/types.ts +98 -0
  154. package/extensions/subagent/agents.ts +9 -8
  155. package/extensions/subagent/invocation.ts +4 -2
  156. package/extensions/team/commands.ts +112 -0
  157. package/extensions/team/dispatch.ts +133 -0
  158. package/extensions/team/format.ts +105 -0
  159. package/extensions/team/index.ts +33 -0
  160. package/extensions/team/schema.ts +29 -0
  161. package/extensions/team/tools.ts +246 -0
  162. package/extensions/team/types.ts +48 -0
  163. package/extensions/watch/index.ts +15 -9
  164. package/extensions/web-access/fetch/page.ts +4 -1
  165. package/extensions/web-access/fetch/structured.ts +447 -0
  166. package/extensions/web-access/fetch/tool.ts +2 -1
  167. package/extensions/web-access/search/tool.ts +2 -1
  168. package/extensions/wechat/bridge.ts +76 -92
  169. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  170. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  171. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  172. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  173. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  174. package/extensions/wechat/dialog/channels.ts +136 -0
  175. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
  176. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
  177. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  178. package/extensions/wechat/index.ts +10 -10
  179. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  180. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  181. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
  182. package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
  183. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
  184. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  185. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  186. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  187. package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
  188. package/extensions/wechat/tools.ts +29 -12
  189. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  190. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
  191. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  192. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  193. package/extensions/wechat/transport/outbound.ts +142 -0
  194. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  195. package/extensions/wechat/types.ts +10 -1
  196. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  197. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  198. package/extensions/worktree/git.ts +5 -0
  199. package/extensions/worktree/guard.ts +3 -5
  200. package/extensions/worktree/registry.ts +11 -5
  201. package/extensions/worktree/status.ts +1 -3
  202. package/lib/README.md +12 -6
  203. package/lib/board/card.ts +290 -0
  204. package/lib/board/links.ts +117 -0
  205. package/lib/board/page-pointer.ts +256 -0
  206. package/lib/board/round-text.ts +72 -0
  207. package/lib/board/round.ts +89 -0
  208. package/lib/board/rules.ts +304 -0
  209. package/lib/board/session.ts +105 -0
  210. package/lib/board/store.ts +310 -0
  211. package/lib/board/view.ts +150 -0
  212. package/lib/errors.ts +12 -4
  213. package/lib/format.ts +13 -0
  214. package/lib/http/guard.ts +112 -0
  215. package/lib/http/index.ts +16 -5
  216. package/lib/http/router.ts +40 -13
  217. package/lib/http/serve.ts +234 -0
  218. package/lib/http/server.ts +29 -13
  219. package/lib/http/sink.ts +110 -0
  220. package/lib/http/token.ts +38 -0
  221. package/lib/interaction.ts +10 -0
  222. package/lib/notice.ts +29 -2
  223. package/lib/plan-entry.ts +7 -2
  224. package/lib/policy.ts +12 -2
  225. package/lib/qr.ts +64 -0
  226. package/lib/repo-key.ts +74 -0
  227. package/lib/report.ts +52 -33
  228. package/lib/requirements.ts +7 -9
  229. package/lib/session-label.ts +64 -0
  230. package/lib/session-roster.ts +130 -0
  231. package/lib/session-tool.ts +184 -0
  232. package/lib/tool-names.ts +25 -2
  233. package/lib/tui/README.md +16 -2
  234. package/lib/tui/dock-screen.ts +85 -17
  235. package/lib/tui/glyphs.ts +2 -0
  236. package/lib/tui/line-pane.ts +15 -4
  237. package/lib/tui/tool.ts +23 -4
  238. package/lib/web/README.md +5 -2
  239. package/lib/web/components.ts +6 -1
  240. package/lib/web/runtime.ts +7 -1
  241. package/lib/web/tokens.ts +25 -2
  242. package/package.json +27 -14
  243. package/skills/init/SKILL.md +80 -45
  244. package/skills/init/references/agents-md-quality.md +26 -2
  245. package/skills/init/references/interview-questions.md +36 -14
  246. package/skills/next/SKILL.md +77 -51
  247. package/skills/project-docs/SKILL.md +133 -0
  248. package/skills/project-docs/references/audit.md +131 -0
  249. package/skills/project-docs/references/changelog.md +105 -0
  250. package/skills/project-docs/references/discovery.md +127 -0
  251. package/skills/project-docs/references/readme.md +80 -0
  252. package/skills/project-docs/references/release.md +84 -0
  253. package/skills/project-docs/references/roadmap.md +93 -0
  254. package/skills/project-docs/scripts/state.ts +316 -0
  255. package/skills/refactor/SKILL.md +100 -125
  256. package/skills/refactor/references/handoff.md +35 -0
  257. package/skills/refactor/references/signals.md +54 -0
  258. package/skills/refactor/scripts/inventory.ts +587 -0
  259. package/extensions/file-browse/README.md +0 -467
  260. package/extensions/file-browse/server.ts +0 -134
  261. package/extensions/plan/README.md +0 -709
  262. package/extensions/plan/config.ts +0 -27
  263. package/extensions/wechat/outbound.ts +0 -100
  264. package/extensions/wechat/qrcode.ts +0 -46
  265. package/skills/changelog/SKILL.md +0 -148
  266. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  267. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  268. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  269. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  270. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  271. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  272. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  273. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  274. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -1,76 +1,130 @@
1
1
  # wechat — a WeChat bridge with one Pi conversation per peer
2
2
 
3
3
  Connect Pi to WeChat over Tencent's Weixin iLink bot API. Each admitted sender
4
- gets their own Pi session - own history, own model context, the package's tools -
4
+ gets their own Pi session — own history, own model context, the package's tools —
5
5
  so a WeChat message starts a turn immediately and the terminal conversation is
6
6
  never mixed into it. The sessions live in-process through the Pi SDK; there is no
7
7
  subprocess per peer.
8
8
 
9
- ```
9
+ ## Capabilities
10
+
11
+ Status records what a reader can rely on: **Shipped** is in a tagged release, **Unreleased** is complete in the current tree but not yet in a tagged release, and **Partial** has one released part and one unreleased part. Rows are ordered by user value, highest first.
12
+
13
+ ### Conversation
14
+
15
+ | Capability | How | What you get | Status |
16
+ |---|---|---|---|
17
+ | One session per peer | automatic: an inbound message | Each admitted sender gets their own Pi session — history, model context, tools — resumed on the next message. | Shipped |
18
+ | Per-message turn | automatic: the wait timers | Each message gets its own turn and answer; a wait is acknowledged once, and an empty or failed turn says so. | Shipped |
19
+ | Inbound media | automatic: an inbound image or file | Images arrive as model image content, files are saved and referenced by path, and voice uses the server's transcript. | Shipped |
20
+ | Isolated terminal | automatic: an inbound message | A WeChat turn runs in the peer's session, so the terminal transcript never sees the exchange. | Shipped |
21
+ | Two peers at once | `PEER_CONCURRENCY` | Two peers' turns run concurrently; a third waits in a fair queue and is acknowledged. | Unreleased |
22
+ | Inbound replay safety | automatic: the WeChat message id | A replayed message id is answered once, remembered for an hour across a reconnect or reload. | Shipped |
23
+ | Inbound text cap | `maxInboundChars` | Text past the configured cap is truncated with a marker instead of becoming an unbounded prompt. | Shipped |
24
+ | Peer language | automatic: a message written in Chinese | Notices and `/help` answer in the peer's language, and a neutral reply does not switch it back. | Shipped |
25
+ | Peer slash commands | `/new`, `/stop`, `/status`, `/history`, `/help` | The peer restarts, stops, inspects, or asks for help without spending a turn. | Shipped |
26
+ | Fresh host session | `/wechat new` | A new terminal session starts; every peer conversation keeps running and stays on disk. | Shipped |
27
+ | Typing indicator | `typingIndicator` | The phone can tell a long turn from a dead bridge. | Shipped |
28
+
29
+ ### Remote control
30
+
31
+ | Capability | How | What you get | Status |
32
+ |---|---|---|---|
33
+ | Plan review over chat | automatic: an `exit_plan_mode` on a WeChat turn | The plan arrives as a file, then one compact editor prompt with three actions. | Shipped |
34
+ | Forwarded dialogs | `remotePrompts` | A dialog raised inside a WeChat turn is asked and answered in that peer's chat. | Shipped |
35
+ | Peer questions | `ask_user_question` | The peer answers their own question, numbered, and a reply cannot reach another peer's prompt. | Shipped |
36
+ | Control screen | `/wechat` | Account, connection, per-peer rows, activity, and any waiting prompt on one live screen. | Shipped |
37
+ | Prompt cancellation | `/wechat`, `c` on the control screen | One waiting question is withdrawn with a notice, without stopping the peer's turn. | Shipped |
38
+ | Peer steering | `/wechat stop <peer>`, `/wechat reset <peer>` | One peer's running turn stops, or their conversation starts fresh. | Shipped |
39
+
40
+ ### Security and admission
41
+
42
+ | Capability | How | What you get | Status |
43
+ |---|---|---|---|
44
+ | Owner-only admission | `allowedPeers`, `/wechat allow [peer]` | Only the owner who scanned the QR drives the agent until another sender is named. | Shipped |
45
+ | Per-peer tool allowlist | `peers[<id>].tools` | An extra admitted peer can be narrowed to named tools; an empty list leaves none. | Shipped |
46
+ | One poller per account | `poller.lock` | A second `pi` process is refused instead of stealing the account's messages. | Shipped |
47
+
48
+ ### Delivery and reliability
49
+
50
+ | Capability | How | What you get | Status |
51
+ |---|---|---|---|
52
+ | Streamed replies | automatic: the reply pipeline | A long answer arrives as it is produced, not as one long silence. | Shipped |
53
+ | Proactive message | `send_wechat` | The model messages the owner one text or one local image, sent as a picture. | Partial |
54
+ | Abandoned-turn notice | automatic: shutdown, `/reload`, `/new`, logout | Each peer with work gets one line saying the turn stopped, rather than silence. | Unreleased |
55
+ | Outbound files | the upload flow, `askHuman` | A remote prompt can deliver a local file before its question, and a failed upload falls back to text. | Shipped |
56
+ | Plain chat text | `maxReplyChars` | Markdown markers are flattened, and a part still over the limit is numbered. | Shipped |
57
+
58
+ ### Pairing and transport
59
+
60
+ | Capability | How | What you get | Status |
61
+ |---|---|---|---|
62
+ | QR login | `/wechat login` | One scan stores the account and the bridge starts polling it, replacing a running poller. | Shipped |
63
+ | Reconnect after expiry | `/wechat login`, `l` on the control screen | A fresh login clears the expired chip and starts a new worker instead of leaving a dead token. | Shipped |
64
+ | Automatic start | `session_start`, a stored account | The bridge runs whenever credentials exist: the account is the on/off switch, so there is no start command. | Shipped |
65
+ | Pause and resume | `/wechat pause`, `/wechat resume` | Polling stops while running turns finish, the lock is released, and resume takes it back. | Shipped |
66
+ | Status chip | `/wechat status` | The status bar names the state — `✆ wechat`, `✆ wechat paused`, or a persistent `✆ wechat expired`. | Shipped |
67
+ | Logout | `/wechat logout` | One confirmation discards the credentials and stops polling. | Shipped |
68
+
69
+ ### Upkeep
70
+
71
+ | Capability | How | What you get | Status |
72
+ |---|---|---|---|
73
+ | Health report | `/wechat doctor` | Connection, token age, last poll, lock holder, per-peer queues, and recent failures in one read-only report. | Shipped |
74
+ | Idle session cleanup | automatic: the 30-minute idle sweep | An idle peer's Pi session is disposed, so idle conversations do not keep costing context or memory. | Shipped |
75
+ | Peer history | `/wechat history <peer> [n]` | That peer's recent messages, ten by default and twenty at most, without a live session. | Shipped |
76
+ | Command help | `/wechat help` | Every subcommand with a line each, printed in the transcript. | Shipped |
77
+
78
+ ## Quickstart
79
+
80
+ ```bash
10
81
  pi --extension ./extensions/wechat # load just this extension
11
82
  pi -e . # load the whole @gavin-hu/my-pi-agent package
12
- pi install ./ # install the package
83
+ pi install ./ # install the package for a user
13
84
  ```
14
85
 
15
- ## Tool rendering
86
+ ```text
87
+ /wechat login # scan the QR with the WeChat app; the bridge starts on the scan
88
+ /wechat # the control screen: account, connection, peers, waiting prompt
89
+ ```
16
90
 
17
- How the `send_wechat` call and its result look in the transcript — the call line as a headline and the body indented under it:
91
+ The account is the on/off switch: once one is stored, `session_start` opens the
92
+ bridge, so there is no start command to run.
93
+
94
+ ## Tool
18
95
 
19
96
  <img src="../../assets/tools/send_wechat.png" alt="The send_wechat tool's call and result: the call line and an indented body with the message text and the sent confirmation" width="100%">
20
97
 
21
- ## What it does
22
-
23
- - **Inbound** — one long-poll loop receives WeChat text, images, and files and
24
- hands them to the sender's own session; that turn's text is sent back to the
25
- sender. Nothing is injected into the terminal session, so the two
26
- conversations cannot contaminate each other.
27
- - **One conversation per peer** — a session per admitted peer, persisted under
28
- `<agent-dir>/wechat/sessions/<peer>/`, resumed on the next message and disposed
29
- after half an hour idle. Turns run one at a time across peers by default, so a
30
- forwarded dialog always has a single owner.
31
- - **Inbound media** — images are downloaded, AES-128-ECB decrypted, and attached
32
- as model image content; files are saved under `<agent-dir>/wechat/media/` and
33
- referenced by path. Voice uses the transcript the server provides. An
34
- attachment the bridge could not read (too large, a failed download, no key) is
35
- reported to the peer and to the model instead of vanishing.
36
- - **Outbound** — replies stream to the chat as they are produced: a part goes out
37
- at each paragraph boundary, after two silent seconds, or at 400 characters,
38
- whichever comes first, so a long answer is not one long silence. Text is plain
39
- chat text (Markdown fences, headings, and emphasis markers are flattened), and
40
- a single part longer than `maxReplyChars` still arrives in numbered parts. The
41
- owner-only `send_wechat` tool lets the model message the owner proactively; it
42
- is refused while a turn with that peer is in flight, so the answer cannot
43
- arrive twice.
44
- - **Typing indicator** — the native typing status is set as soon as a message is
45
- queued and refreshed for as long as the turn runs (`typingIndicator`, on by
46
- default), so the phone can tell a long turn from a dead bridge.
47
- - **Peer feedback** — a burst that arrives while a turn is still running is
48
- answered once, a message waiting on another conversation is acknowledged once,
49
- a slow turn says so, and a turn that ends without text says so instead of going
50
- quiet. Notices and `/help` follow the peer's language: a Chinese message sets it,
51
- and a language-neutral reply (`1`, `ok`) does not switch it back. The peer can
52
- also stop (`/stop`), restart (`/new`), and ask what is happening (`/status`).
53
- - **Remote prompts** — the peer session's own UI is bound to that peer, so
54
- `ask_user_question`, plan approval, and any other dialog raised inside a
55
- WeChat turn is sent to WeChat and answered there. Terminal turns keep the TUI,
56
- including while a peer turn runs (`remotePrompts`, on by default).
57
- - **Status chip** — the status bar shows `✆ wechat` while the bridge runs and
58
- keeps an error-colored `✆ wechat expired` after a session expiry
59
- (`ctx.ui.setStatus`), so the outage stays visible until `/wechat login`
60
- reconnects or `/wechat logout` stops the bridge; it clears on shutdown.
61
- - **Owner-only by default** — only the account owner (who scanned the login QR)
62
- can drive the agent; `allowedPeers` opts in others. An entry matches either id
63
- form (`alice` or `alice@im.wechat`), and a refused sender is named in
64
- `/wechat status` and in one terminal notice, so the id to allowlist can be
65
- copied from either. `a` on the control screen or `/wechat allow [peer]` writes
66
- one entry into the project config; the running bridge picks it up on the next
67
- `/reload` or restart, and the confirmation names the file.
68
- - **Session-bound** — the bridge runs while an account is stored and the Pi
69
- process is alive; it starts on `session_start`, and `/wechat logout` clears the
70
- account and stops it. The peer sessions outlive it on disk.
98
+ `send_wechat` — direct exposure (the default), `executionMode: "sequential"`,
99
+ read-write, open-world. It messages the account owner and nobody else: the
100
+ recipient is not a parameter, so untrusted generated text cannot be aimed at
101
+ another account. Give exactly one of `text` or `image`; neither or both is
102
+ refused with the reason. A text send is also refused while a turn with the owner
103
+ is in flight, because the bridge is already answering it, while an image is
104
+ allowed there — an image cannot duplicate the streamed answer.
105
+
106
+ | Parameter | Type | Meaning |
107
+ |---|---|---|
108
+ | `text` | string, optional | Plain text, 1–4000 characters. |
109
+ | `image` | string, optional | Path to a local image, up to 4096 characters; the bytes must sniff as an image. |
110
+
111
+ Result (`details` / `structuredContent`): `{ sent, error? }`. A failed send is an
112
+ error result carrying the same shape, so the model can report what went wrong.
113
+
114
+ ```text
115
+ send_wechat({ text: "The build is green again." })
116
+ send_wechat({ image: "/tmp/plot.png" })
117
+ ```
118
+
119
+ The call line carries the message text, or an image's file name rather than its
120
+ path; the result is one line — `sent to WeChat owner`, or the failure with a
121
+ reason.
71
122
 
72
123
  ## Commands
73
124
 
125
+ `/wechat` is one command with a verb table: a bare `/wechat` opens the control
126
+ screen, and every subcommand is the same action one of the screen's keys runs.
127
+
74
128
  | Command | Effect |
75
129
  |---|---|
76
130
  | `/wechat` | TUI: the control screen. Elsewhere: a one-line summary. |
@@ -104,6 +158,8 @@ A session that expires while running keeps the `✆ wechat expired` chip:
104
158
  `/wechat status` reports it and points at the fix, and `/wechat login` is the
105
159
  one-step recovery.
106
160
 
161
+ ### The control screen
162
+
107
163
  In the TUI, `/wechat` opens a control screen with the account, connection
108
164
  state, owner, per-peer conversation rows (what each peer is doing, how long, and
109
165
  what is queued behind it), activity counters, and the waiting remote prompt
@@ -117,7 +173,18 @@ polling. `l` logs in or reconnects, `n` starts a new session, `o` logs out,
117
173
  actions and the only route outside the TUI; an unknown subcommand reports the
118
174
  valid ones.
119
175
 
120
- <img src="../../assets/wechat.png" alt="The /wechat control screen: account and an open connection, peer, queued, refused, and waiting-prompt rows, three conversation rows showing running, queued, and idle states, and the key hints" width="100%">
176
+ <img src="../../assets/screens/wechat.png" alt="The /wechat control screen: account and an open connection, peer, queued, and refused rows, one waiting-prompt row per peer that was asked a question, three conversation rows showing two running peers with one queued behind them and an idle peer, and the key hints" width="100%">
177
+
178
+ ### Admission
179
+
180
+ An entry in `allowedPeers` matches either id form (`alice` or
181
+ `alice@im.wechat`), and a refused sender is named in `/wechat status` and in one
182
+ terminal notice, so the id to allowlist can be copied from either. `a` on the
183
+ control screen or `/wechat allow [peer]` writes one entry into the project
184
+ config; the running bridge picks it up on the next `/reload` or restart, and the
185
+ confirmation names the file.
186
+
187
+ ### Pause and resume
121
188
 
122
189
  Pause is per runtime and keeps the account and peer sessions: a running turn
123
190
  may finish and reply, a waiting prompt is withdrawn with the timeout notice,
@@ -126,6 +193,8 @@ reacquires the lock and reports when another runtime holds it. `/reload` or a
126
193
  restart builds a new bridge and resumes polling, so pause is a temporary mute,
127
194
  not a persistent switch.
128
195
 
196
+ ### While a peer turn runs
197
+
129
198
  A peer turn runs in its own session, but it still owns the bridge, so a `/wechat`
130
199
  command typed at the terminal declines while one is running: `status` prints the
131
200
  one-line summary, and `login`/`logout`/`new` reply that they are unavailable
@@ -134,7 +203,7 @@ until the turn finishes. (`login`/`logout` would drop the connection mid-turn an
134
203
  unanswered.) The terminal's own dialogs are unaffected: they are never forwarded
135
204
  to a peer.
136
205
 
137
- ## Slash commands
206
+ ### Peer slash commands
138
207
 
139
208
  A peer can type a slash command in the chat. The bridge intercepts it before it
140
209
  becomes a turn, so a command never reaches the model unless it is unknown.
@@ -166,6 +235,57 @@ becomes a turn, so a command never reaches the model unless it is unknown.
166
235
  | `rpc` | The bridge auto-starts and `status` prints the one-line summary; `login` needs the TUI (the QR screen is unavailable over RPC), while `logout`'s confirmation is forwarded through the RPC UI protocol like any other dialog. |
167
236
  | `json`, `print` | Ephemeral; the bridge does not auto-start and no UI is available, so `logout` reports that it needs a confirmation dialog instead of failing silently. |
168
237
 
238
+ ## Configuration
239
+
240
+ The `wechat` section of the shared `bytefly.json`, the global document overridden
241
+ by the project `<cwd>/.pi/bytefly.json` (project wins); resolution rules and every
242
+ default are in
243
+ [`../configuration.md`](../configuration.md#wechat--the-wechat-bridge).
244
+
245
+ ```jsonc
246
+ // bytefly.json → "wechat"
247
+ {
248
+ "allowedPeers": [], // extra peers, by id or label (alice / alice@im.wechat)
249
+ "peers": {}, // per-peer overrides, e.g. { "alice": { "tools": ["read", "todo"] } }
250
+ "maxTurnsPerHour": 0, // turns per peer per rolling hour; 0 = unlimited
251
+ "maxReplyChars": 4000, // replies longer than this are split into messages
252
+ "botAgent": "pi-wechat", // sent as base_info.bot_agent
253
+ "channelVersion": "0.1.0", // sent as base_info.channel_version
254
+ "cdnBaseUrl": "https://novac2c.cdn.weixin.qq.com/c2c", // CDN media base
255
+ "maxMediaBytes": 20971520, // inbound media larger than this is skipped
256
+ "maxInboundChars": 8000, // inbound text longer than this is truncated
257
+ "typingIndicator": true, // show typing while a turn is generated
258
+ "remotePrompts": true, // answer interactive dialogs on WeChat during a WeChat turn
259
+ // "pollHoldMs": 3000 // experimental: request a shorter long-poll hold; unset by default
260
+ }
261
+ ```
262
+
263
+ State lives under `<agent-dir>/wechat/`: `credentials.json` (mode `0600`, every
264
+ stored account plus the active one), `state.json` (cursor, owner, per-peer reply
265
+ tokens, and recently handled message ids), `media/` (downloaded inbound files),
266
+ `sessions/<peer>/` (one conversation per peer), and `poller.lock` (stops two
267
+ sessions polling one account).
268
+
269
+ ## Security
270
+
271
+ - WeChat text is **untrusted**, and in this design it becomes a turn in your live
272
+ session **with that session's tools** (including `bash`/`write`/`edit`).
273
+ Admission control is the only gate: the default is **owner-only**, and
274
+ `allowedPeers` is an explicit opt-in. A refused sender is counted and named in
275
+ `/wechat status`, not run. `peers[<id>].tools` narrows an admitted peer to a
276
+ tool allowlist (an empty list leaves it with none), so an extra peer need not
277
+ get `bash`.
278
+ - Inbound text is sanitized (control characters stripped) before it reaches the
279
+ transcript.
280
+ - Inbound media is **untrusted**: images become model image content, and files
281
+ are saved under `<agent-dir>/wechat/media/` with sanitized names and a size cap
282
+ (`maxMediaBytes`). Inbound text is capped by `maxInboundChars` before it reaches
283
+ the model, and a replayed message id is answered once. Nothing is executed.
284
+ - Credentials are written `0600` and redacted from diagnostics; egress is only to
285
+ the Weixin endpoint.
286
+ - Automated or unofficial WeChat use can violate WeChat's terms and risk account
287
+ restriction. Single-owner, low-volume use is recommended.
288
+
169
289
  ## What the peer experiences
170
290
 
171
291
  The peer has only the chat window, so the bridge carries the signals the phone
@@ -174,20 +294,23 @@ needs. Per action:
174
294
  | Peer action | What they see |
175
295
  |---|---|
176
296
  | Sends a message | The native typing indicator starts as soon as the message is picked up; the answer is sent when the run settles. |
177
- | Sends several messages quickly | At most one acknowledgement, then a single answer that covers the burst in order. |
297
+ | Sends several messages quickly | Each message is its own turn; one acknowledgement per wait, then the answers in order. |
298
+ | Waits behind two running peers | One line after four seconds, then one more at a minute (`前面还有一条正在处理…` / “Your message is queued behind another conversation…”), and the answer when a slot frees. The next free slot goes to whoever has been waiting longest. |
178
299
  | Waits behind a long turn | The indicator keeps refreshing, and after four seconds one short `收到,正在排队,轮到就回你。` / “Got it — queued; I’ll answer when it’s my turn.”; a turn still running at 30 seconds adds one `还在处理这条,发送 /stop 可取消。` / “Still working — send /stop to cancel.”, so the way out is named when it is wanted. |
179
- | Sends while another peer's turn runs | One line after four seconds, then one more at a minute (`前面还有一条正在处理…` / “Your message is queued behind another conversation…”), and the answer when the slot frees. |
300
+ | Sends while another peer's turn runs | Both conversations are answered in parallel, each with its own typing indicator and its own place in a dialog. |
180
301
  | Sends a photo or a file | The model reads it; an attachment that could not be read (too large, a failed download, no key) is named in the answer. |
302
+ | Is sent an image by the bot | A picture in the chat, from `send_wechat` with a local path, not a document attachment. |
181
303
  | Sends a voice note or video | One line: voice without a transcript and video are not supported. |
182
304
  | Reads a long answer | Streamed as it is produced — one part per paragraph, per two silent seconds, or per 400 characters — and numbered (`[1/3] …`) only when a single part exceeds `maxReplyChars`. |
183
305
  | Reads an answer | Plain chat text — Markdown fences, headings, and emphasis markers are flattened, code-block bodies survive. |
184
- | Is asked a question | A numbered prompt; 确定/是/yes or 不是/否/no answers it, 取消 dismisses. Ignored, it is nudged at the halfway point of its window and then cancelled: three minutes for a dialog, ten for a plan review, where the plan has to be read first. |
306
+ | Is asked a question | Their own prompt, never another peer's: a reply answers the question their own conversation is blocked on. A numbered prompt; 确定/是/yes or 不是/否/no answers it, 取消 dismisses. Ignored, it is nudged at the halfway point of its window and then cancelled: three minutes for a dialog, ten for a plan review, where the plan has to be read first. |
185
307
  | Types `/help`, `/new`, `/stop`, or `/status` | The command list, a fresh conversation, a stopped answer, or the current state — including the dialog a blocked turn is waiting on; `/new` is declined while their run is in flight. A command works while a dialog waits: it is never read as an answer to it. |
186
308
  | Types `/history` | The last eight messages of this conversation (or the requested number, up to 20), one line each. |
187
309
  | Floods the chat | Past eight queued messages, one line asking to wait rather than an unbounded queue. |
188
310
  | Sends the same message twice (a server replay) | Answered once: the WeChat message id is remembered for an hour, across a reconnect or reload. |
189
311
  | Pastes a very long message | The first `maxInboundChars` characters reach the model, followed by a truncation marker, instead of an unbounded prompt. |
190
312
  | Gets an aborted turn | `已停止。` / “Stopped.” |
313
+ | Exits, reloads, or forks mid-turn | One line: `会话已停止,这条还没答完。恢复后再发一次即可。` / “The session stopped before this answer finished — send it again when it's back.”, sent to every peer that still had work before the connection closes. |
191
314
  | Gets a turn that produced no text | `这条处理完了,但没有文字输出。` / “That turn finished without any text to send.” |
192
315
  | Gets a turn that could not start | `处理失败,请稍后重试。` / “Something went wrong — please try again.”, and the terminal says why. |
193
316
  | Keeps messaging an expired or logged-out bridge | Nothing — the bot goes quiet, and only the terminal chip says why. |
@@ -199,17 +322,16 @@ so every bubble has to carry information.
199
322
 
200
323
  ### The reply contract
201
324
 
202
- One acknowledgement per wait episode, one answer per turn. A message that
203
- arrives before the first turn starts is merged into that turn, so it is answered
204
- inside the answer it is already waiting for — not with a second “working on it”.
325
+ One message, one turn, one answer. A queued message is never merged into
326
+ another prompt; it waits for its own turn. One acknowledgement per wait episode.
205
327
 
206
328
  | Situation | What the peer gets |
207
329
  |---|---|
208
330
  | 1st message, session idle | typing, then the answer (no acknowledgement: there is nothing to wait for) |
209
- | 1st message, peer busy | typing, `收到,正在排队,轮到就回你。` at 4 s, `前面还有一条正在处理…` at 60 s, the answer when the slot frees |
210
- | 2nd message after the acknowledgement, still queued | nothing new; it is merged into the same turn and the one answer covers both in order |
211
- | 2nd message while a turn runs | no new acknowledgement; it becomes its own turn and answer after the first |
212
- | 3rd…Nth in a burst | same as the queued case; past eight queued, one line asking to wait |
331
+ | 1st message, peer busy | typing, `收到,排队中,轮到就回。` at 4 s, `还在处理前一条,轮到就回。` at 60 s, the answer when the slot frees |
332
+ | 2nd message while a turn runs | its own turn behind the first; the 4 s ack and 60 s busy line; then its own answer |
333
+ | 2nd message already queued | behind the first in FIFO; no merge |
334
+ | 3rd…Nth in a burst | each is its own turn; past eight queued, one line asking to wait |
213
335
 
214
336
  Two consequences worth knowing: an answer can take up to one long-poll cycle
215
337
  (the server holds each `getupdates`; measured ~18 s) before the turn even
@@ -224,12 +346,13 @@ A dialog raised while a WeChat turn is in flight is forwarded to the sender:
224
346
  so, so a plan review without it does not pretend free text is a choice.
225
347
  - `confirm` — reply 是/否, 确定/不是, or yes/no.
226
348
  - `input` / `editor` — reply with the text.
227
- - Reply 取消 (or cancel) to dismiss; `退出`, `退出 plan 模式`, and
228
- `exit plan mode` are recognized as cancel intent. In a plan review a dismissal
229
- leaves plan mode without approving.
230
- - Hints are bilingual (Chinese and English). A reply that cannot be read
231
- re-sends the prompt with a short “didn’t recognize that” notice instead of
232
- repeating it silently.
349
+ - Reply 取消 (or cancel, quit, exit, 退出) to dismiss. In a plan review a
350
+ dismissal leaves plan mode without approving, and a reply the three actions do
351
+ not name — `exit plan mode`, or 退出 plan 模式 — is feedback the model applies
352
+ before asking again.
353
+ - Hints are rendered in the peer’s language, one language per prompt. A reply
354
+ that cannot be read re-sends the prompt with a short “didn’t recognize that”
355
+ notice instead of repeating it silently.
233
356
  - A command is not an answer. While a dialog waits the model is blocked on it, so
234
357
  the slash commands are matched first: `/status` names the waiting question and
235
358
  `/stop` stops the turn, where before both were parsed as an attempt to answer
@@ -237,15 +360,13 @@ A dialog raised while a WeChat turn is in flight is forwarded to the sender:
237
360
  so a number, 是/否, or 取消 behaves as above.
238
361
  - An unanswered prompt is bounded: a reminder goes out halfway and the question
239
362
  is cancelled at the end of its window with a one-line notice, so an ignored
240
- dialog cannot freeze the session. The window is three minutes for a dialog and
241
- ten for the plan review, which arrives with the plan attached — a request that
242
- carries `timeoutMs` asks for the longer one through the channel, and the
243
- reminder names 取消 rather than a duration that assumes the short window. A
244
- timed-out plan review is treated like a dismissal: plan mode ends without
245
- approval, and the plan file remains.
246
- - A prompt that goes away before it is answered — a pause, a `/stop`, a shutdown —
247
- says so (`问题已撤回。` / “That question was withdrawn.”) instead of reusing the
248
- timeout line: the peer was not slow, the question left.
363
+ dialog cannot freeze the session. A dialog uses three minutes; the plan review
364
+ uses ten. A timed-out plan review is treated like a dismissal: plan mode ends
365
+ without approval, and the plan file remains.
366
+ - A prompt that goes away before it is answered says so: a pause withdraws it with
367
+ `问题已撤回。` / “That question was withdrawn.”, a `/stop` ends its turn with
368
+ `已停止。`, and a shutdown reports the abandoned turn — the peer was not slow, the
369
+ question or the session left.
249
370
 
250
371
  Each peer session is created with its own UI context (`bindExtensions` with
251
372
  `mode: "rpc"`), so its dialogs go to that peer by construction rather than by
@@ -257,56 +378,20 @@ to WeChat. See [Design notes](#design-notes).
257
378
 
258
379
  ## Outbound files
259
380
 
260
- A remote prompt can deliver a local file first. `exit_plan_mode` uses this to
261
- send the plan file itself before the five choices, so WeChat sees the whole plan
262
- (a long one falls back to chunked text if the upload fails). The peer session's
263
- UI context carries the bridge channel, so `askHuman` finds it and posts the file
264
- then the text before the dialogs. Before the choices it also posts a short
265
- digest — the title, the step count, and the first steps — so the review is
266
- answerable from the chat even if the file bubble is never opened, and a file or
267
- digest the bridge cannot deliver is reported on the terminal instead of vanishing.
268
- The digest says plan mode is on and that write access is off. The choices also
269
- include **Send the full plan**, which posts the plan text and asks again, so the
270
- whole plan is reachable without the attachment, and **Exit plan mode (discard)**,
271
- which leaves without approving. After a Keep decision the user can say “exit plan
272
- mode”; the model calls `cancel_plan_mode`, which needs no plan path and records
273
- no todo or goal.
381
+ A remote prompt can deliver a local file first. The plan review uses this: the
382
+ plan file is sent before one compact editor prompt with the title, first steps,
383
+ mode status, and three actions. Reply 2 to keep planning or 3 to exit; any other
384
+ text is a refinement. If the upload fails, the plan text is sent instead. The
385
+ plan file stays on disk. After a Keep decision the user can say “exit plan mode”; the model
386
+ calls `cancel_plan_mode`, which needs no plan path and records no todo or goal.
274
387
  The flow mirrors the
275
388
  reference client: `getuploadurl` → AES-128-ECB encrypt → `POST` the ciphertext
276
389
  (`Content-Type: application/octet-stream`) → read `x-encrypted-param` → send a
277
- `type: 4` file item. Only the plan review uses it today; images, video, and voice
390
+ `type: 4` file item. The plan review is the flow that uses it; `send_wechat`
391
+ uploads an image over the same path as a `type: 2` item, while video and voice
278
392
  are not sent outbound.
279
393
 
280
- ## Configuration
281
-
282
- The `wechat` section of the shared `bytefly.json`, the global document
283
- overridden by the project `<cwd>/.pi/bytefly.json` (project wins):
284
-
285
- ```jsonc
286
- // bytefly.json → "wechat"
287
- {
288
- "allowedPeers": [], // extra peers, by id or label (alice / alice@im.wechat)
289
- "peers": {}, // per-peer overrides, e.g. { "alice": { "tools": ["read", "todo"] } }
290
- "maxTurnsPerHour": 0, // turns per peer per rolling hour; 0 = unlimited
291
- "maxReplyChars": 4000, // replies longer than this are split into messages
292
- "botAgent": "pi-wechat", // sent as base_info.bot_agent
293
- "channelVersion": "0.1.0", // sent as base_info.channel_version
294
- "cdnBaseUrl": "https://novac2c.cdn.weixin.qq.com/c2c", // CDN media base
295
- "maxMediaBytes": 20971520, // inbound media larger than this is skipped
296
- "maxInboundChars": 8000, // inbound text longer than this is truncated
297
- "typingIndicator": true, // show typing while a turn is generated
298
- "remotePrompts": true, // answer interactive dialogs on WeChat during a WeChat turn
299
- // "pollHoldMs": 3000 // experimental: request a shorter long-poll hold; unset by default
300
- }
301
- ```
302
-
303
- State lives under `<agent-dir>/wechat/`: `credentials.json` (mode `0600`, every
304
- stored account plus the active one), `state.json` (cursor, owner, per-peer reply
305
- tokens, and recently handled message ids), `media/` (downloaded inbound files),
306
- `sessions/<peer>/` (one conversation per peer), and `poller.lock` (stops two
307
- sessions polling one account).
308
-
309
- ### The long-poll hold
394
+ ## The long-poll hold
310
395
 
311
396
  A WeChat message is only visible to Pi when a `getupdates` request returns, and
312
397
  the server decides how long it holds one. Measured here: **~18 s**, which is the
@@ -318,7 +403,7 @@ different value is not documented, so it is opt-in and unset by default. To find
318
403
  out on your account, quit `pi` and run this from a repository checkout:
319
404
 
320
405
  ```bash
321
- bun scripts/wechat-poll-probe.ts 3000
406
+ bun run probe:wechat 3000
322
407
  ```
323
408
 
324
409
  It prints the requested hold, the measured one, and the value the server echoed,
@@ -326,25 +411,11 @@ then says whether the request was honoured. It refuses to run while a bridge is
326
411
  polling (two clients on one account can steal each other's messages), and it
327
412
  never touches the stored cursor.
328
413
 
329
- ## Security
330
-
331
- - WeChat text is **untrusted**, and in this design it becomes a turn in your live
332
- session **with that session's tools** (including `bash`/`write`/`edit`).
333
- Admission control is the only gate: the default is **owner-only**, and
334
- `allowedPeers` is an explicit opt-in. A refused sender is counted and named in
335
- `/wechat status`, not run. `peers[<id>].tools` narrows an admitted peer to a
336
- tool allowlist (an empty list leaves it with none), so an extra peer need not
337
- get `bash`.
338
- - Inbound text is sanitized (control characters stripped) before it reaches the
339
- transcript.
340
- - Inbound media is **untrusted**: images become model image content, and files
341
- are saved under `<agent-dir>/wechat/media/` with sanitized names and a size cap
342
- (`maxMediaBytes`). Inbound text is capped by `maxInboundChars` before it reaches
343
- the model, and a replayed message id is answered once. Nothing is executed.
344
- - Credentials are written `0600` and redacted from diagnostics; egress is only to
345
- the Weixin endpoint.
346
- - Automated or unofficial WeChat use can violate WeChat's terms and risk account
347
- restriction. Single-owner, low-volume use is recommended.
414
+ The same run answers a second question: every message it receives is printed with
415
+ the `group_id` and `session_id` the protocol supplied, when it supplied them.
416
+ Nothing in the bridge routes on either — a conversation is keyed by sender — so a
417
+ message posted in a group is the only way to learn whether rooms are delivered at
418
+ all, which is what the roadmap's group-chat item turns on.
348
419
 
349
420
  ## Troubleshooting
350
421
 
@@ -385,11 +456,12 @@ never touches the stored cursor.
385
456
  - An expired bridge or a `/wechat logout` is silence on the phone: the token that
386
457
  would carry a notice is the one that expired, so only the terminal chip and
387
458
  `/wechat status` say what happened.
388
- - Turns run one at a time across peers, fixed at one: a second peer's message
389
- waits for the first peer's turn. That keeps a forwarded dialog unambiguous
390
- (the shared interaction channel has one owner); a peer who is waiting is
391
- acknowledged and told why. More concurrency needs a per-peer channel, not a
392
- config value.
459
+ - Two peers' turns run at once (`PEER_CONCURRENCY`), not more, and it is not a
460
+ setting: every extra concurrent turn is a full model run on the user's budget.
461
+ A third peer's message waits in a fair queue — the oldest wait takes the next
462
+ free slot, so a talkative peer cannot starve a quiet one — and that peer is
463
+ acknowledged and told why. A forwarded dialog stays unambiguous because each
464
+ peer has its own interaction channel.
393
465
  - Each peer conversation is a full Pi session: it carries its own context and
394
466
  costs its own model tokens, and it loads the package's tools (minus this
395
467
  bridge) in the same workspace. The idle sweep disposes a session after 30
@@ -401,40 +473,49 @@ never touches the stored cursor.
401
473
  - Parts are flushed at a paragraph boundary, after two silent seconds, or at 400
402
474
  characters, so an answer that stalls mid-paragraph still arrives in pieces. A
403
475
  part that is itself longer than `maxReplyChars` is numbered.
404
- - A `/wechat new`, `/reload`, fork, or process exit during a WeChat turn abandons
405
- that turn's reply: the shutdown aborts the send, and the peer gets nothing.
476
+ - An abandoned turn's notice is one attempt, not a queue. Any close — `/wechat
477
+ new`, `/reload`, a fork, a logout, or a process exit — sends one short line per
478
+ peer that still had work, and a queued message shares its peer's line rather
479
+ than earning its own bubble. It needs a live connection: a notice the transport
480
+ cannot deliver within five seconds is reported on the terminal and dropped, not
481
+ retried. The one close that stays silent is the worker an expired connection
482
+ replaces on reconnect, whose token is already dead.
406
483
  - Inbound **video** is not handled, and there is no group-chat support. Voice is
407
484
  handled only through the server-provided transcript. Both are answered with a
408
485
  one-line notice rather than being dropped silently, and a voice note the
409
486
  server gave no transcript for is treated the same way.
410
- - Outbound **files** are supported (used by the plan review); outbound images,
411
- video, and voice are not, and `send_wechat` sends text only.
487
+ - Outbound **files** are supported (used by the plan review), and so are
488
+ **images**, which `send_wechat` sends as a picture when given a local path whose
489
+ bytes sniff as one. Outbound video and voice are not supported.
412
490
  - Remote prompts cover the standard dialogs (`select`/`confirm`/`input`/`editor`).
413
491
  A full-screen `ctx.ui.custom` component cannot be answered over WeChat: a peer
414
492
  session's UI context refuses it, exactly like RPC mode.
415
493
  - A peer can only be replied to after they have messaged first (the protocol
416
494
  supplies a `context_token` only then).
417
495
  - Slash commands are fixed built-ins; there is no user-defined snippet or action
418
- list yet, and `/new`, `/stop`, `/status`, and `/help` are registered.
496
+ list yet, and `/new`, `/stop`, `/status`, `/history`, and `/help` are
497
+ registered.
419
498
  - Pause is runtime-local. `/wechat pause` stops polling and releases the lock,
420
499
  but a reload, `/new`, or restart builds a fresh running bridge; only
421
500
  `/wechat logout` stops it persistently (and discards the credentials).
422
501
 
423
502
  ## Non-goals
424
503
 
425
- Outbound image/video/voice media, video, model-callable sends to arbitrary
426
- recipients, group chats, a local quote cache, and a long-lived RPC child (peer
427
- sessions are in-process).
504
+ Outbound video or voice, model-callable sends to arbitrary recipients, group
505
+ chats, a local quote cache, and a long-lived RPC child (peer sessions are
506
+ in-process).
428
507
 
429
508
  ## Pi integration
430
509
 
431
510
  | Integration point | Detail |
432
511
  |---|---|
433
512
  | Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `pause`, `resume`, `doctor`, `stop <peer>`, `reset <peer>`, `history <peer> [n]`, `allow [peer]`, `logout`, `help`; bare `/wechat` opens the control screen. |
434
- | Tools | `send_wechat` — owner-only proactive text; registered at factory time with direct exposure (the default), `executionMode: "sequential"`, `openWorldHint: true`. |
513
+ | Tools | `send_wechat` — one text or one local image, to the owner only; registered at factory time with direct exposure (the default), `executionMode: "sequential"`, `openWorldHint: true`. |
514
+ | Annotations | `readOnlyHint: false`, `destructiveHint: false`, `idempotentHint: false`, `openWorldHint: true` |
515
+ | Output | `outputSchema` — `{ sent, error? }` — also returned as `structuredContent`; a failed send is an `isError` result with the same shape. |
435
516
  | Events | `session_start` (record the live context, auto-start the bridge when an account exists); `session_shutdown` (idempotent stop). No host run events: peer turns are not the host session's runs. |
436
517
  | Sessions | `openPeerSession` creates one SDK `AgentSession` per peer (`createAgentSession`, `SessionManager.continueRecent` under `<agent-dir>/wechat/sessions/<peer>/`), inheriting the host's model and thinking level. `DefaultResourceLoader` standard discovery minus this bridge (`withoutWechatExtensions`), so a peer session cannot start a second poller, plus a short channel contract appended to the system prompt so the model knows it is answering in a phone chat window. When `peers[<id>].tools` is set, only those names stay active. |
437
- | Turn ownership | `peer-worker.ts`: a serial queue per peer, `session.prompt()`/`session.abort()`, `getLastAssistantText()` for the answer, and one idle sweep per session. `concurrency` defaults to 1 so a forwarded dialog has one owner. |
518
+ | Turn ownership | `peer-worker.ts`: a serial queue per peer, `session.prompt()`/`session.abort()`, `getLastAssistantText()` for the answer, one idle sweep per session, and the one notice an abandoning close sends per peer. `PEER_CONCURRENCY` is 2, and `dialog/channels.ts` gives each running peer its own channel, so a forwarded dialog has one owner without serializing the bridge. |
438
519
  | Slash commands | Admitted inbound text is matched against `SLASH_COMMANDS` in `handleMessage`; a snippet is expanded into the turn, an action runs out-of-band and replies, and an unknown command falls through. Actions act on the sender through `SlashSessionControls` (`newSession` → worker reset, `stop` → worker abort, `status` → worker state, `history` → a read-only read of that peer's newest session). |
439
520
  | Peer dialogs | `bindExtensions({ mode: "rpc", uiContext })` on each peer session, with `createPeerUIContext(channel)` routing `select`/`confirm`/`input`/`editor` to that peer and marking the context for `askHuman`'s file/text preface. The terminal's shared `ctx.ui` is deliberately not adapted: a host turn is never answered remotely, so its dialogs stay local even while a peer turn runs. |
440
521
  | Status | The bridge's connection state (`running` / `paused` / `expired` / `stopped`) is published through `onStateChange` to `ctx.ui.setStatus(STATUS_KEYS.wechat, …)`; an expiry keeps a persistent error chip, pause keeps a warning chip, and the status bar renders it on line 2. |
@@ -514,39 +595,41 @@ status, which is what the old start/stop guidance used to do.
514
595
  |---|---|
515
596
  | `index.ts` | Factory: gate, register the command and tool, wire the lifecycle events, build the bridge, open peer sessions from the host context. |
516
597
  | `bridge.ts` | The composition root: wires the poll loop, delivery, presence, admission, and inbound modules to the live client/account/cursor/state; owns the connection lifecycle, slash dispatch, the status snapshot, and `sendToOwner`. |
517
- | `poll.ts` | The long-poll loop: `getupdates`, the watchdog and retry, backoff, cursor persistence, and the expiry stop. |
518
- | `deliver.ts` | The one outbound text path (reply, dialog preface, `send_wechat`) plus file delivery: flatten, split, number, expiry, rejection, and the once-per-outage notice. |
519
- | `presence.ts` | Typing, acknowledgement, and the slow/busy lines; all per-peer wait timers in one registry. |
520
- | `admission.ts` | The rolling-hour turn budget and the per-peer serial admission chain. |
521
- | `inbound.ts` | Inbound media resolution (download, decrypt, save, attach) and the hand-off to a peer's session. |
522
- | `peer-session.ts` | One SDK `AgentSession` per peer: workspace, model, history under the state dir, dialogs bound to that peer, this bridge excluded from its resources, and the channel prompt appended. |
523
- | `channel.ts` | The phone-chat contract appended to a peer session's system prompt. |
524
- | `peer-worker.ts` | Per-peer queue, one turn at a time (`concurrency`), prompt/abort/reset, streamed reply parts, idle-session sweep. |
525
- | `reply-pipeline.ts` | Where a streamed answer is split (paragraph, quiet period, size), sent in order, and finished without duplicating a part. |
526
- | `commands.ts` | `/wechat login|new|status|stop|reset|logout|help`: the verb table that drives the help text and completions, the dispatch, the panel, login/logout, and the per-peer stop/reset; `login`/`new`/`logout` decline and `status` degrades to the summary while a peer turn runs. |
527
- | `tools.ts` | The `send_wechat` tool (owner-only proactive text). |
528
- | `client.ts` | iLink HTTP client (QR, getupdates, sendmessage, file sendmessage, getuploadurl, getconfig, sendtyping, CDN download/upload) with an injected runner. |
529
- | `media.ts` | Media key/encrypt/decrypt, padded size, upload/download URL, MIME sniff, filename sanitize, and save helpers. |
530
- | `outbound.ts` | Upload and send a local file: hash, encrypt, `getUploadUrl`, upload, `sendFileMessage`. |
531
- | `login.ts` | QR login state machine. |
532
- | `qrcode.ts` | QR matrix → Unicode half-block terminal lines; the `qrcode` package is imported on first use, not at registration. |
533
- | `login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
534
- | `status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`): live facts, a focusable peer list with stop/reset/history keys, and the pure one-line/body renderers, drawn from the `lib/tui/` kit (`titleRule`, `keyValueRow`, `listRow`, `paint`, `IdCursor`). |
598
+ | `tools.ts` | The `send_wechat` tool: owner-only proactive text or one image, with the neither-or-both refusal. |
535
599
  | `config.ts` | The `wechat` section of `bytefly.json` loading and normalization. |
536
- | `credentials.ts` | `credentials.json` (`0600`, every stored account and the active one). |
537
- | `state.ts` | `state.json` (cursor, owner, per-peer tokens, recent message ids). |
538
- | `lock.ts` | Poller lockfile. |
539
- | `format.ts` | Sanitize, chunk, and label helpers (text only). |
540
- | `notices.ts` | The peer-facing notice catalog: one line per kind, in the peer's language. |
600
+ | `types.ts` | Wire and domain types. |
541
601
  | `errors.ts` | Re-exports `messageOf` from `lib/errors.ts`, the one error-to-line helper every notice and tool error uses. |
542
602
  | `timer.ts` | The timer seam: the cancellable one-shot `ArmTimer`, `defaultTimer`, and the abortable `defaultSleep`. |
543
- | `interaction.ts` | The WeChat `InteractionChannel`: pending-prompt state machine and reply routing. |
544
- | `prompt.ts` | Pure prompt formatting and reply parsing. |
545
- | `remote-ui.ts` | The peer session's UI context: dialogs and notices bound to that peer, the `askHuman` marker that delivers the file/text preface, and the `remotePrompts` switch. |
546
- | `slash.ts` | Inbound slash commands: the built-in registry (`/new`, `/stop`, `/status`, `/history`, `/help`), the session controls an action may invoke, and the pure parse/lookup/expand helpers. |
547
- | `history.ts` | Read-only `/history`: opens the peer's newest session, formats the last user/assistant messages, and never writes. |
548
- | `doctor.ts` | The plain-text `/wechat doctor` report: connection/token age, poll, lock, peer sessions, recent failures, and config. |
549
- | `types.ts` | Wire and domain types. |
603
+ | `transport/client.ts` | iLink HTTP client (QR, getupdates, sendmessage, file and image sendmessage, getuploadurl, getconfig, sendtyping, CDN download/upload) with an injected runner. |
604
+ | `transport/poll.ts` | The long-poll loop: `getupdates`, the watchdog and retry, backoff, cursor persistence, and the expiry stop. |
605
+ | `transport/deliver.ts` | The one outbound text path (reply, dialog preface, `send_wechat`) plus file and image delivery: flatten, split, number, expiry, rejection, and the once-per-outage notice. |
606
+ | `transport/outbound.ts` | Upload and send local media: one hash/encrypt/`getUploadUrl`/upload body, then a file item or an image item, and an image must sniff as one. |
607
+ | `transport/inbound.ts` | Inbound media resolution (download, decrypt, save, attach) and the hand-off to a peer's session. |
608
+ | `transport/media.ts` | Media key/encrypt/decrypt, padded size, upload/download URL, image MIME sniff (undefined when the bytes are no image), filename sanitize, and save helpers. |
609
+ | `state/state.ts` | `state.json` (cursor, owner, per-peer tokens, recent message ids). |
610
+ | `state/credentials.ts` | `credentials.json` (`0600`, every stored account and the active one). |
611
+ | `state/lock.ts` | Poller lockfile. |
612
+ | `peer/peer-session.ts` | One SDK `AgentSession` per peer: workspace, model, history under the state dir, dialogs bound to that peer, this bridge excluded from its resources, and the channel prompt appended. |
613
+ | `peer/peer-worker.ts` | Per-peer queue, two peers' turns at once (`PEER_CONCURRENCY`), the fair slot hand-off, prompt/abort/reset, streamed reply parts, idle-session sweep, and the notice an abandoning close sends. |
614
+ | `peer/admission.ts` | The rolling-hour turn budget and the per-peer serial admission chain. |
615
+ | `peer/reply-pipeline.ts` | Where a streamed answer is split (paragraph, quiet period, size), sent in order, and finished without duplicating a part. |
616
+ | `peer/presence.ts` | Typing, acknowledgement, and the slow/busy lines; all per-peer wait timers in one registry. |
617
+ | `commands/commands.ts` | `/wechat login\|new\|status\|stop\|reset\|logout\|help`: the verb table that drives the help text and completions, the dispatch, the panel, login/logout, and the per-peer stop/reset; `login`/`new`/`logout` decline and `status` degrades to the summary while a peer turn runs. |
618
+ | `commands/slash.ts` | Inbound slash commands: the built-in registry (`/new`, `/stop`, `/status`, `/history`, `/help`), the session controls an action may invoke, and the pure parse/lookup/expand helpers. |
619
+ | `commands/doctor.ts` | The plain-text `/wechat doctor` report: connection/token age, poll, lock, peer sessions, recent failures, and config. |
620
+ | `commands/login.ts` | QR login state machine. |
621
+ | `commands/history.ts` | Read-only `/history`: opens the peer's newest session, formats the last user/assistant messages, and never writes. |
622
+ | `text/format.ts` | Sanitize, chunk, and label helpers (text only). |
623
+ | `text/notices.ts` | The peer-facing notice catalog: one line per kind, in the peer's language. |
624
+ | `text/channel.ts` | The phone-chat contract appended to a peer session's system prompt. |
625
+ | `dialog/interaction.ts` | One peer's `InteractionChannel`: pending-prompt state machine, the other-answer stash, and reply routing for that peer. |
626
+ | `dialog/channels.ts` | The per-peer channel registry: one channel each, inbound reply routing, the waiting prompts for the control screen, and cancel-all on a pause. |
627
+ | `dialog/prompt.ts` | Pure prompt formatting and reply parsing. |
628
+ | `dialog/remote-ui.ts` | The peer session's UI context: dialogs and notices bound to that peer, the `askHuman` marker that delivers the file/text preface, and the `remotePrompts` switch. |
629
+ | `view/login-screen.ts` | The `/wechat login` screen (`ctx.ui.custom`), drawn from the `lib/tui/` kit (`titleRule`, `clip`, `isCloseKey`). |
630
+ | `view/status-screen.ts` | The `/wechat` control screen (`ctx.ui.custom`): live facts, a focusable peer list with stop/reset/history keys, and the pure one-line/body renderers, drawn from the `lib/tui/` kit (`titleRule`, `keyValueRow`, `listRow`, `paint`, `IdCursor`). |
631
+
632
+ Modules are grouped by the role they play: `transport/` is the only place that talks to iLink, `state/` owns the three files a session leaves on disk, `peer/` runs one peer's session and its queue, `commands/` holds a slash verb, `text/` words everything a peer reads, `dialog/` asks a question over WeChat, and `view/` draws a full-screen surface. `index.ts`, `bridge.ts`, `tools.ts`, `config.ts`, `types.ts`, `errors.ts`, and `timer.ts` stay at the root — the factory, the composition root, the public surface, and the seams the directories import.
550
633
 
551
634
  ## Testing
552
635