@gavin-hu/my-pi-agent 0.14.0 → 0.15.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 (222) hide show
  1. package/README.md +55 -33
  2. package/README.zh-CN.md +44 -27
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +66 -32
  57. package/extensions/file-browse/client.ts +95 -32
  58. package/extensions/file-browse/config.ts +9 -9
  59. package/extensions/file-browse/git.ts +37 -47
  60. package/extensions/file-browse/html.ts +39 -58
  61. package/extensions/file-browse/markdown.ts +110 -92
  62. package/extensions/file-browse/raw-html.ts +478 -0
  63. package/extensions/file-browse/router.ts +32 -70
  64. package/extensions/file-browse/server.ts +18 -56
  65. package/extensions/file-browse/urls.ts +80 -0
  66. package/extensions/goal/README.md +103 -47
  67. package/extensions/goal/clarify-parse.ts +4 -9
  68. package/extensions/goal/clarify.ts +27 -4
  69. package/extensions/goal/commands.ts +24 -11
  70. package/extensions/goal/format.ts +41 -10
  71. package/extensions/goal/index.ts +39 -16
  72. package/extensions/goal/plan-route.ts +51 -0
  73. package/extensions/goal/schema.ts +6 -23
  74. package/extensions/goal/state.ts +39 -57
  75. package/extensions/goal/tools.ts +26 -20
  76. package/extensions/goal/types.ts +11 -1
  77. package/extensions/job/README.md +20 -6
  78. package/extensions/job/format.ts +18 -68
  79. package/extensions/job/index.ts +26 -41
  80. package/extensions/job/log-writer.ts +208 -0
  81. package/extensions/job/logs.ts +3 -2
  82. package/extensions/job/process.ts +6 -99
  83. package/extensions/job/runtime.ts +42 -181
  84. package/extensions/job/schema.ts +1 -4
  85. package/extensions/job/tools.ts +1 -15
  86. package/extensions/job/tui.ts +117 -307
  87. package/extensions/job/ui.ts +7 -33
  88. package/extensions/memory/README.md +1 -1
  89. package/extensions/memory/errors.ts +5 -6
  90. package/extensions/memory/tui.ts +14 -24
  91. package/extensions/plan/README.md +94 -18
  92. package/extensions/plan/commands.ts +9 -5
  93. package/extensions/plan/format.ts +87 -0
  94. package/extensions/plan/index.ts +31 -15
  95. package/extensions/plan/plans.ts +2 -3
  96. package/extensions/plan/policy.ts +0 -6
  97. package/extensions/plan/runtime.ts +4 -2
  98. package/extensions/plan/steps.ts +37 -8
  99. package/extensions/plan/todos.ts +44 -3
  100. package/extensions/plan/tools/enter.ts +94 -0
  101. package/extensions/plan/tools/exit.ts +296 -0
  102. package/extensions/plan/tools/nested.ts +21 -0
  103. package/extensions/plan/tools/steps.ts +136 -0
  104. package/extensions/plan/tools/todo-seed.ts +76 -0
  105. package/extensions/plan/tools/write.ts +127 -0
  106. package/extensions/plan/tools.ts +18 -703
  107. package/extensions/plan/web-client.ts +6 -22
  108. package/extensions/plan/web-page.ts +19 -30
  109. package/extensions/plan/web-router.ts +34 -71
  110. package/extensions/plan/web.ts +13 -40
  111. package/extensions/question/answers.ts +3 -8
  112. package/extensions/question/tui.ts +6 -6
  113. package/extensions/rewind/README.md +95 -22
  114. package/extensions/rewind/checklist.ts +185 -0
  115. package/extensions/rewind/commands.ts +8 -0
  116. package/extensions/rewind/config.ts +3 -11
  117. package/extensions/rewind/format.ts +220 -2
  118. package/extensions/rewind/git.ts +74 -7
  119. package/extensions/rewind/hunk-picker.ts +114 -0
  120. package/extensions/rewind/patch.ts +88 -0
  121. package/extensions/rewind/path-picker.ts +67 -100
  122. package/extensions/rewind/paths.ts +93 -19
  123. package/extensions/rewind/restore.ts +53 -10
  124. package/extensions/rewind/review-tui.ts +320 -0
  125. package/extensions/rewind/review.ts +397 -0
  126. package/extensions/rewind/rewind-tui.ts +4 -3
  127. package/extensions/rewind/runtime.ts +94 -17
  128. package/extensions/subagent/README.md +61 -6
  129. package/extensions/subagent/background-tui.ts +208 -0
  130. package/extensions/subagent/background.ts +470 -0
  131. package/extensions/subagent/commands.ts +80 -0
  132. package/extensions/subagent/config.ts +4 -0
  133. package/extensions/subagent/format.ts +24 -1
  134. package/extensions/subagent/index.ts +91 -2
  135. package/extensions/subagent/render.ts +13 -0
  136. package/extensions/subagent/run.ts +17 -16
  137. package/extensions/subagent/schema.ts +12 -0
  138. package/extensions/subagent/types.ts +11 -0
  139. package/extensions/subagent/ui.ts +50 -0
  140. package/extensions/todo/README.md +18 -13
  141. package/extensions/todo/format.ts +3 -2
  142. package/extensions/todo/runtime.ts +3 -2
  143. package/extensions/todo/schema.ts +8 -84
  144. package/extensions/todo/state.ts +5 -46
  145. package/extensions/todo/tools.ts +2 -1
  146. package/extensions/todo/types.ts +6 -15
  147. package/extensions/watch/README.md +14 -6
  148. package/extensions/watch/child.ts +150 -0
  149. package/extensions/watch/format.ts +8 -48
  150. package/extensions/watch/index.ts +42 -58
  151. package/extensions/watch/runtime.ts +49 -128
  152. package/extensions/watch/schema.ts +1 -4
  153. package/extensions/watch/tools.ts +1 -11
  154. package/extensions/watch/tui.ts +103 -273
  155. package/extensions/watch/ui.ts +5 -32
  156. package/extensions/web-access/README.md +1 -1
  157. package/extensions/web-access/fetch/page.ts +1 -1
  158. package/extensions/web-access/fetch/tool.ts +6 -2
  159. package/extensions/web-access/search/search.ts +2 -5
  160. package/extensions/wechat/README.md +1 -1
  161. package/extensions/wechat/client.ts +23 -0
  162. package/extensions/wechat/commands.ts +57 -42
  163. package/extensions/wechat/deliver.ts +5 -9
  164. package/extensions/wechat/errors.ts +3 -6
  165. package/extensions/wechat/inbound.ts +2 -5
  166. package/extensions/wechat/index.ts +2 -8
  167. package/extensions/wechat/outbound.ts +2 -4
  168. package/extensions/wechat/peer-worker.ts +3 -8
  169. package/extensions/wechat/poll.ts +10 -9
  170. package/extensions/wechat/presence.ts +35 -62
  171. package/extensions/wechat/reply-pipeline.ts +3 -1
  172. package/extensions/wechat/status-screen.ts +1 -1
  173. package/extensions/wechat/timer.ts +57 -0
  174. package/extensions/worktree/config.ts +8 -20
  175. package/extensions/worktree/enter.ts +7 -12
  176. package/extensions/worktree/git.ts +38 -69
  177. package/extensions/worktree/index.ts +5 -4
  178. package/extensions/worktree/registry.ts +7 -7
  179. package/extensions/worktree/root-tools.ts +2 -2
  180. package/extensions/worktree/status.ts +3 -2
  181. package/lib/README.md +22 -8
  182. package/lib/config.ts +50 -3
  183. package/lib/errors.ts +12 -0
  184. package/lib/format.ts +50 -0
  185. package/lib/git/read.ts +8 -0
  186. package/lib/goal-state.ts +23 -8
  187. package/lib/http/headers.ts +54 -0
  188. package/lib/http/index.ts +13 -0
  189. package/lib/http/response.ts +46 -0
  190. package/lib/http/router.ts +52 -0
  191. package/lib/http/server.ts +91 -0
  192. package/lib/lines.ts +77 -0
  193. package/lib/pdf.ts +82 -0
  194. package/lib/plan-entry.ts +71 -0
  195. package/lib/plan-state.ts +34 -0
  196. package/lib/report.ts +99 -0
  197. package/lib/requirements.ts +25 -19
  198. package/lib/spawn.ts +170 -0
  199. package/lib/todo-state.ts +110 -23
  200. package/lib/tool-names.ts +7 -0
  201. package/lib/tool-result.ts +33 -0
  202. package/lib/tui/README.md +68 -3
  203. package/lib/tui/chrome.ts +28 -4
  204. package/lib/tui/columns.ts +6 -1
  205. package/lib/tui/confirm.ts +65 -0
  206. package/lib/tui/dock-screen.ts +410 -0
  207. package/lib/tui/glyphs.ts +2 -1
  208. package/lib/tui/indent.ts +1 -1
  209. package/lib/tui/index.ts +2 -0
  210. package/lib/tui/rows.ts +21 -0
  211. package/lib/tui/status-controller.ts +35 -1
  212. package/lib/tui/tool.ts +59 -1
  213. package/lib/web/components.ts +20 -1
  214. package/lib/web/markup.ts +32 -0
  215. package/lib/web/runtime.ts +50 -0
  216. package/package.json +22 -5
  217. package/skills/init/SKILL.md +165 -0
  218. package/skills/init/references/agents-md-quality.md +103 -0
  219. package/skills/init/references/interview-questions.md +81 -0
  220. package/skills/refactor/SKILL.md +141 -0
  221. package/extensions/watch/process.ts +0 -63
  222. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -25,6 +25,7 @@ import { isAllowedPeer, type Bridge } from "./bridge.ts";
25
25
  import type { WechatClient } from "./client.ts";
26
26
  import type { WechatConfig } from "./config.ts";
27
27
  import { doctorReport } from "./doctor.ts";
28
+ import { messageOf } from "./errors.ts";
28
29
  import { peerLabel, peerKey } from "./format.ts";
29
30
  import { waitForLogin, type LoginOutcome } from "./login.ts";
30
31
  import { defaultSleep } from "./timer.ts";
@@ -91,7 +92,6 @@ export function isWechatVerb(value: string): value is WechatVerb {
91
92
  return (WECHAT_VERBS as readonly string[]).includes(value);
92
93
  }
93
94
 
94
- import { messageOf } from "./errors.ts";
95
95
  /**
96
96
  * Whether a peer turn is running anywhere on the bridge.
97
97
  *
@@ -126,6 +126,54 @@ async function runDoctor(ctx: ExtensionContext, deps: CommandDeps): Promise<void
126
126
  );
127
127
  }
128
128
 
129
+ /** One user-facing outcome: what to say, and how a non-screen caller says it. */
130
+ interface VerbOutcome {
131
+ message: string;
132
+ /** The notice kind the subcommand path posts; the control screen shows the text. */
133
+ kind: "info" | "warning";
134
+ }
135
+
136
+ /**
137
+ * The control verbs, in the one place both callers read them: the control
138
+ * screen's keys and the `/wechat <verb>` subcommands. A peer label carries text
139
+ * the peer chose, so it is sanitized here rather than at each call site.
140
+ */
141
+ function stopMessage(bridge: Bridge, peer: string): VerbOutcome {
142
+ const label = sanitizeLine(peerLabel(peer));
143
+ return bridge.stop(peer)
144
+ ? { message: `Stopping ${label}'s turn.`, kind: "info" }
145
+ : { message: `${label} has no running turn.`, kind: "info" };
146
+ }
147
+
148
+ function resetMessage(bridge: Bridge, peer: string): VerbOutcome {
149
+ const label = sanitizeLine(peerLabel(peer));
150
+ return bridge.reset(peer)
151
+ ? { message: `Started a fresh conversation for ${label}.`, kind: "info" }
152
+ : { message: `${label} is running; stop it before starting a new conversation.`, kind: "info" };
153
+ }
154
+
155
+ function cancelPromptMessage(bridge: Bridge): VerbOutcome {
156
+ if (!bridge.status().promptPending) return { message: "No prompt is waiting.", kind: "info" };
157
+ bridge.channel().cancel();
158
+ return { message: "Cancelled the waiting prompt.", kind: "info" };
159
+ }
160
+
161
+ /** Resume polling, unless it is not paused or the bridge will not start it. */
162
+ async function resumeMessage(bridge: Bridge): Promise<VerbOutcome> {
163
+ if (!bridge.status().paused) return { message: "WeChat is not paused.", kind: "info" };
164
+ return (await bridge.resume())
165
+ ? { message: "WeChat polling resumed.", kind: "info" }
166
+ : { message: "WeChat could not resume; check the terminal notice.", kind: "warning" };
167
+ }
168
+
169
+ /** Pause polling, unless it is already paused or the bridge is not running. */
170
+ async function pauseMessage(bridge: Bridge): Promise<VerbOutcome> {
171
+ if (bridge.status().paused) return { message: "WeChat is already paused.", kind: "warning" };
172
+ return (await bridge.pause())
173
+ ? { message: "WeChat polling paused. Running turns may finish; no new messages will arrive.", kind: "info" }
174
+ : { message: "WeChat is not running.", kind: "warning" };
175
+ }
176
+
129
177
  /** Pause or resume polling; the account and peer sessions stay in place. */
130
178
  async function runPauseToggle(ctx: ExtensionContext, deps: CommandDeps, resume: boolean): Promise<void> {
131
179
  const bridge = deps.getBridge();
@@ -133,27 +181,8 @@ async function runPauseToggle(ctx: ExtensionContext, deps: CommandDeps, resume:
133
181
  ctx.ui.notify("WeChat is not running.", "warning");
134
182
  return;
135
183
  }
136
- if (resume) {
137
- if (!bridge.status().paused) {
138
- ctx.ui.notify("WeChat is not paused.");
139
- return;
140
- }
141
- const ok = await bridge.resume();
142
- ctx.ui.notify(
143
- ok ? "WeChat polling resumed." : "WeChat could not resume; check the terminal notice.",
144
- ok ? "info" : "warning",
145
- );
146
- return;
147
- }
148
- const ok = await bridge.pause();
149
- ctx.ui.notify(
150
- ok
151
- ? "WeChat polling paused. Running turns may finish; no new messages will arrive."
152
- : bridge.status().paused
153
- ? "WeChat is already paused."
154
- : "WeChat is not running.",
155
- ok ? "info" : "warning",
156
- );
184
+ const outcome = await (resume ? resumeMessage(bridge) : pauseMessage(bridge));
185
+ ctx.ui.notify(outcome.message, outcome.kind);
157
186
  }
158
187
 
159
188
  /** Add a refused sender to the project config after the operator confirms. */
@@ -329,29 +358,20 @@ async function runPanel(ctx: ExtensionCommandContext, deps: CommandDeps): Promis
329
358
  actions: {
330
359
  stop: (peer) => {
331
360
  const bridge = deps.getBridge();
332
- if (!bridge) return "WeChat bridge is not running.";
333
- return bridge.stop(peer) ? `Stopping ${peerLabel(peer)}.` : `${peerLabel(peer)} has no running turn.`;
361
+ return bridge ? stopMessage(bridge, peer).message : "WeChat bridge is not running.";
334
362
  },
335
363
  reset: (peer) => {
336
364
  const bridge = deps.getBridge();
337
- if (!bridge) return "WeChat bridge is not running.";
338
- return bridge.reset(peer)
339
- ? `Started a fresh conversation for ${peerLabel(peer)}.`
340
- : `${peerLabel(peer)} is running; stop it before starting fresh.`;
365
+ return bridge ? resetMessage(bridge, peer).message : "WeChat bridge is not running.";
341
366
  },
342
367
  cancelPrompt: () => {
343
368
  const bridge = deps.getBridge();
344
- if (!bridge?.status().promptPending) return "No prompt is waiting.";
345
- bridge.channel().cancel();
346
- return "Cancelled the waiting prompt.";
369
+ return bridge ? cancelPromptMessage(bridge).message : "No prompt is waiting.";
347
370
  },
348
371
  togglePause: async () => {
349
372
  const bridge = deps.getBridge();
350
373
  if (!bridge) return "WeChat bridge is not running.";
351
- if (bridge.status().paused) {
352
- return (await bridge.resume()) ? "Resumed polling." : "Could not resume; see the notice.";
353
- }
354
- return (await bridge.pause()) ? "Paused polling. Running turns may finish." : "WeChat is not running.";
374
+ return (await (bridge.status().paused ? resumeMessage(bridge) : pauseMessage(bridge))).message;
355
375
  },
356
376
  },
357
377
  requestRender: () => tui.requestRender(),
@@ -418,16 +438,11 @@ async function runPeerAction(
418
438
  );
419
439
  return;
420
440
  }
421
- const label = peerLabel(entry.peer);
422
441
  if (verb === "stop") {
423
- ctx.ui.notify(bridge.stop(entry.peer) ? `Stopping ${label}'s turn.` : `${label} has no running turn.`);
442
+ ctx.ui.notify(stopMessage(bridge, entry.peer).message);
424
443
  return;
425
444
  }
426
- ctx.ui.notify(
427
- bridge.reset(entry.peer)
428
- ? `Started a fresh conversation for ${label}.`
429
- : `${label} is running; stop it before starting a new conversation.`,
430
- );
445
+ ctx.ui.notify(resetMessage(bridge, entry.peer).message);
431
446
  }
432
447
 
433
448
  /** Run one verb. The control screen reuses this so a key and a subcommand agree. */
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { BotCredentials } from "./types.ts";
12
- import { isSessionExpired, WechatError, type WechatClient } from "./client.ts";
12
+ import { isSessionExpired, rejectionOf, WechatError, type WechatClient } from "./client.ts";
13
13
  import { messageOf } from "./errors.ts";
14
14
  import { chunkText, toWechatText } from "./format.ts";
15
15
  import { sendLocalFile } from "./outbound.ts";
@@ -105,14 +105,10 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
105
105
  }
106
106
  // A body-level rejection is not a delivery: report it with the part count
107
107
  // instead of letting the answer vanish.
108
- if ((response.ret ?? 0) !== 0 || (response.errcode ?? 0) !== 0) {
109
- // Prefer the non-zero value: the body may carry `ret: 0` plus a real
110
- // `errcode`, and reporting `ret 0` hides the actual reason.
111
- const code = (response.ret ?? 0) !== 0 ? response.ret : (response.errcode ?? 0);
112
- // The server's own description is the only way to tell a bad token
113
- // from a rate limit, so it is part of the error.
114
- const detail = response.errmsg ? `: ${response.errmsg}` : "";
115
- onFailure(new WechatError(`Weixin rejected the message (ret ${code}${detail}).`), {
108
+ const rejection = rejectionOf(response);
109
+ if (rejection) {
110
+ const detail = rejection.detail ? `: ${rejection.detail}` : "";
111
+ onFailure(new WechatError(`Weixin rejected the message (ret ${rejection.code}${detail}).`), {
116
112
  sent: index,
117
113
  total: chunks.length,
118
114
  });
@@ -1,11 +1,8 @@
1
1
  /**
2
2
  * The one place an unknown thrown value becomes a user-facing line.
3
3
  *
4
- * Every terminal notice and tool error in the extension reports the same way, so
5
- * an `Error` yields its message and anything else is stringified.
4
+ * The definition is shared in `lib/errors.ts`; this module keeps the
5
+ * extension-local import path every WeChat notice and tool error uses.
6
6
  */
7
7
 
8
- /** A user-facing message for an unknown thrown value. */
9
- export function messageOf(error: unknown): string {
10
- return error instanceof Error ? error.message : String(error);
11
- }
8
+ export { messageOf } from "../../lib/errors.ts";
@@ -112,6 +112,8 @@ export function createInboundAdmission(deps: InboundDeps): InboundAdmission {
112
112
  if (item.media.length > 0) {
113
113
  const resolved = await resolveMedia(item);
114
114
  if (!deps.isRunning()) return;
115
+ // The bridge has already refused a message with no text and no media, so
116
+ // this is the other case: media arrived but nothing in it could be read.
115
117
  if (!text && resolved.images.length === 0 && resolved.files.length === 0) {
116
118
  if (resolved.dropped > 0) prefix = notice("media", item.language, resolved.dropped);
117
119
  await deps.reply(item, prefix ?? notice("unsupported", item.language));
@@ -125,11 +127,6 @@ export function createInboundAdmission(deps: InboundDeps): InboundAdmission {
125
127
  if (resolved.files.length > 0) text = [text, ...resolved.files].filter(Boolean).join("\n");
126
128
  images = resolved.images.length > 0 ? resolved.images : undefined;
127
129
  }
128
- if (!text && !images) {
129
- await deps.reply(item, notice("unsupported", item.language));
130
- deps.pruneTyping();
131
- return;
132
- }
133
130
  const queued: PeerItem = {
134
131
  peer: item.peer,
135
132
  text,
@@ -15,6 +15,7 @@
15
15
  import { getAgentDir, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
16
16
  import { join } from "node:path";
17
17
  import { isExtensionEnabled } from "../../lib/env.ts";
18
+ import { isPidAlive } from "../../lib/spawn.ts";
18
19
  import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
19
20
  import { paint } from "../../lib/tui/tokens.ts";
20
21
  import { createBridge, type Bridge, type BridgeState } from "./bridge.ts";
@@ -74,14 +75,7 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
74
75
  const lockDeps = () => ({
75
76
  now: () => Date.now(),
76
77
  pid: process.pid,
77
- isAlive: (pid: number) => {
78
- try {
79
- process.kill(pid, 0);
80
- return true;
81
- } catch {
82
- return false;
83
- }
84
- },
78
+ isAlive: isPidAlive,
85
79
  });
86
80
 
87
81
  const build = (ctx: ExtensionContext): Bridge => {
@@ -13,7 +13,7 @@
13
13
  import { createHash, randomBytes } from "node:crypto";
14
14
  import { readFileSync } from "node:fs";
15
15
  import { basename } from "node:path";
16
- import type { WechatClient } from "./client.ts";
16
+ import { rejectionOf, type WechatClient } from "./client.ts";
17
17
  import { aesEcbPaddedSize, encryptEcb, sanitizeFileName, uploadUrlOf } from "./media.ts";
18
18
  import type { BotCredentials, CDNMedia } from "./types.ts";
19
19
 
@@ -93,9 +93,7 @@ export async function sendLocalFile(args: SendLocalFileArgs): Promise<boolean> {
93
93
  args.signal,
94
94
  );
95
95
 
96
- const retBad = response.ret !== undefined && response.ret !== 0;
97
- const codeBad = response.errcode !== undefined && response.errcode !== 0;
98
- return !retBad && !codeBad;
96
+ return rejectionOf(response) === undefined;
99
97
  } catch {
100
98
  return false;
101
99
  }
@@ -27,6 +27,7 @@ import type { ImageContent } from "@earendil-works/pi-ai";
27
27
  import { messageOf } from "./errors.ts";
28
28
  import type { PeerSession } from "./peer-session.ts";
29
29
  import { createReplyPipeline } from "./reply-pipeline.ts";
30
+ import { defaultTimer, type ArmTimer } from "./timer.ts";
30
31
 
31
32
  /** One admitted, media-resolved inbound message. */
32
33
  export interface PeerItem {
@@ -66,7 +67,7 @@ export interface PeerWorkerDeps {
66
67
  partChars?: number;
67
68
  quietMs?: number;
68
69
  now?: () => number;
69
- armTimer?: (ms: number, fn: () => void) => () => void;
70
+ armTimer?: ArmTimer;
70
71
  }
71
72
 
72
73
  /** What a peer is doing right now, for the control screen. */
@@ -134,13 +135,7 @@ function mergeItems(first: PeerItem, next: PeerItem): PeerItem {
134
135
 
135
136
  export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
136
137
  const now = deps.now ?? Date.now;
137
- const armTimer =
138
- deps.armTimer ??
139
- ((ms, fn) => {
140
- const timer = setTimeout(fn, ms);
141
- (timer as { unref?: () => void }).unref?.();
142
- return () => clearTimeout(timer);
143
- });
138
+ const armTimer = deps.armTimer ?? defaultTimer;
144
139
  const idleTimeoutMs = deps.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
145
140
  const concurrency = Math.max(1, deps.concurrency ?? 1);
146
141
  const peers = new Map<string, PeerEntry>();
@@ -9,8 +9,9 @@
9
9
  */
10
10
 
11
11
  import type { BotCredentials, WeixinMessage } from "./types.ts";
12
- import { isSessionExpired, type WechatClient } from "./client.ts";
12
+ import { isSessionExpired, rejectionOf, type WechatClient } from "./client.ts";
13
13
  import { messageOf } from "./errors.ts";
14
+ import { defaultTimer } from "./timer.ts";
14
15
 
15
16
  const BACKOFF_MS = 2000;
16
17
  /** Poll watchdog when the server has not told us its long-poll hold yet. */
@@ -73,10 +74,10 @@ export function createPollLoop(deps: PollDeps): PollLoop {
73
74
  const watchdogMs = deps.pollTimeout(serverHint ?? deps.holdMs());
74
75
  // A cancellable watchdog: `AbortSignal.timeout` cannot be cleared, and a
75
76
  // pending timer per poll would keep the process awake long after the request
76
- // settled. Unref'd, so a wedged poll never holds a shutdown open either.
77
+ // settled. The shared seam unrefs it, so a wedged poll never holds a
78
+ // shutdown open either.
77
79
  const watchdog = new AbortController();
78
- const watchdogTimer = setTimeout(() => watchdog.abort(), watchdogMs);
79
- (watchdogTimer as { unref?: () => void }).unref?.();
80
+ const cancelWatchdog = defaultTimer(watchdogMs, () => watchdog.abort());
80
81
  let response;
81
82
  try {
82
83
  response = await client.getUpdates(account, deps.cursor(), AbortSignal.any([signal, watchdog.signal]), {
@@ -106,7 +107,7 @@ export function createPollLoop(deps: PollDeps): PollLoop {
106
107
  }
107
108
  continue;
108
109
  } finally {
109
- clearTimeout(watchdogTimer);
110
+ cancelWatchdog();
110
111
  }
111
112
  if (!response) break;
112
113
  if (response.timeoutMs !== undefined && response.timeoutMs > 0) serverHint = response.timeoutMs;
@@ -114,14 +115,14 @@ export function createPollLoop(deps: PollDeps): PollLoop {
114
115
  deps.onExpired();
115
116
  return;
116
117
  }
117
- if ((response.ret ?? 0) !== 0 || (response.errcode ?? 0) !== 0) {
118
+ const rejection = rejectionOf(response);
119
+ if (rejection) {
118
120
  // A body-level rejection (bad cursor, rate limit, revoked token) is not an
119
121
  // empty poll: report it once and back off instead of hammering the API.
120
122
  if (!rejectedNotified) {
121
123
  rejectedNotified = true;
122
- const code = (response.ret ?? 0) !== 0 ? response.ret : (response.errcode ?? 0);
123
- deps.onPoll?.({ at: deps.now(), error: `rejected ret ${code}` });
124
- deps.notify(`WeChat poll rejected (ret ${code}); retrying.`, "warning");
124
+ deps.onPoll?.({ at: deps.now(), error: `rejected ret ${rejection.code}` });
125
+ deps.notify(`WeChat poll rejected (ret ${rejection.code}); retrying.`, "warning");
125
126
  }
126
127
  try {
127
128
  await deps.sleep(BACKOFF_MS, signal);
@@ -14,7 +14,7 @@ import type { WechatClient } from "./client.ts";
14
14
  import type { PeerState } from "./peer-worker.ts";
15
15
  import type { PeerLanguage } from "./format.ts";
16
16
  import { notice } from "./notices.ts";
17
- import type { ArmTimer } from "./timer.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;
@@ -78,16 +78,16 @@ export interface Presence {
78
78
  export function createPresence(deps: PresenceDeps): Presence {
79
79
  const typingCache = new Map<string, { ticket?: string; checkedAt: number }>();
80
80
  /** Per-peer keep-alive cancellers for the typing indicator. */
81
- const typing = new Map<string, () => void>();
82
- /** Per-peer pending acknowledgement cancellers. */
83
- const acks = new Map<string, () => void>();
81
+ const typing = createTimerMap(deps.armTimer);
82
+ /** Per-peer pending acknowledgement timers. */
83
+ const acks = createTimerMap(deps.armTimer);
84
84
  /** When each peer was last acknowledged, so a chatty peer is not spammed. */
85
85
  const ackedAt = new Map<string, number>();
86
86
  /** Peers told that a turn is still running, until that turn ends. */
87
87
  const busyNotified = new Set<string>();
88
- /** Per-peer `busy` and `stillWorking` cancellers. */
89
- const busyTimers = new Map<string, () => void>();
90
- const slowTimers = new Map<string, () => void>();
88
+ /** Per-peer `busy` and `stillWorking` timers. */
89
+ const busyTimers = createTimerMap(deps.armTimer);
90
+ const slowTimers = createTimerMap(deps.armTimer);
91
91
 
92
92
  const hasWork = (peer: string): boolean => deps.statusOf(peer) !== "idle";
93
93
  const isRunning = (peer: string): boolean => deps.statusOf(peer) === "running";
@@ -142,10 +142,7 @@ export function createPresence(deps: PresenceDeps): Presence {
142
142
  }
143
143
 
144
144
  function stopTyping(peer: string): void {
145
- const cancel = typing.get(peer);
146
- if (!cancel) return;
147
- typing.delete(peer);
148
- cancel();
145
+ typing.stop(peer);
149
146
  }
150
147
 
151
148
  /**
@@ -160,61 +157,45 @@ export function createPresence(deps: PresenceDeps): Presence {
160
157
  const started = deps.now();
161
158
  const refresh = (): void => {
162
159
  if (!hasWork(peer) || deps.now() - started >= TYPING_LIMIT_MS) {
163
- typing.delete(peer);
160
+ typing.stop(peer);
164
161
  return;
165
162
  }
166
163
  void typingOn(peer, contextToken);
167
- typing.set(peer, deps.armTimer(TYPING_KEEPALIVE_MS, refresh));
164
+ typing.arm(peer, TYPING_KEEPALIVE_MS, refresh);
168
165
  };
169
- typing.set(peer, deps.armTimer(TYPING_KEEPALIVE_MS, refresh));
166
+ typing.arm(peer, TYPING_KEEPALIVE_MS, refresh);
170
167
  }
171
168
 
172
169
  function stopSlowNotice(peer: string): void {
173
- const cancel = slowTimers.get(peer);
174
- if (cancel) cancel();
175
- slowTimers.delete(peer);
170
+ slowTimers.stop(peer);
176
171
  }
177
172
 
178
173
  function stopBusyNotice(peer: string): void {
179
- const cancel = busyTimers.get(peer);
180
- if (cancel) cancel();
181
- busyTimers.delete(peer);
174
+ busyTimers.stop(peer);
182
175
  busyNotified.delete(peer);
183
176
  }
184
177
 
185
178
  function armSlowNotice(peer: string, contextToken: string | undefined): void {
186
- stopSlowNotice(peer);
187
- slowTimers.set(
188
- peer,
189
- deps.armTimer(TURN_NOTICE_MS, () => {
190
- slowTimers.delete(peer);
191
- if (!isRunning(peer)) return;
192
- // A turn waiting on an unanswered dialog is not "still working": the
193
- // prompt itself nudges, and saying both reads as noise.
194
- if (deps.promptPending()) return;
195
- void deps.send(peer, contextToken, notice("stillWorking", deps.languageOf(peer)));
196
- }),
197
- );
179
+ slowTimers.arm(peer, TURN_NOTICE_MS, () => {
180
+ if (!isRunning(peer)) return;
181
+ // A turn waiting on an unanswered dialog is not "still working": the
182
+ // prompt itself nudges, and saying both reads as noise.
183
+ if (deps.promptPending()) return;
184
+ void deps.send(peer, contextToken, notice("stillWorking", deps.languageOf(peer)));
185
+ });
198
186
  }
199
187
 
200
188
  function armBusyNotice(peer: string, contextToken: string | undefined): void {
201
189
  if (busyNotified.has(peer) || busyTimers.has(peer)) return;
202
- busyTimers.set(
203
- peer,
204
- deps.armTimer(BUSY_NOTICE_MS, () => {
205
- busyTimers.delete(peer);
206
- if (!hasWork(peer) || busyNotified.has(peer)) return;
207
- busyNotified.add(peer);
208
- void deps.send(peer, contextToken, notice("busy", deps.languageOf(peer)));
209
- }),
210
- );
190
+ busyTimers.arm(peer, BUSY_NOTICE_MS, () => {
191
+ if (!hasWork(peer) || busyNotified.has(peer)) return;
192
+ busyNotified.add(peer);
193
+ void deps.send(peer, contextToken, notice("busy", deps.languageOf(peer)));
194
+ });
211
195
  }
212
196
 
213
197
  function cancelAck(peer: string): void {
214
- const cancel = acks.get(peer);
215
- if (!cancel) return;
216
- acks.delete(peer);
217
- cancel();
198
+ acks.stop(peer);
218
199
  }
219
200
 
220
201
  /**
@@ -229,25 +210,21 @@ export function createPresence(deps: PresenceDeps): Presence {
229
210
  if (acks.has(target.peer)) return;
230
211
  const last = ackedAt.get(target.peer);
231
212
  if (last !== undefined && deps.now() - last < ACK_REPEAT_MS) return;
232
- acks.set(
233
- target.peer,
234
- deps.armTimer(ACK_DELAY_MS, () => {
235
- acks.delete(target.peer);
236
- if (!hasWork(target.peer) || isRunning(target.peer)) return;
237
- ackedAt.set(target.peer, deps.now());
238
- void deps.send(target.peer, target.contextToken, notice("ack", deps.languageOf(target.peer)));
239
- // Still waiting after a minute behind another turn: say why.
240
- armBusyNotice(target.peer, target.contextToken);
241
- }),
242
- );
213
+ acks.arm(target.peer, ACK_DELAY_MS, () => {
214
+ if (!hasWork(target.peer) || isRunning(target.peer)) return;
215
+ ackedAt.set(target.peer, deps.now());
216
+ void deps.send(target.peer, target.contextToken, notice("ack", deps.languageOf(target.peer)));
217
+ // Still waiting after a minute behind another turn: say why.
218
+ armBusyNotice(target.peer, target.contextToken);
219
+ });
243
220
  }
244
221
 
245
222
  return {
246
223
  keepTyping,
247
224
  typingOff,
248
225
  pruneTyping() {
249
- for (const peer of [...typing.keys()]) {
250
- if (!hasWork(peer)) stopTyping(peer);
226
+ for (const peer of typing.keys()) {
227
+ if (!hasWork(peer)) typing.stop(peer);
251
228
  }
252
229
  },
253
230
  armAck,
@@ -258,13 +235,9 @@ export function createPresence(deps: PresenceDeps): Presence {
258
235
  stopBusyNotice,
259
236
  stopTyping,
260
237
  reset() {
261
- for (const cancel of typing.values()) cancel();
262
238
  typing.clear();
263
- for (const cancel of acks.values()) cancel();
264
239
  acks.clear();
265
- for (const cancel of busyTimers.values()) cancel();
266
240
  busyTimers.clear();
267
- for (const cancel of slowTimers.values()) cancel();
268
241
  slowTimers.clear();
269
242
  typingCache.clear();
270
243
  ackedAt.clear();
@@ -11,11 +11,13 @@
11
11
  * drive it.
12
12
  */
13
13
 
14
+ import type { ArmTimer } from "./timer.ts";
15
+
14
16
  export interface ReplyPipelineDeps {
15
17
  /** Send one part to the peer. Parts arrive strictly in order. */
16
18
  sendPart: (text: string) => Promise<void>;
17
19
  /** Arm a one-shot timer; the returned function cancels it. */
18
- armTimer: (ms: number, fn: () => void) => () => void;
20
+ armTimer: ArmTimer;
19
21
  /** Flush a part that reaches this many characters. Default 400. */
20
22
  partChars?: number;
21
23
  /** Flush a part that has been quiet this long. Default 2000 ms. */
@@ -300,7 +300,7 @@ export class StatusScreenComponent implements Component {
300
300
  private hints(view: StatusView): ScreenHint[] {
301
301
  const hints: ScreenHint[] = ["Esc close"];
302
302
  if (view.peerRows.length > 0) {
303
- hints.push("j/k move", { text: "s stop", color: "accent" }, { text: "d reset", color: "warning" }, "h history");
303
+ hints.push("j/k move", { text: "s stop", color: "warning" }, { text: "d reset", color: "warning" }, "h history");
304
304
  }
305
305
  hints.push(
306
306
  { text: "l login", color: "accent" },
@@ -18,6 +18,63 @@ export const defaultTimer: ArmTimer = (ms, fn) => {
18
18
  return () => clearTimeout(timer);
19
19
  };
20
20
 
21
+ /**
22
+ * A set of one-shot timers keyed by a caller's id (a peer, a watcher).
23
+ *
24
+ * Every per-key timer in this extension follows the same protocol: cancel any
25
+ * pending entry, delete it, then act — and re-arming must replace, or a refresh
26
+ * loop would stack timers. Both rules live here so they are one implementation.
27
+ */
28
+ export interface TimerMap {
29
+ /** Arm a timer for `key`, replacing a pending one. */
30
+ arm(key: string, ms: number, fn: () => void): void;
31
+ /** Whether a timer is pending for `key`. */
32
+ has(key: string): boolean;
33
+ /** Cancel and forget `key`'s timer. */
34
+ stop(key: string): void;
35
+ /** The keys with a pending timer. */
36
+ keys(): string[];
37
+ /** Cancel every pending timer and forget them. */
38
+ clear(): void;
39
+ }
40
+
41
+ /** A {@link TimerMap} over the injected seam; the entries live in this closure. */
42
+ export function createTimerMap(arm: ArmTimer): TimerMap {
43
+ const pending = new Map<string, () => void>();
44
+ const map: TimerMap = {
45
+ arm(key, ms, fn) {
46
+ map.stop(key);
47
+ // The entry is gone before the callback runs, so a callback that re-arms
48
+ // itself (a keep-alive) or asks `has(key)` sees the truth rather than itself.
49
+ pending.set(
50
+ key,
51
+ arm(ms, () => {
52
+ pending.delete(key);
53
+ fn();
54
+ }),
55
+ );
56
+ },
57
+ has(key) {
58
+ return pending.has(key);
59
+ },
60
+ stop(key) {
61
+ const cancel = pending.get(key);
62
+ if (cancel === undefined) return;
63
+ pending.delete(key);
64
+ cancel();
65
+ },
66
+ keys() {
67
+ return [...pending.keys()];
68
+ },
69
+ clear() {
70
+ // A copy: a canceller is free to touch the map it came from.
71
+ for (const cancel of [...pending.values()]) cancel();
72
+ pending.clear();
73
+ },
74
+ };
75
+ return map;
76
+ }
77
+
21
78
  /** An abortable sleep: resolves after `ms`, rejects when `signal` aborts. */
22
79
  export function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
23
80
  return new Promise((resolve, reject) => {