@huynle/hub 0.8.7 → 0.9.1

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 (213) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +75 -16
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/wrapper.ts +2 -2
  6. package/packages/cli/dist/hub-cli.js +1413 -523
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
  9. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
  10. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
  11. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
  12. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
  13. package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
  14. package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
  15. package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
  16. package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
  17. package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
  18. package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
  19. package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
  20. package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
  21. package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
  22. package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
  23. package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
  24. package/packages/cli/src/bootstrap/release/assets.ts +4 -1
  25. package/packages/cli/src/cli.ts +23 -10
  26. package/packages/cli/src/http.ts +1 -0
  27. package/packages/cli/src/launches.ts +5 -1
  28. package/packages/cli/src/mcp.ts +28 -8
  29. package/packages/cli/src/messaging.ts +14 -0
  30. package/packages/cli/src/node.ts +94 -12
  31. package/packages/cli/src/setup/commands.ts +27 -7
  32. package/packages/cli/src/setup/runtime.ts +31 -4
  33. package/packages/cli/src/team-intake-mcp.ts +26 -2
  34. package/packages/codex/package.json +1 -1
  35. package/packages/codex/src/wrapper.ts +2 -2
  36. package/packages/node/package.json +1 -1
  37. package/packages/node/src/config.ts +49 -17
  38. package/packages/node/src/git.ts +16 -2
  39. package/packages/node/src/harnesses.ts +54 -9
  40. package/packages/node/src/host.ts +61 -15
  41. package/packages/node/src/opencode-service.ts +111 -0
  42. package/packages/node/src/opencode-warm.ts +11 -5
  43. package/packages/node/src/run.ts +50 -5
  44. package/packages/preset/package.json +1 -1
  45. package/packages/protocol/package.json +1 -1
  46. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  47. package/packages/protocol/src/artifacts.ts +43 -0
  48. package/packages/protocol/src/credentials.ts +38 -0
  49. package/packages/protocol/src/index.ts +59 -6
  50. package/packages/protocol/src/launch.ts +46 -6
  51. package/packages/protocol/src/markdown.ts +753 -0
  52. package/packages/protocol/src/media-allowlist.ts +240 -0
  53. package/packages/protocol/src/media.ts +120 -0
  54. package/packages/protocol/src/preview-bundle.ts +221 -0
  55. package/packages/protocol/src/voice-recording.ts +8 -0
  56. package/packages/runtime-openshell/package.json +1 -1
  57. package/packages/runtime-sdk/package.json +1 -1
  58. package/packages/sdk/package.json +1 -1
  59. package/packages/sdk/src/client.ts +17 -3
  60. package/packages/sdk/src/errors.ts +1 -0
  61. package/packages/sdk/src/team-intake.ts +8 -0
  62. package/packages/sdk/src/types.ts +16 -2
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/src/central/agent/delivery.ts +35 -6
  65. package/src/central/agent/errors.ts +2 -0
  66. package/src/central/agent/events.ts +11 -2
  67. package/src/central/agent/launch-engine.ts +65 -4
  68. package/src/central/agent/launch-routes.ts +8 -0
  69. package/src/central/agent/launch-spec.ts +22 -3
  70. package/src/central/agent/launch-store.ts +2 -2
  71. package/src/central/agent/message-routes.ts +49 -11
  72. package/src/central/agent/messages.ts +17 -4
  73. package/src/central/agent/not-after.ts +28 -0
  74. package/src/central/agent/placement.ts +36 -4
  75. package/src/central/agent/routes.ts +3 -1
  76. package/src/central/agent/webhook-routes.ts +21 -9
  77. package/src/central/agent/webhooks.ts +27 -9
  78. package/src/central/artifacts/host.ts +3 -1
  79. package/src/central/artifacts/maintenance.ts +2 -1
  80. package/src/central/artifacts/preview-bundle.ts +81 -0
  81. package/src/central/artifacts/previews.ts +94 -0
  82. package/src/central/artifacts/routes.ts +12 -1
  83. package/src/central/artifacts/store.ts +198 -8
  84. package/src/central/config.ts +34 -0
  85. package/src/central/fetch-budget.ts +59 -0
  86. package/src/central/fetcher.ts +372 -0
  87. package/src/central/http.ts +277 -22
  88. package/src/central/install/CONTRACT.md +4 -4
  89. package/src/central/install/install.ps1 +37 -5
  90. package/src/central/install/render.ts +13 -8
  91. package/src/central/media-remote.ts +248 -0
  92. package/src/central/media-timeline.ts +317 -0
  93. package/src/central/media-unfurl.ts +119 -0
  94. package/src/central/profiles/content.ts +3 -35
  95. package/src/central/recording/routes.ts +2 -2
  96. package/src/central/recording/schema.ts +3 -1
  97. package/src/central/registry.ts +449 -21
  98. package/src/central/spoke-ws.ts +17 -3
  99. package/src/central/store.ts +21 -0
  100. package/src/central/teams/intake-routes.ts +2 -1
  101. package/src/central/teams/intake.ts +200 -15
  102. package/src/central/unfurl-parse.ts +313 -0
  103. package/src/index.ts +68 -8
  104. package/src/speakable.ts +33 -1
  105. package/src/spoke/conversation.ts +7 -4
  106. package/src/spoke/deliver.ts +19 -0
  107. package/src/spoke/media-bytes.ts +171 -0
  108. package/src/spoke/media-policy.ts +394 -0
  109. package/src/spoke/media.ts +189 -0
  110. package/src/spoke/own-server.ts +30 -0
  111. package/src/spoke/preview-bundle.ts +538 -0
  112. package/src/spoke/spoke.ts +387 -17
  113. package/src/spoke/timeline.ts +59 -5
  114. package/src/spoke/voice-front.ts +335 -0
  115. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  116. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  117. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  118. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  119. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  120. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  121. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  122. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  123. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  124. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  125. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  126. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  127. package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
  128. package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
  129. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
  130. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  131. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  132. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  133. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  134. package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
  135. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  136. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  137. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  138. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  139. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  140. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  141. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  142. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  143. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  144. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  145. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  146. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  147. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  148. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  149. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  150. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  151. package/web/dist/assets/Library-CcjZXsfF.js +2 -0
  152. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  153. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  154. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  155. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  156. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  157. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  158. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  159. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  160. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  161. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  162. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  163. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
  164. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  165. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  166. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  167. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  168. package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
  169. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  170. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  171. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  172. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  173. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  174. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  175. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  176. package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
  177. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  178. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  179. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  180. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  181. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  182. package/web/dist/assets/index-LNMZCqgE.js +36 -0
  183. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  184. package/web/dist/assets/live-ycZ_aQZO.js +5 -0
  185. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  186. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  187. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  188. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
  189. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
  190. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
  191. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  192. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  193. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  194. package/web/dist/assets/source-BWlvaODa.js +1 -0
  195. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  196. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  197. package/web/dist/index.html +8 -6
  198. package/web/src/voice/turn/LICENSE +28 -0
  199. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  200. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  201. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  202. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  203. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  204. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  205. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  206. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  207. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  208. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  209. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  210. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  211. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  212. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  213. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -0,0 +1,538 @@
1
+ // (Session media §3, item 4b) Local HTML previews: the page an assistant message names and the files it refers to,
2
+ // collected under the media path policy (media-policy.ts: the same open-first, realpath, dev/ino and /proc checks as
3
+ // for images) for the previewBundle op (packages/protocol/src/preview-bundle.ts).
4
+ //
5
+ // - The page must be a target of the message's text (markdownHtmlPaths: code spans and same-site path links ending in
6
+ // .html/.htm, read like image sources: the first 64 KiB up to a blank line, never in indented or fenced code).
7
+ // - References followed (relative ones only, resolved against the file that has them; query and fragment dropped):
8
+ // in HTML `src`/`href`/`srcset` of script, link rel=stylesheet, img, source, video and audio, CSS in <style> and
9
+ // style attributes; in CSS `url()` and `@import` (imported sheets are read in turn; each file once, so @import loops
10
+ // end). JS module imports are not followed. Remote and data: URLs and fragments are left alone (the host's CSP
11
+ // blocks remote ones); absolute paths (`/x`, `//host`) and files the policy refuses are skipped.
12
+ // - Caps (PREVIEW_BUNDLE_LIMITS): ≤ 200 files, ≤ 5 MiB each, ≤ 20 MiB in all. A page over 5 MiB is tooLarge; an asset
13
+ // over a cap is skipped with its reason, as is a file of a type the artifact store does not take (previewMediaType).
14
+ // At most 2 × the file cap references are looked up on disk per walk (the rest: "limit"), and the touched set is
15
+ // resolved once per walk (resolvedPolicy). A file is reported "missing" only where the policy would allow it
16
+ // (missingAllowed); outside it every failure reads "refused", so a preview never tells whether such a file exists.
17
+ // - Layout: files keep their places relative to each other. Bundle paths are relative to the deepest directory that
18
+ // holds them all, and must be artifact paths (letters, digits, `._-/`, ≤ 200): others are skipped ("name").
19
+ //
20
+ // The scanners are tolerant (no DOM) and linear-time: every step moves forward, and every search starts where the
21
+ // last one ended (test/spoke-preview-bundle.test.ts has the adversarial inputs).
22
+ import { createHash } from "node:crypto"
23
+ import { basename, dirname, relative, resolve, sep } from "node:path"
24
+ import { parseMarkdown } from "../../packages/protocol/src/markdown.ts"
25
+ import { htmlTargets, isBundlePath, PREVIEW_BUNDLE_LIMITS, previewMediaType, type PreviewBundleFile, type PreviewBundleResult, type PreviewBundleSkip, type PreviewSkipReason } from "../protocol"
26
+ import type { MediaMessages } from "./media"
27
+ import { missingAllowed, readAllowedPath, resolvedPolicy, scannedPart, sourcePath, touchedPaths, withoutIndentedCode, type PolicyContext } from "./media-policy"
28
+
29
+ /**
30
+ * The local HTML pages a message's text names (htmlTargets), read like its image sources: the first 64 KiB up to its
31
+ * last blank line, and only targets that are still there with indented code left out.
32
+ */
33
+ export function markdownHtmlPaths(markdown: string): string[] {
34
+ const text = scannedPart(markdown)
35
+ if (!/\.htm/i.test(text)) return []
36
+ const outsideIndented = new Set(htmlTargets(parseMarkdown(withoutIndentedCode(text))))
37
+ return htmlTargets(parseMarkdown(text)).filter((t) => outsideIndented.has(t))
38
+ }
39
+
40
+ // ---- scanners -------------------------------------------------------------------------------------------------
41
+
42
+ /** A reference found in a file: `css` when the file it names is a style sheet (read for its own references). */
43
+ export interface Reference {
44
+ readonly url: string
45
+ readonly css: boolean
46
+ }
47
+
48
+ const NAMED: Readonly<Record<string, string>> = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">", nbsp: "\u00a0" }
49
+
50
+ /** HTML character references in an attribute value (named ones of NAMED, decimal and hex). */
51
+ function decodeEntities(value: string): string {
52
+ if (!value.includes("&")) return value
53
+ return value.replace(/&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([A-Za-z]{2,6}));?/g, (whole, dec: string | undefined, hex: string | undefined, name: string | undefined) => {
54
+ if (name !== undefined) return Object.hasOwn(NAMED, name.toLowerCase()) ? NAMED[name.toLowerCase()]! : whole
55
+ const code = dec !== undefined ? Number(dec) : parseInt(hex!, 16)
56
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole
57
+ })
58
+ }
59
+
60
+ /** Elements whose text is never markup (raw text and escapable raw text): skipped to their end tag. */
61
+ const RAW_TEXT = new Set(["script", "style", "textarea", "title", "xmp", "noembed", "noframes", "iframe", "plaintext"])
62
+ const TAG_NAME = /[A-Za-z][A-Za-z0-9-]*/y
63
+ const SPACE = /[\s/]/
64
+
65
+ /** The references of an HTML document, in document order. */
66
+ export function htmlReferences(html: string): Reference[] {
67
+ const out: Reference[] = []
68
+ const n = html.length
69
+ let i = 0
70
+ while (i < n) {
71
+ const lt = html.indexOf("<", i)
72
+ if (lt < 0) break
73
+ if (html.startsWith("<!--", lt)) {
74
+ const end = html.indexOf("-->", lt + 4)
75
+ i = end < 0 ? n : end + 3
76
+ continue
77
+ }
78
+ const next = html[lt + 1]
79
+ if (next === "!" || next === "?" || next === "/") {
80
+ const end = html.indexOf(">", lt + 2)
81
+ i = end < 0 ? n : end + 1
82
+ continue
83
+ }
84
+ TAG_NAME.lastIndex = lt + 1
85
+ const name = TAG_NAME.exec(html)
86
+ if (!name) {
87
+ i = lt + 1
88
+ continue
89
+ }
90
+ const tag = name[0].toLowerCase()
91
+ const { attrs, end } = attributes(html, lt + 1 + name[0].length)
92
+ i = end
93
+ if (attrs) referencesOf(tag, attrs, out)
94
+ if (RAW_TEXT.has(tag) && i < n) {
95
+ const close = new RegExp(`</${tag}[\\s/>]`, "gi")
96
+ close.lastIndex = i
97
+ const found = close.exec(html)
98
+ const body = html.slice(i, found ? found.index : n)
99
+ if (tag === "style") out.push(...cssReferences(body))
100
+ i = found ? found.index : n
101
+ }
102
+ }
103
+ return out
104
+ }
105
+
106
+ /**
107
+ * The attributes of a start tag from `at` (after its name) to its `>`: names lowercased, values decoded. A tag that
108
+ * never closes takes the rest of the text; a quoted value that never closes drops the tag (undefined).
109
+ */
110
+ function attributes(html: string, at: number): { attrs: Map<string, string> | undefined; end: number } {
111
+ const attrs = new Map<string, string>()
112
+ const n = html.length
113
+ let i = at
114
+ while (i < n) {
115
+ const c = html[i]!
116
+ if (c === ">") return { attrs, end: i + 1 }
117
+ if (SPACE.test(c)) {
118
+ i++
119
+ continue
120
+ }
121
+ const start = i
122
+ while (i < n && !/[\s/>=]/.test(html[i]!)) i++
123
+ // An "=" with no name before it is a character of the next name (the HTML tokenizer's rule).
124
+ if (i === start) i++
125
+ const key = html.slice(start, i).toLowerCase()
126
+ while (i < n && /\s/.test(html[i]!)) i++
127
+ if (html[i] !== "=") {
128
+ if (!attrs.has(key)) attrs.set(key, "")
129
+ continue
130
+ }
131
+ i++
132
+ while (i < n && /\s/.test(html[i]!)) i++
133
+ let value: string
134
+ const q = html[i]
135
+ if (q === '"' || q === "'") {
136
+ const close = html.indexOf(q, i + 1)
137
+ if (close < 0) return { attrs: undefined, end: n }
138
+ value = html.slice(i + 1, close)
139
+ i = close + 1
140
+ } else {
141
+ const begin = i
142
+ while (i < n && !/[\s>]/.test(html[i]!)) i++
143
+ value = html.slice(begin, i)
144
+ }
145
+ if (!attrs.has(key)) attrs.set(key, decodeEntities(value))
146
+ }
147
+ return { attrs, end: n }
148
+ }
149
+
150
+ const SRC_TAGS = new Set(["script", "img", "source", "video", "audio"])
151
+
152
+ function referencesOf(tag: string, attrs: Map<string, string>, out: Reference[]): void {
153
+ const add = (url: string | undefined, css = false) => {
154
+ if (url) out.push({ url, css })
155
+ }
156
+ if (tag === "link") {
157
+ const rel = (attrs.get("rel") ?? "").toLowerCase().split(/\s+/)
158
+ if (rel.includes("stylesheet")) add(attrs.get("href")?.trim(), true)
159
+ } else if (SRC_TAGS.has(tag)) {
160
+ add(attrs.get("src")?.trim())
161
+ if (tag === "img" || tag === "source") for (const url of srcsetUrls(attrs.get("srcset") ?? "")) add(url)
162
+ }
163
+ const style = attrs.get("style")
164
+ if (style) out.push(...cssReferences(style))
165
+ }
166
+
167
+ /**
168
+ * The URLs of a srcset attribute (the HTML "parse a srcset attribute" steps, without descriptor checks): each
169
+ * candidate is a run of non-space characters (trailing commas dropped), then descriptors up to the next comma outside
170
+ * parentheses.
171
+ */
172
+ export function srcsetUrls(value: string): string[] {
173
+ const out: string[] = []
174
+ const n = value.length
175
+ let i = 0
176
+ while (i < n) {
177
+ while (i < n && /[\s,]/.test(value[i]!)) i++
178
+ if (i >= n) break
179
+ const start = i
180
+ while (i < n && !/\s/.test(value[i]!)) i++
181
+ let url = value.slice(start, i)
182
+ if (url.endsWith(",")) {
183
+ url = url.replace(/,+$/, "")
184
+ if (url) out.push(url)
185
+ continue
186
+ }
187
+ if (url) out.push(url)
188
+ let depth = 0
189
+ while (i < n) {
190
+ const c = value[i]!
191
+ if (c === "(") depth++
192
+ else if (c === ")") depth = Math.max(0, depth - 1)
193
+ else if (c === "," && depth === 0) break
194
+ i++
195
+ }
196
+ }
197
+ return out
198
+ }
199
+
200
+ const CSS_TOKEN = /\/\*|url\(|@import/gi
201
+
202
+ /** The references of a style sheet: `url()` (files) and `@import` (style sheets), outside comments, in order. */
203
+ export function cssReferences(css: string): Reference[] {
204
+ const out: Reference[] = []
205
+ const n = css.length
206
+ const re = new RegExp(CSS_TOKEN.source, "gi")
207
+ for (let m = re.exec(css); m; m = re.exec(css)) {
208
+ const token = m[0].toLowerCase()
209
+ let i = m.index + m[0].length
210
+ if (token === "/*") {
211
+ const end = css.indexOf("*/", i)
212
+ if (end < 0) break
213
+ re.lastIndex = end + 2
214
+ continue
215
+ }
216
+ let isImport = false
217
+ if (token === "@import") {
218
+ isImport = true
219
+ while (i < n && /\s/.test(css[i]!)) i++
220
+ if (css[i] === '"' || css[i] === "'") {
221
+ const close = css.indexOf(css[i]!, i + 1)
222
+ if (close < 0) break
223
+ out.push({ url: css.slice(i + 1, close).trim(), css: true })
224
+ re.lastIndex = close + 1
225
+ continue
226
+ }
227
+ if (css.slice(i, i + 4).toLowerCase() !== "url(") {
228
+ re.lastIndex = i
229
+ continue
230
+ }
231
+ i += 4
232
+ }
233
+ while (i < n && /\s/.test(css[i]!)) i++
234
+ let url: string
235
+ if (css[i] === '"' || css[i] === "'") {
236
+ const close = css.indexOf(css[i]!, i + 1)
237
+ if (close < 0) break
238
+ url = css.slice(i + 1, close)
239
+ const paren = css.indexOf(")", close + 1)
240
+ re.lastIndex = paren < 0 ? n : paren + 1
241
+ } else {
242
+ const paren = css.indexOf(")", i)
243
+ if (paren < 0) break
244
+ url = css.slice(i, paren)
245
+ re.lastIndex = paren + 1
246
+ }
247
+ url = url.trim()
248
+ if (url) out.push({ url, css: isImport })
249
+ }
250
+ return out
251
+ }
252
+
253
+ // ---- the walk -------------------------------------------------------------------------------------------------
254
+
255
+ export type BundleLimits = { -readonly [K in "files" | "fileBytes" | "totalBytes"]: number }
256
+
257
+ export interface WalkInput extends PolicyContext {
258
+ /** The page as written in the message (a code span or link target). */
259
+ readonly src: string
260
+ /** The message's text (its text parts joined). */
261
+ readonly text: string
262
+ /** For tests: smaller caps. */
263
+ readonly limits?: Partial<BundleLimits>
264
+ }
265
+
266
+ export interface WalkedFile {
267
+ readonly path: string
268
+ readonly mediaType: string
269
+ readonly bytes: Uint8Array
270
+ }
271
+
272
+ export type WalkResult =
273
+ | { readonly entry: string; readonly files: readonly WalkedFile[]; readonly skipped: readonly PreviewBundleSkip[]; readonly skippedMore?: number }
274
+ | { readonly refused: "path" | "type" }
275
+ | { readonly tooLarge: { readonly bytes: number; readonly max: number } }
276
+
277
+ const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/
278
+
279
+ /** A reference as followed: without query and fragment, `\` read as `/` (as browsers do); undefined: left alone. */
280
+ function localReference(raw: string): { written: string; path?: string; refused?: PreviewSkipReason } | undefined {
281
+ const written = raw.trim().replace(/\\/g, "/").replace(/[?#].*$/s, "")
282
+ if (!written || SCHEME.test(written)) return undefined
283
+ if (written.startsWith("/")) return { written, refused: "refused" }
284
+ let path: string
285
+ try {
286
+ path = decodeURIComponent(written)
287
+ } catch {
288
+ return { written, refused: "name" }
289
+ }
290
+ if (path.includes("\u0000")) return { written, refused: "refused" }
291
+ return { written, path }
292
+ }
293
+
294
+ /** The directory every path is under (the deepest common one). */
295
+ function commonDir(dirs: readonly string[]): string {
296
+ let common = dirs[0]!
297
+ for (const d of dirs.slice(1)) {
298
+ while (common !== d && !d.startsWith(common.endsWith(sep) ? common : common + sep)) {
299
+ const up = dirname(common)
300
+ if (up === common) break
301
+ common = up
302
+ }
303
+ }
304
+ return common
305
+ }
306
+
307
+ const posix = (p: string) => (sep === "/" ? p : p.split(sep).join("/"))
308
+
309
+ /** Walks the page `src` names and what it refers to (see the file's header). */
310
+ export async function walkBundle(input: WalkInput): Promise<WalkResult> {
311
+ const limits: BundleLimits = { files: PREVIEW_BUNDLE_LIMITS.files, fileBytes: PREVIEW_BUNDLE_LIMITS.fileBytes, totalBytes: PREVIEW_BUNDLE_LIMITS.totalBytes, ...input.limits }
312
+ if (!markdownHtmlPaths(input.text).includes(input.src)) return { refused: "path" }
313
+ const entryPath = sourcePath(input.src, input.directory)
314
+ if (!entryPath) return { refused: "path" }
315
+ // The touched set is resolved once for the walk, not once per reference.
316
+ const ctx: PolicyContext = await resolvedPolicy({ directory: input.directory, touched: input.touched, ...(input.fs ? { fs: input.fs } : {}) })
317
+ // File-system lookups per walk (each reference read once): past this, references are skipped as "limit".
318
+ const maxProbes = 2 * limits.files
319
+ let probes = 0
320
+ const page = await readAllowedPath(entryPath, { ...ctx, max: limits.fileBytes })
321
+ if ("refused" in page) return page
322
+ if ("tooLarge" in page) return { tooLarge: { bytes: page.tooLarge, max: limits.fileBytes } }
323
+ if (!/\.html?$/i.test(page.path)) return { refused: "type" }
324
+
325
+ // Lexical paths keep the layout the page's relative references expect; the policy checks each real path.
326
+ const entryName = isBundlePath(basename(entryPath)) ? basename(entryPath) : "index.html"
327
+ const files: Array<{ abs: string; bytes: Uint8Array }> = [{ abs: resolve(dirname(entryPath), entryName), bytes: page.bytes }]
328
+ const seen = new Set<string>([entryPath, files[0]!.abs])
329
+ const skipped: PreviewBundleSkip[] = []
330
+ let skippedMore = 0
331
+ let total = page.bytes.length
332
+ let root = dirname(entryPath)
333
+ const skip = (written: string, reason: PreviewSkipReason) => {
334
+ if (skipped.length < PREVIEW_BUNDLE_LIMITS.skipped) skipped.push({ path: written.slice(0, PREVIEW_BUNDLE_LIMITS.refMax), reason })
335
+ else skippedMore++
336
+ }
337
+ const relOf = (abs: string, under: string) => posix(relative(under, abs))
338
+ /** The root with `abs` added, when every file still has a valid bundle path under it. */
339
+ const rootWith = (abs: string): string | undefined => {
340
+ const next = commonDir([root, dirname(abs)])
341
+ return [...files.map((f) => f.abs), abs].every((p) => isBundlePath(relOf(p, next))) ? next : undefined
342
+ }
343
+
344
+ const queue: Array<{ abs: string; css: boolean; text: string }> = [{ abs: entryPath, css: false, text: new TextDecoder().decode(page.bytes) }]
345
+ for (let q = 0; q < queue.length; q++) {
346
+ const file = queue[q]!
347
+ const refs = file.css ? cssReferences(file.text) : htmlReferences(file.text)
348
+ for (const ref of refs) {
349
+ const local = localReference(ref.url)
350
+ if (!local) continue
351
+ if (local.refused) {
352
+ skip(local.written, local.refused)
353
+ continue
354
+ }
355
+ const abs = resolve(dirname(file.abs), local.path!)
356
+ if (seen.has(abs)) continue
357
+ seen.add(abs)
358
+ if (!previewMediaType(abs)) {
359
+ skip(local.written, "type")
360
+ continue
361
+ }
362
+ if (files.length >= limits.files || probes >= maxProbes) {
363
+ skip(local.written, "limit")
364
+ continue
365
+ }
366
+ probes++
367
+ const read = await readAllowedPath(abs, { ...ctx, max: limits.fileBytes })
368
+ if ("refused" in read) {
369
+ // "missing" only where the policy would allow the file: outside it, existing or not reads the same.
370
+ skip(local.written, (await missingAllowed(abs, ctx)) ? "missing" : "refused")
371
+ continue
372
+ }
373
+ if ("tooLarge" in read) {
374
+ skip(local.written, "too_large")
375
+ continue
376
+ }
377
+ if (total + read.bytes.length > limits.totalBytes) {
378
+ skip(local.written, "limit")
379
+ continue
380
+ }
381
+ const next = rootWith(abs)
382
+ if (next === undefined) {
383
+ skip(local.written, "name")
384
+ continue
385
+ }
386
+ root = next
387
+ total += read.bytes.length
388
+ files.push({ abs, bytes: read.bytes })
389
+ if (ref.css) queue.push({ abs, css: true, text: new TextDecoder().decode(read.bytes) })
390
+ }
391
+ }
392
+ const out = files.map((f) => {
393
+ const path = relOf(f.abs, root)
394
+ return { path, mediaType: previewMediaType(path)!, bytes: f.bytes }
395
+ })
396
+ return { entry: out[0]!.path, files: out, skipped, ...(skippedMore > 0 ? { skippedMore } : {}) }
397
+ }
398
+
399
+
400
+ // ---- the op ---------------------------------------------------------------------------------------------------
401
+
402
+ /** A walked bundle as the op serves it: its identity and its files with their hashes. */
403
+ export interface CachedBundle {
404
+ readonly id: string
405
+ readonly entry: string
406
+ readonly files: ReadonlyArray<{ readonly path: string; readonly mediaType: string; readonly bytes: Uint8Array; readonly sha256: string }>
407
+ readonly skipped: readonly PreviewBundleSkip[]
408
+ readonly skippedMore?: number
409
+ }
410
+
411
+ const sizeOf = (b: CachedBundle) => b.files.reduce((n, f) => n + f.bytes.length, 0)
412
+
413
+ /** Walked bundles by key (session, message, page), least recently used first; entries expire after `ttlMs`. */
414
+ export class BundleCache {
415
+ private readonly entries = new Map<string, { bundle: CachedBundle; at: number; size: number }>()
416
+ private total = 0
417
+ private readonly ttlMs: number
418
+ private readonly maxBytes: number
419
+ private readonly now: () => number
420
+
421
+ constructor(options: { ttlMs?: number; maxBytes?: number; now?: () => number } = {}) {
422
+ this.ttlMs = options.ttlMs ?? 60_000
423
+ this.maxBytes = options.maxBytes ?? 48 * 1024 * 1024
424
+ this.now = options.now ?? Date.now
425
+ }
426
+
427
+ get(key: string): CachedBundle | undefined {
428
+ const entry = this.entries.get(key)
429
+ if (!entry) return undefined
430
+ this.entries.delete(key)
431
+ if (this.now() - entry.at >= this.ttlMs) {
432
+ this.total -= entry.size
433
+ return undefined
434
+ }
435
+ this.entries.set(key, entry)
436
+ return entry.bundle
437
+ }
438
+
439
+ set(key: string, bundle: CachedBundle): void {
440
+ this.drop(key)
441
+ const size = sizeOf(bundle)
442
+ if (size > this.maxBytes) return
443
+ this.entries.set(key, { bundle, at: this.now(), size })
444
+ this.total += size
445
+ while (this.total > this.maxBytes) this.drop(this.entries.keys().next().value!)
446
+ }
447
+
448
+ /** Forget a session's bundles (deleted, or a revert hid messages). */
449
+ invalidate(sessionID: string): void {
450
+ for (const key of [...this.entries.keys()]) if (key.startsWith(`${sessionID}\u0000`)) this.drop(key)
451
+ }
452
+
453
+ private drop(key: string): void {
454
+ const entry = this.entries.get(key)
455
+ if (!entry) return
456
+ this.entries.delete(key)
457
+ this.total -= entry.size
458
+ }
459
+ }
460
+
461
+ export interface PreviewBundleInput {
462
+ readonly sessionID: string
463
+ readonly messageID: string
464
+ readonly path: string
465
+ readonly bundle?: string
466
+ readonly file?: number
467
+ readonly offset?: number
468
+ readonly cache: BundleCache
469
+ /** The session's messages (read for every walk, so access is checked again). */
470
+ readonly load: () => Promise<MediaMessages>
471
+ readonly directory: string
472
+ readonly fs?: PolicyContext["fs"]
473
+ }
474
+
475
+ const record = (value: unknown): Record<string, any> => (value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, any>) : {})
476
+
477
+ /** The bundle's identity: a hash of its entry and every file's path, type and content hash. */
478
+ const bundleID = (entry: string, files: CachedBundle["files"]) => createHash("sha256").update(JSON.stringify([entry, files.map((f) => [f.path, f.mediaType, f.sha256])])).digest("hex")
479
+
480
+ /** Walks the page of the message again (message, revert and path checks included). */
481
+ async function walkMessage(input: PreviewBundleInput): Promise<CachedBundle | PreviewBundleResult> {
482
+ const messages = await input.load()
483
+ const message = messages.all.map(record).find((m) => m.id === input.messageID)
484
+ if (!message) return input.messageID === messages.committed ? { refused: "hidden" } : {}
485
+ if (!messages.shown.some((m) => record(m).id === input.messageID)) return { refused: "hidden" }
486
+ if (message.type !== "assistant" || !Array.isArray(message.content)) return { refused: "path" }
487
+ const text = message.content
488
+ .filter((p: any) => p?.type === "text" && typeof p.text === "string")
489
+ .map((p: any) => p.text as string)
490
+ .join("\n\n")
491
+ const walked = await walkBundle({ src: input.path, text, directory: input.directory, touched: touchedPaths(messages.shown, input.directory), ...(input.fs ? { fs: input.fs } : {}) })
492
+ if (!("files" in walked)) return walked
493
+ const files = walked.files.map((f) => ({ ...f, sha256: createHash("sha256").update(f.bytes).digest("hex") }))
494
+ return { id: bundleID(walked.entry, files), entry: walked.entry, files, skipped: walked.skipped, ...(walked.skippedMore ? { skippedMore: walked.skippedMore } : {}) }
495
+ }
496
+
497
+ const isBundle = (v: CachedBundle | PreviewBundleResult): v is CachedBundle => "id" in v
498
+
499
+ /**
500
+ * The answer to previewBundle (packages/protocol/src/preview-bundle.ts): without `bundle`, a fresh walk's manifest
501
+ * (files inlined in order while they fit PREVIEW_BUNDLE_LIMITS.inlineBytes); with `bundle, file, offset`, that chunk,
502
+ * from the cache or a new walk with the same identity, else `stale`. Throws for no such file or an offset past its end.
503
+ */
504
+ export async function previewBundle(input: PreviewBundleInput): Promise<PreviewBundleResult> {
505
+ const key = `${input.sessionID}\u0000${input.messageID}\u0000${input.path}`
506
+ if (input.bundle !== undefined) {
507
+ let bundle = input.cache.get(key)
508
+ if (bundle?.id !== input.bundle) {
509
+ const walked = await walkMessage(input)
510
+ if (!isBundle(walked)) return walked
511
+ input.cache.set(key, walked)
512
+ if (walked.id !== input.bundle) return { stale: true }
513
+ bundle = walked
514
+ }
515
+ const file = bundle.files[input.file ?? -1]
516
+ if (!file) throw new Error("No such file in the preview bundle")
517
+ const offset = input.offset ?? 0
518
+ if (offset >= file.bytes.length) throw new Error("The offset is past the end of the file")
519
+ const end = Math.min(file.bytes.length, offset + PREVIEW_BUNDLE_LIMITS.chunkBytes)
520
+ return { chunk: { offset, data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset + offset, end - offset).toString("base64"), last: end >= file.bytes.length } }
521
+ }
522
+ const walked = await walkMessage(input)
523
+ if (!isBundle(walked)) return walked
524
+ input.cache.set(key, walked)
525
+ let inline = 0
526
+ const files: PreviewBundleFile[] = walked.files.map((f) => {
527
+ const fits = inline + f.bytes.length <= PREVIEW_BUNDLE_LIMITS.inlineBytes
528
+ if (fits) inline += f.bytes.length
529
+ return { path: f.path, mediaType: f.mediaType, bytes: f.bytes.length, sha256: f.sha256, ...(fits ? { data: Buffer.from(f.bytes).toString("base64") } : {}) }
530
+ })
531
+ return {
532
+ bundle: walked.id,
533
+ entry: walked.entry,
534
+ files,
535
+ ...(walked.skipped.length ? { skipped: walked.skipped } : {}),
536
+ ...(walked.skippedMore ? { skippedMore: walked.skippedMore } : {}),
537
+ }
538
+ }