@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
package/lib/http/serve.ts CHANGED
@@ -1,16 +1,21 @@
1
1
  /**
2
- * One loopback server per served surface: bind, accept, route, stream, idle,
3
- * close.
2
+ * The one loopback server a session serves its pages on: bind once, dispatch by
3
+ * mount, stream, idle, close.
4
4
  *
5
- * Both browser surfaces build the same socket around `lib/http`'s pieces, so the
6
- * socket lives here: an extension supplies a handler that turns one request into
7
- * one reply, and this module owns everything the request does not decide — the
8
- * body cap, `HEAD` of a stream, the event-stream head, the port retry, the idle
9
- * grace, and a close that is safe to call twice.
5
+ * A session has one port, so the socket cannot belong to a page. A page *mounts* a
6
+ * path prefix on it — `/board`, `/files`, `/plan/<id>` — and the server owns
7
+ * everything a request does not decide: the longest-prefix dispatch, the body cap
8
+ * of whichever mount was reached, `HEAD` of a stream, the event-stream head, the
9
+ * port retry, and a close that is safe to call twice. A mount's handler is handed
10
+ * the request target *relative to its own prefix*, so a router keeps matching the
11
+ * paths it always matched.
10
12
  *
11
- * The `Host` rule stays with each router: the allowlist is per extension, and for
12
- * a shared review it grows while the server is up, so it cannot be passed in
13
- * once.
13
+ * The idle grace is per mount, not per socket: one page going quiet must not close
14
+ * another's, so a mount carries its own open-stream registry and release, and the
15
+ * socket closes when its last mount leaves.
16
+ *
17
+ * The `Host` rule stays with each router: the allowlist is per extension, and for a
18
+ * shared review it grows while the server is up, so it cannot be passed in once.
14
19
  *
15
20
  * Value-only: the only state is what a returned closure carries.
16
21
  */
@@ -25,6 +30,7 @@ import {
25
30
  import type { ArmTimer } from "../timer.ts";
26
31
  import { jsonHeaders } from "./headers.ts";
27
32
  import { type ServeResponse, textResponse } from "./response.ts";
33
+ import { parseRequestUrl } from "./router.ts";
28
34
  import { bindHost, createCloser, openEventStream, respond } from "./server.ts";
29
35
  import { createSinks, type EventSink, type IdleGrace, type SinkRegistry } from "./sink.ts";
30
36
 
@@ -32,7 +38,7 @@ import { createSinks, type EventSink, type IdleGrace, type SinkRegistry } from "
32
38
  export interface LoopbackRequest {
33
39
  /** Upper-cased method, e.g. `GET`. */
34
40
  method: string;
35
- /** The request target, path and query. */
41
+ /** The request target, path and query, relative to the mount's prefix. */
36
42
  url: string;
37
43
  /** Headers exactly as the socket carried them; the router applies its `Host` rule. */
38
44
  headers: IncomingHttpHeaders;
@@ -57,35 +63,67 @@ export interface LoopbackReply {
57
63
  /** One request in, one reply out. A throw is answered as a plain 500. */
58
64
  export type LoopbackHandler = (request: LoopbackRequest) => Promise<LoopbackReply> | LoopbackReply;
59
65
 
60
- export interface LoopbackOptions {
61
- /** The address to bind; defaults to `127.0.0.1`. */
62
- host?: string;
63
- /** Ports tried in order, so a remembered port can fall back to an OS-chosen one. */
64
- ports: readonly number[];
66
+ /** One page's claim on a path prefix. */
67
+ export interface ServedMount {
68
+ /** The prefix it answers under: `/board`, or `""` for the whole origin. */
69
+ prefix: string;
65
70
  handler: LoopbackHandler;
66
- /** Largest body accepted; absent, no body is read and a route sees `""`. */
71
+ /** Largest body accepted here; absent, no body is read and a route sees `""`. */
67
72
  maxBodyBytes?: number;
68
- /** Grace after the last event stream leaves; omitted, the server waits for `close()`. */
73
+ /** Grace after this mount's last stream leaves; omitted, it waits for `release()`. */
69
74
  idle?: IdleGrace;
70
- /** The timer the idle grace is armed on. */
75
+ /** The timer this mount's idle grace is armed on. */
71
76
  timer?: ArmTimer;
72
77
  }
73
78
 
74
- /** A bound loopback server. */
75
- export interface LoopbackServer {
79
+ /** A prefix served on a running server. */
80
+ export interface Mount {
81
+ /** The normalized prefix, e.g. `/board`. */
82
+ base: string;
83
+ /** The port the server bound. */
84
+ port: number;
85
+ /** `http://<host>:<port>`, the address this mount is reached on. */
86
+ origin: string;
87
+ /**
88
+ * End this mount's streams and free its prefix. Idempotent, and the server closes
89
+ * with its last mount.
90
+ */
91
+ release(): Promise<void>;
92
+ }
93
+
94
+ export interface ServeOptions {
95
+ /** The address to bind; defaults to `127.0.0.1`. */
96
+ host?: string;
97
+ /** Ports tried in order, so a remembered port can fall back to an OS-chosen one. */
98
+ ports: readonly number[];
99
+ }
100
+
101
+ /** A bound server, before any page has mounted on it. */
102
+ export interface ServedServer {
76
103
  /** The port it actually bound. */
77
104
  port: number;
78
105
  /** `http://<host>:<port>`, the address the caller prints. */
79
106
  origin: string;
80
107
  /**
81
- * End every open stream, stop the idle grace, and close the socket. Idempotent.
82
- *
83
- * A last frame is the caller's to send before it calls this: closing is not a
84
- * conclusion, and only the route knows what its readers were waiting for.
108
+ * Add one prefix. `undefined` means the prefix is already taken, which a caller
109
+ * reports rather than lets two pages answer as one.
110
+ */
111
+ mount(spec: ServedMount): Mount | undefined;
112
+ /**
113
+ * End every mount's streams and close the socket. Idempotent, and what the last
114
+ * mount's release does on its own.
85
115
  */
86
116
  close(): Promise<void>;
87
117
  }
88
118
 
119
+ /** One prefix as the server holds it: its spec, its streams, and its mount handle. */
120
+ interface Route {
121
+ spec: ServedMount;
122
+ base: string;
123
+ sinks: SinkRegistry;
124
+ mount: Mount;
125
+ }
126
+
89
127
  /** The one refusal this layer answers itself: a body it will not read. */
90
128
  const BODY_TOO_LARGE: ServeResponse = {
91
129
  status: 413,
@@ -99,6 +137,10 @@ const STREAM_WITHOUT_SINK: ServeResponse = textResponse(500, "Internal error");
99
137
  /** A handler that threw, or a socket that failed after its head was written. */
100
138
  const INTERNAL_ERROR: ServeResponse = textResponse(500, "Internal error");
101
139
 
140
+ /** A prefix no mount claims, and a target no URL parser accepts. */
141
+ const NOT_FOUND: ServeResponse = textResponse(404, "Nothing is served at this path.");
142
+ const MALFORMED: ServeResponse = textResponse(400, "Malformed request URL.");
143
+
102
144
  /**
103
145
  * Bind the first port that answers, falling back in order. Rejects with the last
104
146
  * bind error, so a caller can still name the reason (`EADDRINUSE` and friends).
@@ -115,6 +157,31 @@ async function bindFirst(server: Server, host: string, ports: readonly number[])
115
157
  throw failure instanceof Error ? failure : new Error(`could not bind ${host}`);
116
158
  }
117
159
 
160
+ /** One prefix, spelled the way dispatch compares it: no trailing slash, a leading one. */
161
+ function normalizeBase(prefix: string): string {
162
+ const trimmed = prefix.replace(/\/+$/, "");
163
+ if (trimmed === "" || trimmed === "/") return "";
164
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
165
+ }
166
+
167
+ /** Whether `base` is a prefix of `pathname` at a segment boundary; `""` covers everything. */
168
+ function prefixCovers(base: string, pathname: string): boolean {
169
+ return base === "" || pathname === base || pathname.startsWith(`${base}/`);
170
+ }
171
+
172
+ /**
173
+ * The mount a path belongs to: the longest prefix that covers it, so `/plan/a` wins
174
+ * over a bare `/plan` and a root mount is the fallback rather than the rival.
175
+ */
176
+ function routeFor(routes: Map<string, Route>, pathname: string): Route | undefined {
177
+ let best: Route | undefined;
178
+ for (const route of routes.values()) {
179
+ if (!prefixCovers(route.base, pathname)) continue;
180
+ if (best === undefined || route.base.length > best.base.length) best = route;
181
+ }
182
+ return best;
183
+ }
184
+
118
185
  /**
119
186
  * Read a request body, stopping at `limit`. An oversize body is reported rather
120
187
  * than destroyed, so it can still be answered 413 over the open socket instead of
@@ -157,22 +224,45 @@ function once(fn: (() => void) | undefined): (() => void) | undefined {
157
224
  };
158
225
  }
159
226
 
160
- /** Route one request: read, dispatch, then answer, stream, or refuse. */
227
+ /**
228
+ * Route one request: pick the mount, read, dispatch, then answer, stream, or refuse.
229
+ *
230
+ * A request that names a prefix without its slash is redirected to the slashed form,
231
+ * because a page's own links are relative to its directory and `/board` is not one.
232
+ */
161
233
  async function answer(
162
234
  request: IncomingMessage,
163
235
  response: ServerResponse,
164
- options: LoopbackOptions,
165
- sinks: SinkRegistry,
236
+ routes: Map<string, Route>,
237
+ redirect: (to: string) => ServeResponse,
166
238
  ): Promise<void> {
167
239
  const method = request.method ?? "GET";
168
- const body = options.maxBodyBytes === undefined ? "" : await readBody(request, options.maxBodyBytes);
240
+ const target = request.url ?? "/";
241
+ const parsed = parseRequestUrl(target);
242
+ if (!parsed) {
243
+ respond(request, response, MALFORMED);
244
+ return;
245
+ }
246
+ const route = routeFor(routes, parsed.pathname);
247
+ if (route === undefined) {
248
+ respond(request, response, NOT_FOUND);
249
+ return;
250
+ }
251
+ if (route.base !== "" && parsed.pathname === route.base) {
252
+ respond(request, response, redirect(`${route.base}/${parsed.search.toString() === "" ? "" : `?${parsed.search}`}`));
253
+ return;
254
+ }
255
+ const rest = target.slice(route.base.length);
256
+ const url = rest === "" ? "/" : rest.startsWith("?") ? `/${rest}` : rest;
257
+ const limit = route.spec.maxBodyBytes;
258
+ const body = limit === undefined ? "" : await readBody(request, limit);
169
259
  if (body === undefined) {
170
260
  respond(request, response, BODY_TOO_LARGE);
171
261
  return;
172
262
  }
173
263
  let reply: LoopbackReply;
174
264
  try {
175
- reply = await options.handler({ method, url: request.url ?? "/", headers: request.headers, body });
265
+ reply = await route.spec.handler({ method, url, headers: request.headers, body });
176
266
  } catch {
177
267
  reply = { result: INTERNAL_ERROR };
178
268
  }
@@ -186,7 +276,7 @@ async function answer(
186
276
  // the client would otherwise wait on frames it asked not to get.
187
277
  if (reply.openSink !== undefined && method.toUpperCase() !== "HEAD") {
188
278
  openEventStream(response, reply.result);
189
- const sink = sinks.open(response);
279
+ const sink = route.sinks.open(response);
190
280
  const detach = reply.openSink(sink);
191
281
  let closed = false;
192
282
  const finish = (): void => {
@@ -205,33 +295,67 @@ async function answer(
205
295
  }
206
296
 
207
297
  /** Start a server on the first port that binds, and close it idempotently. */
208
- export async function serveLoopback(options: LoopbackOptions): Promise<LoopbackServer> {
298
+ export async function serveHttp(options: ServeOptions): Promise<ServedServer> {
209
299
  const host = options.host ?? "127.0.0.1";
210
- const sinks = createSinks({ idle: options.idle, timer: options.timer });
300
+ const routes = new Map<string, Route>();
301
+ let closed = false;
302
+ let closeServer: (() => Promise<void>) | undefined;
303
+
304
+ const close = async (): Promise<void> => {
305
+ if (closed) return;
306
+ closed = true;
307
+ // Streams end first: a connected page must not hold the close open.
308
+ const open = [...routes.values()];
309
+ routes.clear();
310
+ for (const route of open) route.sinks.endAll();
311
+ await closeServer?.();
312
+ };
313
+
211
314
  const server = createServer((request, response) => {
212
315
  // A page that walks away mid-frame must not take the session down.
213
316
  response.on("error", () => {});
214
- void answer(request, response, options, sinks).catch(() => {
317
+ void answer(request, response, routes, (to) => ({
318
+ status: 301,
319
+ headers: { ...jsonHeaders(), Location: to },
320
+ body: "",
321
+ })).catch(() => {
215
322
  // A route that failed after its head was written can only be cut off; ending
216
323
  // the socket beats a client waiting on a reply that will not come.
217
324
  if (!response.writableEnded) response.end();
218
325
  });
219
326
  });
220
327
  const port = await bindFirst(server, host, options.ports);
221
- // The socket is bound but no page may ever read it: a server that asked for a first
222
- // grace arms it here, so a browser that never came up cannot hold the port all session.
223
- sinks.start();
224
- const closeServer = createCloser(server);
225
- let closed = false;
328
+ closeServer = createCloser(server);
329
+ const origin = `http://${host}:${port}`;
330
+
226
331
  return {
227
332
  port,
228
- origin: `http://${host}:${port}`,
229
- close: async () => {
230
- if (closed) return;
231
- closed = true;
232
- // Streams end first: a connected page must not hold the close open.
233
- sinks.endAll();
234
- await closeServer();
333
+ origin,
334
+ mount(spec) {
335
+ const base = normalizeBase(spec.prefix);
336
+ if (routes.has(base)) return undefined;
337
+ const sinks = createSinks({ idle: spec.idle, timer: spec.timer });
338
+ const route: Route = {
339
+ spec: { ...spec, prefix: base },
340
+ base,
341
+ sinks,
342
+ mount: {
343
+ base,
344
+ port,
345
+ origin,
346
+ release: async () => {
347
+ if (!routes.delete(base)) return;
348
+ sinks.endAll();
349
+ if (routes.size === 0) await close();
350
+ },
351
+ },
352
+ };
353
+ routes.set(base, route);
354
+ // A mount that asked for a first grace arms it here, so a browser that never
355
+ // came up cannot hold the prefix for the rest of the session.
356
+ sinks.start();
357
+ return route.mount;
235
358
  },
359
+ close,
236
360
  };
237
361
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The socket primitives `serveLoopback` is built from: bind, write a response
3
- * out, and close.
2
+ * The socket primitives `serveHttp` is built from: bind, write a response out, and
3
+ * close.
4
4
  *
5
5
  * Internal to the layer: an extension starts a server through
6
- * `serveLoopback`, which composes these, so this module is reached from `lib/http`
6
+ * `serveHttp`, which composes these, so this module is reached from `lib/http`
7
7
  * and its own suite — not from the barrel. Everything here is behavior a caller
8
8
  * cannot get right by accident: a bind on an address the caller names (loopback
9
9
  * unless it asked for more), an accept-level `error` listener so a transient
package/lib/lazy.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One async load, run at most once.
3
+ *
4
+ * A surface a command needs but registration does not — a browser page, a
5
+ * review server, a screen — is imported through this: `get()` runs the loader
6
+ * and remembers the value, and `peek()` answers the value already loaded, or
7
+ * `undefined`, so a shutdown path never starts a load the session never used.
8
+ *
9
+ * A rejected load is not remembered. A transient failure can be retried, and a
10
+ * caller that sees the error can ask again; the alternative would make one bad
11
+ * import permanent for the process.
12
+ *
13
+ * State lives in this closure, not at module scope, because `lib/` is loaded
14
+ * with isolated module caches and is value-only.
15
+ */
16
+
17
+ /** A value loaded on first use, with a way to ask whether it is already here. */
18
+ export interface Lazy<T> {
19
+ /** Load the value, or return the one already loaded; concurrent calls share one load. */
20
+ get(): Promise<T>;
21
+ /** The loaded value, or `undefined` before a load and after a failed one. */
22
+ peek(): T | undefined;
23
+ }
24
+
25
+ export function lazy<T>(load: () => Promise<T>): Lazy<T> {
26
+ let loaded = false;
27
+ let value: T;
28
+ let pending: Promise<T> | undefined;
29
+ const get = (): Promise<T> => {
30
+ if (loaded) return Promise.resolve(value);
31
+ pending ??= load().then(
32
+ (result) => {
33
+ loaded = true;
34
+ value = result;
35
+ return result;
36
+ },
37
+ (error: unknown) => {
38
+ pending = undefined;
39
+ throw error;
40
+ },
41
+ );
42
+ return pending;
43
+ };
44
+ return { get, peek: () => (loaded ? value : undefined) };
45
+ }
package/lib/plan-entry.ts CHANGED
@@ -16,21 +16,26 @@
16
16
  */
17
17
 
18
18
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import { type BusAnswer, requestOverBus } from "./bus-request.ts";
19
20
  import { isPlanModeEnabled } from "./plan-state.ts";
20
- import { PLAN_ENTER_TOOL } from "./tool-names.ts";
21
+ import { PLAN_TOOL } from "./tool-names.ts";
21
22
 
22
23
  /** EventBus channel on which a caller asks `plan` to enable plan mode. */
23
24
  export const PLAN_ENTER_REQUEST = "my-pi-agent/plan-mode/enter";
24
25
 
26
+ /** EventBus channel on which a caller asks `plan` to flip the mode. */
27
+ export const PLAN_TOGGLE_REQUEST = "my-pi-agent/plan-mode/toggle";
28
+
25
29
  /** One clause describing the read-only guarantee, reused across prompts. */
26
30
  export const READ_ONLY_SUMMARY =
27
31
  "write and edit are disabled, bash is limited to read-only git commands, and other mutating tools are blocked; " +
28
- "subagent delegation is forced read-only, and the only file write is write_plan (a plan file in the agent " +
29
- "directory)";
32
+ "subagent delegation is forced read-only, and the only file write is the plan file, saved by the `plan` tool's " +
33
+ "`write` action in the agent directory";
30
34
 
31
35
  /**
32
- * The consent question, as the user sees it. Shown by `enter_plan_mode` and by
33
- * any caller that offers plan mode itself, so both describe the same guarantee.
36
+ * The consent question, as the user sees it. Shown by the `plan` tool's `enter`
37
+ * action and by any caller that offers plan mode itself, so both describe the same
38
+ * guarantee.
34
39
  */
35
40
  export const PLAN_CONSENT =
36
41
  `Plan mode is read-only exploration: ${READ_ONLY_SUMMARY}. ` +
@@ -52,12 +57,19 @@ export function planModeRefusal(action: string): string {
52
57
  return `Plan mode is read-only. Approve the plan before ${action}.`;
53
58
  }
54
59
 
55
- /** What a caller sends on {@link PLAN_ENTER_REQUEST}. */
56
- export interface PlanEnterRequest {
60
+ /** What a caller sends on {@link PLAN_ENTER_REQUEST} or {@link PLAN_TOGGLE_REQUEST}. */
61
+ export interface PlanModeRequest {
57
62
  /** The caller's context; `plan` uses it for the footer status. */
58
63
  ctx: ExtensionContext;
59
- /** Called once, synchronously, with whether plan mode is on. */
60
- respond(entered: boolean): void;
64
+ /** Claimed once, with whether plan mode is on; the caller settles on that claim. */
65
+ respond(enabled: BusAnswer<boolean>): void;
66
+ }
67
+
68
+ /**
69
+ * Whether the `plan` extension registered its tool in this session.
70
+ */
71
+ export function isPlanExtensionLoaded(pi: Pick<ExtensionAPI, "getAllTools">): boolean {
72
+ return pi.getAllTools().some((tool) => tool.name === PLAN_TOOL);
61
73
  }
62
74
 
63
75
  /**
@@ -65,23 +77,36 @@ export interface PlanEnterRequest {
65
77
  *
66
78
  * Resolves `true` without asking when the branch is already planning, and
67
79
  * `false` when the `plan` tool is not registered — so `goal` loaded alone keeps
68
- * working and nothing waits on a listener that is not there. The listener answers
69
- * synchronously; the microtask is the fallback that guarantees resolution.
80
+ * working and nothing waits on a listener that is not there. The listener claims
81
+ * synchronously; the shared handshake is what guarantees a resolution.
70
82
  */
71
83
  export function requestPlanMode(
72
84
  pi: Pick<ExtensionAPI, "events" | "getAllTools">,
73
85
  ctx: ExtensionContext,
74
86
  ): Promise<boolean> {
75
87
  if (isPlanModeEnabled(ctx.sessionManager.getBranch())) return Promise.resolve(true);
76
- if (!pi.getAllTools().some((tool) => tool.name === PLAN_ENTER_TOOL)) return Promise.resolve(false);
77
- return new Promise<boolean>((resolve) => {
78
- let settled = false;
79
- const respond = (entered: boolean): void => {
80
- if (settled) return;
81
- settled = true;
82
- resolve(entered);
83
- };
84
- pi.events.emit(PLAN_ENTER_REQUEST, { ctx, respond } satisfies PlanEnterRequest);
85
- queueMicrotask(() => respond(false));
88
+ if (!isPlanExtensionLoaded(pi)) return Promise.resolve(false);
89
+ return requestOverBus<boolean, PlanModeRequest>(pi, {
90
+ channel: PLAN_ENTER_REQUEST,
91
+ payload: (respond) => ({ ctx, respond }),
92
+ unclaimed: () => false,
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Ask `plan` to flip the mode, and report the state it left behind.
98
+ *
99
+ * A toggle has no branch precondition: the owner reads the flag it persisted and
100
+ * resolves the opposite. `undefined` means nothing is listening (the `plan`
101
+ * extension is not loaded), so the caller can leave its own key alone.
102
+ */
103
+ export function requestPlanToggle(
104
+ pi: Pick<ExtensionAPI, "events">,
105
+ ctx: ExtensionContext,
106
+ ): Promise<boolean | undefined> {
107
+ return requestOverBus<boolean | undefined, PlanModeRequest>(pi, {
108
+ channel: PLAN_TOGGLE_REQUEST,
109
+ payload: (respond) => ({ ctx, respond }),
110
+ unclaimed: () => undefined,
86
111
  });
87
112
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Where a plan lives, and which paths may be read or written as one.
3
+ *
4
+ * A plan file is the artifact a card is executed against, so two extensions have to
5
+ * agree about it: `plan` writes and reads the store, and `board` records the file a
6
+ * card promises. The store is `<agent-dir>/plans/<repo-key>` for a repository and
7
+ * `<agent-dir>/plans` outside one — the key is the same one `repo-key.ts` hands
8
+ * every other per-repository store, which is why the keyed branch lives here rather
9
+ * than in either caller.
10
+ *
11
+ * The rules are one body for the same reason: a copy of `isContainedPlan` that
12
+ * dropped the symlink half would look identical at a glance and would be the one
13
+ * that let a link inside the store point outside it. `plan` passes a path here
14
+ * before it writes; `board` passes the path it is about to record, and asks whether
15
+ * it is a plan of this repository.
16
+ *
17
+ * Value-only: no module state, and the only I/O is the `lstat` a symlink check needs.
18
+ */
19
+
20
+ import { lstatSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
23
+ import { isInside, isInsideReal } from "../path.ts";
24
+ import type { RepoKey } from "../repo-key.ts";
25
+
26
+ /** The agent-directory entry plans are filed under. */
27
+ export const PLANS_DIR_NAME = "plans";
28
+
29
+ /** The plan store: `<agent-dir>/plans`, or `<agent-dir>/plans/<key>` inside a repository. */
30
+ export function plansDir(agentDir: string, key?: string): string {
31
+ return key === undefined ? join(agentDir, PLANS_DIR_NAME) : join(agentDir, PLANS_DIR_NAME, key);
32
+ }
33
+
34
+ /**
35
+ * The store for a resolved repository: keyed when git answered, unkeyed outside one.
36
+ *
37
+ * The `fromGit` branch lives here and nowhere else — that is the line a copy gets wrong,
38
+ * and both the plan tool and the board reach it through this one function.
39
+ */
40
+ export function plansDirFor(agentDir: string, repo: Pick<RepoKey, "key" | "fromGit">): string {
41
+ return plansDir(agentDir, repo.fromGit ? repo.key : undefined);
42
+ }
43
+
44
+ /** A plan file name: markdown and not a dotfile, so the self-ignore file is never a target. */
45
+ export function isPlanFileName(path: string): boolean {
46
+ const name = basename(path);
47
+ return name.toLowerCase().endsWith(".md") && !name.startsWith(".");
48
+ }
49
+
50
+ /**
51
+ * Whether `path` may be read, written, or deleted as a plan. Beyond lexical
52
+ * containment (shared with the other path guards through `lib/path.ts`), this
53
+ * rejects dotfiles (including the directory's own `.gitignore`) and resolves
54
+ * symlinks, so a link placed inside the plans directory cannot redirect a read
55
+ * or write outside it.
56
+ */
57
+ export function isContainedPlan(dir: string, path: string): boolean {
58
+ if (!isInside(dir, path) || !isPlanFileName(path)) return false;
59
+ try {
60
+ // A symlink itself must never be the target; a missing file is a new plan.
61
+ if (lstatSync(path).isSymbolicLink()) return false;
62
+ } catch {
63
+ // Missing (ENOENT) is expected; any other error falls through to the
64
+ // real-path check, which rejects the path if it cannot be resolved.
65
+ }
66
+ return isInsideReal(dir, dirname(path));
67
+ }
68
+
69
+ /**
70
+ * Resolve a `plan_path` argument against the store.
71
+ *
72
+ * A plan lives outside the project, so a path relative to the working directory
73
+ * can no longer name one. Absolute paths and the `~/…` form the tools display are
74
+ * accepted, and a bare name resolves inside the store — which is what a reader of
75
+ * a tool result has in hand.
76
+ */
77
+ export function resolvePlanArg(dir: string, planPath: string): string {
78
+ const text = planPath.trim();
79
+ if (text === "~") return homedir();
80
+ if (text.startsWith("~/") || text.startsWith(`~${sep}`)) return resolve(homedir(), text.slice(2));
81
+ return isAbsolute(text) ? resolve(text) : resolve(dir, text);
82
+ }
package/lib/policy.ts CHANGED
@@ -10,24 +10,27 @@
10
10
  * `write`/`edit` being the only mutators.
11
11
  *
12
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.
13
+ * annotation lookup, the one reader name comes from `tool-names.ts`, and which board
14
+ * actions read comes from `board/actions.ts`, so this module stays value-only.
15
15
  */
16
16
 
17
17
  import type { ToolAnnotations } from "@earendil-works/pi-coding-agent";
18
- import { BOARD_READ_ACTION, READ_DOC_TOOL } from "./tool-names.ts";
18
+ import { isBoardReadAction } from "./board/actions.ts";
19
+ import { READ_DOC_TOOL } from "./tool-names.ts";
19
20
 
20
21
  /**
21
22
  * Whether a call to the `board` tool only reads it.
22
23
  *
23
- * The board is one tool over ten actions, so its `readOnlyHint` says "may write" and
24
+ * The board is one tool over many actions, so its `readOnlyHint` says "may write" and
24
25
  * cannot answer this. The action can, and it travels in the call's input — and in the
25
26
  * 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
+ * asks before the call and the todo nudge after it, so the answer lives here once —
28
+ * and the answer is the board's own table, because an action list kept here is a list
29
+ * that drifts.
27
30
  */
28
31
  export function isBoardRead(call: unknown): boolean {
29
32
  if (typeof call !== "object" || call === null) return false;
30
- return (call as { action?: unknown }).action === BOARD_READ_ACTION;
33
+ return isBoardReadAction((call as { action?: unknown }).action);
31
34
  }
32
35
 
33
36
  /**
package/lib/report.ts CHANGED
@@ -147,8 +147,9 @@ export interface PendingChannelOptions {
147
147
  * The order inside `session_start` is the contract: the reporter forgets the
148
148
  * previous session's busy window, the runtime loads the records, and only then
149
149
  * is the report trigger armed — a reload or a replaced session reuses the same
150
- * runtime. A shutdown clears the trigger before the runtime releases the table it
151
- * was calling into.
150
+ * runtime, so a runtime that can settle an outcome while loading holds what it
151
+ * settled until this arms. A shutdown clears the trigger before the runtime
152
+ * releases the table it was calling into.
152
153
  */
153
154
  export function registerPendingChannel(
154
155
  pi: Pick<ExtensionAPI, "on">,