@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
@@ -1,15 +1,17 @@
1
1
  /**
2
- * The socket seam shared by the browser servers: bind, write a response out,
3
- * and close.
2
+ * The socket primitives `serveLoopback` is built from: bind, write a response
3
+ * out, and close.
4
4
  *
5
- * Everything here is behavior a caller cannot get right by accident: a bind on
6
- * an address the caller names (loopback unless it asked for more), an
7
- * accept-level `error` listener so a transient failure cannot take the host
8
- * down, a `HEAD` that sends headers and no body, a `filePath` response streamed
9
- * instead of buffered, a `stream` response opened as an event stream, and a
10
- * close that is safe to call twice and drops keep-alive connections. What each
11
- * server does *around* this — which frames an event stream carries, request
12
- * bodies, session settling — stays with that server.
5
+ * Internal to the layer: an extension starts a server through
6
+ * `serveLoopback`, which composes these, so this module is reached from `lib/http`
7
+ * and its own suite — not from the barrel. Everything here is behavior a caller
8
+ * cannot get right by accident: a bind on an address the caller names (loopback
9
+ * unless it asked for more), an accept-level `error` listener so a transient
10
+ * failure cannot take the host down, a `HEAD` that sends headers and no body, a
11
+ * `filePath` response streamed instead of buffered, a `stream` response opened as
12
+ * an event stream, and a close that is safe to call twice and drops keep-alive
13
+ * connections. What a server does *around* this — which frames an event stream
14
+ * carries, request bodies, session settling — stays with `serve.ts`.
13
15
  */
14
16
 
15
17
  import { createReadStream } from "node:fs";
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The event streams one loopback server keeps open, and the idle grace that ends
3
+ * a server nobody is reading.
4
+ *
5
+ * The sink is the only thing a route writes: it owns liveness, so a frame after
6
+ * the end is a no-op and `end` twice is fine, and no caller has to ask the raw
7
+ * response whether it is still writable. The registry is the one place that knows
8
+ * how many are open, which is exactly what an idle policy needs — the last one
9
+ * leaving arms the grace, and a reload re-attaching stands it down.
10
+ *
11
+ * Value-only: the only state is what the returned closure carries.
12
+ */
13
+
14
+ import type { ServerResponse } from "node:http";
15
+ import { type ArmTimer, defaultTimer } from "../timer.ts";
16
+
17
+ /** One open event stream, as a route writes it. */
18
+ export interface EventSink {
19
+ /** Write one frame; a no-op once the stream has ended. */
20
+ write(frame: string): void;
21
+ /** End the stream, optionally with a last frame; safe to call twice. */
22
+ end(frame?: string): void;
23
+ }
24
+
25
+ /** Why a server went idle: no stream ever opened, or the last one left. */
26
+ export type IdleReason = "first" | "last";
27
+
28
+ /** The idle policy a server was given, or none. */
29
+ export interface IdleGrace {
30
+ /** How long the last stream's departure is held, in ms. */
31
+ ms: number;
32
+ /**
33
+ * How long a server may stand with no stream ever opened, in ms.
34
+ *
35
+ * A page opened in a browser that never came up would otherwise hold its port for the
36
+ * rest of the session. Omitted, the grace still waits for the first stream, which is
37
+ * what a server whose page is opened by hand wants.
38
+ */
39
+ firstMs?: number;
40
+ /** Called once no stream has been open for the grace that applied. */
41
+ onIdle: (reason: IdleReason) => void;
42
+ }
43
+
44
+ /** What the registry needs besides the streams themselves. */
45
+ export interface SinkDeps {
46
+ /** Grace after the last stream leaves; omitted, the server waits for `endAll`. */
47
+ idle?: IdleGrace;
48
+ /** The timer the grace is armed on; defaults to an unref'd one. */
49
+ timer?: ArmTimer;
50
+ }
51
+
52
+ /** The open streams of one loopback server. */
53
+ export interface SinkRegistry {
54
+ /** Open a sink over one response; ending it starts the idle grace. */
55
+ open(response: ServerResponse): EventSink;
56
+ /** How many streams are open. */
57
+ size(): number;
58
+ /** Arm the grace for a server nobody may ever read. Does nothing without `firstMs`. */
59
+ start(): void;
60
+ /** End every open stream, because the server is closing. */
61
+ endAll(): void;
62
+ }
63
+
64
+ export function createSinks(deps: SinkDeps = {}): SinkRegistry {
65
+ const streams = new Set<EventSink>();
66
+ const timer = deps.timer ?? defaultTimer;
67
+ let cancelIdle: (() => void) | undefined;
68
+ /** A stream has attached at least once, so a departure is one to wait on. */
69
+ let sawStream = false;
70
+ /** The server asked for the first grace, so it may go idle without a reader. */
71
+ let started = false;
72
+
73
+ const standDown = (): void => {
74
+ cancelIdle?.();
75
+ cancelIdle = undefined;
76
+ };
77
+
78
+ /** Arm `ms` from now, unless a stream is open or a grace already runs. */
79
+ const arm = (ms: number, reason: IdleReason): void => {
80
+ if (deps.idle === undefined || (!started && !sawStream) || streams.size > 0 || cancelIdle !== undefined) return;
81
+ cancelIdle = timer(ms, () => {
82
+ cancelIdle = undefined;
83
+ // A tab that attached while the grace ran has already stood it down, but a
84
+ // second look costs nothing and keeps a race from closing a page being read.
85
+ if (streams.size === 0) deps.idle?.onIdle(reason);
86
+ });
87
+ };
88
+
89
+ return {
90
+ open(response) {
91
+ standDown();
92
+ sawStream = true;
93
+ const sink = createSink(response, () => {
94
+ if (!streams.delete(sink)) return;
95
+ const idle = deps.idle;
96
+ if (idle !== undefined) arm(idle.ms, "last");
97
+ });
98
+ streams.add(sink);
99
+ return sink;
100
+ },
101
+ size: () => streams.size,
102
+ start() {
103
+ const idle = deps.idle;
104
+ if (idle?.firstMs === undefined || started || sawStream) return;
105
+ started = true;
106
+ arm(idle.firstMs, "first");
107
+ },
108
+ endAll() {
109
+ standDown();
110
+ const open = [...streams];
111
+ streams.clear();
112
+ for (const sink of open) sink.end();
113
+ },
114
+ };
115
+ }
116
+
117
+ /** A sink over one response, released from its registry exactly once. */
118
+ function createSink(response: ServerResponse, release: () => void): EventSink {
119
+ let ended = false;
120
+ return {
121
+ write(frame) {
122
+ if (ended || response.writableEnded || response.destroyed) return;
123
+ response.write(frame);
124
+ },
125
+ end(frame) {
126
+ if (ended) return;
127
+ ended = true;
128
+ if (!response.writableEnded && !response.destroyed) {
129
+ if (frame === undefined) response.end();
130
+ else response.end(frame);
131
+ }
132
+ release();
133
+ },
134
+ };
135
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The bearer token a loopback page carries in its URL.
3
+ *
4
+ * Any page a browser can open is a page any local process can call, so the token
5
+ * is the only thing standing between a served surface and a stranger's `fetch`:
6
+ * it is 128 random bits, minted once per server, and it is compared in constant
7
+ * time. Both halves live here rather than in each extension, because a page that
8
+ * mints its own secret and a page that verifies one another way is exactly the
9
+ * drift this directory exists to prevent.
10
+ *
11
+ * The comparison digests both sides first, so a wrong-length guess costs the same
12
+ * as a wrong-value one and the length of the real token is not observable.
13
+ */
14
+
15
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
16
+
17
+ /** Bytes of entropy per token. Hex-encoded, so a token is twice this many characters. */
18
+ export const TOKEN_BYTES = 16;
19
+
20
+ /** One fresh 128-bit token, as hex. */
21
+ export function mintToken(): string {
22
+ return randomBytes(TOKEN_BYTES).toString("hex");
23
+ }
24
+
25
+ /**
26
+ * Whether `given` is the token `expected`.
27
+ *
28
+ * An empty `expected` never matches: a caller that failed to mint one must not
29
+ * accept the empty string a request without a token presents.
30
+ */
31
+ export function tokenMatches(expected: string, given: string): boolean {
32
+ if (expected === "") return false;
33
+ return timingSafeEqual(sha256Of(expected), sha256Of(given));
34
+ }
35
+
36
+ function sha256Of(value: string): Buffer {
37
+ return createHash("sha256").update(value).digest();
38
+ }
package/lib/plan-entry.ts CHANGED
@@ -41,6 +41,17 @@ export const PLAN_CONSENT_SHORT =
41
41
  "Enter read-only plan mode? Writes and mutating tools are disabled until you approve a plan.";
42
42
  export const PLAN_CONSENT_SHORT_ZH = "进入只读 plan 模式?批准计划前会禁用写入和修改。";
43
43
 
44
+ /**
45
+ * The refusal a command shows when plan mode forbids what it does.
46
+ *
47
+ * Plan mode is read-only, so every extension whose command changes something says so the
48
+ * same way and names the action it is not doing: one sentence in `lib/`, because five
49
+ * copies is how the guarantee stops reading as one guarantee.
50
+ */
51
+ export function planModeRefusal(action: string): string {
52
+ return `Plan mode is read-only. Approve the plan before ${action}.`;
53
+ }
54
+
44
55
  /** What a caller sends on {@link PLAN_ENTER_REQUEST}. */
45
56
  export interface PlanEnterRequest {
46
57
  /** The caller's context; `plan` uses it for the footer status. */
package/lib/policy.ts CHANGED
@@ -9,11 +9,34 @@
9
9
  * — `bash`, MCP servers, future extension tools — rather than relying on
10
10
  * `write`/`edit` being the only mutators.
11
11
  *
12
- * No runtime dependencies: the caller supplies the annotation lookup. Only the
13
- * MCP hint type is imported (type-only).
12
+ * No host dependency beyond the annotation type: the caller supplies the
13
+ * annotation lookup, and the one reader name comes from `tool-names.ts`, so this
14
+ * module stays value-only.
14
15
  */
15
16
 
16
17
  import type { ToolAnnotations } from "@earendil-works/pi-coding-agent";
18
+ import { BOARD_READ_ACTION, READ_DOC_TOOL } from "./tool-names.ts";
19
+
20
+ /**
21
+ * Whether a call to the `board` tool only reads it.
22
+ *
23
+ * The board is one tool over ten actions, so its `readOnlyHint` says "may write" and
24
+ * cannot answer this. The action can, and it travels in the call's input — and in the
25
+ * result's `details`, which is all a `tool_execution_end` event carries. Plan mode
26
+ * asks before the call and the todo nudge after it, so the answer lives here once.
27
+ */
28
+ export function isBoardRead(call: unknown): boolean {
29
+ if (typeof call !== "object" || call === null) return false;
30
+ return (call as { action?: unknown }).action === BOARD_READ_ACTION;
31
+ }
32
+
33
+ /**
34
+ * Tools that only read the workspace: the built-in structured readers and the
35
+ * packaged document reader. One list for the read-only consumers — plan mode's
36
+ * allowlist, rewind's snapshot policy, and subagent's read-only child allowlist —
37
+ * so adding a reader is one edit rather than three.
38
+ */
39
+ export const WORKSPACE_READERS = ["read", "grep", "find", "ls", READ_DOC_TOOL] as const;
17
40
 
18
41
  interface ReadOnlyPolicyOptions {
19
42
  /** Tools allowed regardless of annotations (structured readers, plan tracker). */
@@ -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
@@ -211,6 +208,8 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
211
208
  },
212
209
  todo: {},
213
210
  goal: {},
211
+ board: {},
212
+ team: { notes: ["`assign` needs `session-bus` loaded to deliver a handoff; without it the card is untouched."] },
214
213
  check: {
215
214
  notes: ["Runs the project's detected command through a shell; a stack with no detector needs `check.command`."],
216
215
  },
@@ -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,201 @@
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
+ * The rule answers three things, not two: a record whose process is gone is `gone` at
13
+ * once, rather than aging into `quiet` for ten minutes while the paths it claimed stay
14
+ * held and the cards it held read `idle`.
15
+ *
16
+ * A missing directory is an answer, not an error: it means no session has ever
17
+ * joined this repository, and a caller renders that differently from an empty bus.
18
+ *
19
+ * Pure I/O, no state: one read answers one caller, and nothing is cached — a
20
+ * heartbeat is ten seconds old at most, and the caller polls on its own clock.
21
+ */
22
+
23
+ import { readdir, readFile } from "node:fs/promises";
24
+ import { join } from "node:path";
25
+ import { isPidAlive, type LivenessFn, startTokenOf, type StartTokenFn } from "./spawn.ts";
26
+
27
+ /** The bus directory under the agent dir, `session-bus`. */
28
+ export const BUS_DIR_NAME = "session-bus";
29
+ /** One file per session inside it. */
30
+ export const SESSIONS_DIR_NAME = "sessions";
31
+ /** One directory per session, holding its mail. */
32
+ export const INBOX_DIR_NAME = "inbox";
33
+ /** How long a record's heartbeat stays believable: the rule the bus prunes by. */
34
+ export const SESSION_TTL_MS = 10 * 60_000;
35
+
36
+ /** One session as the page shows it. The rest of the record stays on disk. */
37
+ export interface RosterSession {
38
+ id: string;
39
+ label: string;
40
+ mode: string;
41
+ /** When the session last proved it was alive. */
42
+ lastSeen: number;
43
+ /** Messages waiting in its inbox. */
44
+ mail: number;
45
+ /**
46
+ * The process that owns the record, when the record names one.
47
+ *
48
+ * Absent is "cannot tell": a record written before this existed, or by a mode that
49
+ * is not a local process, is judged by its heartbeat alone rather than being called
50
+ * dead on no evidence.
51
+ */
52
+ pid?: number;
53
+ /** That process's start time, as the bus wrote it, when the record carries it. */
54
+ startToken?: string;
55
+ /**
56
+ * Whether the session is listening for mail, when its record says.
57
+ *
58
+ * Absent is "not listening": a record written before this existed and a session that
59
+ * stopped listening read the same way, so a reader never claims a peer will be reached.
60
+ */
61
+ watching?: { armed: boolean; wake: boolean };
62
+ }
63
+
64
+ /** What a record's heartbeat and its process say together. */
65
+ export type RosterState = "live" | "quiet" | "gone";
66
+
67
+ /**
68
+ * What a record's heartbeat and its process say together.
69
+ *
70
+ * The fields the rule reads, rather than the whole roster row: `session-bus` judges its
71
+ * own records by the same rule without carrying a `mail` count it never shows.
72
+ */
73
+ export type RosterFacts = Pick<RosterSession, "lastSeen" | "pid" | "startToken">;
74
+
75
+ /**
76
+ * The host facts {@link rosterStateOf} reads, injectable so every branch is testable.
77
+ *
78
+ * The token spawns a process, so it is asked for only where liveness cannot decide.
79
+ */
80
+ export interface RosterSeams {
81
+ alive?: LivenessFn;
82
+ token?: StartTokenFn;
83
+ }
84
+
85
+ /**
86
+ * What one record says about its session: alive, aged out, or attached to nothing.
87
+ *
88
+ * Three answers, in the order they are decided:
89
+ *
90
+ * - `gone` — the record names a process and that process is not running. A heartbeat
91
+ * written ten seconds before a crash is not evidence the process exists, so death
92
+ * outranks a fresh one.
93
+ * - `live` — the heartbeat is fresh. When the pid is alive too, the start token has not
94
+ * been asked for yet: a session that wrote ten seconds ago is not the interesting
95
+ * case for pid reuse, and `ps` costs a process.
96
+ * - `quiet` — the heartbeat has aged. If the pid is alive but its start time is not the
97
+ * one the record wrote, the process is somebody else's and the session is `gone`
98
+ * after all; a platform that cannot answer the start time leaves it at `quiet`.
99
+ */
100
+ export function rosterStateOf(
101
+ session: RosterFacts,
102
+ now: number,
103
+ ttlMs = SESSION_TTL_MS,
104
+ seams: RosterSeams = {},
105
+ ): RosterState {
106
+ const alive = seams.alive ?? isPidAlive;
107
+ if (session.pid !== undefined && !alive(session.pid)) return "gone";
108
+ if (now - session.lastSeen <= ttlMs) return "live";
109
+ if (session.pid !== undefined && session.startToken !== undefined) {
110
+ const current = (seams.token ?? startTokenOf)(session.pid);
111
+ if (current !== undefined && current !== session.startToken) return "gone";
112
+ }
113
+ return "quiet";
114
+ }
115
+
116
+ /** The fields this reader trusts; anything else in the file is the bus's business. */
117
+ interface SessionPayload {
118
+ id: string;
119
+ label: string;
120
+ mode: string;
121
+ lastSeen: number;
122
+ }
123
+
124
+ /** A record's pid, read only when it is the shape a pid has. */
125
+ function pidOf(value: unknown): number | undefined {
126
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
127
+ }
128
+
129
+ function isSessionPayload(value: unknown): value is SessionPayload {
130
+ if (typeof value !== "object" || value === null) return false;
131
+ const { id, label, mode, lastSeen } = value as Record<string, unknown>;
132
+ return (
133
+ typeof id === "string" && typeof label === "string" && typeof mode === "string" && typeof lastSeen === "number"
134
+ );
135
+ }
136
+
137
+ /**
138
+ * The listening field, read only when it is the shape the bus writes.
139
+ *
140
+ * A malformed field is not a reason to hide a teammate: the row still draws, and the
141
+ * only thing lost is the claim that mail will reach it. Exported because the field is
142
+ * the record's, and `lib/session-tool.ts` reads the same one over the tool rather than
143
+ * over the files — one reader for one shape.
144
+ */
145
+ export function watchingOf(value: unknown): { armed: boolean; wake: boolean } | undefined {
146
+ if (typeof value !== "object" || value === null) return undefined;
147
+ const { armed, wake } = value as Record<string, unknown>;
148
+ return typeof armed === "boolean" && typeof wake === "boolean" ? { armed, wake } : undefined;
149
+ }
150
+
151
+ /** How many message files one session's inbox holds. A missing inbox is empty. */
152
+ async function mailCount(dir: string, stem: string): Promise<number> {
153
+ try {
154
+ const entries = await readdir(join(dir, INBOX_DIR_NAME, stem));
155
+ return entries.filter((name) => name.endsWith(".json")).length;
156
+ } catch {
157
+ return 0;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Every session the bus has recorded for one repository, or `undefined` when the bus
163
+ * has never been used here.
164
+ *
165
+ * One file that does not parse is skipped rather than thrown: a roster with three
166
+ * good sessions is worth more than an error about the fourth.
167
+ */
168
+ export async function readRoster(dir: string): Promise<RosterSession[] | undefined> {
169
+ let names: string[];
170
+ try {
171
+ names = await readdir(join(dir, SESSIONS_DIR_NAME));
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ const sessions: RosterSession[] = [];
176
+ for (const name of names) {
177
+ if (!name.endsWith(".json")) continue;
178
+ let parsed: unknown;
179
+ try {
180
+ parsed = JSON.parse(await readFile(join(dir, SESSIONS_DIR_NAME, name), "utf-8"));
181
+ } catch {
182
+ continue;
183
+ }
184
+ if (!isSessionPayload(parsed)) continue;
185
+ const { id, label, mode, lastSeen } = parsed;
186
+ const watching = watchingOf((parsed as { watching?: unknown }).watching);
187
+ const pid = pidOf((parsed as { pid?: unknown }).pid);
188
+ const rawToken = (parsed as { startToken?: unknown }).startToken;
189
+ sessions.push({
190
+ id,
191
+ label,
192
+ mode,
193
+ lastSeen,
194
+ mail: await mailCount(dir, name.slice(0, -".json".length)),
195
+ ...(pid === undefined ? {} : { pid }),
196
+ ...(typeof rawToken === "string" && rawToken !== "" ? { startToken: rawToken } : {}),
197
+ ...(watching === undefined ? {} : { watching }),
198
+ });
199
+ }
200
+ return sessions.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
201
+ }