@gavin-hu/my-pi-agent 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/README.md +61 -45
  2. package/README.zh-CN.md +54 -42
  3. package/docs/extensions/README.md +34 -0
  4. package/docs/extensions/board.md +344 -0
  5. package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +465 -416
  6. package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
  7. package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
  8. package/docs/extensions/file-browse.md +554 -0
  9. package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
  10. package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
  11. package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
  12. package/{extensions/job/README.md → docs/extensions/job.md} +66 -53
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +74 -63
  14. package/docs/extensions/plan.md +976 -0
  15. package/{extensions/question/README.md → docs/extensions/question.md} +54 -28
  16. package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
  17. package/docs/extensions/session-bus.md +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +166 -119
  19. package/docs/extensions/team.md +226 -0
  20. package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
  21. package/{extensions/watch/README.md → docs/extensions/watch.md} +60 -38
  22. package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
  23. package/{extensions/wechat/README.md → docs/extensions/wechat.md} +275 -192
  24. package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
  25. package/extensions/board/commands.ts +87 -0
  26. package/extensions/board/config.ts +36 -0
  27. package/extensions/board/format.ts +72 -0
  28. package/extensions/board/index.ts +40 -0
  29. package/extensions/board/input.ts +54 -0
  30. package/extensions/board/join.ts +42 -0
  31. package/extensions/board/page.ts +312 -0
  32. package/extensions/board/result.ts +99 -0
  33. package/extensions/board/rows.ts +48 -0
  34. package/extensions/board/schema.ts +75 -0
  35. package/extensions/board/seat.ts +29 -0
  36. package/extensions/board/snapshot.ts +41 -0
  37. package/extensions/board/tools.ts +279 -0
  38. package/extensions/board/transport/router.ts +172 -0
  39. package/extensions/board/transport/server.ts +149 -0
  40. package/extensions/board/types.ts +54 -0
  41. package/extensions/board/view/actions.ts +306 -0
  42. package/extensions/board/view/client.ts +41 -0
  43. package/extensions/board/view/keys.ts +121 -0
  44. package/extensions/board/view/live.ts +397 -0
  45. package/extensions/board/view/page.ts +158 -0
  46. package/extensions/board/view/render.ts +496 -0
  47. package/extensions/board/view/styles.ts +211 -0
  48. package/extensions/board/view/team.ts +305 -0
  49. package/extensions/board/writes.ts +99 -0
  50. package/extensions/bytefly/notify/types.ts +1 -1
  51. package/extensions/bytefly/panel/commands.ts +40 -7
  52. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  53. package/extensions/bytefly/panel/settings.ts +11 -21
  54. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  55. package/extensions/bytefly/picker/discovery.ts +0 -2
  56. package/extensions/bytefly/picker/types.ts +0 -2
  57. package/extensions/bytefly/update/check.ts +39 -0
  58. package/extensions/bytefly/update/format.ts +15 -0
  59. package/extensions/bytefly/update/register.ts +73 -34
  60. package/extensions/bytefly/update/state.ts +6 -3
  61. package/extensions/context/tui.ts +1 -1
  62. package/extensions/file-browse/{config.ts → config/config.ts} +13 -4
  63. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  64. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  65. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  66. package/extensions/file-browse/index.ts +4 -4
  67. package/extensions/file-browse/{live.ts → live/live.ts} +54 -82
  68. package/extensions/file-browse/live/rev.ts +42 -0
  69. package/extensions/file-browse/{router.ts → transport/router.ts} +44 -17
  70. package/extensions/file-browse/transport/server.ts +110 -0
  71. package/extensions/file-browse/view/chrome.ts +83 -0
  72. package/extensions/file-browse/{client.ts → view/client.ts} +63 -1
  73. package/extensions/file-browse/view/diff-html.ts +94 -0
  74. package/extensions/file-browse/{html.ts → view/html.ts} +32 -158
  75. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  76. package/extensions/file-browse/{live-client.ts → view/live-client.ts} +47 -1
  77. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  78. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  79. package/extensions/file-browse/{styles.ts → view/styles.ts} +31 -5
  80. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  81. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  82. package/extensions/file-read/schema.ts +2 -1
  83. package/extensions/goal/plan-route.ts +8 -2
  84. package/extensions/job/index.ts +15 -7
  85. package/extensions/memory/store.ts +12 -5
  86. package/extensions/plan/blocks.ts +23 -2
  87. package/extensions/plan/commands.ts +77 -21
  88. package/extensions/plan/feedback.ts +409 -63
  89. package/extensions/plan/index.ts +7 -5
  90. package/extensions/plan/plan-index.ts +44 -10
  91. package/extensions/plan/plan-text.ts +51 -24
  92. package/extensions/plan/plans.ts +162 -57
  93. package/extensions/plan/policy.ts +20 -12
  94. package/extensions/plan/review-prompt.ts +93 -0
  95. package/extensions/plan/review.ts +51 -95
  96. package/extensions/plan/runtime.ts +22 -10
  97. package/extensions/plan/sections.ts +112 -0
  98. package/extensions/plan/steps.ts +63 -95
  99. package/extensions/plan/tools/enter.ts +8 -3
  100. package/extensions/plan/tools/exit.ts +13 -19
  101. package/extensions/plan/tools/write.ts +8 -3
  102. package/extensions/plan/tools.ts +1 -1
  103. package/extensions/plan/tui.ts +29 -15
  104. package/extensions/plan/types.ts +29 -3
  105. package/extensions/plan/web-chrome.ts +2 -1
  106. package/extensions/plan/web-client.ts +122 -178
  107. package/extensions/plan/web-collab.ts +207 -0
  108. package/extensions/plan/web-comments.ts +269 -0
  109. package/extensions/plan/web-page.ts +96 -17
  110. package/extensions/plan/web-router.ts +109 -47
  111. package/extensions/plan/web-share-session.ts +191 -0
  112. package/extensions/plan/web-share.ts +169 -0
  113. package/extensions/plan/web-streams.ts +124 -0
  114. package/extensions/plan/web-tokens.ts +63 -0
  115. package/extensions/plan/web.ts +245 -151
  116. package/extensions/question/dialogs.ts +91 -1
  117. package/extensions/question/tools.ts +2 -1
  118. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  119. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  120. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  121. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  122. package/extensions/rewind/index.ts +3 -3
  123. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  124. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  125. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  126. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  127. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  128. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  129. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  130. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  131. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  132. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  133. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  134. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  135. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  136. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  137. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  138. package/extensions/session-bus/commands.ts +113 -0
  139. package/extensions/session-bus/format.ts +101 -0
  140. package/extensions/session-bus/index.ts +75 -0
  141. package/extensions/session-bus/join-request.ts +40 -0
  142. package/extensions/session-bus/mail-watch.ts +134 -0
  143. package/extensions/session-bus/notice.ts +86 -0
  144. package/extensions/session-bus/overlap.ts +36 -0
  145. package/extensions/session-bus/protocol.ts +104 -0
  146. package/extensions/session-bus/resolve.ts +67 -0
  147. package/extensions/session-bus/result.ts +208 -0
  148. package/extensions/session-bus/rows.ts +171 -0
  149. package/extensions/session-bus/runtime.ts +466 -0
  150. package/extensions/session-bus/store.ts +365 -0
  151. package/extensions/session-bus/tools.ts +192 -0
  152. package/extensions/session-bus/tui.ts +394 -0
  153. package/extensions/session-bus/types.ts +98 -0
  154. package/extensions/subagent/agents.ts +9 -8
  155. package/extensions/subagent/invocation.ts +4 -2
  156. package/extensions/team/commands.ts +112 -0
  157. package/extensions/team/dispatch.ts +133 -0
  158. package/extensions/team/format.ts +105 -0
  159. package/extensions/team/index.ts +33 -0
  160. package/extensions/team/schema.ts +29 -0
  161. package/extensions/team/tools.ts +246 -0
  162. package/extensions/team/types.ts +48 -0
  163. package/extensions/watch/index.ts +15 -9
  164. package/extensions/web-access/fetch/page.ts +4 -1
  165. package/extensions/web-access/fetch/structured.ts +447 -0
  166. package/extensions/web-access/fetch/tool.ts +2 -1
  167. package/extensions/web-access/search/tool.ts +2 -1
  168. package/extensions/wechat/bridge.ts +76 -92
  169. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  170. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  171. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  172. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  173. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  174. package/extensions/wechat/dialog/channels.ts +136 -0
  175. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +66 -51
  176. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +40 -25
  177. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  178. package/extensions/wechat/index.ts +10 -10
  179. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  180. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  181. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +119 -56
  182. package/extensions/wechat/{presence.ts → peer/presence.ts} +11 -11
  183. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +13 -5
  184. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  185. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  186. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  187. package/extensions/wechat/{notices.ts → text/notices.ts} +15 -7
  188. package/extensions/wechat/tools.ts +29 -12
  189. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  190. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +104 -40
  191. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  192. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  193. package/extensions/wechat/transport/outbound.ts +142 -0
  194. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  195. package/extensions/wechat/types.ts +10 -1
  196. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  197. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  198. package/extensions/worktree/git.ts +5 -0
  199. package/extensions/worktree/guard.ts +3 -5
  200. package/extensions/worktree/registry.ts +11 -5
  201. package/extensions/worktree/status.ts +1 -3
  202. package/lib/README.md +12 -6
  203. package/lib/board/card.ts +290 -0
  204. package/lib/board/links.ts +117 -0
  205. package/lib/board/page-pointer.ts +256 -0
  206. package/lib/board/round-text.ts +72 -0
  207. package/lib/board/round.ts +89 -0
  208. package/lib/board/rules.ts +304 -0
  209. package/lib/board/session.ts +105 -0
  210. package/lib/board/store.ts +310 -0
  211. package/lib/board/view.ts +150 -0
  212. package/lib/errors.ts +12 -4
  213. package/lib/format.ts +13 -0
  214. package/lib/http/guard.ts +112 -0
  215. package/lib/http/index.ts +16 -5
  216. package/lib/http/router.ts +40 -13
  217. package/lib/http/serve.ts +234 -0
  218. package/lib/http/server.ts +29 -13
  219. package/lib/http/sink.ts +110 -0
  220. package/lib/http/token.ts +38 -0
  221. package/lib/interaction.ts +10 -0
  222. package/lib/notice.ts +29 -2
  223. package/lib/plan-entry.ts +7 -2
  224. package/lib/policy.ts +12 -2
  225. package/lib/qr.ts +64 -0
  226. package/lib/repo-key.ts +74 -0
  227. package/lib/report.ts +52 -33
  228. package/lib/requirements.ts +7 -9
  229. package/lib/session-label.ts +64 -0
  230. package/lib/session-roster.ts +130 -0
  231. package/lib/session-tool.ts +184 -0
  232. package/lib/tool-names.ts +25 -2
  233. package/lib/tui/README.md +16 -2
  234. package/lib/tui/dock-screen.ts +85 -17
  235. package/lib/tui/glyphs.ts +2 -0
  236. package/lib/tui/line-pane.ts +15 -4
  237. package/lib/tui/tool.ts +23 -4
  238. package/lib/web/README.md +5 -2
  239. package/lib/web/components.ts +6 -1
  240. package/lib/web/runtime.ts +7 -1
  241. package/lib/web/tokens.ts +25 -2
  242. package/package.json +27 -14
  243. package/skills/init/SKILL.md +80 -45
  244. package/skills/init/references/agents-md-quality.md +26 -2
  245. package/skills/init/references/interview-questions.md +36 -14
  246. package/skills/next/SKILL.md +77 -51
  247. package/skills/project-docs/SKILL.md +133 -0
  248. package/skills/project-docs/references/audit.md +131 -0
  249. package/skills/project-docs/references/changelog.md +105 -0
  250. package/skills/project-docs/references/discovery.md +127 -0
  251. package/skills/project-docs/references/readme.md +80 -0
  252. package/skills/project-docs/references/release.md +84 -0
  253. package/skills/project-docs/references/roadmap.md +93 -0
  254. package/skills/project-docs/scripts/state.ts +316 -0
  255. package/skills/refactor/SKILL.md +100 -125
  256. package/skills/refactor/references/handoff.md +35 -0
  257. package/skills/refactor/references/signals.md +54 -0
  258. package/skills/refactor/scripts/inventory.ts +587 -0
  259. package/extensions/file-browse/README.md +0 -467
  260. package/extensions/file-browse/server.ts +0 -134
  261. package/extensions/plan/README.md +0 -709
  262. package/extensions/plan/config.ts +0 -27
  263. package/extensions/wechat/outbound.ts +0 -100
  264. package/extensions/wechat/qrcode.ts +0 -46
  265. package/skills/changelog/SKILL.md +0 -148
  266. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  267. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  268. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  269. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  270. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  271. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  272. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  273. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  274. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
package/lib/qr.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A QR code as terminal lines or an inline SVG.
3
+ *
4
+ * Uses the `qrcode` package only for the module matrix, then draws Unicode
5
+ * half-blocks for a terminal and hands the package's SVG to a page. The package
6
+ * loads on the first QR, not when an extension registers: a session that never
7
+ * shows one pays nothing for it. No ANSI is emitted; the caller decides colours,
8
+ * and the width is the matrix width plus a quiet zone.
9
+ *
10
+ * Two surfaces share this: `wechat` shows a login QR, and `plan` shows a review
11
+ * link a teammate can scan.
12
+ */
13
+
14
+ interface QrModules {
15
+ size: number;
16
+ get(row: number, col: number): boolean;
17
+ }
18
+
19
+ /** QR error-correction level, one setting for both surfaces. */
20
+ const ERROR_CORRECTION = "M" as const;
21
+
22
+ /** Build the module matrix for `text`, loading `qrcode` on first use. */
23
+ export async function qrMatrix(text: string): Promise<QrModules> {
24
+ const { default: QRCode } = await import("qrcode");
25
+ const qr = QRCode.create(text, { errorCorrectionLevel: ERROR_CORRECTION });
26
+ return qr.modules as unknown as QrModules;
27
+ }
28
+
29
+ /**
30
+ * The QR as terminal lines. Each string is exactly `matrix.size + 2 * quiet`
31
+ * columns wide (half-block characters are single width), so a caller can test
32
+ * `qrLines(text)[0].length` against the terminal width.
33
+ */
34
+ export async function qrLines(text: string, options: { quiet?: number } = {}): Promise<string[]> {
35
+ const quiet = Math.max(0, options.quiet ?? 2);
36
+ const matrix = await qrMatrix(text);
37
+ const size = matrix.size;
38
+ const isDark = (row: number, col: number): boolean =>
39
+ row >= 0 && col >= 0 && row < size && col < size && matrix.get(row, col);
40
+
41
+ const lines: string[] = [];
42
+ for (let y = -quiet; y < size + quiet; y += 2) {
43
+ let line = "";
44
+ for (let x = -quiet; x < size + quiet; x += 1) {
45
+ const top = isDark(y, x);
46
+ const bottom = isDark(y + 1, x);
47
+ line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " ";
48
+ }
49
+ lines.push(line);
50
+ }
51
+ return lines;
52
+ }
53
+
54
+ /**
55
+ * The QR as an inline SVG, for a page that can paste markup instead of pixels.
56
+ *
57
+ * The markup is generated by the package from a URL this process built, so it
58
+ * carries no caller-supplied text; a page still serves it under its own CSP,
59
+ * which allows no external image.
60
+ */
61
+ export async function qrSvg(text: string): Promise<string> {
62
+ const { default: QRCode } = await import("qrcode");
63
+ return QRCode.toString(text, { type: "svg", errorCorrectionLevel: ERROR_CORRECTION, margin: 1 });
64
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The per-repository storage key that outlives one checkout.
3
+ *
4
+ * Two extensions file state by repository — `session-bus` its sessions, mail, and
5
+ * claims, `plan` its plans — and both must resolve the same key from any
6
+ * worktree, so a plan approved in `.pi/worktrees/x` is the same plan from the
7
+ * main checkout. The key is the repository's *common* git directory, which every
8
+ * worktree of that repository shares; outside a repository the working directory
9
+ * is the identity.
10
+ *
11
+ * Hashing keeps a mail directory, a claims directory, and a plans directory short
12
+ * enough to read and safe on every filesystem, while staying unique per
13
+ * repository.
14
+ */
15
+
16
+ import { createHash } from "node:crypto";
17
+ import { realpathSync } from "node:fs";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { createExecRunner, gitCommonDir, repoRoot } from "./git/index.ts";
20
+
21
+ /** Hex characters kept from the digest: collision-free in practice and short enough for a path. */
22
+ export const REPO_KEY_LENGTH = 12;
23
+
24
+ /** The key for one identity string, hashed and shortened. */
25
+ export function repoKeyOf(value: string): string {
26
+ return createHash("sha256").update(canonical(value)).digest("hex").slice(0, REPO_KEY_LENGTH);
27
+ }
28
+
29
+ /**
30
+ * One path, one spelling.
31
+ *
32
+ * git prints the path it was told to work from, so a repository reached through a
33
+ * symlinked prefix reports a different common dir for the same repository. Two
34
+ * spellings would file one repository's plans and claims under two keys, so the
35
+ * real path is what gets hashed; a path that cannot be resolved keeps its own
36
+ * spelling rather than failing.
37
+ */
38
+ function canonical(value: string): string {
39
+ try {
40
+ return realpathSync.native(value);
41
+ } catch {
42
+ return value;
43
+ }
44
+ }
45
+
46
+ /** A resolved repository identity. */
47
+ export interface RepoKey {
48
+ /** Absolute repository root, or `cwd` when git did not answer. */
49
+ root: string;
50
+ /** The shared key, derived from the common git directory. */
51
+ key: string;
52
+ /**
53
+ * Whether git resolved the repository. A caller that needs a *project-local*
54
+ * location branches on this; one that only needs a stable directory does not.
55
+ */
56
+ fromGit: boolean;
57
+ }
58
+
59
+ /**
60
+ * Resolve the repository identity for `cwd`.
61
+ *
62
+ * Never throws: without git, or when a git call fails, the working directory is
63
+ * the identity — which is what a caller outside a repository wants anyway.
64
+ */
65
+ export async function repoKeyFor(pi: Pick<ExtensionAPI, "exec">, cwd: string): Promise<RepoKey> {
66
+ try {
67
+ const run = createExecRunner(pi);
68
+ const [common, root] = await Promise.all([gitCommonDir(run, cwd), repoRoot(run, cwd)]);
69
+ if (root) return { root, key: repoKeyOf(common ?? root), fromGit: true };
70
+ } catch {
71
+ // Fall through: no git, or a git call that failed, is the same answer here.
72
+ }
73
+ return { root: cwd, key: repoKeyOf(cwd), fromGit: false };
74
+ }
package/lib/report.ts CHANGED
@@ -2,12 +2,15 @@
2
2
  * The pending-report coordinator: how a live feature tells the model what
3
3
  * happened without interrupting it.
4
4
  *
5
- * A watcher match and a finished job follow one rule — report at the next turn,
6
- * and raise at most one triggered turn when the work opted into `wake`. The two
7
- * traps are subtle enough to have been re-derived once per extension:
5
+ * A watcher match, a finished job, and arriving mail follow one rule — report at
6
+ * the next turn, and raise at most one triggered turn per burst when the work
7
+ * opted into `wake`, as soon as the agent is idle. The three traps are subtle
8
+ * enough to have been re-derived once per extension:
8
9
  *
9
10
  * - a wake is only safe while the agent is idle, so an outcome that arrives
10
- * mid-turn must be queued rather than sent;
11
+ * mid-turn is queued rather than sent, and `agent_settled` retries it;
12
+ * - an outcome outlives the run it arrived in, so the retry asks
13
+ * `wakeRequested()` rather than trusting the one call that queued it;
11
14
  * - between the triggered send and the `agent_start` it causes, the agent still
12
15
  * looks idle, so a second outcome in that window must not trigger a second
13
16
  * turn. `waking` closes that window.
@@ -33,6 +36,11 @@ export interface PendingReporterOptions<T> {
33
36
  markReported(items: T[]): void;
34
37
  /** Build the notice for one burst: model text plus renderer details. */
35
38
  buildNotice(items: T[]): NoticeMessage;
39
+ /**
40
+ * Whether any pending outcome asks for a triggered turn. Called by the settle
41
+ * retry, so a mid-turn outcome still wakes once the agent is idle.
42
+ */
43
+ wakeRequested(): boolean;
36
44
  /** The notice's source, so only its own stale entries are pruned. */
37
45
  source: string;
38
46
  /** Runs after a wake turn was successfully requested, for an extra announcement. */
@@ -47,8 +55,8 @@ export interface PendingReporter {
47
55
  sessionStart(): void;
48
56
  /** A turn began: outcomes queue up, and none of them triggers a turn. */
49
57
  agentStart(): void;
50
- /** The turn settled: a later outcome may wake again. */
51
- agentSettled(): void;
58
+ /** The turn settled: retry a wake it suppressed, unless it was aborted. */
59
+ agentSettled(event?: { aborted?: boolean }): void;
52
60
  /** Report what is queued at the start of a turn, without triggering one. */
53
61
  beforeAgentStart(): { message: NoticeMessage } | undefined;
54
62
  /** Drop this source's older notices from the outgoing context. */
@@ -56,26 +64,29 @@ export interface PendingReporter {
56
64
  }
57
65
 
58
66
  export function createPendingReporter<T>(options: PendingReporterOptions<T>): PendingReporter {
59
- const { wake, pending, markReported, buildNotice, source, onWoke } = options;
67
+ const { wake, pending, markReported, buildNotice, wakeRequested, source, onWoke } = options;
60
68
  let busy = false;
61
69
  let waking = false;
62
70
 
71
+ const report = (): void => {
72
+ if (busy || waking) return;
73
+ if (!wakeRequested()) return;
74
+ const items = pending();
75
+ if (items.length === 0) return;
76
+ try {
77
+ wake(buildNotice(items));
78
+ } catch {
79
+ // Reporting is best-effort; the next turn reports the same items.
80
+ return;
81
+ }
82
+ // Mark only after the send succeeded, so a throw changes nothing.
83
+ markReported(items);
84
+ waking = true;
85
+ onWoke?.(items);
86
+ };
87
+
63
88
  return {
64
- report: () => {
65
- if (busy || waking) return;
66
- const items = pending();
67
- if (items.length === 0) return;
68
- try {
69
- wake(buildNotice(items));
70
- } catch {
71
- // Reporting is best-effort; the next turn reports the same items.
72
- return;
73
- }
74
- // Mark only after the send succeeded, so a throw changes nothing.
75
- markReported(items);
76
- waking = true;
77
- onWoke?.(items);
78
- },
89
+ report,
79
90
  sessionStart: () => {
80
91
  busy = false;
81
92
  waking = false;
@@ -83,9 +94,13 @@ export function createPendingReporter<T>(options: PendingReporterOptions<T>): Pe
83
94
  agentStart: () => {
84
95
  busy = true;
85
96
  },
86
- agentSettled: () => {
97
+ agentSettled: (event) => {
87
98
  busy = false;
88
99
  waking = false;
100
+ // An interrupted run is not a request to interrupt again; its outcomes
101
+ // stay queued for the next turn.
102
+ if (event?.aborted === true) return;
103
+ report();
89
104
  },
90
105
  beforeAgentStart: () => {
91
106
  const items = pending();
@@ -103,10 +118,14 @@ export function createPendingReporter<T>(options: PendingReporterOptions<T>): Pe
103
118
 
104
119
  /** The session-scoped state a channel drives, narrowed to what its hooks need. */
105
120
  export interface PendingChannelRuntime {
106
- /** Load this session's records; called on every session start. */
107
- load(ctx: ExtensionContext): void;
108
- /** Repaint the status chip. */
109
- setStatus(ctx: ExtensionContext): void;
121
+ /**
122
+ * Load this session's records on every session start. A promise is awaited, so a
123
+ * runtime that resolves a repository or opens a store is fully loaded before the
124
+ * report trigger is armed.
125
+ */
126
+ load(ctx: ExtensionContext): void | Promise<void>;
127
+ /** Repaint the status chip; a runtime that publishes none omits it. */
128
+ setStatus?(ctx: ExtensionContext): void;
110
129
  /** Release the session's resources; idempotent. */
111
130
  shutdown(): Promise<void>;
112
131
  }
@@ -135,29 +154,29 @@ export function registerPendingChannel(
135
154
  pi: Pick<ExtensionAPI, "on">,
136
155
  { reporter, runtime, onSessionStart, onSessionShutdown }: PendingChannelOptions,
137
156
  ): void {
138
- pi.on("session_start", (_event, ctx) => {
157
+ pi.on("session_start", async (_event, ctx) => {
139
158
  reporter.sessionStart();
140
- runtime.load(ctx);
159
+ await runtime.load(ctx);
141
160
  onSessionStart?.(ctx);
142
161
  });
143
162
 
144
163
  // Tree navigation does not change the pending records; only repaint.
145
164
  pi.on("session_tree", (_event, ctx) => {
146
- runtime.setStatus(ctx);
165
+ runtime.setStatus?.(ctx);
147
166
  });
148
167
 
149
168
  pi.on("agent_start", () => {
150
169
  reporter.agentStart();
151
170
  });
152
- pi.on("agent_settled", () => {
153
- reporter.agentSettled();
171
+ pi.on("agent_settled", (event) => {
172
+ reporter.agentSettled(event);
154
173
  });
155
174
 
156
175
  // Report what is queued at the start of the next turn, without triggering one.
157
176
  pi.on("before_agent_start", (_event, ctx) => {
158
177
  const reported = reporter.beforeAgentStart();
159
178
  if (!reported) return undefined;
160
- runtime.setStatus(ctx);
179
+ runtime.setStatus?.(ctx);
161
180
  return reported;
162
181
  });
163
182
 
@@ -1,11 +1,8 @@
1
1
  /**
2
- * Per-extension runtime prerequisites.
3
- *
4
- * One place that says what each extension needs in order to actually work, plus
5
- * a pure probe over injected seams. `doctor` reports on this table, and the
6
- * extensions that own an external dependency format their error text from the
7
- * same entries, so the hint a model sees in a tool error and the hint `doctor`
8
- * prints cannot drift apart.
2
+ * Per-extension runtime prerequisites: one place that says what each extension needs
3
+ * in order to actually work, plus a pure probe over injected seams. `doctor` reports
4
+ * on this table, and the extensions that own an external dependency format their error
5
+ * text from the same entries, so a tool error and a `doctor` line cannot drift apart.
9
6
  *
10
7
  * The table is data: no module state, no I/O, no host access. Everything a probe
11
8
  * needs (running a command, reading a file, loading an optional package, the
@@ -19,7 +16,6 @@ import { configSection, configValueAt, type JsonReadResult, SHARED_CONFIG_FILE }
19
16
  import type { WebSearchProviderEvent } from "./events.ts";
20
17
  import { pdfGuidance } from "./pdf.ts";
21
18
  import { getPiInvocation, type PiInvocation } from "./pi-invocation.ts";
22
-
23
19
  /** How a check came out. Only `missing` is worth interrupting a session for. */
24
20
  export type FindingSeverity = "ok" | "missing" | "degraded" | "info";
25
21
 
@@ -212,11 +208,12 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
212
208
  },
213
209
  todo: {},
214
210
  goal: {},
211
+ board: {},
212
+ team: { notes: ["`assign` needs `session-bus` loaded to deliver a handoff; without it the card is untouched."] },
215
213
  check: {
216
214
  notes: ["Runs the project's detected command through a shell; a stack with no detector needs `check.command`."],
217
215
  },
218
216
  plan: {
219
- optional: [gitCheck("project-local `.pi/plans` (otherwise plans land in the agent directory)")],
220
217
  notes: ["Needs an interactive UI to enter plan mode."],
221
218
  },
222
219
  subagent: {
@@ -267,6 +264,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
267
264
  optional: [gitCheck("project-scope memory under the repository root")],
268
265
  },
269
266
  context: {},
267
+ "session-bus": {},
270
268
  wechat: {
271
269
  optional: [
272
270
  {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The session label: the one name another session addresses this one by.
3
+ *
4
+ * `session-bus` settles a label for every session — the Pi session name, else the
5
+ * working directory's basename, else the id — and the board records the same name
6
+ * on a card it claims, so the two surfaces agree on who "billing-core" is without
7
+ * the board reading the bus. The rules are here rather than in either extension
8
+ * because a label is a shared vocabulary: it is displayed, it is a send target, and
9
+ * it is stored in a card file that outlives one session.
10
+ *
11
+ * Value-only: no state, no I/O.
12
+ */
13
+
14
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { sanitizeLine } from "./format.ts";
16
+
17
+ /** Longest label, in characters, that a session may be addressed by. */
18
+ export const MAX_LABEL = 40;
19
+
20
+ /**
21
+ * A safe label, used both for display and as a target.
22
+ *
23
+ * Flattened to one line and capped, so a name can never wrap a row or open a
24
+ * second line in a page; an empty one falls back rather than leaving a blank chip.
25
+ */
26
+ export function normalizeLabel(raw: string | undefined, fallback: string): string {
27
+ const text = sanitizeLine(raw ?? "")
28
+ .replace(/\s+/g, " ")
29
+ .trim();
30
+ return (text || fallback).slice(0, MAX_LABEL);
31
+ }
32
+
33
+ /** A short, display-only suffix for a session id. */
34
+ export function shortId(id: string): string {
35
+ const clean = id.replace(/[^a-zA-Z0-9]/g, "");
36
+ return (clean || id).slice(-6);
37
+ }
38
+
39
+ /**
40
+ * The automatic label, before uniqueness is applied.
41
+ *
42
+ * The bus appends a suffix when two live sessions share one; that decision belongs
43
+ * to the registry, so this is only the name a session would pick for itself.
44
+ */
45
+ export function defaultLabel(sessionName: string | undefined, cwd: string, id: string): string {
46
+ const directory = cwd.split(/[\\/]/).filter(Boolean).at(-1);
47
+ const base = normalizeLabel(sessionName, normalizeLabel(directory, "session"));
48
+ return normalizeLabel(base, `session-${shortId(id)}`);
49
+ }
50
+
51
+ /**
52
+ * Pi's session name, which is the first choice for a label.
53
+ *
54
+ * A host that has no session name, or refuses the call, yields `undefined` rather
55
+ * than an error: a label is a convenience, and a session that cannot name itself
56
+ * still works under the fallback chain above.
57
+ */
58
+ export function sessionNameOf(ctx: Pick<ExtensionContext, "sessionManager">): string | undefined {
59
+ try {
60
+ return ctx.sessionManager.getSessionName();
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Who is on this repository's session bus, read as data.
3
+ *
4
+ * The Team tab lists teammates, and `session-bus` owns those records. Neither
5
+ * extension may import the other, and the board must keep working when the bus is
6
+ * not loaded — so the records are read where the bus already put them,
7
+ * `<agentDir>/session-bus/<repo-key>/`, as files rather than through its code. The
8
+ * directory names, the record fields this reader trusts, and the liveness rule live
9
+ * here once, and `session-bus` imports them, so a layout change cannot land in one
10
+ * copy and not the other.
11
+ *
12
+ * A missing directory is an answer, not an error: it means no session has ever
13
+ * joined this repository, and a caller renders that differently from an empty bus.
14
+ *
15
+ * Pure I/O, no state: one read answers one caller, and nothing is cached — a
16
+ * heartbeat is ten seconds old at most, and the caller polls on its own clock.
17
+ */
18
+
19
+ import { readdir, readFile } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+
22
+ /** The bus directory under the agent dir, `session-bus`. */
23
+ export const BUS_DIR_NAME = "session-bus";
24
+ /** One file per session inside it. */
25
+ export const SESSIONS_DIR_NAME = "sessions";
26
+ /** One directory per session, holding its mail. */
27
+ export const INBOX_DIR_NAME = "inbox";
28
+ /** How long a record's heartbeat stays believable: the rule the bus prunes by. */
29
+ export const SESSION_TTL_MS = 10 * 60_000;
30
+
31
+ /** One session as the page shows it. The rest of the record stays on disk. */
32
+ export interface RosterSession {
33
+ id: string;
34
+ label: string;
35
+ mode: string;
36
+ /** When the session last proved it was alive. */
37
+ lastSeen: number;
38
+ /** Messages waiting in its inbox. */
39
+ mail: number;
40
+ /**
41
+ * Whether the session is listening for mail, when its record says.
42
+ *
43
+ * Absent is "not listening": a record written before this existed and a session that
44
+ * stopped listening read the same way, so a reader never claims a peer will be reached.
45
+ */
46
+ watching?: { armed: boolean; wake: boolean };
47
+ }
48
+
49
+ /** Whether a record's heartbeat is fresh enough to call the session live. */
50
+ export function rosterStateOf(session: RosterSession, now: number, ttlMs = SESSION_TTL_MS): "live" | "quiet" {
51
+ return now - session.lastSeen <= ttlMs ? "live" : "quiet";
52
+ }
53
+
54
+ /** The fields this reader trusts; anything else in the file is the bus's business. */
55
+ interface SessionPayload {
56
+ id: string;
57
+ label: string;
58
+ mode: string;
59
+ lastSeen: number;
60
+ }
61
+
62
+ function isSessionPayload(value: unknown): value is SessionPayload {
63
+ if (typeof value !== "object" || value === null) return false;
64
+ const { id, label, mode, lastSeen } = value as Record<string, unknown>;
65
+ return (
66
+ typeof id === "string" && typeof label === "string" && typeof mode === "string" && typeof lastSeen === "number"
67
+ );
68
+ }
69
+
70
+ /**
71
+ * The listening field, read only when it is the shape the bus writes.
72
+ *
73
+ * A malformed field is not a reason to hide a teammate: the row still draws, and the
74
+ * only thing lost is the claim that mail will reach it. Exported because the field is
75
+ * the record's, and `lib/session-tool.ts` reads the same one over the tool rather than
76
+ * over the files — one reader for one shape.
77
+ */
78
+ export function watchingOf(value: unknown): { armed: boolean; wake: boolean } | undefined {
79
+ if (typeof value !== "object" || value === null) return undefined;
80
+ const { armed, wake } = value as Record<string, unknown>;
81
+ return typeof armed === "boolean" && typeof wake === "boolean" ? { armed, wake } : undefined;
82
+ }
83
+
84
+ /** How many message files one session's inbox holds. A missing inbox is empty. */
85
+ async function mailCount(dir: string, stem: string): Promise<number> {
86
+ try {
87
+ const entries = await readdir(join(dir, INBOX_DIR_NAME, stem));
88
+ return entries.filter((name) => name.endsWith(".json")).length;
89
+ } catch {
90
+ return 0;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Every session the bus has recorded for one repository, or `undefined` when the bus
96
+ * has never been used here.
97
+ *
98
+ * One file that does not parse is skipped rather than thrown: a roster with three
99
+ * good sessions is worth more than an error about the fourth.
100
+ */
101
+ export async function readRoster(dir: string): Promise<RosterSession[] | undefined> {
102
+ let names: string[];
103
+ try {
104
+ names = await readdir(join(dir, SESSIONS_DIR_NAME));
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ const sessions: RosterSession[] = [];
109
+ for (const name of names) {
110
+ if (!name.endsWith(".json")) continue;
111
+ let parsed: unknown;
112
+ try {
113
+ parsed = JSON.parse(await readFile(join(dir, SESSIONS_DIR_NAME, name), "utf-8"));
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (!isSessionPayload(parsed)) continue;
118
+ const { id, label, mode, lastSeen } = parsed;
119
+ const watching = watchingOf((parsed as { watching?: unknown }).watching);
120
+ sessions.push({
121
+ id,
122
+ label,
123
+ mode,
124
+ lastSeen,
125
+ mail: await mailCount(dir, name.slice(0, -".json".length)),
126
+ ...(watching === undefined ? {} : { watching }),
127
+ });
128
+ }
129
+ return sessions.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
130
+ }