@gavin-hu/my-pi-agent 0.18.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 (249) hide show
  1. package/README.md +52 -44
  2. package/README.zh-CN.md +46 -41
  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} +446 -404
  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} +61 -50
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
  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 +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  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} +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} +258 -162
  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/panel/commands.ts +40 -7
  51. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  52. package/extensions/bytefly/panel/settings.ts +3 -20
  53. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  54. package/extensions/bytefly/picker/discovery.ts +0 -2
  55. package/extensions/bytefly/picker/types.ts +0 -2
  56. package/extensions/bytefly/update/check.ts +39 -0
  57. package/extensions/bytefly/update/format.ts +15 -0
  58. package/extensions/bytefly/update/register.ts +73 -34
  59. package/extensions/bytefly/update/state.ts +6 -3
  60. package/extensions/context/tui.ts +1 -1
  61. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  62. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  63. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  64. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  65. package/extensions/file-browse/index.ts +4 -4
  66. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  67. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -15
  68. package/extensions/file-browse/transport/server.ts +110 -0
  69. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  70. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  71. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  72. package/extensions/file-browse/{html.ts → view/html.ts} +14 -12
  73. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  74. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  75. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  76. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  77. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  78. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  79. package/extensions/file-read/schema.ts +2 -1
  80. package/extensions/memory/store.ts +12 -5
  81. package/extensions/plan/blocks.ts +23 -2
  82. package/extensions/plan/commands.ts +21 -16
  83. package/extensions/plan/feedback.ts +147 -38
  84. package/extensions/plan/index.ts +6 -4
  85. package/extensions/plan/plan-index.ts +43 -9
  86. package/extensions/plan/plan-text.ts +33 -23
  87. package/extensions/plan/plans.ts +8 -11
  88. package/extensions/plan/policy.ts +20 -12
  89. package/extensions/plan/review.ts +3 -27
  90. package/extensions/plan/runtime.ts +21 -9
  91. package/extensions/plan/sections.ts +112 -0
  92. package/extensions/plan/steps.ts +3 -22
  93. package/extensions/plan/tools/exit.ts +11 -95
  94. package/extensions/plan/tui.ts +5 -34
  95. package/extensions/plan/types.ts +0 -8
  96. package/extensions/plan/web-client.ts +1 -1
  97. package/extensions/plan/web-page.ts +0 -7
  98. package/extensions/plan/web-router.ts +3 -57
  99. package/extensions/plan/web-share-session.ts +24 -30
  100. package/extensions/plan/web-streams.ts +14 -14
  101. package/extensions/plan/web-tokens.ts +2 -2
  102. package/extensions/plan/web.ts +66 -120
  103. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  104. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  105. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  106. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  107. package/extensions/rewind/index.ts +3 -3
  108. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  109. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  110. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  111. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  112. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  113. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  114. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  115. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  116. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  117. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  118. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  119. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  120. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  121. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  122. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  123. package/extensions/session-bus/commands.ts +17 -69
  124. package/extensions/session-bus/format.ts +22 -45
  125. package/extensions/session-bus/index.ts +13 -7
  126. package/extensions/session-bus/join-request.ts +40 -0
  127. package/extensions/session-bus/mail-watch.ts +134 -0
  128. package/extensions/session-bus/notice.ts +32 -4
  129. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  130. package/extensions/session-bus/protocol.ts +19 -49
  131. package/extensions/session-bus/resolve.ts +67 -0
  132. package/extensions/session-bus/result.ts +16 -51
  133. package/extensions/session-bus/rows.ts +4 -2
  134. package/extensions/session-bus/runtime.ts +129 -238
  135. package/extensions/session-bus/store.ts +42 -6
  136. package/extensions/session-bus/tools.ts +31 -66
  137. package/extensions/session-bus/tui.ts +4 -4
  138. package/extensions/session-bus/types.ts +13 -13
  139. package/extensions/subagent/agents.ts +9 -8
  140. package/extensions/subagent/invocation.ts +4 -2
  141. package/extensions/team/commands.ts +112 -0
  142. package/extensions/team/dispatch.ts +133 -0
  143. package/extensions/team/format.ts +105 -0
  144. package/extensions/team/index.ts +33 -0
  145. package/extensions/team/schema.ts +29 -0
  146. package/extensions/team/tools.ts +246 -0
  147. package/extensions/team/types.ts +48 -0
  148. package/extensions/web-access/fetch/page.ts +4 -1
  149. package/extensions/web-access/fetch/structured.ts +447 -0
  150. package/extensions/web-access/fetch/tool.ts +2 -1
  151. package/extensions/web-access/search/tool.ts +2 -1
  152. package/extensions/wechat/bridge.ts +76 -92
  153. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  154. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  155. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  156. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  157. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  158. package/extensions/wechat/dialog/channels.ts +136 -0
  159. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  160. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  161. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  162. package/extensions/wechat/index.ts +10 -10
  163. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  164. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  165. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  166. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  167. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  168. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  169. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  170. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  171. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  172. package/extensions/wechat/tools.ts +29 -12
  173. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  174. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  175. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  176. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  177. package/extensions/wechat/transport/outbound.ts +142 -0
  178. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  179. package/extensions/wechat/types.ts +10 -1
  180. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  181. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  182. package/extensions/worktree/guard.ts +3 -5
  183. package/lib/README.md +8 -6
  184. package/lib/board/card.ts +290 -0
  185. package/lib/board/links.ts +117 -0
  186. package/lib/board/page-pointer.ts +256 -0
  187. package/lib/board/round-text.ts +72 -0
  188. package/lib/board/round.ts +89 -0
  189. package/lib/board/rules.ts +304 -0
  190. package/lib/board/session.ts +105 -0
  191. package/lib/board/store.ts +310 -0
  192. package/lib/board/view.ts +150 -0
  193. package/lib/errors.ts +12 -4
  194. package/lib/format.ts +13 -0
  195. package/lib/http/guard.ts +112 -0
  196. package/lib/http/index.ts +16 -5
  197. package/lib/http/serve.ts +234 -0
  198. package/lib/http/server.ts +12 -10
  199. package/lib/http/sink.ts +110 -0
  200. package/lib/http/token.ts +38 -0
  201. package/lib/policy.ts +12 -2
  202. package/lib/requirements.ts +6 -7
  203. package/lib/session-label.ts +64 -0
  204. package/lib/session-roster.ts +130 -0
  205. package/lib/session-tool.ts +184 -0
  206. package/lib/tool-names.ts +25 -2
  207. package/lib/tui/glyphs.ts +2 -0
  208. package/lib/web/README.md +5 -2
  209. package/lib/web/components.ts +5 -0
  210. package/lib/web/runtime.ts +7 -1
  211. package/lib/web/tokens.ts +25 -2
  212. package/package.json +26 -14
  213. package/skills/next/SKILL.md +4 -3
  214. package/skills/project-docs/SKILL.md +133 -0
  215. package/skills/project-docs/references/audit.md +131 -0
  216. package/skills/project-docs/references/changelog.md +105 -0
  217. package/skills/project-docs/references/discovery.md +127 -0
  218. package/skills/project-docs/references/readme.md +80 -0
  219. package/skills/project-docs/references/release.md +84 -0
  220. package/skills/project-docs/references/roadmap.md +93 -0
  221. package/skills/project-docs/scripts/state.ts +316 -0
  222. package/skills/refactor/SKILL.md +100 -125
  223. package/skills/refactor/references/handoff.md +35 -0
  224. package/skills/refactor/references/signals.md +54 -0
  225. package/skills/refactor/scripts/inventory.ts +587 -0
  226. package/extensions/file-browse/README.md +0 -506
  227. package/extensions/file-browse/server.ts +0 -141
  228. package/extensions/plan/split.ts +0 -87
  229. package/extensions/session-bus/README.md +0 -247
  230. package/extensions/session-bus/team.ts +0 -102
  231. package/extensions/wechat/outbound.ts +0 -100
  232. package/lib/plans/sections.ts +0 -172
  233. package/lib/plans/setting.ts +0 -52
  234. package/lib/plans/split.ts +0 -215
  235. package/lib/plans/store.ts +0 -19
  236. package/lib/plans/team.ts +0 -151
  237. package/skills/changelog/SKILL.md +0 -148
  238. package/skills/parallel-dev/SKILL.md +0 -150
  239. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  240. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  241. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  242. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  243. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  244. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  245. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  246. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  247. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  248. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  249. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -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) {
@@ -8,12 +8,12 @@
8
8
  */
9
9
 
10
10
  import type { ImageContent } from "@earendil-works/pi-ai";
11
- import type { BotCredentials, InboundItem } from "./types.ts";
11
+ import type { BotCredentials, InboundItem } from "../types.ts";
12
12
  import type { WechatClient } from "./client.ts";
13
- import type { PeerItem } from "./peer-worker.ts";
14
- import { messageOf } from "./errors.ts";
13
+ import type { PeerItem } from "../peer/peer-worker.ts";
14
+ import { messageOf } from "../errors.ts";
15
15
  import { decodeMediaKey, decryptEcb, saveMedia, sniffImageMime } from "./media.ts";
16
- import { notice } from "./notices.ts";
16
+ import { notice } from "../text/notices.ts";
17
17
 
18
18
  /** What the inbound side needs from the bridge; every value is read at call time. */
19
19
  export interface InboundDeps {
@@ -84,7 +84,7 @@ export function createInboundAdmission(deps: InboundDeps): InboundAdmission {
84
84
  images.push({
85
85
  type: "image",
86
86
  data: Buffer.from(plain).toString("base64"),
87
- mimeType: sniffImageMime(plain),
87
+ mimeType: sniffImageMime(plain) ?? "image/jpeg",
88
88
  });
89
89
  }
90
90
  } catch (error) {
@@ -10,8 +10,8 @@
10
10
  import { createCipheriv, createDecipheriv } from "node:crypto";
11
11
  import { mkdirSync, writeFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
- import { stripControlChars } from "../../lib/format.ts";
14
- import type { CDNMedia } from "./types.ts";
13
+ import { stripControlChars } from "../../../lib/format.ts";
14
+ import type { CDNMedia } from "../types.ts";
15
15
 
16
16
  /** Decode a Weixin media AES key from 32 hex chars or base64 to 16 bytes. */
17
17
  export function decodeMediaKey(value: string): Buffer {
@@ -38,8 +38,14 @@ export function aesEcbPaddedSize(plaintextSize: number): number {
38
38
  return Math.ceil((plaintextSize + 1) / 16) * 16;
39
39
  }
40
40
 
41
- /** Sniff a common image MIME from magic bytes; defaults to `image/jpeg`. */
42
- export function sniffImageMime(bytes: Uint8Array): string {
41
+ /**
42
+ * The MIME a common image's magic bytes name, or `undefined` when they name none.
43
+ *
44
+ * `undefined` is what an outbound image send refuses on; the inbound attach path
45
+ * decides its own fallback, because a peer's photo is worth passing on even when
46
+ * the header is unfamiliar.
47
+ */
48
+ export function sniffImageMime(bytes: Uint8Array): string | undefined {
43
49
  if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
44
50
  return "image/png";
45
51
  }
@@ -60,7 +66,7 @@ export function sniffImageMime(bytes: Uint8Array): string {
60
66
  ) {
61
67
  return "image/webp";
62
68
  }
63
- return "image/jpeg";
69
+ return undefined;
64
70
  }
65
71
 
66
72
  /** Strip control characters and path separators, cap length, and never empty. */
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Outbound media: encrypt one local file and deliver it as a Weixin attachment.
3
+ *
4
+ * The flow mirrors the reference client: read the plaintext, compute its size and
5
+ * MD5, generate a 16-byte AES key and a file key, request the CDN upload target,
6
+ * upload the AES-128-ECB ciphertext, then send the item whose media reference
7
+ * carries the returned download parameter. The upload is one body; the item is a
8
+ * `type: 4` file or a `type: 2` image, and an image must sniff as one.
9
+ *
10
+ * Everything is injected, so it is tested with a fake client and a temp file.
11
+ */
12
+
13
+ import { createHash, randomBytes } from "node:crypto";
14
+ import { readFileSync } from "node:fs";
15
+ import { basename } from "node:path";
16
+ import { rejectionOf, type WechatClient } from "./client.ts";
17
+ import { aesEcbPaddedSize, encryptEcb, sanitizeFileName, sniffImageMime, uploadUrlOf } from "./media.ts";
18
+ import type { BotCredentials, CDNMedia } from "../types.ts";
19
+
20
+ /** `UploadMediaType.FILE` from the protocol. */
21
+ export const UPLOAD_MEDIA_TYPE_FILE = 3;
22
+
23
+ /** `UploadMediaType.IMAGE` from the protocol. */
24
+ export const UPLOAD_MEDIA_TYPE_IMAGE = 1;
25
+
26
+ export interface SendLocalFileArgs {
27
+ client: WechatClient;
28
+ account: BotCredentials;
29
+ cdnBaseUrl: string;
30
+ /** Files larger than this are refused. */
31
+ maxBytes: number;
32
+ to: string;
33
+ contextToken?: string;
34
+ filePath: string;
35
+ /** Display name; defaults to the file's base name, sanitized. */
36
+ fileName?: string;
37
+ signal?: AbortSignal;
38
+ /** Random bytes for the AES key and file key; defaults to `crypto.randomBytes`. */
39
+ random?: (size: number) => Buffer;
40
+ }
41
+
42
+ /** One encrypted upload, with what both item shapes need to describe it. */
43
+ interface Uploaded {
44
+ plain: Buffer;
45
+ /** Plaintext size in bytes, the `len` of a file item. */
46
+ rawsize: number;
47
+ /** Padded ciphertext size, the `mid_size` of an image item. */
48
+ filesize: number;
49
+ media: CDNMedia;
50
+ /** The AES key as 32 hex characters, which an image item names itself. */
51
+ aeskey: string;
52
+ }
53
+
54
+ /** Encrypt and upload one local file's bytes; `undefined` when it is empty or too big. */
55
+ async function uploadLocal(args: SendLocalFileArgs, mediaType: number, plain: Buffer): Promise<Uploaded | undefined> {
56
+ if (plain.length === 0 || plain.length > args.maxBytes) return undefined;
57
+
58
+ const random = args.random ?? randomBytes;
59
+ const aesKey = random(16);
60
+ const filekey = random(16).toString("hex");
61
+ const rawsize = plain.length;
62
+ const rawfilemd5 = createHash("md5").update(plain).digest("hex");
63
+ const filesize = aesEcbPaddedSize(rawsize);
64
+ const aeskey = aesKey.toString("hex");
65
+
66
+ const upload = await args.client.getUploadUrl(
67
+ args.account,
68
+ { filekey, mediaType, toUserId: args.to, rawsize, rawfilemd5, filesize, aeskey },
69
+ args.signal,
70
+ );
71
+
72
+ const downloadParam = await args.client.uploadCdn(
73
+ uploadUrlOf(upload, args.cdnBaseUrl, filekey),
74
+ encryptEcb(plain, aesKey),
75
+ args.signal,
76
+ );
77
+
78
+ // The reference encodes the hex key string as base64 (not the raw 16 bytes);
79
+ // the download decoder accepts either.
80
+ const media: CDNMedia = {
81
+ encrypt_query_param: downloadParam,
82
+ aes_key: Buffer.from(aeskey).toString("base64"),
83
+ encrypt_type: 1,
84
+ };
85
+ return { plain, rawsize, filesize, media, aeskey };
86
+ }
87
+
88
+ /**
89
+ * Upload and send a local file. Returns true when the message was accepted.
90
+ * Any failure (unreadable file, size cap, upload rejection) returns false rather
91
+ * than throwing, so the caller can fall back.
92
+ */
93
+ export async function sendLocalFile(args: SendLocalFileArgs): Promise<boolean> {
94
+ try {
95
+ const uploaded = await uploadLocal(args, UPLOAD_MEDIA_TYPE_FILE, readFileSync(args.filePath));
96
+ if (!uploaded) return false;
97
+ const response = await args.client.sendFileMessage(
98
+ args.account,
99
+ {
100
+ to: args.to,
101
+ fileName: sanitizeFileName(args.fileName ?? basename(args.filePath)),
102
+ len: String(uploaded.rawsize),
103
+ media: uploaded.media,
104
+ contextToken: args.contextToken,
105
+ },
106
+ args.signal,
107
+ );
108
+ return rejectionOf(response) === undefined;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Upload and send a local image as a picture, not a document.
116
+ *
117
+ * A file whose bytes sniff as no image is refused before the upload, so this path
118
+ * cannot become a general file sender. Everything else matches
119
+ * {@link sendLocalFile}: false on any failure, for the caller to fall back on.
120
+ */
121
+ export async function sendLocalImage(args: SendLocalFileArgs): Promise<boolean> {
122
+ try {
123
+ const plain = readFileSync(args.filePath);
124
+ if (sniffImageMime(plain) === undefined) return false;
125
+ const uploaded = await uploadLocal(args, UPLOAD_MEDIA_TYPE_IMAGE, plain);
126
+ if (!uploaded) return false;
127
+ const response = await args.client.sendImageMessage(
128
+ args.account,
129
+ {
130
+ to: args.to,
131
+ media: uploaded.media,
132
+ midSize: uploaded.filesize,
133
+ aeskey: uploaded.aeskey,
134
+ contextToken: args.contextToken,
135
+ },
136
+ args.signal,
137
+ );
138
+ return rejectionOf(response) === undefined;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
@@ -8,10 +8,10 @@
8
8
  * stops the loop.
9
9
  */
10
10
 
11
- import type { BotCredentials, WeixinMessage } from "./types.ts";
11
+ import type { BotCredentials, WeixinMessage } from "../types.ts";
12
12
  import { isSessionExpired, rejectionOf, type WechatClient } from "./client.ts";
13
- import { messageOf } from "./errors.ts";
14
- import { defaultTimer } from "./timer.ts";
13
+ import { messageOf } from "../errors.ts";
14
+ import { defaultTimer } from "../timer.ts";
15
15
 
16
16
  const BACKOFF_MS = 2000;
17
17
  /** Poll watchdog when the server has not told us its long-poll hold yet. */
@@ -6,7 +6,7 @@
6
6
  * the persisted credentials, cursor, and peer state.
7
7
  */
8
8
 
9
- import type { PeerLanguage } from "./format.ts";
9
+ import type { PeerLanguage } from "./text/format.ts";
10
10
 
11
11
  /** One content item inside a message (`type` 1 = text). */
12
12
  export interface MessageItem {
@@ -68,6 +68,15 @@ export interface WeixinMessage {
68
68
  message_id?: number;
69
69
  from_user_id?: string;
70
70
  to_user_id?: string;
71
+ /**
72
+ * A group message's room, declared by the protocol as "when applicable".
73
+ *
74
+ * Nothing routes on it: the bridge keys a conversation by sender. The poll
75
+ * probe prints it so a probe can tell whether rooms are delivered at all.
76
+ */
77
+ group_id?: string;
78
+ /** The conversation the message belongs to, when the protocol supplies one. */
79
+ session_id?: string;
71
80
  message_type?: number;
72
81
  message_state?: number;
73
82
  item_list?: MessageItem[];
@@ -8,10 +8,10 @@
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
10
  import { type Component, visibleWidth } from "@earendil-works/pi-tui";
11
- import { screenFooter, screenHint } from "../../lib/tui/chrome.ts";
12
- import { clip } from "../../lib/tui/columns.ts";
13
- import { isCloseKey } from "../../lib/tui/cursor.ts";
14
- import { titleRule } from "../../lib/tui/rules.ts";
11
+ import { screenFooter, screenHint } from "../../../lib/tui/chrome.ts";
12
+ import { clip } from "../../../lib/tui/columns.ts";
13
+ import { isCloseKey } from "../../../lib/tui/cursor.ts";
14
+ import { titleRule } from "../../../lib/tui/rules.ts";
15
15
 
16
16
  export interface LoginViewState {
17
17
  /** Raw `get_qrcode_status` value, or `wait` before the first poll. */
@@ -11,17 +11,17 @@
11
11
 
12
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
13
  import type { Component } from "@earendil-works/pi-tui";
14
- import { formatDuration, sanitizeLine } from "../../lib/format.ts";
15
- import { screenFooter, screenHint, type ScreenHint } from "../../lib/tui/chrome.ts";
16
- import { clip } from "../../lib/tui/columns.ts";
17
- import { formatRange, IdCursor, isCloseKey, navIntent } from "../../lib/tui/cursor.ts";
18
- import { SEPARATORS } from "../../lib/tui/glyphs.ts";
19
- import { keyValueRow, listRow } from "../../lib/tui/rows.ts";
20
- import { titleRule } from "../../lib/tui/rules.ts";
21
- import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
22
- import type { BridgeStatus } from "./bridge.ts";
23
- import { peerLabel } from "./format.ts";
24
- import type { BotCredentials } from "./types.ts";
14
+ import { formatDuration, sanitizeLine } from "../../../lib/format.ts";
15
+ import { screenFooter, screenHint, type ScreenHint } from "../../../lib/tui/chrome.ts";
16
+ import { clip } from "../../../lib/tui/columns.ts";
17
+ import { formatRange, IdCursor, isCloseKey, navIntent } from "../../../lib/tui/cursor.ts";
18
+ import { SEPARATORS } from "../../../lib/tui/glyphs.ts";
19
+ import { keyValueRow, listRow } from "../../../lib/tui/rows.ts";
20
+ import { titleRule } from "../../../lib/tui/rules.ts";
21
+ import { paint, type TuiRole } from "../../../lib/tui/tokens.ts";
22
+ import type { BridgeStatus } from "../bridge.ts";
23
+ import { peerLabel } from "../text/format.ts";
24
+ import type { BotCredentials } from "../types.ts";
25
25
 
26
26
  /** User-facing connection word; the internal `BridgeState` stays running/stopped. */
27
27
  export type StatusConnection = "open" | "expired" | "paused" | "closed";
@@ -48,8 +48,8 @@ export interface StatusView {
48
48
  refusedPeers: string[];
49
49
  mediaDropped: number;
50
50
  promptPending: boolean;
51
- /** The waiting prompt's peer, title, and start time. */
52
- prompt?: { peer: string; title: string; startedAt: number };
51
+ /** Every waiting prompt, one per peer that was asked something. */
52
+ prompts: Array<{ peer: string; title: string; startedAt: number }>;
53
53
  /** The clock the view was built with, for the prompt age. */
54
54
  now?: number;
55
55
  }
@@ -96,13 +96,11 @@ export function statusView(
96
96
  refusedPeers: status?.refusedPeers ?? [],
97
97
  mediaDropped: status?.mediaDropped ?? 0,
98
98
  promptPending: status?.promptPending ?? false,
99
- prompt: status?.prompt
100
- ? {
101
- peer: sanitizeLine(peerLabel(status.prompt.peer)),
102
- title: sanitizeLine(status.prompt.title),
103
- startedAt: status.prompt.startedAt,
104
- }
105
- : undefined,
99
+ prompts: (status?.prompts ?? []).map((prompt) => ({
100
+ peer: sanitizeLine(peerLabel(prompt.peer)),
101
+ title: sanitizeLine(prompt.title),
102
+ startedAt: prompt.startedAt,
103
+ })),
106
104
  now,
107
105
  };
108
106
  }
@@ -186,9 +184,12 @@ export function statusBody(view: StatusView, theme: Theme, width: number, select
186
184
  lines.push(row(theme, target, "Queued", String(view.queued), "meta"));
187
185
  if (view.refused > 0) lines.push(row(theme, target, "Refused", refusedValue(view), "meta"));
188
186
  if (view.mediaDropped > 0) lines.push(row(theme, target, "Media", `${view.mediaDropped} dropped`, "meta"));
189
- if (view.prompt) {
190
- const age = formatDuration(Math.max(0, (view.now ?? Date.now()) - view.prompt.startedAt));
191
- lines.push(row(theme, target, "Prompt", `${view.prompt.peer} · ${view.prompt.title} · ${age}`, "accent"));
187
+ if (view.prompts.length > 0) {
188
+ // One line per peer: with two turns running, two prompts can wait at once.
189
+ for (const prompt of view.prompts) {
190
+ const age = formatDuration(Math.max(0, (view.now ?? Date.now()) - prompt.startedAt));
191
+ lines.push(row(theme, target, "Prompt", `${prompt.peer} · ${prompt.title} · ${age}`, "accent"));
192
+ }
192
193
  } else if (view.promptPending) {
193
194
  lines.push(row(theme, target, "Prompt", "waiting for a reply", "accent"));
194
195
  }
@@ -257,7 +258,8 @@ export interface StatusActions {
257
258
  /** Start a fresh conversation for the peer; returns the line to flash. */
258
259
  reset(peer: string): string;
259
260
  /** Withdraw the waiting prompt; returns the line to flash. */
260
- cancelPrompt(): string;
261
+ /** Withdraw the focused peer's waiting prompt. */
262
+ cancelPrompt(peer: string): string;
261
263
  /** Pause or resume polling; async because it settles the poll loop first. */
262
264
  togglePause(): string | Promise<string>;
263
265
  }
@@ -360,11 +362,14 @@ export class StatusScreenComponent implements Component {
360
362
  return;
361
363
  }
362
364
  if (data === "c" || data === "C") {
363
- if (!view.prompt) {
365
+ // Cancel the focused peer's own prompt: with two turns running, the other
366
+ // peer's question is not this operator's to withdraw.
367
+ const waiting = peer ? view.prompts.find((entry) => entry.peer === peer.label) : undefined;
368
+ if (!peer || !waiting) {
364
369
  this.setFlash("No prompt waiting.");
365
370
  return;
366
371
  }
367
- this.setFlash(this.options.actions?.cancelPrompt() ?? "Prompt cancel is unavailable.");
372
+ this.setFlash(this.options.actions?.cancelPrompt(peer.id ?? peer.label) ?? "Prompt cancel is unavailable.");
368
373
  return;
369
374
  }
370
375
  if (data === "s" || data === "S" || data === "d" || data === "D" || data === "h" || data === "H") {
@@ -15,6 +15,7 @@
15
15
  import { homedir } from "node:os";
16
16
  import { isAbsolute, join, resolve } from "node:path";
17
17
  import { isInside, isInsideReal, realPathOfNearest } from "../../lib/path.ts";
18
+ import { WORKSPACE_READERS } from "../../lib/policy.ts";
18
19
  import { isShellEscapable } from "../../lib/shell.ts";
19
20
  import type { WorktreeConfig } from "./config.ts";
20
21
 
@@ -33,14 +34,11 @@ export function resolveUnder(root: string, path: string | undefined): string {
33
34
  // File tools (check 1)
34
35
  // ---------------------------------------------------------------------------
35
36
 
37
+ /** The file tools and the one argument that carries their path. */
36
38
  const PATH_FIELD: Record<string, string> = {
37
- read: "path",
38
- read_doc: "path",
39
+ ...Object.fromEntries(WORKSPACE_READERS.map((name): [string, string] => [name, "path"])),
39
40
  write: "path",
40
41
  edit: "path",
41
- grep: "path",
42
- find: "path",
43
- ls: "path",
44
42
  };
45
43
 
46
44
  const WRITE_TOOLS = new Set(["write", "edit"]);