@gavin-hu/my-pi-agent 0.18.0 → 0.20.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 (324) hide show
  1. package/README.md +66 -52
  2. package/README.zh-CN.md +56 -46
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +507 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +774 -574
  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 +561 -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} +62 -52
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +440 -322
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +298 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  19. package/docs/extensions/team.md +254 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +260 -163
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +137 -79
  25. package/extensions/board/commands.ts +105 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +170 -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 +388 -0
  32. package/extensions/board/result.ts +108 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +90 -0
  35. package/extensions/board/seat.ts +28 -0
  36. package/extensions/board/snapshot.ts +42 -0
  37. package/extensions/board/tools.ts +444 -0
  38. package/extensions/board/transport/router.ts +91 -0
  39. package/extensions/board/transport/server.ts +138 -0
  40. package/extensions/board/types.ts +121 -0
  41. package/extensions/board/view/client.ts +51 -0
  42. package/extensions/board/view/dialog.ts +43 -0
  43. package/extensions/board/view/keys.ts +125 -0
  44. package/extensions/board/view/live.ts +231 -0
  45. package/extensions/board/view/names.ts +91 -0
  46. package/extensions/board/view/page.ts +118 -0
  47. package/extensions/board/view/render.ts +519 -0
  48. package/extensions/board/view/styles.ts +228 -0
  49. package/extensions/board/view/team.ts +327 -0
  50. package/extensions/bytefly/footer/budget.ts +6 -5
  51. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  52. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  53. package/extensions/bytefly/footer/config.ts +19 -8
  54. package/extensions/bytefly/footer/price/price.ts +30 -8
  55. package/extensions/bytefly/footer/register.ts +5 -5
  56. package/extensions/bytefly/footer/render/format.ts +18 -0
  57. package/extensions/bytefly/footer/render/lines.ts +23 -7
  58. package/extensions/bytefly/footer/render/segments.ts +117 -31
  59. package/extensions/bytefly/footer/types.ts +29 -5
  60. package/extensions/bytefly/footer/unpriced.ts +42 -17
  61. package/extensions/bytefly/panel/commands.ts +67 -7
  62. package/extensions/bytefly/panel/filter.ts +68 -0
  63. package/extensions/bytefly/panel/format.ts +16 -15
  64. package/extensions/bytefly/panel/join.ts +56 -13
  65. package/extensions/bytefly/panel/prices.ts +43 -0
  66. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  67. package/extensions/bytefly/panel/settings.ts +75 -43
  68. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  69. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  70. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  71. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  72. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  73. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  74. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  75. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  76. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  77. package/extensions/bytefly/panel/tui.ts +306 -259
  78. package/extensions/bytefly/picker/discovery.ts +0 -2
  79. package/extensions/bytefly/picker/types.ts +0 -2
  80. package/extensions/bytefly/update/check.ts +39 -0
  81. package/extensions/bytefly/update/format.ts +15 -0
  82. package/extensions/bytefly/update/register.ts +73 -34
  83. package/extensions/bytefly/update/state.ts +6 -3
  84. package/extensions/check/commands.ts +2 -1
  85. package/extensions/context/tui.ts +1 -1
  86. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  87. package/extensions/file-browse/files/classify.ts +136 -0
  88. package/extensions/file-browse/files/files.ts +12 -0
  89. package/extensions/file-browse/files/index.ts +90 -0
  90. package/extensions/file-browse/files/list.ts +261 -0
  91. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  92. package/extensions/file-browse/files/tree.ts +101 -0
  93. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  94. package/extensions/file-browse/index.ts +4 -4
  95. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  96. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -17
  97. package/extensions/file-browse/transport/server.ts +110 -0
  98. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  99. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  100. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  101. package/extensions/file-browse/view/emit.ts +51 -0
  102. package/extensions/file-browse/{html.ts → view/html.ts} +19 -18
  103. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  104. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +18 -20
  105. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +19 -14
  106. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  107. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  108. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  109. package/extensions/file-read/schema.ts +2 -1
  110. package/extensions/job/runtime.ts +4 -4
  111. package/extensions/memory/store.ts +12 -5
  112. package/extensions/plan/blocks.ts +23 -2
  113. package/extensions/plan/commands.ts +21 -16
  114. package/extensions/plan/feedback.ts +185 -52
  115. package/extensions/plan/format.ts +10 -3
  116. package/extensions/plan/index.ts +23 -5
  117. package/extensions/plan/plan-files.ts +88 -0
  118. package/extensions/plan/plan-index.ts +43 -9
  119. package/extensions/plan/plan-text.ts +33 -23
  120. package/extensions/plan/plans.ts +27 -34
  121. package/extensions/plan/policy.ts +30 -17
  122. package/extensions/plan/review.ts +3 -27
  123. package/extensions/plan/runtime.ts +52 -11
  124. package/extensions/plan/sections.ts +112 -0
  125. package/extensions/plan/steps.ts +3 -22
  126. package/extensions/plan/todos.ts +51 -3
  127. package/extensions/plan/tools/exit.ts +20 -95
  128. package/extensions/plan/tools/todo-seed.ts +4 -2
  129. package/extensions/plan/tools/write.ts +12 -1
  130. package/extensions/plan/tui.ts +5 -34
  131. package/extensions/plan/types.ts +15 -8
  132. package/extensions/plan/web-blocks.ts +89 -0
  133. package/extensions/plan/web-chrome.ts +3 -5
  134. package/extensions/plan/web-client.ts +179 -330
  135. package/extensions/plan/web-collab.ts +3 -3
  136. package/extensions/plan/web-comments.ts +18 -8
  137. package/extensions/plan/web-composer.ts +80 -0
  138. package/extensions/plan/web-draft.ts +40 -0
  139. package/extensions/plan/web-keys.ts +134 -0
  140. package/extensions/plan/web-outline.ts +192 -0
  141. package/extensions/plan/web-page.ts +4 -8
  142. package/extensions/plan/web-plan.ts +150 -0
  143. package/extensions/plan/web-router.ts +3 -58
  144. package/extensions/plan/web-settle.ts +194 -0
  145. package/extensions/plan/web-share-session.ts +24 -30
  146. package/extensions/plan/web-streams.ts +14 -14
  147. package/extensions/plan/web-tokens.ts +2 -2
  148. package/extensions/plan/web.ts +66 -120
  149. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  150. package/extensions/rewind/{review.ts → commands/review.ts} +15 -14
  151. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  152. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  153. package/extensions/rewind/index.ts +3 -3
  154. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  155. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  156. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  157. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  158. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  159. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  160. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  161. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  162. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  163. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  164. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  165. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  166. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  167. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  168. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  169. package/extensions/session-bus/commands.ts +17 -69
  170. package/extensions/session-bus/format.ts +22 -45
  171. package/extensions/session-bus/index.ts +13 -7
  172. package/extensions/session-bus/join-request.ts +40 -0
  173. package/extensions/session-bus/mail-watch.ts +134 -0
  174. package/extensions/session-bus/notice.ts +32 -4
  175. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  176. package/extensions/session-bus/protocol.ts +19 -49
  177. package/extensions/session-bus/resolve.ts +67 -0
  178. package/extensions/session-bus/result.ts +16 -51
  179. package/extensions/session-bus/rows.ts +11 -5
  180. package/extensions/session-bus/runtime.ts +220 -267
  181. package/extensions/session-bus/store.ts +55 -7
  182. package/extensions/session-bus/tools.ts +31 -66
  183. package/extensions/session-bus/tui.ts +5 -5
  184. package/extensions/session-bus/types.ts +23 -14
  185. package/extensions/subagent/agents.ts +9 -8
  186. package/extensions/subagent/commands.ts +2 -1
  187. package/extensions/subagent/invocation.ts +4 -2
  188. package/extensions/team/commands.ts +145 -0
  189. package/extensions/team/dispatch.ts +133 -0
  190. package/extensions/team/format.ts +119 -0
  191. package/extensions/team/index.ts +34 -0
  192. package/extensions/team/schema.ts +29 -0
  193. package/extensions/team/tools.ts +267 -0
  194. package/extensions/team/types.ts +65 -0
  195. package/extensions/todo/index.ts +4 -1
  196. package/extensions/todo/nudge.ts +8 -2
  197. package/extensions/web-access/fetch/page.ts +4 -1
  198. package/extensions/web-access/fetch/structured.ts +447 -0
  199. package/extensions/web-access/fetch/tool.ts +2 -1
  200. package/extensions/web-access/search/tool.ts +2 -1
  201. package/extensions/wechat/bridge.ts +106 -251
  202. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  203. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  204. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  205. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  206. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  207. package/extensions/wechat/dialog/channels.ts +136 -0
  208. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  209. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  210. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  211. package/extensions/wechat/index.ts +10 -10
  212. package/extensions/wechat/intake.ts +245 -0
  213. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  214. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  215. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  216. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  217. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  218. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  219. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  220. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  221. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  222. package/extensions/wechat/tools.ts +29 -12
  223. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  224. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  225. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  226. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  227. package/extensions/wechat/transport/outbound.ts +142 -0
  228. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  229. package/extensions/wechat/types.ts +10 -1
  230. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  231. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  232. package/extensions/worktree/commands.ts +24 -17
  233. package/extensions/worktree/exit.ts +12 -6
  234. package/extensions/worktree/git.ts +22 -14
  235. package/extensions/worktree/guard.ts +18 -290
  236. package/extensions/worktree/handoff.ts +10 -2
  237. package/extensions/worktree/index.ts +2 -1
  238. package/extensions/worktree/lifecycle.ts +2 -0
  239. package/extensions/worktree/registry.ts +20 -11
  240. package/extensions/worktree/root-tools.ts +13 -3
  241. package/extensions/worktree/runtime.ts +8 -2
  242. package/extensions/worktree/screen.ts +15 -8
  243. package/extensions/worktree/shell.ts +303 -0
  244. package/extensions/worktree/state.ts +1 -1
  245. package/extensions/worktree/status.ts +42 -9
  246. package/extensions/worktree/tools.ts +19 -13
  247. package/lib/README.md +11 -8
  248. package/lib/board/card.ts +479 -0
  249. package/lib/board/links.ts +141 -0
  250. package/lib/board/page-pointer.ts +310 -0
  251. package/lib/board/round-text.ts +81 -0
  252. package/lib/board/round.ts +94 -0
  253. package/lib/board/rules.ts +437 -0
  254. package/lib/board/session.ts +105 -0
  255. package/lib/board/store.ts +497 -0
  256. package/lib/board/view.ts +234 -0
  257. package/lib/errors.ts +12 -4
  258. package/lib/format.ts +35 -0
  259. package/lib/http/guard.ts +112 -0
  260. package/lib/http/index.ts +16 -5
  261. package/lib/http/serve.ts +237 -0
  262. package/lib/http/server.ts +12 -10
  263. package/lib/http/sink.ts +135 -0
  264. package/lib/http/token.ts +38 -0
  265. package/lib/plan-entry.ts +11 -0
  266. package/lib/policy.ts +25 -2
  267. package/lib/requirements.ts +6 -7
  268. package/lib/session-label.ts +64 -0
  269. package/lib/session-roster.ts +201 -0
  270. package/lib/session-tool.ts +184 -0
  271. package/lib/spawn.ts +37 -3
  272. package/lib/stray-fields.ts +45 -0
  273. package/lib/tool-names.ts +38 -2
  274. package/lib/tui/glyphs.ts +3 -2
  275. package/lib/web/README.md +57 -4
  276. package/lib/web/client-runtime.ts +123 -0
  277. package/lib/web/components.ts +5 -0
  278. package/lib/web/index.ts +4 -2
  279. package/lib/web/markup.ts +88 -1
  280. package/lib/web/names.ts +42 -0
  281. package/lib/web/runtime.ts +7 -1
  282. package/lib/web/tokens.ts +25 -2
  283. package/package.json +26 -14
  284. package/skills/next/SKILL.md +4 -3
  285. package/skills/project-docs/SKILL.md +133 -0
  286. package/skills/project-docs/references/audit.md +131 -0
  287. package/skills/project-docs/references/changelog.md +105 -0
  288. package/skills/project-docs/references/discovery.md +127 -0
  289. package/skills/project-docs/references/readme.md +80 -0
  290. package/skills/project-docs/references/release.md +84 -0
  291. package/skills/project-docs/references/roadmap.md +93 -0
  292. package/skills/project-docs/scripts/state.ts +316 -0
  293. package/skills/refactor/SKILL.md +100 -125
  294. package/skills/refactor/references/handoff.md +35 -0
  295. package/skills/refactor/references/signals.md +54 -0
  296. package/skills/refactor/scripts/inventory.ts +587 -0
  297. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  298. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  299. package/extensions/file-browse/README.md +0 -506
  300. package/extensions/file-browse/files.ts +0 -564
  301. package/extensions/file-browse/server.ts +0 -141
  302. package/extensions/job/process.ts +0 -35
  303. package/extensions/plan/split.ts +0 -87
  304. package/extensions/session-bus/README.md +0 -247
  305. package/extensions/session-bus/team.ts +0 -102
  306. package/extensions/wechat/outbound.ts +0 -100
  307. package/lib/plans/sections.ts +0 -172
  308. package/lib/plans/setting.ts +0 -52
  309. package/lib/plans/split.ts +0 -215
  310. package/lib/plans/store.ts +0 -19
  311. package/lib/plans/team.ts +0 -151
  312. package/skills/changelog/SKILL.md +0 -148
  313. package/skills/parallel-dev/SKILL.md +0 -150
  314. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  315. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  316. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  317. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  318. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  319. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  320. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  321. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  322. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  323. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  324. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -32,8 +32,8 @@ import {
32
32
  SessionManager,
33
33
  } from "@earendil-works/pi-coding-agent";
34
34
  import { join } from "node:path";
35
- import { WECHAT_CHANNEL_PROMPT } from "./channel.ts";
36
- import { peerKey } from "./format.ts";
35
+ import { WECHAT_CHANNEL_PROMPT } from "../text/channel.ts";
36
+ import { peerKey } from "../text/format.ts";
37
37
 
38
38
  /** The SDK pieces a peer session needs; injected so tests never load resources. */
39
39
  export interface PeerSessionSeams {
@@ -10,13 +10,18 @@
10
10
  *
11
11
  * Contract:
12
12
  *
13
- * - One turn at a time per peer, and by default one turn at a time overall
14
- * (`concurrency`), so a forwarded dialog always has a single owning peer.
13
+ * - One turn at a time per peer, and by default two peers' turns at once
14
+ * (`PEER_CONCURRENCY`), so each answered peer owns its dialog through its own
15
+ * interaction channel. A third peer waits for a slot and is told so.
15
16
  * - Every message is its own turn, in arrival order. A message that arrives
16
17
  * while a turn runs waits for its own turn and answer; nothing is merged.
17
18
  * - A turn's answer is the text it produced. A requested `/stop` says `stopped`
18
19
  * and drops the partial answer, a finished turn with no text says `noText`,
19
20
  * and a thrown turn says `failed` — never silence.
21
+ * - A close that abandons the session says `abandoned` once per peer that still
22
+ * had work, after its turn stops and before its session goes. The turn's own
23
+ * line is suppressed then, and each notice carries its own deadline, so a
24
+ * connection that never answers cannot hold the exit.
20
25
  * - The session is opened lazily, reset on request, and disposed after an idle
21
26
  * timeout, so a peer who stops writing does not keep a context alive.
22
27
  *
@@ -24,10 +29,11 @@
24
29
  */
25
30
 
26
31
  import type { ImageContent } from "@earendil-works/pi-ai";
27
- import { messageOf } from "./errors.ts";
32
+ import { messageOf } from "../errors.ts";
33
+ import type { NoticeKind } from "../text/notices.ts";
28
34
  import type { PeerSession } from "./peer-session.ts";
29
35
  import { createReplyPipeline, type ReplyPipeline } from "./reply-pipeline.ts";
30
- import { defaultTimer, type ArmTimer } from "./timer.ts";
36
+ import { defaultTimer, type ArmTimer } from "../timer.ts";
31
37
 
32
38
  /** One admitted, media-resolved inbound message. */
33
39
  export interface PeerItem {
@@ -45,13 +51,13 @@ export interface PeerItem {
45
51
  }
46
52
 
47
53
  /** What the worker says to the peer when a turn ends without text of its own. */
48
- export type TurnFallback = "stopped" | "noText" | "failed" | "compacted";
54
+ export type TurnFallback = Extract<NoticeKind, "stopped" | "abandoned" | "noText" | "failed" | "compacted">;
49
55
 
50
56
  export interface PeerWorkerDeps {
51
57
  /** Open (or resume) one peer's session; the bridge owns the directory layout. */
52
58
  openSession: (peer: string, options: { fresh: boolean }) => Promise<PeerSession>;
53
- /** Deliver one turn's text to the peer; false stops the rest of a stream. */
54
- send: (item: PeerItem, text: string) => Promise<boolean>;
59
+ /** Deliver one line to the peer; false stops the rest of a stream. */
60
+ send: (item: PeerItem, text: string, options?: { signal?: AbortSignal }) => Promise<boolean>;
55
61
  /** The fallback line for a turn that produced no text. */
56
62
  fallback: (item: PeerItem, reason: TurnFallback) => string;
57
63
  /** A turn started / finished for this item, for typing and notices. */
@@ -59,7 +65,7 @@ export interface PeerWorkerDeps {
59
65
  onSettled?: (item: PeerItem) => void;
60
66
  /** A session or turn failure worth showing on the terminal. */
61
67
  notify?: (message: string, kind?: "info" | "warning" | "error") => void;
62
- /** Turns running at once across peers. Default 1, so a dialog has one owner. */
68
+ /** Turns running at once across peers. Defaults to {@link PEER_CONCURRENCY}. */
63
69
  concurrency?: number;
64
70
  /** Dispose an idle session after this long. `0` disables the idle sweep. */
65
71
  idleTimeoutMs?: number;
@@ -86,7 +92,7 @@ export interface PeerSnapshot {
86
92
  }
87
93
 
88
94
  export interface PeerWorker {
89
- /** Queue one message; resolves as soon as it is queued or merged. */
95
+ /** Queue one message; it is never merged into another. */
90
96
  enqueue(item: PeerItem): void;
91
97
  /** Stop the running turn for `peer`; false when nothing was running. */
92
98
  abort(peer: string): boolean;
@@ -95,18 +101,30 @@ export interface PeerWorker {
95
101
  * Refused while a turn runs, and the queued messages go with it.
96
102
  */
97
103
  reset(peer: string): boolean;
98
- /** The turn running right now, if any (one with the default concurrency). */
99
- current(): PeerItem | undefined;
104
+ /** The turn running for this peer, if any. */
105
+ running(peer: string): PeerItem | undefined;
100
106
  /** Flush buffered assistant text for a running peer before a prompt/file. */
101
107
  flush(peer: string): Promise<void>;
102
108
  state(peer: string): PeerState;
103
109
  snapshot(): PeerSnapshot[];
104
110
  /** Drop every session; queued messages are dropped with them. */
105
- close(): Promise<void>;
111
+ close(options?: { abandon?: boolean }): Promise<void>;
106
112
  }
107
113
 
108
114
  const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
109
115
 
116
+ /**
117
+ * Peers whose turns run at once.
118
+ *
119
+ * Two, because a phone chat rarely has more than two conversations in flight and
120
+ * every concurrent turn is a full model run on the user's budget; the rest wait
121
+ * in a fair queue and are acknowledged, exactly as a second peer waits today.
122
+ */
123
+ export const PEER_CONCURRENCY = 2;
124
+
125
+ /** How long a shutdown waits for its one notice per peer before giving up. */
126
+ const ABANDON_NOTICE_MS = 5000;
127
+
110
128
  interface PeerEntry {
111
129
  queue: PeerItem[];
112
130
  running?: PeerItem;
@@ -123,7 +141,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
123
141
  const now = deps.now ?? Date.now;
124
142
  const armTimer = deps.armTimer ?? defaultTimer;
125
143
  const idleTimeoutMs = deps.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
126
- const concurrency = Math.max(1, deps.concurrency ?? 1);
144
+ const concurrency = Math.max(1, deps.concurrency ?? PEER_CONCURRENCY);
127
145
  const peers = new Map<string, PeerEntry>();
128
146
  /** Active reply pipelines, so a dialog can flush buffered text first. */
129
147
  const pipelines = new Map<string, ReplyPipeline>();
@@ -132,7 +150,9 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
132
150
  /** Peers whose next session must start empty (`/new`). */
133
151
  const fresh = new Set<string>();
134
152
  let running = 0;
135
- let currentItem: PeerItem | undefined;
153
+ /** When each queued item arrived, so the next free slot goes to the oldest wait. */
154
+ const arrived = new WeakMap<PeerItem, number>();
155
+ let arrivals = 0;
136
156
  let closed = false;
137
157
 
138
158
  function entry(peer: string): PeerEntry {
@@ -280,16 +300,25 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
280
300
  unsubscribe();
281
301
  pipelines.delete(item.peer);
282
302
  }
303
+ // A closing worker has already said, once, that the session ended.
304
+ if (closed) return;
283
305
  if (reason) await sendPart(deps.fallback(item, reason));
284
306
  if (compacted) await sendPart(deps.fallback(item, "compacted"));
285
307
  }
286
308
 
287
309
  /** The next peer with work, so a finished turn can hand its slot on. */
310
+ /** The next peer with work, oldest queued message first so no peer starves. */
288
311
  function nextRunnable(): [string, PeerEntry] | undefined {
312
+ let picked: [string, PeerEntry] | undefined;
289
313
  for (const [peer, target] of peers) {
290
- if (!target.running && target.queue.length > 0) return [peer, target];
314
+ if (target.running || target.queue.length === 0) continue;
315
+ const waiting = target.queue[0];
316
+ const oldest = picked?.[1].queue[0];
317
+ if (!picked || (waiting && oldest && (arrived.get(waiting) ?? 0) < (arrived.get(oldest) ?? 0))) {
318
+ picked = [peer, target];
319
+ }
291
320
  }
292
- return undefined;
321
+ return picked;
293
322
  }
294
323
 
295
324
  /** Start turns until the concurrency limit or the queues are empty. */
@@ -302,7 +331,6 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
302
331
  target.running = item;
303
332
  target.startedAt = now();
304
333
  running += 1;
305
- currentItem = item;
306
334
  cancelIdleSweep(target);
307
335
  deps.onStart?.(item);
308
336
  void (async () => {
@@ -311,12 +339,13 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
311
339
  await runTurn(item, opened);
312
340
  } catch (error) {
313
341
  deps.notify?.(`WeChat session failed for ${item.peer}: ${messageOf(error)}`, "error");
314
- await deps.send(item, deps.fallback(item, "failed"));
342
+ // A closing worker says one line of its own instead: the peer does not need
343
+ // two reasons, and the second send may well fail on the dying connection.
344
+ if (!closed) await deps.send(item, deps.fallback(item, "failed"));
315
345
  } finally {
316
346
  target.running = undefined;
317
347
  target.startedAt = undefined;
318
348
  running -= 1;
319
- currentItem = undefined;
320
349
  deps.onSettled?.(item);
321
350
  armIdleSweep(target);
322
351
  wake();
@@ -325,11 +354,50 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
325
354
  }
326
355
  }
327
356
 
357
+ /** One abandon notice; true when the deadline, not the transport, gave up on it. */
358
+ async function announce(item: PeerItem): Promise<boolean> {
359
+ const controller = new AbortController();
360
+ const cancel = armTimer(ABANDON_NOTICE_MS, () => controller.abort());
361
+ try {
362
+ await deps.send(item, deps.fallback(item, "abandoned"), { signal: controller.signal });
363
+ return controller.signal.aborted;
364
+ } catch (error) {
365
+ // A send the deadline cancelled rejects like any aborted request; that is the
366
+ // deadline's report. Anything else is the transport's own failure.
367
+ if (controller.signal.aborted) return true;
368
+ deps.notify?.(`WeChat could not tell ${item.peer} the session ended: ${messageOf(error)}`, "warning");
369
+ return false;
370
+ } finally {
371
+ cancel();
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Tell every peer that still had work that the session ended.
377
+ *
378
+ * One line per peer, sent after the turns have stopped and before the sessions
379
+ * go. Each send carries its own deadline, so a connection that never answers
380
+ * cannot hold the exit; a deadline is reported here because the deliverer keeps
381
+ * an aborted send quiet — an abort is normally the peer's own `/stop`.
382
+ */
383
+ async function announceAbandoned(items: readonly PeerItem[]): Promise<void> {
384
+ const gaveUp = await Promise.all(items.map((item) => announce(item)));
385
+ for (const [index, timedOut] of gaveUp.entries()) {
386
+ if (!timedOut) continue;
387
+ deps.notify?.(
388
+ `WeChat could not tell ${items[index]?.peer ?? ""} the session ended: no reply in ${ABANDON_NOTICE_MS / 1000} s.`,
389
+ "warning",
390
+ );
391
+ }
392
+ }
393
+
328
394
  return {
329
395
  enqueue(item: PeerItem): void {
330
396
  if (closed) return;
331
397
  const target = entry(item.peer);
332
398
  // One message, one turn: a queued item is never folded into another.
399
+ arrivals += 1;
400
+ arrived.set(item, arrivals);
333
401
  target.queue.push(item);
334
402
  wake();
335
403
  },
@@ -354,8 +422,8 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
354
422
  return true;
355
423
  },
356
424
 
357
- current(): PeerItem | undefined {
358
- return currentItem;
425
+ running(peer: string): PeerItem | undefined {
426
+ return peers.get(peer)?.running;
359
427
  },
360
428
 
361
429
  async flush(peer: string): Promise<void> {
@@ -370,8 +438,17 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
370
438
  return [...peers.keys()].map((peer) => ({ peer, state: stateOf(peer) }));
371
439
  },
372
440
 
373
- async close(): Promise<void> {
441
+ async close(options?: { abandon?: boolean }): Promise<void> {
374
442
  closed = true;
443
+ // Captured before the teardown forgets it: a running turn and a queue that
444
+ // never started are both an answer the peer will not get.
445
+ const abandoned: PeerItem[] = [];
446
+ if (options?.abandon) {
447
+ for (const target of peers.values()) {
448
+ const item = target.running ?? target.queue[0];
449
+ if (item) abandoned.push(item);
450
+ }
451
+ }
375
452
  pipelines.clear();
376
453
  const runningAborts = [...peers.values()]
377
454
  .filter((target) => target.session)
@@ -383,6 +460,7 @@ export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
383
460
  }
384
461
  aborted.clear();
385
462
  fresh.clear();
463
+ await announceAbandoned(abandoned);
386
464
  },
387
465
  };
388
466
  }
@@ -9,12 +9,12 @@
9
9
  * notice is bounded to one per wait episode.
10
10
  */
11
11
 
12
- import type { BotCredentials } from "./types.ts";
13
- import type { WechatClient } from "./client.ts";
12
+ import type { BotCredentials } from "../types.ts";
13
+ import type { WechatClient } from "../transport/client.ts";
14
14
  import type { PeerState } from "./peer-worker.ts";
15
- import type { PeerLanguage } from "./format.ts";
16
- import { notice } from "./notices.ts";
17
- import { createTimerMap, type ArmTimer } from "./timer.ts";
15
+ import type { PeerLanguage } from "../text/format.ts";
16
+ import { notice } from "../text/notices.ts";
17
+ import { createTimerMap, type ArmTimer } from "../timer.ts";
18
18
 
19
19
  /** How long a fetched typing ticket is reused. */
20
20
  const TYPING_TTL_MS = 5 * 60 * 1000;
@@ -44,7 +44,8 @@ export interface PresenceDeps {
44
44
  /** Send one stock line to the peer (chunked by the bridge). */
45
45
  send: (peer: string, contextToken: string | undefined, text: string) => Promise<unknown>;
46
46
  /** A forwarded dialog is waiting; its own reminder covers the wait. */
47
- promptPending: () => boolean;
47
+ /** Whether that peer is waiting on a dialog, so "still working" would be noise. */
48
+ promptPending: (peer: string) => boolean;
48
49
  }
49
50
 
50
51
  /** One message a notice refers to. */
@@ -180,7 +181,7 @@ export function createPresence(deps: PresenceDeps): Presence {
180
181
  if (!isRunning(peer)) return;
181
182
  // A turn waiting on an unanswered dialog is not "still working": the
182
183
  // prompt itself nudges, and saying both reads as noise.
183
- if (deps.promptPending()) return;
184
+ if (deps.promptPending(peer)) return;
184
185
  void deps.send(peer, contextToken, notice("stillWorking", deps.languageOf(peer)));
185
186
  });
186
187
  }
@@ -11,7 +11,7 @@
11
11
  * drive it.
12
12
  */
13
13
 
14
- import type { ArmTimer } from "./timer.ts";
14
+ import type { ArmTimer } from "../timer.ts";
15
15
 
16
16
  export interface ReplyPipelineDeps {
17
17
  /** Send one part to the peer. Parts arrive strictly in order. */
@@ -9,8 +9,8 @@
9
9
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
11
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
12
- import { DEFAULT_BASE_URL } from "./client.ts";
13
- import type { BotCredentials, CredentialsFile } from "./types.ts";
12
+ import { DEFAULT_BASE_URL } from "../transport/client.ts";
13
+ import type { BotCredentials, CredentialsFile } from "../types.ts";
14
14
 
15
15
  /** The bridge's directory under the agent dir. */
16
16
  export function wechatDir(): string {
@@ -8,9 +8,10 @@
8
8
 
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { dirname, join } from "node:path";
11
- import type { PeerState, SeenMessage, WechatState } from "./types.ts";
11
+ import type { BotCredentials, PeerState, SeenMessage, WechatState } from "../types.ts";
12
+ import type { MessageTarget } from "../transport/deliver.ts";
12
13
  import { wechatDir } from "./credentials.ts";
13
- import { peerKey } from "./format.ts";
14
+ import { peerKey } from "../text/format.ts";
14
15
 
15
16
  /** How long a handled message id is remembered for a replay. */
16
17
  export const SEEN_TTL_MS = 60 * 60 * 1000;
@@ -114,6 +115,17 @@ export function findPeer(state: WechatState, peer: string): PeerState | undefine
114
115
  return state.peers[storedKey(state, peer)];
115
116
  }
116
117
 
118
+ /**
119
+ * Who a proactive send goes to, and the token that carries it.
120
+ *
121
+ * Either id form may be stored: the owner id comes from the login response and the
122
+ * token from an inbound `from_user_id`, and they need not agree.
123
+ */
124
+ export function ownerTarget(state: WechatState, account: BotCredentials): MessageTarget {
125
+ const peer = state.ownerId ?? account.ilinkUserId;
126
+ return { peer, contextToken: findPeer(state, peer)?.lastContextToken };
127
+ }
128
+
117
129
  /** Whether this message id was already handled for the peer. */
118
130
  export function seenMessage(state: WechatState, peer: string, id: number): boolean {
119
131
  const key = peerKey(peer);
@@ -3,9 +3,9 @@
3
3
  * and shorten peer ids for display. The peer-facing notices live in `notices.ts`.
4
4
  */
5
5
 
6
- import { stripControlChars } from "../../lib/format.ts";
7
- import { sanitizeFileName } from "./media.ts";
8
- import type { CDNMedia, MediaRef, MessageItem } from "./types.ts";
6
+ import { stripControlChars } from "../../../lib/format.ts";
7
+ import { sanitizeFileName } from "../transport/media.ts";
8
+ import type { CDNMedia, MediaRef, MessageItem } from "../types.ts";
9
9
 
10
10
  /**
11
11
  * Prepare inbound WeChat text before it becomes a user turn: strip terminal
@@ -17,6 +17,7 @@ export type NoticeKind =
17
17
  | "media"
18
18
  | "unsupported"
19
19
  | "stopped"
20
+ | "abandoned"
20
21
  | "noText"
21
22
  | "compacted"
22
23
  | "budget"
@@ -46,6 +47,10 @@ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
46
47
  en: "Voice without a transcript and video aren't supported — please send text or an image.",
47
48
  },
48
49
  stopped: { zh: "已停止。", en: "Stopped." },
50
+ abandoned: {
51
+ zh: "会话已停止,这条还没答完。恢复后再发一次即可。",
52
+ en: "The session stopped before this answer finished — send it again when it's back.",
53
+ },
49
54
  noText: {
50
55
  zh: "这条处理完了,但没有文字输出。",
51
56
  en: "That turn finished without any text to send.",
@@ -71,6 +76,9 @@ const NOTICES: Record<NoticeKind, Record<PeerLanguage, string>> = {
71
76
  },
72
77
  };
73
78
 
79
+ /** Every kind the bridge can send, so a check over the catalogue cannot miss one. */
80
+ export const NOTICE_KINDS = Object.keys(NOTICES) as NoticeKind[];
81
+
74
82
  /** Render one notice, with `{n}` replaced when a count applies. */
75
83
  export function notice(kind: NoticeKind, lang: PeerLanguage, count?: number): string {
76
84
  const text = NOTICES[kind][lang];
@@ -2,11 +2,13 @@
2
2
  * Model-facing registration for the `send_wechat` tool.
3
3
  *
4
4
  * The bridge replies to inbound messages automatically; this tool is for the
5
- * agent to message the owner proactively. It is deliberately owner-only and
6
- * text-only: the model cannot choose a recipient, so untrusted generated text
7
- * cannot be aimed at an arbitrary WeChat account.
5
+ * agent to message the owner proactively. It is deliberately owner-only: the
6
+ * model cannot choose a recipient, so untrusted generated text cannot be aimed at
7
+ * an arbitrary WeChat account. It carries one text or one local image, and an
8
+ * image arrives as a picture rather than a document.
8
9
  */
9
10
 
11
+ import { basename } from "node:path";
10
12
  import type { JsonValue } from "@earendil-works/pi-ai";
11
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
14
  import { Type, type Static } from "typebox";
@@ -19,11 +21,20 @@ import type { Bridge } from "./bridge.ts";
19
21
  export const TOOL_NAME = "send_wechat";
20
22
 
21
23
  const WechatSendParams = Type.Object({
22
- text: Type.String({
23
- minLength: 1,
24
- maxLength: 4000,
25
- description: "The message to send to the WeChat owner. Plain text.",
26
- }),
24
+ text: Type.Optional(
25
+ Type.String({
26
+ minLength: 1,
27
+ maxLength: 4000,
28
+ description: "The message to send to the WeChat owner. Plain text.",
29
+ }),
30
+ ),
31
+ image: Type.Optional(
32
+ Type.String({
33
+ minLength: 1,
34
+ maxLength: 4096,
35
+ description: "Path to a local image to send instead, as a picture. One of text or image.",
36
+ }),
37
+ ),
27
38
  });
28
39
  type WechatSendArgs = Static<typeof WechatSendParams>;
29
40
 
@@ -49,12 +60,13 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
49
60
  name: TOOL_NAME,
50
61
  label: "WeChat send",
51
62
  description: [
52
- "Send a plain-text WeChat message to the account owner from the running bridge (start it with /wechat login).",
63
+ "Send a WeChat message — plain text, or one local image as a picture — to the account owner from the running bridge (start it with /wechat login).",
53
64
  "The recipient is always the owner and cannot be chosen.",
54
65
  ].join(" "),
55
- promptSnippet: "Send a plain-text WeChat message to the account owner.",
66
+ promptSnippet: "Send a WeChat text or image to the account owner.",
56
67
  promptGuidelines: [
57
68
  "Use send_wechat only to message the owner proactively, not to answer an inbound WeChat message (the bridge replies automatically).",
69
+ "Give one of text or image: a plot or screenshot goes as a path, not described in words.",
58
70
  "Keep messages concise and do not include secrets unless the user asked for them.",
59
71
  ],
60
72
  parameters: WechatSendParams,
@@ -64,16 +76,21 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
64
76
 
65
77
  async execute(_toolCallId, params) {
66
78
  const args = params as WechatSendArgs;
79
+ if ((args.text === undefined) === (args.image === undefined)) {
80
+ return sendResult(false, "Give exactly one of text or image.");
81
+ }
67
82
  const bridge = deps.getBridge();
68
83
  if (!bridge) {
69
84
  return sendResult(false, "WeChat bridge is not started. Run /wechat login first.");
70
85
  }
71
- const outcome = await bridge.sendToOwner(args.text);
86
+ const outcome = await bridge.sendToOwner(args.text, args.image);
72
87
  return sendResult(outcome.ok, outcome.error);
73
88
  },
74
89
 
75
90
  renderCall(args, theme, context) {
76
- const body = (args as { text?: unknown }).text;
91
+ const { text, image } = args as { text?: unknown; image?: unknown };
92
+ // An image is named by its file, so a call line never carries a whole path.
93
+ const body = typeof image === "string" ? basename(image) : text;
77
94
  const label = context.argsComplete && typeof body === "string" ? body : "…";
78
95
  return callLine(theme, context.lastComponent, TOOL_NAME, label);
79
96
  },
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { randomUUID } from "node:crypto";
12
- import { asNumber } from "../../lib/config.ts";
12
+ import { asNumber } from "../../../lib/config.ts";
13
13
  import { mediaDownloadUrl } from "./media.ts";
14
14
  import type {
15
15
  BotCredentials,
@@ -21,7 +21,7 @@ import type {
21
21
  SendTypingResponse,
22
22
  UpdatesResponse,
23
23
  WeixinMessage,
24
- } from "./types.ts";
24
+ } from "../types.ts";
25
25
 
26
26
  /** Fixed public API base used for QR login. */
27
27
  export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
@@ -324,6 +324,30 @@ export class WechatClient {
324
324
  };
325
325
  }
326
326
 
327
+ /** `POST /ilink/bot/sendmessage` — send one image. */
328
+ async sendImageMessage(
329
+ account: BotCredentials,
330
+ args: { to: string; media: CDNMedia; midSize: number; aeskey: string; contextToken?: string },
331
+ signal?: AbortSignal,
332
+ ): Promise<SendResponse> {
333
+ const url = `${account.baseUrl}/ilink/bot/sendmessage`;
334
+ const msg: Record<string, unknown> = {
335
+ from_user_id: "",
336
+ to_user_id: args.to,
337
+ client_id: randomUUID(),
338
+ message_type: 2,
339
+ message_state: 2,
340
+ item_list: [{ type: 2, image_item: { media: args.media, mid_size: args.midSize, aeskey: args.aeskey } }],
341
+ };
342
+ if (args.contextToken) msg.context_token = args.contextToken;
343
+ const data = await this.post(url, this.authHeaders(account), { msg, base_info: this.baseInfo() }, signal);
344
+ return {
345
+ ret: asNumber(data.ret),
346
+ errcode: asNumber(data.errcode),
347
+ errmsg: asString(data.errmsg),
348
+ };
349
+ }
350
+
327
351
  /** `POST /ilink/bot/getconfig` — the per-user typing ticket. */
328
352
  async getConfig(
329
353
  account: BotCredentials,
@@ -8,11 +8,12 @@
8
8
  * token is forgotten so the next inbound message can replace it.
9
9
  */
10
10
 
11
- import type { BotCredentials } from "./types.ts";
11
+ import type { BotCredentials } from "../types.ts";
12
12
  import { isSessionExpired, rejectionOf, WechatError, type WechatClient } from "./client.ts";
13
- import { messageOf } from "./errors.ts";
14
- import { chunkText, toWechatText } from "./format.ts";
15
- import { sendLocalFile } from "./outbound.ts";
13
+ import { messageOf } from "../errors.ts";
14
+ import { chunkText, toWechatText } from "../text/format.ts";
15
+ import { basename } from "node:path";
16
+ import { sendLocalFile, sendLocalImage } from "./outbound.ts";
16
17
 
17
18
  /** One addressed text destination. */
18
19
  export interface MessageTarget {
@@ -63,6 +64,8 @@ export interface Deliverer {
63
64
  prompt(target: MessageTarget, text: string, signal?: AbortSignal): Promise<boolean>;
64
65
  /** Upload and send a local file to a peer. */
65
66
  file(target: MessageTarget, filePath: string, fileName: string, signal?: AbortSignal): Promise<boolean>;
67
+ /** Upload and send a local image to a peer, as a picture. */
68
+ image(target: MessageTarget, filePath: string, signal?: AbortSignal): Promise<boolean>;
66
69
  /** Message the owner; returns the tool's result instead of logging. */
67
70
  toOwner(owner: string, contextToken: string | undefined, text: string, signal?: AbortSignal): Promise<DeliveryResult>;
68
71
  /** Forget the one-notice-per-outage state (a new connection). */
@@ -163,6 +166,33 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
163
166
  return delivery.ok;
164
167
  }
165
168
 
169
+ /**
170
+ * Send one attachment, reporting a failure the model cannot see otherwise.
171
+ *
172
+ * The sender reports only success, and a caller may fall back to text; without
173
+ * this report the operator would never know the attachment was dropped.
174
+ */
175
+ function media(
176
+ target: MessageTarget,
177
+ label: "file" | "image",
178
+ name: string,
179
+ send: (client: WechatClient, account: BotCredentials, signal?: AbortSignal) => Promise<boolean>,
180
+ signal?: AbortSignal,
181
+ ): Promise<boolean> {
182
+ return serialize(target.peer, async () => {
183
+ const client = deps.client();
184
+ const account = deps.account();
185
+ if (!client || !account) return false;
186
+ const sent = await send(client, account, signal);
187
+ if (!sent && !signal?.aborted) {
188
+ deps.onSendError?.(`${label} delivery failed: ${name}`);
189
+ deps.notify(`WeChat ${label} delivery failed for ${name}.`, "warning");
190
+ }
191
+ if (sent) deps.onSent?.();
192
+ return sent;
193
+ });
194
+ }
195
+
166
196
  return {
167
197
  reply(target, text, signal) {
168
198
  return serialize(target.peer, () => deliverWithNotice(target, text, "reply", signal));
@@ -173,30 +203,44 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
173
203
  },
174
204
 
175
205
  file(target, filePath, fileName, signal) {
176
- return serialize(target.peer, async () => {
177
- const client = deps.client();
178
- const account = deps.account();
179
- if (!client || !account) return false;
180
- const sent = await sendLocalFile({
181
- client,
182
- account,
183
- cdnBaseUrl: deps.cdnBaseUrl,
184
- maxBytes: deps.maxMediaBytes,
185
- to: target.peer,
186
- contextToken: target.contextToken,
187
- filePath,
188
- fileName,
189
- signal,
190
- });
191
- // `sendLocalFile` reports only success, and the caller may fall back to text;
192
- // without this line the operator would never know the attachment was dropped.
193
- if (!sent && !signal?.aborted) {
194
- deps.onSendError?.(`file delivery failed: ${fileName}`);
195
- deps.notify(`WeChat file delivery failed for ${fileName}.`, "warning");
196
- }
197
- if (sent) deps.onSent?.();
198
- return sent;
199
- });
206
+ return media(
207
+ target,
208
+ "file",
209
+ fileName,
210
+ (client, account, abortSignal) =>
211
+ sendLocalFile({
212
+ client,
213
+ account,
214
+ cdnBaseUrl: deps.cdnBaseUrl,
215
+ maxBytes: deps.maxMediaBytes,
216
+ to: target.peer,
217
+ contextToken: target.contextToken,
218
+ filePath,
219
+ fileName,
220
+ signal: abortSignal,
221
+ }),
222
+ signal,
223
+ );
224
+ },
225
+
226
+ image(target, filePath, signal) {
227
+ return media(
228
+ target,
229
+ "image",
230
+ basename(filePath),
231
+ (client, account, abortSignal) =>
232
+ sendLocalImage({
233
+ client,
234
+ account,
235
+ cdnBaseUrl: deps.cdnBaseUrl,
236
+ maxBytes: deps.maxMediaBytes,
237
+ to: target.peer,
238
+ contextToken: target.contextToken,
239
+ filePath,
240
+ signal: abortSignal,
241
+ }),
242
+ signal,
243
+ );
200
244
  },
201
245
 
202
246
  toOwner(owner, contextToken, text, signal) {