@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
@@ -11,37 +11,35 @@
11
11
  * The peer only has the chat window, so the bridge carries the signals the phone
12
12
  * needs: typing starts as soon as a message is picked up, a message waiting on
13
13
  * another conversation is acknowledged once and told why, a slow turn says so,
14
- * and a burst that arrives before its turn starts is answered once, in order.
14
+ * and every queued message is answered in its own turn, in order.
15
15
  *
16
16
  * The model may message the owner proactively through {@link Bridge.sendToOwner}.
17
17
  */
18
18
 
19
- import { WechatError, type WechatClient } from "./client.ts";
20
- import { createAdmissionQueue, createTurnBudget } from "./admission.ts";
21
- import { createDeliverer, type MessageTarget } from "./deliver.ts";
22
- import { createInboundAdmission } from "./inbound.ts";
23
- import { createPollLoop, defaultPollTimeout } from "./poll.ts";
19
+ import { WechatError, type WechatClient } from "./transport/client.ts";
20
+ import { createAdmissionQueue, createTurnBudget } from "./peer/admission.ts";
21
+ import { createDeliverer, type MessageTarget } from "./transport/deliver.ts";
22
+ import { createInboundAdmission } from "./transport/inbound.ts";
23
+ import { createPollLoop, defaultPollTimeout } from "./transport/poll.ts";
24
24
  import { messageOf } from "./errors.ts";
25
- import { parseInbound, peerKey, peerLabel, peerLanguage, sanitizeInbound, type PeerLanguage } from "./format.ts";
26
- import { notice } from "./notices.ts";
27
- import { createPresence } from "./presence.ts";
28
- import {
29
- createWechatInteractionChannel,
30
- type PendingPromptInfo,
31
- type WechatInteractionChannel,
32
- } from "./interaction.ts";
25
+ import { parseInbound, peerKey, peerLabel, peerLanguage, sanitizeInbound, type PeerLanguage } from "./text/format.ts";
26
+ import { notice } from "./text/notices.ts";
27
+ import { createPresence } from "./peer/presence.ts";
28
+ import { createPeerChannels, type PeerChannels } from "./dialog/channels.ts";
29
+ import type { PendingPromptInfo, WechatInteractionChannel } from "./dialog/interaction.ts";
33
30
  import { defaultSleep, defaultTimer, type ArmTimer } from "./timer.ts";
34
- import { createPeerWorker, type PeerState, type PeerWorker } from "./peer-worker.ts";
35
- import type { PeerSession } from "./peer-session.ts";
36
- import { emptyState, findPeer, recordInbound, rememberMessage, seenMessage } from "./state.ts";
31
+ import { createPeerWorker, type PeerState, type PeerWorker } from "./peer/peer-worker.ts";
32
+ import type { PeerSession } from "./peer/peer-session.ts";
33
+ import { emptyState, findPeer, ownerTarget, recordInbound, rememberMessage, seenMessage } from "./state/state.ts";
37
34
  import {
38
35
  expandSnippet,
39
36
  findSlashCommand,
40
37
  parseSlash,
38
+ slashControls,
41
39
  SLASH_COMMANDS,
42
40
  type SlashCommand,
43
41
  type SlashContext,
44
- } from "./slash.ts";
42
+ } from "./commands/slash.ts";
45
43
  import type { BotCredentials, InboundItem, WechatState, WeixinMessage } from "./types.ts";
46
44
  import type { WechatConfig } from "./config.ts";
47
45
 
@@ -120,10 +118,10 @@ export interface BridgeStatus {
120
118
  /** Labels of the senders refused since the bridge opened, newest last, capped. */
121
119
  refusedPeers: string[];
122
120
  mediaDropped: number;
123
- /** Whether a remote dialog prompt is waiting for an answer. */
121
+ /** Whether any peer's dialogs prompt is waiting for an answer. */
124
122
  promptPending: boolean;
125
- /** The waiting prompt's peer, kind, title, and age. */
126
- prompt?: PendingPromptInfo;
123
+ /** Every waiting prompt, one per peer, with its kind, title, and age. */
124
+ prompts: PendingPromptInfo[];
127
125
  /** When this connection opened. */
128
126
  startedAt?: number;
129
127
  /** When the last poll settled, successful or not. */
@@ -160,10 +158,10 @@ export interface Bridge {
160
158
  /** What one peer's conversation is doing, for `/status`. */
161
159
  peerState(peer: string): PeerState;
162
160
  status(): BridgeStatus;
163
- /** The remote interaction channel the peer sessions route their dialogs through. */
164
- channel(): WechatInteractionChannel;
165
- /** Message the owner proactively. Owner-only and text-only by design. */
166
- sendToOwner(text: string): Promise<SendResult>;
161
+ /** One peer's interaction channel: the channel its session routes its dialogs through. */
162
+ channel(peer: string): WechatInteractionChannel;
163
+ /** Message the owner proactively: one text, or one image, never a chosen recipient. */
164
+ sendToOwner(text: string | undefined, image?: string): Promise<SendResult>;
167
165
  }
168
166
 
169
167
  /** Create the bridge over an injected environment. */
@@ -225,8 +223,8 @@ export function createBridge(deps: BridgeDeps): Bridge {
225
223
  }
226
224
 
227
225
  /** Send one bubble to a peer and report whether it was delivered. */
228
- async function reply(target: MessageTarget, text: string): Promise<boolean> {
229
- return deliverer.reply(target, text, abort?.signal);
226
+ async function reply(target: MessageTarget, text: string, signal?: AbortSignal): Promise<boolean> {
227
+ return deliverer.reply(target, text, signal ?? abort?.signal);
230
228
  }
231
229
 
232
230
  /** The outbound surface, reading the live client and account at call time. */
@@ -246,7 +244,6 @@ export function createBridge(deps: BridgeDeps): Bridge {
246
244
  lastSendError = undefined;
247
245
  },
248
246
  });
249
-
250
247
  /** Run a slash action out-of-band and reply to the sender; never starts a turn. */
251
248
  async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
252
249
  if (!command.run) return;
@@ -258,18 +255,15 @@ export function createBridge(deps: BridgeDeps): Bridge {
258
255
  },
259
256
  activeTurn: worker?.state(target.peer).status === "running",
260
257
  language: languageOf(target.peer),
261
- session: {
262
- newSession: () => {
263
- worker?.reset(target.peer);
258
+ session: slashControls(target.peer, {
259
+ state: (peer) => worker?.state(peer) ?? { status: "idle", queued: 0 },
260
+ prompt: (peer) => channels.pendingFor(peer)?.title,
261
+ reset: (peer) => {
262
+ worker?.reset(peer);
264
263
  },
265
- stop: () => worker?.abort(target.peer) ?? false,
266
- status: () => {
267
- const current = worker?.state(target.peer) ?? { status: "idle" as const, queued: 0 };
268
- const waiting = interaction.pendingInfo();
269
- return waiting?.peer === target.peer ? { ...current, prompt: waiting.title } : current;
270
- },
271
- history: (count) => deps.readHistory(target.peer, count),
272
- },
264
+ stop: (peer) => worker?.abort(peer) ?? false,
265
+ history: (peer, count) => deps.readHistory(peer, count),
266
+ }),
273
267
  };
274
268
  try {
275
269
  await command.run(ctx);
@@ -280,30 +274,27 @@ export function createBridge(deps: BridgeDeps): Bridge {
280
274
  }
281
275
 
282
276
  /**
283
- * Send a dialog preface (or plan digest) to the peer owning the in-flight turn.
277
+ * Every peer's dialogs, one channel each.
284
278
  *
285
- * A failure is reported on the terminal once per peer by the deliverer; a silent
286
- * failure would leave the peer with only the choices, as if nothing preceded
287
- * them.
279
+ * A prompt is sent through the peer's channel to the turn it belongs to, so a
280
+ * reply can only answer its own peer's question even when two turns run at once.
281
+ * A failure is reported once per peer by the deliverer; a silent failure would
282
+ * leave the peer with only the choices, as if nothing preceded them.
288
283
  */
289
- async function sendPrompt(text: string, signal?: AbortSignal): Promise<boolean> {
290
- const current = worker?.current();
291
- if (!current) return false;
292
- return deliverer.prompt(current, text, signal ?? abort?.signal);
293
- }
294
-
295
- /** Upload and send a local file to the peer owning the in-flight turn. */
296
- async function sendFileToPeer(filePath: string, fileName: string, signal?: AbortSignal): Promise<boolean> {
297
- const current = worker?.current();
298
- if (!current) return false;
299
- return deliverer.file(current, filePath, fileName, signal ?? abort?.signal);
300
- }
301
-
302
- const interaction = createWechatInteractionChannel({
303
- send: (text, signal) => sendPrompt(text, signal),
304
- sendFile: (path, name, signal) => sendFileToPeer(path, name, signal),
305
- activePeer: () => worker?.current()?.peer,
306
- language: () => languageOf(worker?.current()?.peer ?? ""),
284
+ const channels: PeerChannels = createPeerChannels({
285
+ flush: async (peer) => {
286
+ await worker?.flush(peer);
287
+ },
288
+ send: (peer, text, signal) => {
289
+ const item = worker?.running(peer);
290
+ return item ? deliverer.prompt(item, text, signal ?? abort?.signal) : Promise.resolve(false);
291
+ },
292
+ sendFile: (peer, filePath, fileName, signal) => {
293
+ const item = worker?.running(peer);
294
+ return item ? deliverer.file(item, filePath, fileName, signal ?? abort?.signal) : Promise.resolve(false);
295
+ },
296
+ active: (peer) => worker?.state(peer).status === "running",
297
+ language: (peer) => languageOf(peer),
307
298
  armTimer: deps.armTimer,
308
299
  });
309
300
 
@@ -316,11 +307,6 @@ export function createBridge(deps: BridgeDeps): Bridge {
316
307
  return languages.get(peer) ?? "en";
317
308
  }
318
309
 
319
- /** The peer's conversation state, for the wait decisions in presence. */
320
- function peerStatus(peer: string): PeerState["status"] {
321
- return worker?.state(peer).status ?? "idle";
322
- }
323
-
324
310
  /** Typing, acknowledgement, and the slow/busy lines; one timer registry each. */
325
311
  const presence = createPresence({
326
312
  typingIndicator: () => deps.config.typingIndicator,
@@ -329,10 +315,10 @@ export function createBridge(deps: BridgeDeps): Bridge {
329
315
  client: () => client,
330
316
  account: () => account,
331
317
  signal: () => abort?.signal,
332
- statusOf: peerStatus,
318
+ statusOf: (peer) => worker?.state(peer).status ?? "idle",
333
319
  languageOf,
334
320
  send: (peer, contextToken, text) => reply({ peer, contextToken }, text),
335
- promptPending: () => interaction.hasPending(),
321
+ promptPending: (peer) => channels.hasPending(peer),
336
322
  });
337
323
 
338
324
  /** Inbound media resolution and the hand-off to a peer session. */
@@ -410,7 +396,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
410
396
  }
411
397
  // A reply to a pending prompt is consumed here, before queueing, so it never
412
398
  // starts a turn of its own. A duplicate answer was stopped by the id check.
413
- if (interaction.handleInbound(peer, text)) return;
399
+ if (channels.handleInbound(peer, text)) return;
414
400
  const language = languageOf(peer, text);
415
401
  const item: InboundItem = { ...target, text, media, language };
416
402
  if (!text && media.length === 0) {
@@ -477,7 +463,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
477
463
  overflow.clear();
478
464
  refused = 0;
479
465
  refusedPeers.clear();
480
- interaction.reset();
466
+ channels.reset();
481
467
  deliverer.reset();
482
468
  presence.reset();
483
469
  budget.reset();
@@ -552,10 +538,10 @@ export function createBridge(deps: BridgeDeps): Bridge {
552
538
  // a login (or a reconnect after an expiry) starts from a clean slate while
553
539
  // each peer's history stays on disk.
554
540
  worker = createPeerWorker({
555
- openSession: (peer, options) => deps.openSession(peer, { fresh: options.fresh, channel: interaction }),
541
+ openSession: (peer, options) => deps.openSession(peer, { fresh: options.fresh, channel: channels.for(peer) }),
556
542
  // Each part is delivered as its own bubble; the worker puts the
557
543
  // attachment line on the first one. A false result stops the stream.
558
- send: (item, text) => reply(item, text),
544
+ send: (item, text, options) => reply(item, text, options?.signal),
559
545
  fallback: (item, reason) => notice(reason, item.language),
560
546
  onStart: (item) => {
561
547
  presence.cancelAck(item.peer);
@@ -602,7 +588,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
602
588
  // worker, so close it unconditionally rather than leaking the sessions.
603
589
  const closing = worker;
604
590
  worker = undefined;
605
- await closing?.close();
591
+ await closing?.close({ abandon: true });
606
592
  resetEphemeral();
607
593
  publishState();
608
594
  releaseSafe();
@@ -618,7 +604,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
618
604
  // not be cancelled by the poll abort that follows.
619
605
  const stopping = abort;
620
606
  abort = undefined;
621
- if (interaction.hasPending()) interaction.cancel();
607
+ await channels.cancelAll();
622
608
  stopping?.abort();
623
609
  try {
624
610
  await loopPromise;
@@ -682,8 +668,8 @@ export function createBridge(deps: BridgeDeps): Bridge {
682
668
  refused,
683
669
  refusedPeers: [...refusedPeers],
684
670
  mediaDropped: inbound.dropped(),
685
- promptPending: interaction.hasPending(),
686
- prompt: interaction.pendingInfo(),
671
+ promptPending: channels.hasPending(),
672
+ prompts: channels.prompts(),
687
673
  startedAt,
688
674
  lastPollAt,
689
675
  lastPollError,
@@ -694,27 +680,25 @@ export function createBridge(deps: BridgeDeps): Bridge {
694
680
  };
695
681
  },
696
682
 
697
- channel(): WechatInteractionChannel {
698
- return interaction;
683
+ channel(peer: string): WechatInteractionChannel {
684
+ return channels.for(peer);
699
685
  },
700
686
 
701
- async sendToOwner(text: string): Promise<SendResult> {
687
+ async sendToOwner(text: string | undefined, image?: string): Promise<SendResult> {
702
688
  if (!running || !client || !account) return { ok: false, error: "WeChat bridge is not started." };
703
- const owner = state.ownerId ?? account.ilinkUserId;
704
- const current = worker?.current();
705
- if (current && peerKey(current.peer) === peerKey(owner)) {
706
- // The bridge answers this turn itself, so a tool send would land twice.
707
- return {
708
- ok: false,
709
- error: "The bridge replies to this turn automatically; do not repeat it.",
710
- };
689
+ const target = ownerTarget(state, account);
690
+ if (text !== undefined && worker?.state(target.peer).status === "running") {
691
+ // The bridge answers this turn itself, so a text send would land twice.
692
+ return { ok: false, error: "The bridge replies to this turn automatically; do not repeat it." };
693
+ }
694
+ if (image !== undefined) {
695
+ // An image cannot duplicate the streamed answer, so a running turn is fine.
696
+ const sent = await deliverer.image(target, image, abort?.signal);
697
+ return sent ? { ok: true } : { ok: false, error: "The image could not be sent; see the terminal notice." };
711
698
  }
712
- const trimmed = sanitizeInbound(text);
699
+ const trimmed = sanitizeInbound(text ?? "");
713
700
  if (!trimmed) return { ok: false, error: "Nothing to send." };
714
- // Either id form may be stored: the owner id comes from the login response
715
- // and the token from an inbound `from_user_id`, and they need not agree.
716
- const contextToken = findPeer(state, owner)?.lastContextToken;
717
- return deliverer.toOwner(owner, contextToken, trimmed, abort?.signal);
701
+ return deliverer.toOwner(target.peer, target.contextToken, trimmed, abort?.signal);
718
702
  },
719
703
  };
720
704
  }
@@ -18,21 +18,27 @@ import {
18
18
  type ExtensionContext,
19
19
  } from "@earendil-works/pi-coding-agent";
20
20
  import { join } from "node:path";
21
- import { writeConfigValue } from "../../lib/config.ts";
22
- import { sanitizeLine } from "../../lib/format.ts";
23
- import { supportsCustomUI } from "../../lib/interaction.ts";
24
- import { isAllowedPeer, type Bridge } from "./bridge.ts";
25
- import type { WechatClient } from "./client.ts";
26
- import type { WechatConfig } from "./config.ts";
21
+ import { writeConfigValue } from "../../../lib/config.ts";
22
+ import { sanitizeLine } from "../../../lib/format.ts";
23
+ import { supportsCustomUI } from "../../../lib/interaction.ts";
24
+ import { isAllowedPeer, type Bridge } from "../bridge.ts";
25
+ import type { WechatClient } from "../transport/client.ts";
26
+ import type { WechatConfig } from "../config.ts";
27
27
  import { doctorReport } from "./doctor.ts";
28
- import { messageOf } from "./errors.ts";
29
- import { peerLabel, peerKey } from "./format.ts";
28
+ import { messageOf } from "../errors.ts";
29
+ import { peerLabel, peerKey } from "../text/format.ts";
30
30
  import { waitForLogin, type LoginOutcome } from "./login.ts";
31
- import { defaultSleep } from "./timer.ts";
32
- import { LoginScreenComponent, type LoginViewState } from "./login-screen.ts";
33
- import { qrLines } from "./qrcode.ts";
34
- import { statusLine, statusView, StatusScreenComponent, type StatusAction, type StatusView } from "./status-screen.ts";
35
- import type { BotCredentials } from "./types.ts";
31
+ import { defaultSleep } from "../timer.ts";
32
+ import { LoginScreenComponent, type LoginViewState } from "../view/login-screen.ts";
33
+ import { qrLines } from "../../../lib/qr.ts";
34
+ import {
35
+ statusLine,
36
+ statusView,
37
+ StatusScreenComponent,
38
+ type StatusAction,
39
+ type StatusView,
40
+ } from "../view/status-screen.ts";
41
+ import type { BotCredentials } from "../types.ts";
36
42
 
37
43
  export interface CommandDeps {
38
44
  getBridge: () => Bridge | undefined;
@@ -152,9 +158,11 @@ function resetMessage(bridge: Bridge, peer: string): VerbOutcome {
152
158
  : { message: `${label} is running; stop it before starting a new conversation.`, kind: "info" };
153
159
  }
154
160
 
155
- function cancelPromptMessage(bridge: Bridge): VerbOutcome {
156
- if (!bridge.status().promptPending) return { message: "No prompt is waiting.", kind: "info" };
157
- bridge.channel().cancel();
161
+ function cancelPromptMessage(bridge: Bridge, peer: string): VerbOutcome {
162
+ if (!bridge.status().prompts.some((prompt) => prompt.peer === peer)) {
163
+ return { message: "No prompt is waiting.", kind: "info" };
164
+ }
165
+ void bridge.channel(peer).cancel();
158
166
  return { message: "Cancelled the waiting prompt.", kind: "info" };
159
167
  }
160
168
 
@@ -364,9 +372,9 @@ async function runPanel(ctx: ExtensionCommandContext, deps: CommandDeps): Promis
364
372
  const bridge = deps.getBridge();
365
373
  return bridge ? resetMessage(bridge, peer).message : "WeChat bridge is not running.";
366
374
  },
367
- cancelPrompt: () => {
375
+ cancelPrompt: (focused) => {
368
376
  const bridge = deps.getBridge();
369
- return bridge ? cancelPromptMessage(bridge).message : "No prompt is waiting.";
377
+ return bridge ? cancelPromptMessage(bridge, focused).message : "No prompt is waiting.";
370
378
  },
371
379
  togglePause: async () => {
372
380
  const bridge = deps.getBridge();
@@ -7,11 +7,11 @@
7
7
  * the wording and the age math are unit-tested without a bridge.
8
8
  */
9
9
 
10
- import { formatDuration } from "../../lib/format.ts";
11
- import type { BridgeStatus } from "./bridge.ts";
12
- import type { WechatConfig } from "./config.ts";
13
- import type { BotCredentials } from "./types.ts";
14
- import { peerLabel } from "./format.ts";
10
+ import { formatDuration } from "../../../lib/format.ts";
11
+ import type { BridgeStatus } from "../bridge.ts";
12
+ import type { WechatConfig } from "../config.ts";
13
+ import type { BotCredentials } from "../types.ts";
14
+ import { peerLabel } from "../text/format.ts";
15
15
 
16
16
  export interface DoctorInput {
17
17
  /** Undefined when the bridge has never been built in this runtime. */
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { SessionManager, type SessionEntry, type SessionMessageEntry } from "@earendil-works/pi-coding-agent";
11
- import { peerSessionDir } from "./peer-session.ts";
11
+ import { peerSessionDir } from "../peer/peer-session.ts";
12
12
 
13
13
  /** Longest text one history line carries before it is elided. */
14
14
  export const HISTORY_LINE_CHARS = 160;
@@ -6,9 +6,9 @@
6
6
  * without a real timer, and the caller owns displaying the QR.
7
7
  */
8
8
 
9
- import { DEFAULT_BASE_URL, type WechatClient } from "./client.ts";
10
- import { messageOf } from "./errors.ts";
11
- import type { BotCredentials, QrResponse, QrStatus } from "./types.ts";
9
+ import { DEFAULT_BASE_URL, type WechatClient } from "../transport/client.ts";
10
+ import { messageOf } from "../errors.ts";
11
+ import type { BotCredentials, QrResponse, QrStatus } from "../types.ts";
12
12
 
13
13
  /** Categorical meaning of a `get_qrcode_status` value. */
14
14
  export type StatusKind = "pending" | "confirmed" | "expired" | "verify" | "blocked" | "binded";
@@ -10,8 +10,8 @@
10
10
  * Value-only: no module-level state.
11
11
  */
12
12
 
13
- import { type PeerLanguage } from "./format.ts";
14
- import { notice } from "./notices.ts";
13
+ import { type PeerLanguage } from "../text/format.ts";
14
+ import { notice } from "../text/notices.ts";
15
15
 
16
16
  /** The two command kinds. */
17
17
  export type SlashKind = "snippet" | "action";
@@ -37,6 +37,42 @@ export interface SlashSessionControls {
37
37
  history?: (count: number) => Promise<string>;
38
38
  }
39
39
 
40
+ /** What a builder needs to bind the session controls to one sender. */
41
+ export interface SlashControlsDeps {
42
+ /** What that peer's conversation is doing. */
43
+ state: (peer: string) => SlashPeerState;
44
+ /** The title of a dialog waiting on that peer, if one waits. */
45
+ prompt: (peer: string) => string | undefined;
46
+ /** Drop the peer's conversation, so the next message starts empty. */
47
+ reset: (peer: string) => void;
48
+ /** Stop the peer's running turn; false when nothing was running. */
49
+ stop: (peer: string) => boolean;
50
+ /** The last `count` messages of the peer's own conversation. */
51
+ history: (peer: string, count: number) => Promise<string>;
52
+ }
53
+
54
+ /**
55
+ * The controls one action runs against, for the peer that typed the command.
56
+ *
57
+ * The registry owns what a command may do; the bridge owns the queue and the
58
+ * channels, so it supplies them here and keeps no per-peer session shape of its
59
+ * own.
60
+ */
61
+ export function slashControls(peer: string, deps: SlashControlsDeps): SlashSessionControls {
62
+ return {
63
+ newSession: () => {
64
+ deps.reset(peer);
65
+ },
66
+ stop: () => deps.stop(peer),
67
+ status: () => {
68
+ const waiting = deps.prompt(peer);
69
+ const state = deps.state(peer);
70
+ return waiting === undefined ? state : { ...state, prompt: waiting };
71
+ },
72
+ history: (count) => deps.history(peer, count),
73
+ };
74
+ }
75
+
40
76
  /** The environment an action handler runs in. */
41
77
  export interface SlashContext {
42
78
  /** Text after the command name, trimmed. */
@@ -0,0 +1,136 @@
1
+ /**
2
+ * One interaction channel per peer.
3
+ *
4
+ * Two peers' turns can run at once ({@link PEER_CONCURRENCY}), and a forwarded
5
+ * dialog is only unambiguous if the channel that asks knows which peer it is
6
+ * asking — so each peer owns a channel, and a reply can only answer its own
7
+ * peer's prompt. This registry builds them lazily, routes an inbound message to
8
+ * that peer's channel, and aggregates what waits so the control screen and
9
+ * `/wechat status` can name each waiting peer.
10
+ *
11
+ * It also owns the dialogs' outbound path — the flush of buffered text, the
12
+ * prompt gate, and the deliverer call — because that path is per peer and belongs
13
+ * with the channel that uses it rather than with the bridge that wires it.
14
+ *
15
+ * Value-only: the map lives in the closure.
16
+ */
17
+
18
+ import type { PendingPromptInfo, WechatInteractionChannel } from "./interaction.ts";
19
+ import { createWechatInteractionChannel } from "./interaction.ts";
20
+ import type { PeerLanguage } from "../text/format.ts";
21
+ import type { ArmTimer } from "../timer.ts";
22
+
23
+ /** What the registry needs from the bridge; the turn's item carries the token. */
24
+ export interface PeerChannelDeps {
25
+ /** Put buffered assistant text on the wire before a prompt or a file. */
26
+ flush: (peer: string) => Promise<void>;
27
+ /** Send one dialog message to a peer as that peer's next bubble. */
28
+ send: (peer: string, text: string, signal?: AbortSignal) => Promise<boolean>;
29
+ /** Send one local file to a peer. */
30
+ sendFile: (peer: string, path: string, name: string, signal?: AbortSignal) => Promise<boolean>;
31
+ /** Whether the peer owns a running turn, so a prompt has somewhere to land. */
32
+ active: (peer: string) => boolean;
33
+ /** The language the peer writes in, for the prompt and the timeout notices. */
34
+ language: (peer: string) => PeerLanguage;
35
+ /** Arm a cancellable one-shot timer. Defaults to a real, unref'd one. */
36
+ armTimer?: ArmTimer;
37
+ /** Clock for a prompt's start time; defaults to `Date.now`. */
38
+ now?: () => number;
39
+ }
40
+
41
+ export interface PeerChannels {
42
+ /** This peer's channel, created on first use. */
43
+ for(peer: string): WechatInteractionChannel;
44
+ /** Route an inbound message to the peer's pending prompt; true when it answered one. */
45
+ handleInbound(peer: string, text: string): boolean;
46
+ /** The prompt one peer is waiting on, if any. */
47
+ pendingFor(peer: string): PendingPromptInfo | undefined;
48
+ /** Every waiting prompt, oldest channel first. */
49
+ prompts(): PendingPromptInfo[];
50
+ /** Whether that peer — or with no argument, any peer — has a prompt waiting. */
51
+ hasPending(peer?: string): boolean;
52
+ /** Withdraw every waiting prompt, telling each peer. */
53
+ cancelAll(): Promise<void>;
54
+ /** Drop every channel and its waiting prompt, silently. */
55
+ reset(): void;
56
+ }
57
+
58
+ export function createPeerChannels(deps: PeerChannelDeps): PeerChannels {
59
+ const channels = new Map<string, WechatInteractionChannel>();
60
+
61
+ function build(peer: string): WechatInteractionChannel {
62
+ let channel: WechatInteractionChannel | undefined;
63
+ /**
64
+ * Dialog text is only sent while its prompt is registered; the timeout,
65
+ * waiting, and withdrawal lines force their way through. Reading the channel
66
+ * this closure builds keeps one owner for "is a prompt waiting".
67
+ */
68
+ const allowed = (options?: { force?: boolean }): boolean =>
69
+ options?.force === true || (channel?.hasPending() ?? false);
70
+
71
+ channel = createWechatInteractionChannel({
72
+ peer,
73
+ send: async (text, signal, options) => {
74
+ if (!allowed(options)) return false;
75
+ await deps.flush(peer);
76
+ return deps.send(peer, text, signal);
77
+ },
78
+ sendFile: async (path, name, signal, options) => {
79
+ if (!allowed(options)) return false;
80
+ await deps.flush(peer);
81
+ return deps.sendFile(peer, path, name, signal);
82
+ },
83
+ active: () => deps.active(peer),
84
+ language: () => deps.language(peer),
85
+ armTimer: deps.armTimer,
86
+ now: deps.now,
87
+ });
88
+ return channel;
89
+ }
90
+
91
+ return {
92
+ for(peer: string): WechatInteractionChannel {
93
+ const existing = channels.get(peer);
94
+ if (existing) return existing;
95
+ const created = build(peer);
96
+ channels.set(peer, created);
97
+ return created;
98
+ },
99
+
100
+ handleInbound(peer: string, text: string): boolean {
101
+ // Only a channel that exists can be waiting, so an inbound message never
102
+ // creates one for a peer that has not been asked anything.
103
+ return channels.get(peer)?.handleInbound(text) ?? false;
104
+ },
105
+
106
+ pendingFor(peer: string): PendingPromptInfo | undefined {
107
+ return channels.get(peer)?.pendingInfo();
108
+ },
109
+
110
+ prompts(): PendingPromptInfo[] {
111
+ const waiting: PendingPromptInfo[] = [];
112
+ for (const channel of channels.values()) {
113
+ const info = channel.pendingInfo();
114
+ if (info) waiting.push(info);
115
+ }
116
+ return waiting;
117
+ },
118
+
119
+ hasPending(peer?: string): boolean {
120
+ if (peer !== undefined) return channels.get(peer)?.hasPending() ?? false;
121
+ for (const channel of channels.values()) if (channel.hasPending()) return true;
122
+ return false;
123
+ },
124
+
125
+ async cancelAll(): Promise<void> {
126
+ for (const channel of channels.values()) {
127
+ if (channel.hasPending()) await channel.cancel();
128
+ }
129
+ },
130
+
131
+ reset(): void {
132
+ for (const channel of channels.values()) channel.reset();
133
+ channels.clear();
134
+ },
135
+ };
136
+ }