@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
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Model-facing text for the session bus.
3
+ *
4
+ * Everything that came from another session is untrusted text: it is sanitized
5
+ * before it reaches the transcript, and paths/labels are rendered as one line.
6
+ */
7
+
8
+ import { sanitizeLine } from "../../lib/format.ts";
9
+ import type { BusMessage, ClaimConflict, ClaimRecord, SessionStatus } from "./types.ts";
10
+ /**
11
+ * The last few characters of a peer's id, as the transcript shows it.
12
+ *
13
+ * Neutralized like every other peer-supplied field, and deliberately not
14
+ * `lib/session-label.ts`'s `shortId`: that one keeps the id's alphanumerics to
15
+ * build a default *label*, while this one is the id's own tail, sanitized for a
16
+ * row.
17
+ */
18
+ export function idSuffix(id: string): string {
19
+ return sanitizeLine(id.slice(-6));
20
+ }
21
+
22
+ function claimLine(claim: ClaimRecord): string {
23
+ return `${sanitizeLine(claim.path)} · ${sanitizeLine(claim.ownerLabel)}`;
24
+ }
25
+
26
+ /** The status listing: sessions, unread counts, claims, and this session's alerts. */
27
+ export function formatStatus(status: SessionStatus): string {
28
+ const sessions = status.sessions.map((session) => {
29
+ const unread = session.unread > 0 ? ` · ${session.unread} unread` : "";
30
+ const label = sanitizeLine(session.label);
31
+ const cwd = sanitizeLine(session.cwd);
32
+ return `- ${label} (${idSuffix(session.id)}) · ${cwd} · ${sanitizeLine(session.mode)}${unread}`;
33
+ });
34
+ const claims = status.claims.map((claim) => `- ${claimLine(claim)}`);
35
+ const alerts = status.watching ? [`Alerts: inbox watch on${status.watching.wake ? " (wake)" : ""}`] : [];
36
+ return [
37
+ `Sessions (${status.sessions.length})`,
38
+ ...(sessions.length > 0 ? sessions : ["- none"]),
39
+ "",
40
+ `Claims (${status.claims.length})`,
41
+ ...(claims.length > 0 ? claims : ["- none"]),
42
+ ...(alerts.length > 0 ? ["", ...alerts] : []),
43
+ "",
44
+ `You: ${sanitizeLine(status.me.label)} (${idSuffix(status.me.id)})${status.onBus ? "" : " \u00b7 not on the bus yet \u2014 `session join` puts you on it"}`,
45
+ ].join("\n");
46
+ }
47
+
48
+ /**
49
+ * The join result: the label peers address, and whether mail will reach this session.
50
+ *
51
+ * One sentence for one act, because `join` is one action — the name and the watch are
52
+ * two facts about the same decision, and a reader that had to ask twice would be reading
53
+ * a bus the user is not supposed to operate.
54
+ */
55
+ export function formatJoin(label: string, watching: { armed: boolean; wake: boolean }): string {
56
+ const who = `On the bus as "${sanitizeLine(label)}"`;
57
+ if (!watching.armed) return `${who}, not listening: mail waits in the inbox.`;
58
+ return watching.wake
59
+ ? `${who}, listening: a new message raises one turn.`
60
+ : `${who}, listening: new messages appear at your next turn.`;
61
+ }
62
+
63
+ /** One consumed inbox, oldest first. */
64
+ export function formatInbox(messages: readonly BusMessage[]): string {
65
+ if (messages.length === 0) return "Inbox empty.";
66
+ return messages
67
+ .map((message, index) => {
68
+ const paths = message.paths?.length
69
+ ? `\n paths: ${message.paths.map((path) => sanitizeLine(path)).join(", ")}`
70
+ : "";
71
+ const from = sanitizeLine(message.fromLabel);
72
+ return `${index + 1}. ${from} · ${sanitizeLine(message.kind)} · ${sanitizeLine(message.text)}${paths}`;
73
+ })
74
+ .join("\n");
75
+ }
76
+
77
+ /** Confirmation for a send. */
78
+ export function formatSend(delivered: readonly string[], messageId: string): string {
79
+ if (delivered.length === 0) return "Delivered to 0 sessions.";
80
+ const names = delivered.map((label) => sanitizeLine(label)).join(", ");
81
+ return `Delivered to ${names} (${sanitizeLine(messageId)}).`;
82
+ }
83
+
84
+ /** Claim result, including the owner that blocked it. */
85
+ export function formatClaim(claimed: readonly string[], conflicts: readonly ClaimConflict[]): string {
86
+ const lines: string[] = [];
87
+ if (claimed.length > 0) lines.push(`Claimed: ${claimed.map((path) => sanitizeLine(path)).join(", ")}.`);
88
+ for (const conflict of conflicts) {
89
+ const owner = sanitizeLine(conflict.ownerLabel);
90
+ lines.push(`${sanitizeLine(conflict.path)} is owned by ${owner} (${idSuffix(conflict.owner)}).`);
91
+ }
92
+ return lines.length > 0 ? lines.join("\n") : "Nothing claimed.";
93
+ }
94
+
95
+ /** Release result. */
96
+ export function formatRelease(released: readonly string[], notOwned: readonly string[]): string {
97
+ const lines: string[] = [];
98
+ if (released.length > 0) lines.push(`Released: ${released.map((path) => sanitizeLine(path)).join(", ")}.`);
99
+ if (notOwned.length > 0) lines.push(`Not yours: ${notOwned.map((path) => sanitizeLine(path)).join(", ")}.`);
100
+ return lines.length > 0 ? lines.join("\n") : "Nothing to release.";
101
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * session-bus — coordinate parallel Pi sessions on one repository.
3
+ *
4
+ * No session joins on start: it joins through `join`, through the first action
5
+ * that makes it addressable, or through `w` on its own board, in any mode.
6
+ * `/sessions` and `status` only read, so a fresh board is empty and a peer never
7
+ * appears merely because the package loaded. The factory only registers; the
8
+ * runtime owns the heartbeat and removes the registry entry on shutdown. Nothing
9
+ * arms an inbox watch by itself, so delivery stays pull-based until a session
10
+ * asks: with the watch armed, new mail is reported at the next turn, or — with
11
+ * `wake` — raises one turn as soon as the agent is idle, including right after a
12
+ * running turn settles.
13
+ *
14
+ * Load with: pi --extension ./extensions/session-bus
15
+ */
16
+
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { isExtensionEnabled } from "../../lib/env.ts";
19
+ import { messageOf } from "../../lib/errors.ts";
20
+ import { createPendingReporter, registerPendingChannel } from "../../lib/report.ts";
21
+ import { registerCommands } from "./commands.ts";
22
+ import { registerJoinRequests } from "./join-request.ts";
23
+ import { createSessionBus, type SessionBus } from "./runtime.ts";
24
+ import { registerTools } from "./tools.ts";
25
+ import { mailNotice } from "./notice.ts";
26
+ import type { BusMessage } from "./types.ts";
27
+
28
+ export interface SessionBusDeps {
29
+ /** Test seam: a runtime with a temp dir and fixed clock. */
30
+ bus?: SessionBus;
31
+ }
32
+
33
+ export default function sessionBus(pi: ExtensionAPI, deps: SessionBusDeps = {}): void {
34
+ if (!isExtensionEnabled("session-bus")) return;
35
+ const bus = deps.bus ?? createSessionBus(pi);
36
+
37
+ const reporter = createPendingReporter<BusMessage>({
38
+ wake: (notice) => pi.sendMessage(notice, { triggerTurn: true }),
39
+ pending: () => bus.pending(),
40
+ markReported: (messages) => bus.markReported(messages.map((message) => message.id)),
41
+ buildNotice: mailNotice,
42
+ source: "session-bus",
43
+ wakeRequested: () => bus.wantsWake(),
44
+ });
45
+
46
+ registerTools(pi, bus);
47
+ registerCommands(pi, bus);
48
+ // `board` and `team` join this session through one request: their commands cannot call
49
+ // a tool, and the user's surface for the team is theirs, not this extension's.
50
+ registerJoinRequests(pi, bus);
51
+
52
+ registerPendingChannel(pi, {
53
+ reporter,
54
+ runtime: {
55
+ // A start failure is reported once and leaves the runtime retryable: the next
56
+ // tool call starts it again, and a bus that never started simply never polls.
57
+ load: async (ctx) => {
58
+ try {
59
+ await bus.start(ctx);
60
+ } catch (error) {
61
+ ctx.ui.notify(`Session bus did not start: ${messageOf(error)}`, "warning");
62
+ }
63
+ },
64
+ shutdown: () => bus.stop(),
65
+ },
66
+ // A reload or a replaced session reuses the runtime, so the mail trigger is
67
+ // armed on every session start and cleared before the runtime releases.
68
+ onSessionStart: () => {
69
+ bus.onWake = () => reporter.report();
70
+ },
71
+ onSessionShutdown: () => {
72
+ bus.onWake = undefined;
73
+ },
74
+ });
75
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The join request, answered.
3
+ *
4
+ * `board` and `team` sit above the bus: `/board`, `/team join`, and a card write all
5
+ * put this session on the team, and none of them may import this extension. A *tool*
6
+ * context could reach the `session` tool and an `ExtensionCommandContext` cannot —
7
+ * the same limit `lib/plan-entry.ts` works around for plan mode — so the request
8
+ * travels on `pi.events` and this module answers it with the runtime's own `join`.
9
+ *
10
+ * The answer always comes: every path here ends in `respond`, including a failure, so
11
+ * a caller never waits on a listener that threw. The label also becomes Pi's session
12
+ * name, because a team label and the footer have to agree.
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { messageOf } from "../../lib/errors.ts";
17
+ import { type JoinResult, TEAM_JOIN_REQUEST, type TeamJoinRequest } from "../../lib/session-tool.ts";
18
+ import type { SessionBus } from "./runtime.ts";
19
+
20
+ export function registerJoinRequests(pi: ExtensionAPI, bus: SessionBus): void {
21
+ pi.events.on(TEAM_JOIN_REQUEST, (data) => {
22
+ const request = data as TeamJoinRequest | undefined;
23
+ if (!request || typeof request.respond !== "function") return;
24
+ void answer(request);
25
+ });
26
+
27
+ async function answer(request: TeamJoinRequest): Promise<void> {
28
+ let result: JoinResult;
29
+ try {
30
+ await bus.start(request.ctx);
31
+ const joined = await bus.join(request.input);
32
+ // One identity: the bus's label is also the name Pi's footer and `/resume` show.
33
+ if (request.input.label !== undefined) pi.setSessionName(joined.label);
34
+ result = { ok: true, label: joined.label, watching: joined.watching };
35
+ } catch (error) {
36
+ result = { ok: false, error: messageOf(error) };
37
+ }
38
+ request.respond(result);
39
+ }
40
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The armed inbox watch: polling this session's own inbox and the queue of mail
3
+ * that arrived and has not been reported yet.
4
+ *
5
+ * It announces and never consumes: `inbox` keeps sole ownership of deleting mail,
6
+ * so a wake cannot eat a message the model did not read. Nothing here knows about
7
+ * the store, the repository, or the transcript — the runtime supplies one read-only
8
+ * peek and the timer, and the extension's reporter supplies `onWake`.
9
+ *
10
+ * Polling rather than `fs.watch`, like the `watch` extension's tail: one readdir on
11
+ * this session's own directory stands in for a platform event API.
12
+ */
13
+
14
+ import type { RepeatTimer } from "../../lib/timer.ts";
15
+ import type { BusMessage } from "./types.ts";
16
+
17
+ export interface MailWatchOptions {
18
+ /** This session's unread mail, read-only: nothing is consumed or marked. */
19
+ peek(): Promise<BusMessage[]>;
20
+ /** The repeating timer behind the poll. */
21
+ interval: RepeatTimer;
22
+ /** Poll interval; 0 disables the ticker (tests). */
23
+ watchMs: number;
24
+ }
25
+
26
+ /** Whether the watch is on, and whether it raises a turn. */
27
+ export interface WatchingState {
28
+ armed: boolean;
29
+ wake: boolean;
30
+ }
31
+
32
+ export interface MailWatch {
33
+ /** Arm or disarm this session's inbox watch. */
34
+ watch(input: { wake?: boolean; stop?: boolean }): Promise<WatchingState>;
35
+ /** The current state, for the status line. */
36
+ state(): WatchingState;
37
+ /** Whether the armed watch asked for a triggered turn; the settle retry reads it. */
38
+ wantsWake(): boolean;
39
+ /** Read the inbox without consuming it, queueing what is new; no-op when unarmed. */
40
+ poll(): Promise<void>;
41
+ /** Mail that arrived and has not been reported yet, oldest first. */
42
+ pending(): BusMessage[];
43
+ /** Drop mail from the pending queue, once reported or consumed. */
44
+ markReported(ids: readonly string[]): void;
45
+ /** Release the ticker and the queue, on shutdown. */
46
+ stop(): void;
47
+ set onWake(callback: (() => void) | undefined);
48
+ }
49
+
50
+ export function createMailWatch(options: MailWatchOptions): MailWatch {
51
+ const { peek, interval, watchMs } = options;
52
+
53
+ let armed = false;
54
+ let wake = false;
55
+ let stopTicker: (() => void) | undefined;
56
+ let pendingMail: BusMessage[] = [];
57
+ /** Ids already queued, reported, or consumed; a broadcast shares one id. */
58
+ let reported = new Set<string>();
59
+ let onWake: (() => void) | undefined;
60
+
61
+ function dropFromPending(ids: ReadonlySet<string>): void {
62
+ pendingMail = pendingMail.filter((message) => !ids.has(message.id));
63
+ }
64
+
65
+ function markReported(ids: readonly string[]): void {
66
+ for (const id of ids) reported.add(id);
67
+ dropFromPending(new Set(ids));
68
+ }
69
+
70
+ /**
71
+ * Queue what arrived since the last poll, once, and wake a turn when this
72
+ * session asked for one.
73
+ *
74
+ * The reported set is narrowed to the mail still on disk, so it is bounded by
75
+ * the unread count rather than by the session's whole history; an id is unique
76
+ * for good, so a message that was consumed can never be queued again.
77
+ */
78
+ async function pollOnce(): Promise<void> {
79
+ if (!armed) return;
80
+ const mail = await peek();
81
+ const unread = new Set(mail.map((message) => message.id));
82
+ reported = new Set([...reported].filter((id) => unread.has(id)));
83
+ // A message that expired or was consumed leaves the queue with its file.
84
+ pendingMail = pendingMail.filter((message) => unread.has(message.id));
85
+ const queued = new Set(pendingMail.map((message) => message.id));
86
+ const fresh = mail.filter((message) => !reported.has(message.id) && !queued.has(message.id));
87
+ if (fresh.length === 0) return;
88
+ pendingMail = [...pendingMail, ...fresh];
89
+ if (wake) onWake?.();
90
+ }
91
+
92
+ function disarm(): void {
93
+ stopTicker?.();
94
+ stopTicker = undefined;
95
+ armed = false;
96
+ wake = false;
97
+ pendingMail = [];
98
+ }
99
+
100
+ async function watch(input: { wake?: boolean; stop?: boolean }): Promise<WatchingState> {
101
+ if (input.stop) {
102
+ disarm();
103
+ return { armed: false, wake: false };
104
+ }
105
+ if (!armed) {
106
+ armed = true;
107
+ wake = input.wake === true;
108
+ await pollOnce();
109
+ if (watchMs > 0) stopTicker = interval(watchMs, () => void pollOnce().catch(() => {}));
110
+ return { armed, wake };
111
+ }
112
+ // The mode is a setting, not an arm-time flag: an explicit `wake` changes it
113
+ // under a queued notice, and an omitted one keeps it, so bare `watch` never
114
+ // demotes an armed session.
115
+ if (input.wake !== undefined) wake = input.wake;
116
+ // Asking for wake over mail already queued is a request for that burst too;
117
+ // mid-turn it is suppressed here and retried once the run settles.
118
+ if (wake && pendingMail.length > 0) onWake?.();
119
+ return { armed, wake };
120
+ }
121
+
122
+ return {
123
+ watch,
124
+ state: () => ({ armed, wake }),
125
+ wantsWake: () => armed && wake,
126
+ poll: pollOnce,
127
+ pending: () => [...pendingMail],
128
+ markReported,
129
+ stop: disarm,
130
+ set onWake(callback: (() => void) | undefined) {
131
+ onWake = callback;
132
+ },
133
+ };
134
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The mail notice: what the model reads when an armed session has new mail, and
3
+ * the rows bytefly draws from the same burst.
4
+ *
5
+ * The message bodies came from another session, so they are sanitized here before
6
+ * they reach either audience.
7
+ */
8
+
9
+ import { sanitizeLine } from "../../lib/format.ts";
10
+ import { burstText, noticeMessage, type NoticeItem, type NoticeMessage } from "../../lib/notice.ts";
11
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
12
+ import type { BusMessage } from "./types.ts";
13
+
14
+ /** Marker embedded in the injected mail context. */
15
+ const BUS_CONTEXT_MARKER = "[SESSION BUS]";
16
+
17
+ /** Messages previewed in the model-facing text. The renderer shows its own cap. */
18
+ const MAX_NOTICE_LINES = 5;
19
+
20
+ /** A message body is clipped to this many characters in a preview. */
21
+ const MAX_PREVIEW = 200;
22
+
23
+ /**
24
+ * A handoff is clipped far less generously, because it is an instruction and not a
25
+ * notification: the card, its paths, and the step to take all sit past the point
26
+ * where a message preview would have stopped. The inbox still holds the full text.
27
+ */
28
+ const HANDOFF_PREVIEW = 800;
29
+
30
+ /** What a waking turn is told to do with a handoff: work it, not acknowledge it. */
31
+ const HANDOFF_HINT =
32
+ "A handoff is work you now own: run `session inbox`, record the card's plan, and start — do not just acknowledge it.";
33
+
34
+ /** `1 handoff`, `2 new messages` — a burst of handoffs is named as the work it is. */
35
+ function headlineOf(messages: readonly BusMessage[]): string {
36
+ const count = messages.length;
37
+ const handoffs = messages.filter((message) => message.kind === "handoff").length;
38
+ if (handoffs === 0 || handoffs !== count) return `${count} new message${count === 1 ? "" : "s"}`;
39
+ return `${count} handoff${count === 1 ? "" : "s"}`;
40
+ }
41
+
42
+ /** One message as a preview line: sender, kind, and the head of its text. */
43
+ function previewOf(message: BusMessage): string {
44
+ const text = sanitizeLine(message.text);
45
+ const limit = message.kind === "handoff" ? HANDOFF_PREVIEW : MAX_PREVIEW;
46
+ const head = text.length > limit ? `${text.slice(0, limit)}…` : text;
47
+ const paths = message.paths?.length ? ` (${message.paths.map((path) => sanitizeLine(path)).join(", ")})` : "";
48
+ return `${sanitizeLine(message.fromLabel)}${SEPARATORS.item}${sanitizeLine(message.kind)}${SEPARATORS.item}${head}${paths}`;
49
+ }
50
+
51
+ /**
52
+ * The shared notice for pending mail: model text plus renderer details.
53
+ *
54
+ * The model reads five lines, so a burst stays bounded in its context; every
55
+ * message is passed to the renderer, whose own cap draws the `… N more` row. The
56
+ * two caps are deliberately separate — one is a context budget, the other a
57
+ * screen height.
58
+ *
59
+ * A `handoff` is the one kind that assigns work, so it is the one kind the text
60
+ * treats as an instruction rather than as mail: the headline counts handoffs, the
61
+ * preview keeps enough of the body to be actionable, and the hint says to start it.
62
+ * Nothing else about the kind changes — it still does not block or wait for a reply.
63
+ */
64
+ export function mailNotice(messages: BusMessage[]): NoticeMessage {
65
+ const count = messages.length;
66
+ const shown = messages.slice(0, MAX_NOTICE_LINES);
67
+ const more = count - shown.length;
68
+ const items: NoticeItem[] = messages.map((message) => ({
69
+ label: `${sanitizeLine(message.fromLabel)}${SEPARATORS.item}${sanitizeLine(message.kind)}`,
70
+ detail: sanitizeLine(message.text).slice(0, MAX_PREVIEW),
71
+ severity: message.kind === "event" ? "warn" : "info",
72
+ }));
73
+ const headline = headlineOf(messages);
74
+ return noticeMessage({
75
+ content: burstText({
76
+ marker: BUS_CONTEXT_MARKER,
77
+ headline,
78
+ lines: shown.map((message) => `- ${previewOf(message)}`),
79
+ ...(more > 0 ? { more: `… ${more} more` } : {}),
80
+ hint: messages.some((message) => message.kind === "handoff")
81
+ ? HANDOFF_HINT
82
+ : "Run `session inbox` to read and consume them.",
83
+ }),
84
+ details: { source: "session-bus", title: headline, items },
85
+ });
86
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Claim overlap (pure).
3
+ *
4
+ * The bus's one answer to "do these paths collide?": the runtime checks it before
5
+ * a claim is written, and the store checks it when a claim is read back. Two
6
+ * copies would let a claim succeed here and read back as a conflict there, which
7
+ * teaches the user that a claim is arbitrary.
8
+ *
9
+ * One export. `normalizeClaimPath` is the predicate's own step — a spellings rule
10
+ * with a single caller, inside this module — so a caller cannot normalize a path
11
+ * one way and compare it another.
12
+ *
13
+ * A claim on a directory covers every file below it, in either direction, and a
14
+ * claim is always a repository-relative, posix-separated path: both sides are
15
+ * normalized here, because a Windows spelling (`a\b`) and a posix one (`a/b`)
16
+ * naming the same file must not read as two claims.
17
+ */
18
+
19
+ /** A repository-relative path with one separator and no trailing slash. */
20
+ function normalizeClaimPath(raw: string): string {
21
+ const posix = raw.trim().replace(/\\/g, "/");
22
+ const trimmed = posix
23
+ .replace(/^\.\//, "")
24
+ .replace(/\/{2,}/g, "/")
25
+ .replace(/\/+$/, "");
26
+ return trimmed === "." ? "" : trimmed;
27
+ }
28
+
29
+ /** Whether one claim covers the other path in either direction. */
30
+ export function claimsConflict(a: string, b: string): boolean {
31
+ const left = normalizeClaimPath(a);
32
+ const right = normalizeClaimPath(b);
33
+ // The root claim (an empty path, or ".") covers everything below it.
34
+ if (left === "" || right === "") return true;
35
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
36
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pure protocol rules for the session bus.
3
+ *
4
+ * No filesystem or process access lives here: labels, targets, claim paths, and
5
+ * validation are testable without a runtime. `store.ts` owns the files.
6
+ */
7
+
8
+ import { isAbsolute, relative, resolve, sep } from "node:path";
9
+ import { MAX_MESSAGE_TEXT } from "../../lib/session-tool.ts";
10
+ import { SESSION_TOOL } from "../../lib/tool-names.ts";
11
+
12
+ /**
13
+ * The label rules live in `lib/` because a label is shared vocabulary: the board
14
+ * records one on a card it claims, and `session-bus` settles the one it is
15
+ * addressed by. Re-exported here so every module in this extension keeps
16
+ * importing its rules from the protocol they belong to.
17
+ */
18
+ export { defaultLabel, MAX_LABEL, normalizeLabel, shortId } from "../../lib/session-label.ts";
19
+
20
+ export const TOOL_NAME = SESSION_TOOL;
21
+ export const COMMAND_NAME = "sessions";
22
+ /**
23
+ * The text cap, from `lib/session-tool.ts`, where the callers that clip to it live.
24
+ * Re-exported the way the label rules are, so this extension imports its protocol's
25
+ * constants from one place and two extensions cannot drift apart on the number.
26
+ */
27
+ export const MAX_TEXT = MAX_MESSAGE_TEXT;
28
+ export const MAX_PATHS = 20;
29
+ export const DEFAULT_LIMIT = 20;
30
+ export const MAX_LIMIT = 50;
31
+
32
+ export type SessionMode = "tui" | "rpc" | "json" | "print" | "unknown";
33
+ export type SessionAction = "status" | "send" | "inbox" | "claim" | "release" | "join";
34
+ export type MessageKind = "message" | "event" | "ask" | "handoff";
35
+
36
+ const ACTIONS: readonly SessionAction[] = ["status", "send", "inbox", "claim", "release", "join"];
37
+ const KINDS: readonly MessageKind[] = ["message", "event", "ask", "handoff"];
38
+
39
+ /** The session mode as the record stores it. */
40
+ export function sessionMode(mode: string | undefined): SessionMode {
41
+ return mode === "tui" || mode === "rpc" || mode === "json" || mode === "print" ? mode : "unknown";
42
+ }
43
+
44
+ /** An action, or undefined when the model sent an unknown one. */
45
+ export function normalizeAction(raw: unknown): SessionAction | undefined {
46
+ return typeof raw === "string" && ACTIONS.includes(raw as SessionAction) ? (raw as SessionAction) : undefined;
47
+ }
48
+
49
+ /** A message kind; `message` is the default. */
50
+ export function normalizeKind(raw: unknown): MessageKind {
51
+ return typeof raw === "string" && KINDS.includes(raw as MessageKind) ? (raw as MessageKind) : "message";
52
+ }
53
+
54
+ /** Trimmed message text, or an error message. */
55
+ export function normalizeText(raw: unknown): { text: string } | { error: string } {
56
+ if (typeof raw !== "string" || raw.trim() === "") return { error: "text is required." };
57
+ const text = raw.trim();
58
+ if (text.length > MAX_TEXT) return { error: `text must be at most ${MAX_TEXT} characters.` };
59
+ return { text };
60
+ }
61
+
62
+ /** A shared inbox limit. */
63
+ export function normalizeLimit(raw: unknown): number {
64
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return DEFAULT_LIMIT;
65
+ return Math.min(MAX_LIMIT, Math.max(1, Math.floor(raw)));
66
+ }
67
+
68
+ /** Repository-relative, posix-normalized paths, or an error. */
69
+ export function normalizePaths(repoRoot: string, cwd: string, raw: unknown): { paths: string[] } | { error: string } {
70
+ if (!Array.isArray(raw) || raw.length === 0) return { error: "paths must contain at least one path." };
71
+ if (raw.length > MAX_PATHS) return { error: `at most ${MAX_PATHS} paths are allowed.` };
72
+ const paths: string[] = [];
73
+ for (const entry of raw) {
74
+ if (typeof entry !== "string" || entry.trim() === "") return { error: "every path must be a non-empty string." };
75
+ const absolute = resolve(cwd, entry.trim());
76
+ const rel = relative(repoRoot, absolute);
77
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
78
+ return { error: `path "${entry}" is outside the repository.` };
79
+ }
80
+ const normalized = rel.split(sep).join("/");
81
+ if (!paths.includes(normalized)) paths.push(normalized);
82
+ }
83
+ return { paths };
84
+ }
85
+
86
+ /** Resolve `to` to active sessions; `*` is broadcast and excludes the sender. */
87
+ export function resolveRecipients(
88
+ sessions: readonly { id: string; label: string }[],
89
+ to: string,
90
+ from: string,
91
+ ): { recipients: Array<{ id: string; label: string }> } | { error: string } {
92
+ const target = to.trim();
93
+ if (target === "") return { error: "to is required." };
94
+ if (target === "*") {
95
+ const recipients = sessions.filter((session) => session.id !== from);
96
+ return recipients.length === 0 ? { error: "there are no other active sessions." } : { recipients };
97
+ }
98
+ const exact = sessions.find((session) => session.id === target);
99
+ if (exact) return { recipients: [exact] };
100
+ const byLabel = sessions.filter((session) => session.label.toLowerCase() === target.toLowerCase());
101
+ if (byLabel.length === 1) return { recipients: byLabel };
102
+ if (byLabel.length > 1) return { error: `the label "${target}" is ambiguous; use a session id.` };
103
+ return { error: `no active session "${target}".` };
104
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A claim path as the store keeps it: repository-relative, posix, canonical.
3
+ *
4
+ * A path is data — it is resolved against the repository root, and canonicalised
5
+ * through its real target before it is compared or stored — so a symlinked
6
+ * spelling inside the repository and the file it names are one claim, and a path
7
+ * that leaves the repository is refused before anything is written.
8
+ *
9
+ * `lib/path.ts` owns the containment predicates; this owns the claim's shape.
10
+ */
11
+
12
+ import { isAbsolute, join, relative, sep } from "node:path";
13
+ import { isInside, realPathOfNearest } from "../../lib/path.ts";
14
+
15
+ /** A path this module declined, with the reason a caller can print. */
16
+ export interface ClaimPathError {
17
+ /** The path as the caller passed it, so a refusal names what it read. */
18
+ path: string;
19
+ /** Why it cannot be a claim. */
20
+ reason: string;
21
+ }
22
+
23
+ /** Every path resolved and deduplicated, or the first one refused. */
24
+ export type ResolvedClaimPaths = { paths: string[] } | { error: ClaimPathError };
25
+
26
+ /**
27
+ * The wording of the one refusal, shared so approval and the bus cannot phrase it
28
+ * two ways: the caller prefixes the subject it knows (a unit, or a session's call).
29
+ */
30
+ export function claimPathMessage(error: ClaimPathError): string {
31
+ return `path "${error.path}" ${error.reason}.`;
32
+ }
33
+
34
+ /**
35
+ * What the root itself resolves to, which is never a claim: it would cover every
36
+ * file in the repository, so a unit that lists it owns the repository.
37
+ */
38
+ const ROOT_ITSELF = "is the repository, not a path in it";
39
+
40
+ /** What a path outside the root resolves to. */
41
+ const OUTSIDE = "does not resolve to a file in the repository";
42
+
43
+ /** What an absolute path is refused for: it is not the claim's to place. */
44
+ const ABSOLUTE = "is absolute; a claim is relative to the repository root";
45
+
46
+ /**
47
+ * Resolve `paths` against `root`, or refuse the first one that is not in it.
48
+ *
49
+ * A path that does not exist yet is accepted: `realPathOfNearest` resolves the
50
+ * nearest existing ancestor, so a unit may name the directory its work creates.
51
+ */
52
+ export function resolveClaimPaths(root: string, paths: readonly string[]): ResolvedClaimPaths {
53
+ const realRoot = realPathOfNearest(root);
54
+ const canonical: string[] = [];
55
+ for (const path of paths) {
56
+ // `join` would re-root an absolute path under the repository, which is not what
57
+ // a caller meant: refuse it rather than claim a directory it never named.
58
+ if (isAbsolute(path)) return { error: { path, reason: ABSOLUTE } };
59
+ const target = realPathOfNearest(join(root, path));
60
+ const rel = relative(realRoot, target);
61
+ if (rel === "") return { error: { path, reason: ROOT_ITSELF } };
62
+ if (!isInside(realRoot, target)) return { error: { path, reason: OUTSIDE } };
63
+ const normalized = rel.split(sep).join("/");
64
+ if (!canonical.includes(normalized)) canonical.push(normalized);
65
+ }
66
+ return { paths: canonical };
67
+ }