@gavin-hu/my-pi-agent 0.18.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 (249) hide show
  1. package/README.md +52 -44
  2. package/README.zh-CN.md +46 -41
  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} +446 -404
  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} +61 -50
  13. package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
  14. package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
  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 +285 -0
  18. package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
  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} +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} +258 -162
  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/panel/commands.ts +40 -7
  51. package/extensions/bytefly/panel/settings-runtime.ts +0 -6
  52. package/extensions/bytefly/panel/settings.ts +3 -20
  53. package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
  54. package/extensions/bytefly/picker/discovery.ts +0 -2
  55. package/extensions/bytefly/picker/types.ts +0 -2
  56. package/extensions/bytefly/update/check.ts +39 -0
  57. package/extensions/bytefly/update/format.ts +15 -0
  58. package/extensions/bytefly/update/register.ts +73 -34
  59. package/extensions/bytefly/update/state.ts +6 -3
  60. package/extensions/context/tui.ts +1 -1
  61. package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
  62. package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
  63. package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
  64. package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
  65. package/extensions/file-browse/index.ts +4 -4
  66. package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
  67. package/extensions/file-browse/{router.ts → transport/router.ts} +21 -15
  68. package/extensions/file-browse/transport/server.ts +110 -0
  69. package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
  70. package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
  71. package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
  72. package/extensions/file-browse/{html.ts → view/html.ts} +14 -12
  73. package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
  74. package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
  75. package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
  76. package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
  77. package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
  78. package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
  79. package/extensions/file-read/schema.ts +2 -1
  80. package/extensions/memory/store.ts +12 -5
  81. package/extensions/plan/blocks.ts +23 -2
  82. package/extensions/plan/commands.ts +21 -16
  83. package/extensions/plan/feedback.ts +147 -38
  84. package/extensions/plan/index.ts +6 -4
  85. package/extensions/plan/plan-index.ts +43 -9
  86. package/extensions/plan/plan-text.ts +33 -23
  87. package/extensions/plan/plans.ts +8 -11
  88. package/extensions/plan/policy.ts +20 -12
  89. package/extensions/plan/review.ts +3 -27
  90. package/extensions/plan/runtime.ts +21 -9
  91. package/extensions/plan/sections.ts +112 -0
  92. package/extensions/plan/steps.ts +3 -22
  93. package/extensions/plan/tools/exit.ts +11 -95
  94. package/extensions/plan/tui.ts +5 -34
  95. package/extensions/plan/types.ts +0 -8
  96. package/extensions/plan/web-client.ts +1 -1
  97. package/extensions/plan/web-page.ts +0 -7
  98. package/extensions/plan/web-router.ts +3 -57
  99. package/extensions/plan/web-share-session.ts +24 -30
  100. package/extensions/plan/web-streams.ts +14 -14
  101. package/extensions/plan/web-tokens.ts +2 -2
  102. package/extensions/plan/web.ts +66 -120
  103. package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
  104. package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
  105. package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
  106. package/extensions/rewind/{config.ts → config/config.ts} +1 -1
  107. package/extensions/rewind/index.ts +3 -3
  108. package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
  109. package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
  110. package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
  111. package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
  112. package/extensions/rewind/{format.ts → text/format.ts} +3 -3
  113. package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
  114. package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
  115. package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
  116. package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
  117. package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
  118. package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
  119. package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
  120. package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
  121. package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
  122. package/extensions/rewind/{status.ts → view/status.ts} +4 -4
  123. package/extensions/session-bus/commands.ts +17 -69
  124. package/extensions/session-bus/format.ts +22 -45
  125. package/extensions/session-bus/index.ts +13 -7
  126. package/extensions/session-bus/join-request.ts +40 -0
  127. package/extensions/session-bus/mail-watch.ts +134 -0
  128. package/extensions/session-bus/notice.ts +32 -4
  129. package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
  130. package/extensions/session-bus/protocol.ts +19 -49
  131. package/extensions/session-bus/resolve.ts +67 -0
  132. package/extensions/session-bus/result.ts +16 -51
  133. package/extensions/session-bus/rows.ts +4 -2
  134. package/extensions/session-bus/runtime.ts +129 -238
  135. package/extensions/session-bus/store.ts +42 -6
  136. package/extensions/session-bus/tools.ts +31 -66
  137. package/extensions/session-bus/tui.ts +4 -4
  138. package/extensions/session-bus/types.ts +13 -13
  139. package/extensions/subagent/agents.ts +9 -8
  140. package/extensions/subagent/invocation.ts +4 -2
  141. package/extensions/team/commands.ts +112 -0
  142. package/extensions/team/dispatch.ts +133 -0
  143. package/extensions/team/format.ts +105 -0
  144. package/extensions/team/index.ts +33 -0
  145. package/extensions/team/schema.ts +29 -0
  146. package/extensions/team/tools.ts +246 -0
  147. package/extensions/team/types.ts +48 -0
  148. package/extensions/web-access/fetch/page.ts +4 -1
  149. package/extensions/web-access/fetch/structured.ts +447 -0
  150. package/extensions/web-access/fetch/tool.ts +2 -1
  151. package/extensions/web-access/search/tool.ts +2 -1
  152. package/extensions/wechat/bridge.ts +76 -92
  153. package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
  154. package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
  155. package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
  156. package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
  157. package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
  158. package/extensions/wechat/dialog/channels.ts +136 -0
  159. package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
  160. package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
  161. package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
  162. package/extensions/wechat/index.ts +10 -10
  163. package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
  164. package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
  165. package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
  166. package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
  167. package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
  168. package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
  169. package/extensions/wechat/{state.ts → state/state.ts} +14 -2
  170. package/extensions/wechat/{format.ts → text/format.ts} +3 -3
  171. package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
  172. package/extensions/wechat/tools.ts +29 -12
  173. package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
  174. package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
  175. package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
  176. package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
  177. package/extensions/wechat/transport/outbound.ts +142 -0
  178. package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
  179. package/extensions/wechat/types.ts +10 -1
  180. package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
  181. package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
  182. package/extensions/worktree/guard.ts +3 -5
  183. package/lib/README.md +8 -6
  184. package/lib/board/card.ts +290 -0
  185. package/lib/board/links.ts +117 -0
  186. package/lib/board/page-pointer.ts +256 -0
  187. package/lib/board/round-text.ts +72 -0
  188. package/lib/board/round.ts +89 -0
  189. package/lib/board/rules.ts +304 -0
  190. package/lib/board/session.ts +105 -0
  191. package/lib/board/store.ts +310 -0
  192. package/lib/board/view.ts +150 -0
  193. package/lib/errors.ts +12 -4
  194. package/lib/format.ts +13 -0
  195. package/lib/http/guard.ts +112 -0
  196. package/lib/http/index.ts +16 -5
  197. package/lib/http/serve.ts +234 -0
  198. package/lib/http/server.ts +12 -10
  199. package/lib/http/sink.ts +110 -0
  200. package/lib/http/token.ts +38 -0
  201. package/lib/policy.ts +12 -2
  202. package/lib/requirements.ts +6 -7
  203. package/lib/session-label.ts +64 -0
  204. package/lib/session-roster.ts +130 -0
  205. package/lib/session-tool.ts +184 -0
  206. package/lib/tool-names.ts +25 -2
  207. package/lib/tui/glyphs.ts +2 -0
  208. package/lib/web/README.md +5 -2
  209. package/lib/web/components.ts +5 -0
  210. package/lib/web/runtime.ts +7 -1
  211. package/lib/web/tokens.ts +25 -2
  212. package/package.json +26 -14
  213. package/skills/next/SKILL.md +4 -3
  214. package/skills/project-docs/SKILL.md +133 -0
  215. package/skills/project-docs/references/audit.md +131 -0
  216. package/skills/project-docs/references/changelog.md +105 -0
  217. package/skills/project-docs/references/discovery.md +127 -0
  218. package/skills/project-docs/references/readme.md +80 -0
  219. package/skills/project-docs/references/release.md +84 -0
  220. package/skills/project-docs/references/roadmap.md +93 -0
  221. package/skills/project-docs/scripts/state.ts +316 -0
  222. package/skills/refactor/SKILL.md +100 -125
  223. package/skills/refactor/references/handoff.md +35 -0
  224. package/skills/refactor/references/signals.md +54 -0
  225. package/skills/refactor/scripts/inventory.ts +587 -0
  226. package/extensions/file-browse/README.md +0 -506
  227. package/extensions/file-browse/server.ts +0 -141
  228. package/extensions/plan/split.ts +0 -87
  229. package/extensions/session-bus/README.md +0 -247
  230. package/extensions/session-bus/team.ts +0 -102
  231. package/extensions/wechat/outbound.ts +0 -100
  232. package/lib/plans/sections.ts +0 -172
  233. package/lib/plans/setting.ts +0 -52
  234. package/lib/plans/split.ts +0 -215
  235. package/lib/plans/store.ts +0 -19
  236. package/lib/plans/team.ts +0 -151
  237. package/skills/changelog/SKILL.md +0 -148
  238. package/skills/parallel-dev/SKILL.md +0 -150
  239. /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
  240. /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
  241. /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
  242. /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
  243. /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
  244. /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
  245. /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
  246. /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
  247. /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
  248. /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
  249. /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * One loopback server per served surface: bind, accept, route, stream, idle,
3
+ * close.
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.
10
+ *
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.
14
+ *
15
+ * Value-only: the only state is what a returned closure carries.
16
+ */
17
+
18
+ import {
19
+ createServer,
20
+ type IncomingHttpHeaders,
21
+ type IncomingMessage,
22
+ type Server,
23
+ type ServerResponse,
24
+ } from "node:http";
25
+ import type { ArmTimer } from "../timer.ts";
26
+ import { jsonHeaders } from "./headers.ts";
27
+ import { type ServeResponse, textResponse } from "./response.ts";
28
+ import { bindHost, createCloser, openEventStream, respond } from "./server.ts";
29
+ import { createSinks, type EventSink, type IdleGrace, type SinkRegistry } from "./sink.ts";
30
+
31
+ /** One request as a route sees it: the envelope, and a body already capped. */
32
+ export interface LoopbackRequest {
33
+ /** Upper-cased method, e.g. `GET`. */
34
+ method: string;
35
+ /** The request target, path and query. */
36
+ url: string;
37
+ /** Headers exactly as the socket carried them; the router applies its `Host` rule. */
38
+ headers: IncomingHttpHeaders;
39
+ /** The body, read up to the cap; `""` for every method when no cap was set. */
40
+ body: string;
41
+ }
42
+
43
+ /** What a route answers with. */
44
+ export interface LoopbackReply {
45
+ /** The response to write. A `stream` one needs `openSink` to go with it. */
46
+ result: ServeResponse;
47
+ /**
48
+ * Runs once, on whichever comes first of the response reaching the wire and the
49
+ * socket closing — the point a route may settle a session without a client
50
+ * seeing a dropped connection.
51
+ */
52
+ afterResponse?: () => void;
53
+ /** Open the reply as an event stream; the returned function detaches it. */
54
+ openSink?: (sink: EventSink) => () => void;
55
+ }
56
+
57
+ /** One request in, one reply out. A throw is answered as a plain 500. */
58
+ export type LoopbackHandler = (request: LoopbackRequest) => Promise<LoopbackReply> | LoopbackReply;
59
+
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[];
65
+ handler: LoopbackHandler;
66
+ /** Largest body accepted; absent, no body is read and a route sees `""`. */
67
+ maxBodyBytes?: number;
68
+ /** Grace after the last event stream leaves; omitted, the server waits for `close()`. */
69
+ idle?: IdleGrace;
70
+ /** The timer the idle grace is armed on. */
71
+ timer?: ArmTimer;
72
+ }
73
+
74
+ /** A bound loopback server. */
75
+ export interface LoopbackServer {
76
+ /** The port it actually bound. */
77
+ port: number;
78
+ /** `http://<host>:<port>`, the address the caller prints. */
79
+ origin: string;
80
+ /**
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.
85
+ */
86
+ close(): Promise<void>;
87
+ }
88
+
89
+ /** The one refusal this layer answers itself: a body it will not read. */
90
+ const BODY_TOO_LARGE: ServeResponse = {
91
+ status: 413,
92
+ headers: { ...jsonHeaders(), Connection: "close" },
93
+ body: JSON.stringify({ error: "The request body is too large." }),
94
+ };
95
+
96
+ /** A reply that asked to stream but offered no sink cannot be served as events. */
97
+ const STREAM_WITHOUT_SINK: ServeResponse = textResponse(500, "Internal error");
98
+
99
+ /** A handler that threw, or a socket that failed after its head was written. */
100
+ const INTERNAL_ERROR: ServeResponse = textResponse(500, "Internal error");
101
+
102
+ /**
103
+ * Bind the first port that answers, falling back in order. Rejects with the last
104
+ * bind error, so a caller can still name the reason (`EADDRINUSE` and friends).
105
+ */
106
+ async function bindFirst(server: Server, host: string, ports: readonly number[]): Promise<number> {
107
+ let failure: unknown;
108
+ for (const port of ports.length === 0 ? [0] : ports) {
109
+ try {
110
+ return await bindHost(server, host, port);
111
+ } catch (error) {
112
+ failure = error;
113
+ }
114
+ }
115
+ throw failure instanceof Error ? failure : new Error(`could not bind ${host}`);
116
+ }
117
+
118
+ /**
119
+ * Read a request body, stopping at `limit`. An oversize body is reported rather
120
+ * than destroyed, so it can still be answered 413 over the open socket instead of
121
+ * the client seeing a dropped connection.
122
+ */
123
+ function readBody(request: IncomingMessage, limit: number): Promise<string | undefined> {
124
+ return new Promise((resolve) => {
125
+ const chunks: Buffer[] = [];
126
+ let size = 0;
127
+ let done = false;
128
+ const settle = (body: string | undefined): void => {
129
+ if (done) return;
130
+ done = true;
131
+ resolve(body);
132
+ };
133
+ request.on("data", (chunk: Buffer) => {
134
+ if (done) return;
135
+ size += chunk.length;
136
+ if (size > limit) {
137
+ chunks.length = 0;
138
+ request.pause();
139
+ settle(undefined);
140
+ return;
141
+ }
142
+ chunks.push(chunk);
143
+ });
144
+ request.on("end", () => settle(Buffer.concat(chunks).toString("utf-8")));
145
+ request.on("error", () => settle(undefined));
146
+ });
147
+ }
148
+
149
+ /** A function that runs at most once; `undefined` stays `undefined`. */
150
+ function once(fn: (() => void) | undefined): (() => void) | undefined {
151
+ if (fn === undefined) return undefined;
152
+ let done = false;
153
+ return () => {
154
+ if (done) return;
155
+ done = true;
156
+ fn();
157
+ };
158
+ }
159
+
160
+ /** Route one request: read, dispatch, then answer, stream, or refuse. */
161
+ async function answer(
162
+ request: IncomingMessage,
163
+ response: ServerResponse,
164
+ options: LoopbackOptions,
165
+ sinks: SinkRegistry,
166
+ ): Promise<void> {
167
+ const method = request.method ?? "GET";
168
+ const body = options.maxBodyBytes === undefined ? "" : await readBody(request, options.maxBodyBytes);
169
+ if (body === undefined) {
170
+ respond(request, response, BODY_TOO_LARGE);
171
+ return;
172
+ }
173
+ let reply: LoopbackReply;
174
+ try {
175
+ reply = await options.handler({ method, url: request.url ?? "/", headers: request.headers, body });
176
+ } catch {
177
+ reply = { result: INTERNAL_ERROR };
178
+ }
179
+ const settle = once(reply.afterResponse);
180
+ if (reply.result.stream === true && reply.openSink === undefined) {
181
+ respond(request, response, STREAM_WITHOUT_SINK, settle);
182
+ response.on("close", () => settle?.());
183
+ return;
184
+ }
185
+ // A `HEAD` of a stream is a plain response: there is no body to hold open, and
186
+ // the client would otherwise wait on frames it asked not to get.
187
+ if (reply.openSink !== undefined && method.toUpperCase() !== "HEAD") {
188
+ openEventStream(response, reply.result);
189
+ const sink = sinks.open(response);
190
+ const detach = reply.openSink(sink);
191
+ let closed = false;
192
+ const finish = (): void => {
193
+ if (closed) return;
194
+ closed = true;
195
+ detach();
196
+ sink.end();
197
+ settle?.();
198
+ };
199
+ request.on("close", finish);
200
+ response.on("close", finish);
201
+ return;
202
+ }
203
+ respond(request, response, reply.result, settle);
204
+ response.on("close", () => settle?.());
205
+ }
206
+
207
+ /** Start a server on the first port that binds, and close it idempotently. */
208
+ export async function serveLoopback(options: LoopbackOptions): Promise<LoopbackServer> {
209
+ const host = options.host ?? "127.0.0.1";
210
+ const sinks = createSinks({ idle: options.idle, timer: options.timer });
211
+ const server = createServer((request, response) => {
212
+ // A page that walks away mid-frame must not take the session down.
213
+ response.on("error", () => {});
214
+ void answer(request, response, options, sinks).catch(() => {
215
+ // A route that failed after its head was written can only be cut off; ending
216
+ // the socket beats a client waiting on a reply that will not come.
217
+ if (!response.writableEnded) response.end();
218
+ });
219
+ });
220
+ const port = await bindFirst(server, host, options.ports);
221
+ const closeServer = createCloser(server);
222
+ let closed = false;
223
+ return {
224
+ port,
225
+ origin: `http://${host}:${port}`,
226
+ close: async () => {
227
+ if (closed) return;
228
+ closed = true;
229
+ // Streams end first: a connected page must not hold the close open.
230
+ sinks.endAll();
231
+ await closeServer();
232
+ },
233
+ };
234
+ }
@@ -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,110 @@
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
+ /** The idle policy a server was given, or none. */
26
+ export interface IdleGrace {
27
+ /** How long the last stream's departure is held, in ms. */
28
+ ms: number;
29
+ /** Called once no stream has been open for `ms`. */
30
+ onIdle: () => void;
31
+ }
32
+
33
+ /** What the registry needs besides the streams themselves. */
34
+ export interface SinkDeps {
35
+ /** Grace after the last stream leaves; omitted, the server waits for `endAll`. */
36
+ idle?: IdleGrace;
37
+ /** The timer the grace is armed on; defaults to an unref'd one. */
38
+ timer?: ArmTimer;
39
+ }
40
+
41
+ /** The open streams of one loopback server. */
42
+ export interface SinkRegistry {
43
+ /** Open a sink over one response; ending it starts the idle grace. */
44
+ open(response: ServerResponse): EventSink;
45
+ /** How many streams are open. */
46
+ size(): number;
47
+ /** End every open stream, because the server is closing. */
48
+ endAll(): void;
49
+ }
50
+
51
+ export function createSinks(deps: SinkDeps = {}): SinkRegistry {
52
+ const streams = new Set<EventSink>();
53
+ const timer = deps.timer ?? defaultTimer;
54
+ let cancelIdle: (() => void) | undefined;
55
+ /** A grace armed before any page ever attached would close a server nobody opened. */
56
+ let sawStream = false;
57
+
58
+ const standDown = (): void => {
59
+ cancelIdle?.();
60
+ cancelIdle = undefined;
61
+ };
62
+
63
+ const arm = (): void => {
64
+ const idle = deps.idle;
65
+ if (idle === undefined || !sawStream || streams.size > 0 || cancelIdle !== undefined) return;
66
+ cancelIdle = timer(idle.ms, () => {
67
+ cancelIdle = undefined;
68
+ if (streams.size === 0) deps.idle?.onIdle();
69
+ });
70
+ };
71
+
72
+ return {
73
+ open(response) {
74
+ standDown();
75
+ sawStream = true;
76
+ const sink = createSink(response, () => {
77
+ if (streams.delete(sink)) arm();
78
+ });
79
+ streams.add(sink);
80
+ return sink;
81
+ },
82
+ size: () => streams.size,
83
+ endAll() {
84
+ standDown();
85
+ const open = [...streams];
86
+ streams.clear();
87
+ for (const sink of open) sink.end();
88
+ },
89
+ };
90
+ }
91
+
92
+ /** A sink over one response, released from its registry exactly once. */
93
+ function createSink(response: ServerResponse, release: () => void): EventSink {
94
+ let ended = false;
95
+ return {
96
+ write(frame) {
97
+ if (ended || response.writableEnded || response.destroyed) return;
98
+ response.write(frame);
99
+ },
100
+ end(frame) {
101
+ if (ended) return;
102
+ ended = true;
103
+ if (!response.writableEnded && !response.destroyed) {
104
+ if (frame === undefined) response.end();
105
+ else response.end(frame);
106
+ }
107
+ release();
108
+ },
109
+ };
110
+ }
@@ -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/policy.ts CHANGED
@@ -9,11 +9,21 @@
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 { READ_DOC_TOOL } from "./tool-names.ts";
19
+
20
+ /**
21
+ * Tools that only read the workspace: the built-in structured readers and the
22
+ * packaged document reader. One list for the read-only consumers — plan mode's
23
+ * allowlist, rewind's snapshot policy, and subagent's read-only child allowlist —
24
+ * so adding a reader is one edit rather than three.
25
+ */
26
+ export const WORKSPACE_READERS = ["read", "grep", "find", "ls", READ_DOC_TOOL] as const;
17
27
 
18
28
  interface ReadOnlyPolicyOptions {
19
29
  /** 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,130 @@
1
+ /**
2
+ * Who is on this repository's session bus, read as data.
3
+ *
4
+ * The Team tab lists teammates, and `session-bus` owns those records. Neither
5
+ * extension may import the other, and the board must keep working when the bus is
6
+ * not loaded — so the records are read where the bus already put them,
7
+ * `<agentDir>/session-bus/<repo-key>/`, as files rather than through its code. The
8
+ * directory names, the record fields this reader trusts, and the liveness rule live
9
+ * here once, and `session-bus` imports them, so a layout change cannot land in one
10
+ * copy and not the other.
11
+ *
12
+ * A missing directory is an answer, not an error: it means no session has ever
13
+ * joined this repository, and a caller renders that differently from an empty bus.
14
+ *
15
+ * Pure I/O, no state: one read answers one caller, and nothing is cached — a
16
+ * heartbeat is ten seconds old at most, and the caller polls on its own clock.
17
+ */
18
+
19
+ import { readdir, readFile } from "node:fs/promises";
20
+ import { join } from "node:path";
21
+
22
+ /** The bus directory under the agent dir, `session-bus`. */
23
+ export const BUS_DIR_NAME = "session-bus";
24
+ /** One file per session inside it. */
25
+ export const SESSIONS_DIR_NAME = "sessions";
26
+ /** One directory per session, holding its mail. */
27
+ export const INBOX_DIR_NAME = "inbox";
28
+ /** How long a record's heartbeat stays believable: the rule the bus prunes by. */
29
+ export const SESSION_TTL_MS = 10 * 60_000;
30
+
31
+ /** One session as the page shows it. The rest of the record stays on disk. */
32
+ export interface RosterSession {
33
+ id: string;
34
+ label: string;
35
+ mode: string;
36
+ /** When the session last proved it was alive. */
37
+ lastSeen: number;
38
+ /** Messages waiting in its inbox. */
39
+ mail: number;
40
+ /**
41
+ * Whether the session is listening for mail, when its record says.
42
+ *
43
+ * Absent is "not listening": a record written before this existed and a session that
44
+ * stopped listening read the same way, so a reader never claims a peer will be reached.
45
+ */
46
+ watching?: { armed: boolean; wake: boolean };
47
+ }
48
+
49
+ /** Whether a record's heartbeat is fresh enough to call the session live. */
50
+ export function rosterStateOf(session: RosterSession, now: number, ttlMs = SESSION_TTL_MS): "live" | "quiet" {
51
+ return now - session.lastSeen <= ttlMs ? "live" : "quiet";
52
+ }
53
+
54
+ /** The fields this reader trusts; anything else in the file is the bus's business. */
55
+ interface SessionPayload {
56
+ id: string;
57
+ label: string;
58
+ mode: string;
59
+ lastSeen: number;
60
+ }
61
+
62
+ function isSessionPayload(value: unknown): value is SessionPayload {
63
+ if (typeof value !== "object" || value === null) return false;
64
+ const { id, label, mode, lastSeen } = value as Record<string, unknown>;
65
+ return (
66
+ typeof id === "string" && typeof label === "string" && typeof mode === "string" && typeof lastSeen === "number"
67
+ );
68
+ }
69
+
70
+ /**
71
+ * The listening field, read only when it is the shape the bus writes.
72
+ *
73
+ * A malformed field is not a reason to hide a teammate: the row still draws, and the
74
+ * only thing lost is the claim that mail will reach it. Exported because the field is
75
+ * the record's, and `lib/session-tool.ts` reads the same one over the tool rather than
76
+ * over the files — one reader for one shape.
77
+ */
78
+ export function watchingOf(value: unknown): { armed: boolean; wake: boolean } | undefined {
79
+ if (typeof value !== "object" || value === null) return undefined;
80
+ const { armed, wake } = value as Record<string, unknown>;
81
+ return typeof armed === "boolean" && typeof wake === "boolean" ? { armed, wake } : undefined;
82
+ }
83
+
84
+ /** How many message files one session's inbox holds. A missing inbox is empty. */
85
+ async function mailCount(dir: string, stem: string): Promise<number> {
86
+ try {
87
+ const entries = await readdir(join(dir, INBOX_DIR_NAME, stem));
88
+ return entries.filter((name) => name.endsWith(".json")).length;
89
+ } catch {
90
+ return 0;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Every session the bus has recorded for one repository, or `undefined` when the bus
96
+ * has never been used here.
97
+ *
98
+ * One file that does not parse is skipped rather than thrown: a roster with three
99
+ * good sessions is worth more than an error about the fourth.
100
+ */
101
+ export async function readRoster(dir: string): Promise<RosterSession[] | undefined> {
102
+ let names: string[];
103
+ try {
104
+ names = await readdir(join(dir, SESSIONS_DIR_NAME));
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ const sessions: RosterSession[] = [];
109
+ for (const name of names) {
110
+ if (!name.endsWith(".json")) continue;
111
+ let parsed: unknown;
112
+ try {
113
+ parsed = JSON.parse(await readFile(join(dir, SESSIONS_DIR_NAME, name), "utf-8"));
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (!isSessionPayload(parsed)) continue;
118
+ const { id, label, mode, lastSeen } = parsed;
119
+ const watching = watchingOf((parsed as { watching?: unknown }).watching);
120
+ sessions.push({
121
+ id,
122
+ label,
123
+ mode,
124
+ lastSeen,
125
+ mail: await mailCount(dir, name.slice(0, -".json".length)),
126
+ ...(watching === undefined ? {} : { watching }),
127
+ });
128
+ }
129
+ return sessions.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
130
+ }