@gavin-hu/my-pi-agent 0.19.0 → 0.21.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 (208) hide show
  1. package/README.md +19 -12
  2. package/README.zh-CN.md +15 -9
  3. package/docs/extensions/README.md +2 -1
  4. package/docs/extensions/board.md +329 -151
  5. package/docs/extensions/bytefly.md +427 -273
  6. package/docs/extensions/file-browse.md +46 -44
  7. package/docs/extensions/job.md +1 -2
  8. package/docs/extensions/plan.md +86 -24
  9. package/docs/extensions/serve.md +129 -0
  10. package/docs/extensions/session-bus.md +23 -10
  11. package/docs/extensions/team.md +56 -28
  12. package/docs/extensions/wechat.md +2 -1
  13. package/docs/extensions/worktree.md +30 -15
  14. package/extensions/board/commands.ts +48 -17
  15. package/extensions/board/format.ts +107 -9
  16. package/extensions/board/index.ts +14 -2
  17. package/extensions/board/join.ts +31 -10
  18. package/extensions/board/page.ts +202 -156
  19. package/extensions/board/result.ts +10 -24
  20. package/extensions/board/rows.ts +1 -1
  21. package/extensions/board/schema.ts +31 -10
  22. package/extensions/board/seat.ts +9 -10
  23. package/extensions/board/snapshot.ts +7 -5
  24. package/extensions/board/tools.ts +211 -34
  25. package/extensions/board/transport/router.ts +27 -110
  26. package/extensions/board/transport/server.ts +76 -56
  27. package/extensions/board/types.ts +57 -0
  28. package/extensions/board/view/client.ts +24 -13
  29. package/extensions/board/view/dialog.ts +43 -0
  30. package/extensions/board/view/keys.ts +28 -24
  31. package/extensions/board/view/live.ts +6 -178
  32. package/extensions/board/view/names.ts +91 -0
  33. package/extensions/board/view/page.ts +60 -89
  34. package/extensions/board/view/render.ts +228 -200
  35. package/extensions/board/view/styles.ts +72 -49
  36. package/extensions/board/view/team.ts +63 -41
  37. package/extensions/bytefly/footer/budget.ts +6 -5
  38. package/extensions/bytefly/footer/collect/entries.ts +12 -4
  39. package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
  40. package/extensions/bytefly/footer/config.ts +19 -11
  41. package/extensions/bytefly/footer/price/price.ts +30 -8
  42. package/extensions/bytefly/footer/register.ts +5 -5
  43. package/extensions/bytefly/footer/render/footer.ts +18 -4
  44. package/extensions/bytefly/footer/render/format.ts +18 -0
  45. package/extensions/bytefly/footer/render/key.ts +46 -0
  46. package/extensions/bytefly/footer/render/layout.ts +13 -3
  47. package/extensions/bytefly/footer/render/lines.ts +23 -7
  48. package/extensions/bytefly/footer/render/segments.ts +93 -31
  49. package/extensions/bytefly/footer/types.ts +29 -5
  50. package/extensions/bytefly/footer/unpriced.ts +42 -17
  51. package/extensions/bytefly/header/component.ts +4 -20
  52. package/extensions/bytefly/panel/commands.ts +28 -1
  53. package/extensions/bytefly/panel/filter.ts +68 -0
  54. package/extensions/bytefly/panel/format.ts +16 -15
  55. package/extensions/bytefly/panel/join.ts +56 -13
  56. package/extensions/bytefly/panel/prices.ts +43 -0
  57. package/extensions/bytefly/panel/settings.ts +72 -23
  58. package/extensions/bytefly/panel/tui/entries.ts +26 -8
  59. package/extensions/bytefly/panel/tui/frame.ts +130 -93
  60. package/extensions/bytefly/panel/tui/keys.ts +352 -0
  61. package/extensions/bytefly/panel/tui/list-view.ts +25 -46
  62. package/extensions/bytefly/panel/tui/panes.ts +76 -0
  63. package/extensions/bytefly/panel/tui/section-view.ts +126 -0
  64. package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
  65. package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
  66. package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
  67. package/extensions/bytefly/panel/tui.ts +306 -259
  68. package/extensions/check/commands.ts +2 -1
  69. package/extensions/check/render.ts +10 -1
  70. package/extensions/file-browse/config/config.ts +4 -19
  71. package/extensions/file-browse/files/classify.ts +136 -0
  72. package/extensions/file-browse/files/files.ts +8 -547
  73. package/extensions/file-browse/files/index.ts +90 -0
  74. package/extensions/file-browse/files/list.ts +261 -0
  75. package/extensions/file-browse/files/tree.ts +101 -0
  76. package/extensions/file-browse/index.ts +27 -48
  77. package/extensions/file-browse/live/live.ts +1 -1
  78. package/extensions/file-browse/transport/router.ts +12 -6
  79. package/extensions/file-browse/transport/server.ts +61 -28
  80. package/extensions/file-browse/view/chrome.ts +4 -4
  81. package/extensions/file-browse/view/client.ts +3 -1
  82. package/extensions/file-browse/view/emit.ts +51 -0
  83. package/extensions/file-browse/view/html.ts +78 -46
  84. package/extensions/file-browse/view/live-client.ts +2 -2
  85. package/extensions/file-browse/view/markdown.ts +23 -26
  86. package/extensions/file-browse/view/palette.ts +2 -2
  87. package/extensions/file-browse/view/raw-html.ts +27 -22
  88. package/extensions/file-browse/view/tree.ts +1 -1
  89. package/extensions/file-browse/view/urls.ts +20 -6
  90. package/extensions/goal/tui.ts +12 -2
  91. package/extensions/job/runtime.ts +4 -4
  92. package/extensions/job/tools.ts +19 -7
  93. package/extensions/job/tui.ts +2 -28
  94. package/extensions/plan/commands.ts +2 -2
  95. package/extensions/plan/feedback.ts +88 -18
  96. package/extensions/plan/format.ts +10 -3
  97. package/extensions/plan/index.ts +24 -2
  98. package/extensions/plan/plan-files.ts +88 -0
  99. package/extensions/plan/plan-text.ts +114 -0
  100. package/extensions/plan/plans.ts +19 -23
  101. package/extensions/plan/policy.ts +10 -5
  102. package/extensions/plan/runtime.ts +43 -4
  103. package/extensions/plan/todos.ts +51 -3
  104. package/extensions/plan/tools/exit.ts +10 -1
  105. package/extensions/plan/tools/todo-seed.ts +4 -2
  106. package/extensions/plan/tools/write.ts +12 -1
  107. package/extensions/plan/tools.ts +2 -2
  108. package/extensions/plan/types.ts +15 -0
  109. package/extensions/plan/web-blocks.ts +160 -0
  110. package/extensions/plan/web-chrome.ts +7 -5
  111. package/extensions/plan/web-client.ts +271 -325
  112. package/extensions/plan/web-collab.ts +7 -7
  113. package/extensions/plan/web-comments.ts +57 -16
  114. package/extensions/plan/web-composer.ts +91 -0
  115. package/extensions/plan/web-draft.ts +40 -0
  116. package/extensions/plan/web-keys.ts +258 -0
  117. package/extensions/plan/web-markdown.ts +1 -1
  118. package/extensions/plan/web-outline.ts +212 -0
  119. package/extensions/plan/web-page.ts +59 -86
  120. package/extensions/plan/web-payload.ts +188 -0
  121. package/extensions/plan/web-plan.ts +160 -0
  122. package/extensions/plan/web-router.ts +0 -1
  123. package/extensions/plan/web-settle.ts +201 -0
  124. package/extensions/plan/web-share-session.ts +24 -55
  125. package/extensions/plan/web-share.ts +19 -37
  126. package/extensions/plan/web-write.ts +112 -0
  127. package/extensions/plan/web.ts +77 -130
  128. package/extensions/rewind/commands/review.ts +2 -1
  129. package/extensions/serve/config.ts +37 -0
  130. package/extensions/serve/index.ts +71 -0
  131. package/extensions/serve/registry.ts +251 -0
  132. package/extensions/session-bus/join-request.ts +16 -9
  133. package/extensions/session-bus/result.ts +1 -27
  134. package/extensions/session-bus/rows.ts +7 -3
  135. package/extensions/session-bus/runtime.ts +100 -38
  136. package/extensions/session-bus/store.ts +14 -2
  137. package/extensions/session-bus/tools.ts +5 -4
  138. package/extensions/session-bus/tui.ts +1 -1
  139. package/extensions/session-bus/types.ts +10 -1
  140. package/extensions/subagent/commands.ts +2 -1
  141. package/extensions/team/commands.ts +44 -9
  142. package/extensions/team/format.ts +14 -0
  143. package/extensions/team/index.ts +2 -1
  144. package/extensions/team/tools.ts +30 -9
  145. package/extensions/team/types.ts +17 -0
  146. package/extensions/todo/index.ts +4 -1
  147. package/extensions/todo/nudge.ts +8 -2
  148. package/extensions/todo/tui.ts +21 -3
  149. package/extensions/wechat/bridge.ts +43 -172
  150. package/extensions/wechat/intake.ts +245 -0
  151. package/extensions/worktree/commands.ts +24 -17
  152. package/extensions/worktree/exit.ts +12 -6
  153. package/extensions/worktree/git.ts +22 -14
  154. package/extensions/worktree/guard.ts +15 -285
  155. package/extensions/worktree/handoff.ts +10 -2
  156. package/extensions/worktree/index.ts +2 -1
  157. package/extensions/worktree/lifecycle.ts +2 -0
  158. package/extensions/worktree/registry.ts +20 -11
  159. package/extensions/worktree/root-tools.ts +13 -3
  160. package/extensions/worktree/runtime.ts +8 -2
  161. package/extensions/worktree/screen.ts +15 -8
  162. package/extensions/worktree/shell.ts +303 -0
  163. package/extensions/worktree/state.ts +1 -1
  164. package/extensions/worktree/status.ts +42 -9
  165. package/extensions/worktree/tools.ts +19 -13
  166. package/lib/README.md +9 -7
  167. package/lib/board/card.ts +192 -3
  168. package/lib/board/links.ts +26 -2
  169. package/lib/board/page-pointer.ts +111 -32
  170. package/lib/board/round-text.ts +11 -2
  171. package/lib/board/round.ts +25 -20
  172. package/lib/board/rules.ts +144 -11
  173. package/lib/board/session.ts +84 -18
  174. package/lib/board/store.ts +268 -55
  175. package/lib/board/view.ts +114 -24
  176. package/lib/bus-request.ts +83 -0
  177. package/lib/format.ts +22 -0
  178. package/lib/http/index.ts +11 -7
  179. package/lib/http/mount.ts +142 -0
  180. package/lib/http/port.ts +25 -0
  181. package/lib/http/serve.ts +170 -43
  182. package/lib/http/server.ts +3 -3
  183. package/lib/http/sink.ts +34 -9
  184. package/lib/plan-entry.ts +20 -13
  185. package/lib/policy.ts +14 -1
  186. package/lib/requirements.ts +2 -2
  187. package/lib/session-label.ts +23 -7
  188. package/lib/session-roster.ts +74 -3
  189. package/lib/session-tool.ts +37 -21
  190. package/lib/spawn.ts +37 -3
  191. package/lib/stray-fields.ts +45 -0
  192. package/lib/tool-names.ts +13 -0
  193. package/lib/tui/README.md +6 -3
  194. package/lib/tui/glyphs.ts +3 -6
  195. package/lib/tui/render-cache.ts +29 -0
  196. package/lib/tui/tool.ts +30 -1
  197. package/lib/web/README.md +52 -2
  198. package/lib/web/client-runtime.ts +149 -0
  199. package/lib/web/index.ts +4 -2
  200. package/lib/web/markup.ts +106 -2
  201. package/lib/web/names.ts +42 -0
  202. package/package.json +3 -2
  203. package/extensions/board/config.ts +0 -36
  204. package/extensions/board/view/actions.ts +0 -306
  205. package/extensions/board/writes.ts +0 -99
  206. package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
  207. package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
  208. package/extensions/job/process.ts +0 -35
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The request/response handshake each caller runs over `pi.events`.
3
+ *
4
+ * Pi loads every extension with its own module cache, so one extension cannot call
5
+ * another: `plan` is asked to enter plan mode, `session-bus` to join the team, and
6
+ * `serve` to hand a page a prefix — each on a channel, each with a payload of its own.
7
+ * What they share is not the question but the answer's two guarantees: it settles
8
+ * exactly once, and a caller with no listener is told so rather than left waiting.
9
+ *
10
+ * The refusal stays with the caller: this module never names an extension, a tool, or
11
+ * a sentence. Value-only, as `lib/` must be: the state is the caller's bus and one
12
+ * closure.
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+
17
+ /**
18
+ * What a listener answers with: the value, or the promise of one it has not got yet.
19
+ *
20
+ * An owner may have to bind a socket, start a bus, or read a repository before it
21
+ * knows the answer, and the *claim* is the answer, not the result — so it hands back
22
+ * the promise and stays claimed while it works, rather than leaving the caller to
23
+ * guess whether anyone is there.
24
+ */
25
+ export type BusAnswer<T> = T | Promise<T>;
26
+
27
+ /** One question for one channel. */
28
+ export interface BusRequest<T, P> {
29
+ /** The `pi.events` channel the owner answers on. */
30
+ channel: string;
31
+ /**
32
+ * The object it reads, with `respond` given beside it: the helper supplies the one
33
+ * answer a request has, so no listener can answer twice and no caller can settle
34
+ * its own request.
35
+ */
36
+ payload(respond: (answer: BusAnswer<T>) => void): P;
37
+ /**
38
+ * The answer when no listener claimed the request — the sentence that names the
39
+ * extension which is not loaded. Settled on the microtask after the emit, because
40
+ * a listener claims synchronously or not at all.
41
+ */
42
+ unclaimed(): T;
43
+ /**
44
+ * The answer when a listener claimed the request and then failed; defaults to
45
+ * {@link unclaimed}, which reads the same to a caller that cannot tell why.
46
+ */
47
+ failed?(error: unknown): T;
48
+ }
49
+
50
+ /**
51
+ * Ask `request`'s channel for its answer, and settle once.
52
+ *
53
+ * Resolves the claimant's answer — including one it is still producing — or
54
+ * {@link BusRequest.unclaimed} when nobody claimed the request, so a session running
55
+ * without that extension reports a sentence instead of hanging.
56
+ */
57
+ export function requestOverBus<T, P>(pi: Pick<ExtensionAPI, "events">, request: BusRequest<T, P>): Promise<T> {
58
+ return new Promise<T>((resolve) => {
59
+ let claimed = false;
60
+ let settled = false;
61
+ const settle = (answer: T): void => {
62
+ if (settled) return;
63
+ settled = true;
64
+ resolve(answer);
65
+ };
66
+ const respond = (answer: BusAnswer<T>): void => {
67
+ if (claimed) return;
68
+ claimed = true;
69
+ // A claim that is still binding answers later, so its failure needs one place to
70
+ // become the sentence the caller reports rather than an unhandled rejection.
71
+ Promise.resolve(answer).then(settle, (error: unknown) => {
72
+ const failed = request.failed;
73
+ settle(failed === undefined ? request.unclaimed() : failed(error));
74
+ });
75
+ };
76
+ pi.events.emit(request.channel, request.payload(respond));
77
+ // A listener claims synchronously or not at all, so a request nothing claimed is one
78
+ // no extension is listening for.
79
+ queueMicrotask(() => {
80
+ if (!claimed) settle(request.unclaimed());
81
+ });
82
+ });
83
+ }
package/lib/format.ts CHANGED
@@ -53,6 +53,28 @@ export function formatDuration(ms: number): string {
53
53
  return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
54
54
  }
55
55
 
56
+ /**
57
+ * A coarse age, the way a reader says it: `now`, `3m`, `2h`, `5d`.
58
+ *
59
+ * One rule for both surfaces that print an age — a card's offer in the transcript, and
60
+ * the same card on the page — so one fact cannot read `2h ago` in a tool result and `3h`
61
+ * on the page. Written as concatenation because the page's bundle carries this function's
62
+ * *source* (`FORMAT_AGE_JS`): a backtick or an interpolation in it would end the string it
63
+ * is spliced into.
64
+ */
65
+ export function formatAge(now: number, at: number): string {
66
+ const seconds = Math.max(0, Math.round((now - at) / 1000));
67
+ if (seconds < 45) return "now";
68
+ const minutes = Math.round(seconds / 60);
69
+ if (minutes < 60) return minutes + "m";
70
+ const hours = Math.round(minutes / 60);
71
+ if (hours < 24) return hours + "h";
72
+ return Math.round(hours / 24) + "d";
73
+ }
74
+
75
+ /** {@link formatAge} as the page's bundle receives it. */
76
+ export const FORMAT_AGE_JS = String(formatAge);
77
+
56
78
  /** `1 tool`, `3 tools`: a tool count with the right word. */
57
79
  export function formatToolCount(count: number): string {
58
80
  const n = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0;
package/lib/http/index.ts CHANGED
@@ -1,22 +1,26 @@
1
1
  /**
2
2
  * The loopback HTTP layer shared by `file-browse`, `plan`, and `board`: the
3
3
  * response shape, the header policy, the request-envelope helpers and the
4
- * guards built on them, the one server entry, and the socket seam behind it.
4
+ * guards built on them, the one server entry, the socket seam behind it, and the
5
+ * mount contract a page uses to claim a prefix on the session's one port.
5
6
  *
6
- * Import from here rather than a leaf module, so the layer reads as one kit. An
7
- * extension imports {@link serveLoopback} and never `./server.ts`: a hand-built
8
- * `node:http` server beside this one is how the close path and the idle grace
9
- * start to differ again. The same rule covers the guards: `plan` and `board` both
10
- * serve a page that can write, so the `Host`, origin, and JSON rules are
7
+ * Import from here rather than a leaf module, so the layer reads as one kit. The
8
+ * `serve` extension imports {@link serveHttp} and never `./server.ts`; every other
9
+ * extension imports {@link requestMount} and never binds a socket at all — a
10
+ * hand-built `node:http` server beside this one is how the close path and the idle
11
+ * grace start to differ again. The same rule covers the guards: `plan` and `board`
12
+ * both serve a page that can write, so the `Host`, origin, and JSON rules are
11
13
  * {@link envelopeRefusal} and {@link requireJsonBody} rather than two copies, and
12
14
  * the credential a page carries is {@link mintToken} / {@link tokenMatches}.
13
15
  *
14
16
  * Value-only, like the rest of `lib/`: the only state is what a returned closure
15
- * carries (`createCloser`, `serveLoopback`).
17
+ * carries (`createCloser`, `serveHttp`).
16
18
  */
17
19
 
18
20
  export * from "./guard.ts";
19
21
  export * from "./headers.ts";
22
+ export * from "./mount.ts";
23
+ export * from "./port.ts";
20
24
  export * from "./response.ts";
21
25
  export * from "./router.ts";
22
26
  export * from "./serve.ts";
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The contract a page and the session's one server agree on.
3
+ *
4
+ * A session has one port and `serve` owns it, so a page cannot bind: it *mounts* a
5
+ * prefix and is handed back where it answers. Pi loads every extension with an
6
+ * isolated module cache and forbids one extension importing another, so the request
7
+ * travels on `pi.events` as an in-process request/response, on the handshake in
8
+ * `lib/bus-request.ts`.
9
+ *
10
+ * The handshake is claimed, not awaited: a mount binds, and `server.listen` cannot
11
+ * answer synchronously, so `respond` takes the *pending* outcome and only a request
12
+ * no listener claimed settles as "no server". That distinction is the difference
13
+ * between a page that starts and a page that is told the extension is missing.
14
+ *
15
+ * Value-only, as `lib/` must be: the state is the caller's bus and its closures.
16
+ */
17
+
18
+ import type { NetworkInterfaceInfo } from "node:os";
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { type BusAnswer, requestOverBus } from "../bus-request.ts";
21
+ import { messageOf } from "../errors.ts";
22
+ import type { IdleGrace } from "./sink.ts";
23
+ import type { LoopbackHandler } from "./serve.ts";
24
+
25
+ /** EventBus channel on which a page asks the session's server for a prefix. */
26
+ export const HTTP_MOUNT_CHANNEL = "my-pi-agent/http/mount";
27
+
28
+ /** The refusal a page shows when nothing owns the session's socket. */
29
+ export const NO_SERVER_REASON = "the serve extension is not loaded, so this session has no port";
30
+
31
+ /** One page's request for a prefix on the session's server. */
32
+ export interface MountSpec {
33
+ /**
34
+ * The Pi session asking. The server is per session, so a mount from another one —
35
+ * a WeChat peer, a second session in this process — never lands on this socket.
36
+ */
37
+ session: string;
38
+ /** The prefix it answers under, e.g. `/board`; `""` claims the whole origin. */
39
+ prefix: string;
40
+ handler: LoopbackHandler;
41
+ /** Largest request body accepted here; absent, a route sees `""`. */
42
+ maxBodyBytes?: number;
43
+ /** Grace after this page's last reader leaves; omitted, it waits for `release()`. */
44
+ idle?: IdleGrace;
45
+ }
46
+
47
+ /** A prefix this page now serves. */
48
+ export interface HttpMount {
49
+ /** The prefix, normalized: `/board`. */
50
+ base: string;
51
+ /** `http://127.0.0.1:<port>`, the session's one origin. */
52
+ origin: string;
53
+ /** The port the session's server bound. */
54
+ port: number;
55
+ /**
56
+ * Free the prefix. Idempotent; the socket closes with its last mount, so a page
57
+ * that is done does not hold the session's port.
58
+ */
59
+ release(): Promise<void>;
60
+ /**
61
+ * Serve this same page on one LAN address, for a review shared with a teammate.
62
+ * `undefined` when the machine has no address to offer or the bind failed, so the
63
+ * caller keeps the sentence that explains it.
64
+ */
65
+ share(): Promise<SharedListener | undefined>;
66
+ /** Stop serving on the LAN address; the loopback mount keeps answering. */
67
+ unshare(): Promise<void>;
68
+ }
69
+
70
+ /** The LAN address a shared page answers on. */
71
+ export interface SharedListener {
72
+ /** The address a link names, e.g. `192.168.1.24`. */
73
+ address: string;
74
+ /** `http://<address>:<port>`, which may differ from the loopback port. */
75
+ origin: string;
76
+ }
77
+
78
+ /** The machine's interfaces, as `os.networkInterfaces()` reports them. */
79
+ export type NetworkInterfaces = Record<string, NetworkInterfaceInfo[] | undefined>;
80
+
81
+ /**
82
+ * The addresses a teammate could reach this machine on: one per non-internal IPv4
83
+ * interface, ordered so a link is stable between calls.
84
+ *
85
+ * This is what a shared page is offered, never `0.0.0.0`: a review opens exactly
86
+ * one address, so the link the owner sends is the whole surface. Link-local
87
+ * (`169.254.*`) addresses are left out because they reach nothing.
88
+ */
89
+ export function lanAddresses(interfaces: NetworkInterfaces): string[] {
90
+ const addresses = new Set<string>();
91
+ for (const entries of Object.values(interfaces)) {
92
+ for (const entry of entries ?? []) {
93
+ if (entry.family !== "IPv4" || entry.internal) continue;
94
+ if (entry.address === "0.0.0.0" || entry.address.startsWith("169.254.")) continue;
95
+ addresses.add(entry.address);
96
+ }
97
+ }
98
+ return [...addresses].sort(byAddress);
99
+ }
100
+
101
+ /** Numeric IPv4 comparison, so `…1.9` sorts before `…1.24`. */
102
+ function byAddress(a: string, b: string): number {
103
+ const left = a.split(".").map(Number);
104
+ const right = b.split(".").map(Number);
105
+ for (let index = 0; index < 4; index++) {
106
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
107
+ if (difference !== 0) return difference;
108
+ }
109
+ return a.localeCompare(b);
110
+ }
111
+
112
+ /** What a mount request answers with: the page's place, or the reason it has none. */
113
+ export type MountOutcome = { kind: "mounted"; mount: HttpMount } | { kind: "refused"; reason: string };
114
+
115
+ /** The question, as it travels on the bus. */
116
+ export interface MountRequest {
117
+ spec: MountSpec;
118
+ /**
119
+ * Called once, by the owner, **synchronously** with the bind it just started. A
120
+ * request nobody claims is a session without a server.
121
+ */
122
+ respond(pending: BusAnswer<MountOutcome>): void;
123
+ }
124
+
125
+ /** How a page asks for its prefix; the seam a caller substitutes in a test. */
126
+ export type MountSource = (spec: MountSpec) => Promise<MountOutcome>;
127
+
128
+ /**
129
+ * Ask the session's server for `spec`'s prefix.
130
+ *
131
+ * Resolves `refused` with {@link NO_SERVER_REASON} when no listener claims the
132
+ * request, so a session running without `serve` fails with a sentence instead of
133
+ * hanging or binding a rival socket.
134
+ */
135
+ export function requestMount(pi: Pick<ExtensionAPI, "events">, spec: MountSpec): Promise<MountOutcome> {
136
+ return requestOverBus<MountOutcome, MountRequest>(pi, {
137
+ channel: HTTP_MOUNT_CHANNEL,
138
+ payload: (respond) => ({ spec, respond }),
139
+ unclaimed: () => ({ kind: "refused", reason: NO_SERVER_REASON }),
140
+ failed: (error) => ({ kind: "refused", reason: messageOf(error) }),
141
+ });
142
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The per-session port band the loopback pages share.
3
+ *
4
+ * `serve` draws the session's one port from it, so every page that session serves
5
+ * clusters where a person's bookmarks already point. The band is one product decision,
6
+ * stated here once: a number repeated in each extension's config is several chances to
7
+ * move one of them, and nothing would notice until a bookmark landed outside the range.
8
+ *
9
+ * A pick is a guess, not a reservation. `serveHttp` binds it and falls back to whatever
10
+ * free port the OS gives, so a port another process holds costs a retry rather than a
11
+ * start.
12
+ */
13
+
14
+ import { randomInt } from "node:crypto";
15
+
16
+ /** Base of the per-session port range. */
17
+ export const PORT_BASE = 4780;
18
+
19
+ /** Number of ports in the per-session range. */
20
+ export const PORT_RANGE = 1000;
21
+
22
+ /** A random port in the per-session range `PORT_BASE`–`PORT_BASE + PORT_RANGE - 1`. */
23
+ export function randomPort(): number {
24
+ return PORT_BASE + randomInt(PORT_RANGE);
25
+ }
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,30 +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
- const closeServer = createCloser(server);
222
- let closed = false;
328
+ closeServer = createCloser(server);
329
+ const origin = `http://${host}:${port}`;
330
+
223
331
  return {
224
332
  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();
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;
232
358
  },
359
+ close,
233
360
  };
234
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