@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
@@ -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") {
@@ -16,6 +16,7 @@
16
16
 
17
17
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
18
  import { messageOf } from "../../lib/errors.ts";
19
+ import { planModeRefusal } from "../../lib/plan-entry.ts";
19
20
  import { isPlanModeEnabled } from "../../lib/plan-state.ts";
20
21
  import { withRailsSuppressed } from "../../lib/rails.ts";
21
22
  import {
@@ -24,7 +25,7 @@ import {
24
25
  formatWorktreeStatus,
25
26
  isWorktreeCancelled,
26
27
  pruneWorktrees,
27
- worktreeLabel,
28
+ enteredWorktree,
28
29
  worktreeSnapshot,
29
30
  } from "./lifecycle.ts";
30
31
  import { type WorktreeAction, WorktreeListComponent } from "./screen.ts";
@@ -46,36 +47,42 @@ const USAGE = "Usage: /worktree [enter [name]|exit|prune]";
46
47
  /** Refuse a lifecycle change while plan mode is read-only, as `/check` does. */
47
48
  function refuseInPlanMode(ctx: ExtensionContext): boolean {
48
49
  if (!isPlanModeEnabled(ctx.sessionManager.getBranch())) return false;
49
- ctx.ui.notify("Plan mode is read-only. Approve the plan before changing worktrees.", "warning");
50
+ ctx.ui.notify(planModeRefusal("changing worktrees"), "warning");
50
51
  return true;
51
52
  }
52
53
 
53
- async function runEnter(pi: ExtensionAPI, ctx: ExtensionContext, name: string | undefined): Promise<void> {
54
- if (refuseInPlanMode(ctx)) return;
54
+ /**
55
+ * Run one lifecycle verb and report it the way a command does.
56
+ *
57
+ * Enter and exit differ in what they call and in the sentence a success prints, never in
58
+ * how a refusal is reported: a cancellation is the choice the user just made and reads as
59
+ * info, and everything else is a failure that names the verb that failed.
60
+ */
61
+ async function reportLifecycle(ctx: ExtensionContext, verb: string, run: () => Promise<string>): Promise<void> {
55
62
  try {
56
- const { state } = await enterWorktree(pi, ctx, { name });
57
- ctx.ui.notify(`Entered worktree ${worktreeLabel(state)}\n${state.path}`, "info");
63
+ ctx.ui.notify(await run(), "info");
58
64
  } catch (error) {
59
65
  if (isWorktreeCancelled(error)) {
60
66
  ctx.ui.notify(messageOf(error), "info");
61
67
  return;
62
68
  }
63
- ctx.ui.notify(`Enter failed: ${messageOf(error)}`, "error");
69
+ ctx.ui.notify(`${verb} failed: ${messageOf(error)}`, "error");
64
70
  }
65
71
  }
66
72
 
73
+ async function runEnter(pi: ExtensionAPI, ctx: ExtensionContext, name: string | undefined): Promise<void> {
74
+ if (refuseInPlanMode(ctx)) return;
75
+ // The path on its own line: a command's answer is read without a tool result beside it,
76
+ // and "which directory am I in now" is the question entering answers.
77
+ await reportLifecycle(ctx, "Enter", async () => {
78
+ const { state } = await enterWorktree(pi, ctx, { name });
79
+ return `${enteredWorktree(state)}\n${state.path}`;
80
+ });
81
+ }
82
+
67
83
  async function runExit(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
68
84
  if (refuseInPlanMode(ctx)) return;
69
- try {
70
- const result = await exitWorktree(pi, ctx, {});
71
- ctx.ui.notify(result.output, "info");
72
- } catch (error) {
73
- if (isWorktreeCancelled(error)) {
74
- ctx.ui.notify(messageOf(error), "info");
75
- return;
76
- }
77
- ctx.ui.notify(`Exit failed: ${messageOf(error)}`, "error");
78
- }
85
+ await reportLifecycle(ctx, "Exit", async () => (await exitWorktree(pi, ctx, {})).output);
79
86
  }
80
87
 
81
88
  async function runPrune(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
@@ -36,13 +36,17 @@ export interface ExitOptions {
36
36
  * must not fail because the default branch could not be read.
37
37
  */
38
38
  async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, recorded: number): Promise<Handoff> {
39
- if (!state.branch) return { branch: null, base: null, ahead: recorded, merged: false };
39
+ // `recorded` is the count `inspectWork` took against the commit the worktree was created
40
+ // at, read while the directory was still there; without that commit there is no fork
41
+ // point, and the count is unknown rather than zero.
42
+ const commits = state.baseCommit ? recorded : undefined;
43
+ if (!state.branch) return { branch: null, base: null, ahead: recorded, commits, merged: false };
40
44
  try {
41
45
  // `origin/HEAD` names the merge target when the repository has a remote; a
42
46
  // local-only repository has none, and the main checkout's own branch is the
43
47
  // branch a merge would actually land on.
44
48
  const base = (await defaultBranch(pi, state.repoRoot)) ?? (await currentBranch(pi, state.repoRoot)) ?? null;
45
- if (!base) return { branch: state.branch, base: null, ahead: recorded, merged: false };
49
+ if (!base) return { branch: state.branch, base: null, ahead: recorded, commits, merged: false };
46
50
  // Count against the resolved base branch, not the commit recorded at entry:
47
51
  // a worktree branch rebased onto a newer local branch would otherwise report
48
52
  // every commit the base gained since, as its own.
@@ -55,15 +59,17 @@ async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, recorded:
55
59
  : (await refExists(pi, root, `origin/${base}`))
56
60
  ? `origin/${base}`
57
61
  : null;
58
- if (!ref) return { branch: state.branch, base: null, ahead: recorded, merged: false };
62
+ if (!ref) return { branch: state.branch, base: null, ahead: recorded, commits, merged: false };
63
+ const ahead = await commitsAheadOf(pi, root, state.branch, ref);
59
64
  return {
60
65
  branch: state.branch,
61
66
  base,
62
- ahead: await commitsAheadOf(pi, root, state.branch, ref),
63
- merged: await isBranchMerged(pi, root, state.branch, ref),
67
+ ahead,
68
+ commits,
69
+ merged: ahead === 0 && commits !== 0 && (await isBranchMerged(pi, root, state.branch, ref)),
64
70
  };
65
71
  } catch {
66
- return { branch: state.branch, base: null, ahead: recorded, merged: false };
72
+ return { branch: state.branch, base: null, ahead: recorded, commits, merged: false };
67
73
  }
68
74
  }
69
75
 
@@ -171,7 +171,19 @@ export async function worktreeExists(pi: ExtensionAPI, dir: string): Promise<boo
171
171
  .some((line) => line.startsWith("worktree ") && realPathOfNearest(line.slice("worktree ".length).trim()) === want);
172
172
  }
173
173
 
174
- /** Whether a local branch exists. */
174
+ /**
175
+ * `rev-list --count` for one range, e.g. `main..HEAD`; 0 when git cannot answer.
176
+ *
177
+ * The three counts below are the same command with a different range, and a caller that
178
+ * reads a count as "no work here" must not have a failed read look like zero work.
179
+ */
180
+ async function revListCount(pi: ExtensionAPI, dir: string, range: string): Promise<number> {
181
+ const result = await git(pi, ["rev-list", "--count", range], dir);
182
+ if (result.code !== 0) return 0;
183
+ const count = Number.parseInt(result.stdout.trim(), 10);
184
+ return Number.isFinite(count) ? count : 0;
185
+ }
186
+
175
187
  /**
176
188
  * Count of commits on `branch` that `base` does not contain.
177
189
  *
@@ -179,10 +191,7 @@ export async function worktreeExists(pi: ExtensionAPI, dir: string): Promise<boo
179
191
  * after the worktree may already be gone while its branch refs outlive it.
180
192
  */
181
193
  export async function commitsAheadOf(pi: ExtensionAPI, dir: string, branch: string, base: string): Promise<number> {
182
- const result = await git(pi, ["rev-list", "--count", `${base}..${branch}`], dir);
183
- if (result.code !== 0) return 0;
184
- const count = Number.parseInt(result.stdout.trim(), 10);
185
- return Number.isFinite(count) ? count : 0;
194
+ return revListCount(pi, dir, `${base}..${branch}`);
186
195
  }
187
196
 
188
197
  /** Whether `ref` resolves here: a branch, a remote-tracking ref, or a tag. */
@@ -190,6 +199,7 @@ export async function refExists(pi: ExtensionAPI, dir: string, ref: string): Pro
190
199
  return (await revParse(createExecRunner(pi), dir, ref)) !== undefined;
191
200
  }
192
201
 
202
+ /** Whether a local branch exists. */
193
203
  export async function branchExists(pi: ExtensionAPI, repoRoot: string, branch: string): Promise<boolean> {
194
204
  const result = await git(pi, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot);
195
205
  return result.code === 0;
@@ -257,18 +267,12 @@ export async function statusEntries(pi: ExtensionAPI, dir: string): Promise<stri
257
267
 
258
268
  /** Count of commits reachable from HEAD but not from `base`. */
259
269
  export async function commitsAhead(pi: ExtensionAPI, dir: string, base: string): Promise<number> {
260
- const result = await git(pi, ["rev-list", "--count", `${base}..HEAD`], dir);
261
- if (result.code !== 0) return 0;
262
- const count = Number.parseInt(result.stdout.trim(), 10);
263
- return Number.isFinite(count) ? count : 0;
270
+ return revListCount(pi, dir, `${base}..HEAD`);
264
271
  }
265
272
 
266
273
  /** Count of commits reachable from `base` but not from HEAD. */
267
274
  export async function commitsBehind(pi: ExtensionAPI, dir: string, base: string): Promise<number> {
268
- const result = await git(pi, ["rev-list", "--count", `HEAD..${base}`], dir);
269
- if (result.code !== 0) return 0;
270
- const count = Number.parseInt(result.stdout.trim(), 10);
271
- return Number.isFinite(count) ? count : 0;
275
+ return revListCount(pi, dir, `HEAD..${base}`);
272
276
  }
273
277
 
274
278
  /** Whether `branch` is fully contained in `ref` (already merged). */
@@ -283,7 +287,11 @@ export async function mergeBase(pi: ExtensionAPI, dir: string, ref: string): Pro
283
287
  return result.code === 0 ? result.stdout.trim() || undefined : undefined;
284
288
  }
285
289
 
286
- export type CheckoutCheck = { ok: true } | { ok: false; reason: "gone" | "unsafe" | "unverified"; detail: string };
290
+ /** Why a checkout is unusable, as `checkCheckout` reports it. */
291
+ export type CheckoutRefusal = { ok: false; reason: "gone" | "unsafe" | "unverified"; detail: string };
292
+
293
+ /** The verdict on a recorded worktree: usable, or the refusal that says why not. */
294
+ export type CheckoutCheck = { ok: true } | CheckoutRefusal;
287
295
 
288
296
  /**
289
297
  * Verify a directory is a usable, separate checkout before restoring it.