@huynle/hub 0.8.6 → 0.9.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 (184) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +69 -12
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +278 -35
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/adapters/opencode/README.md +4 -2
  8. package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
  9. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
  10. package/packages/cli/src/cli.ts +23 -10
  11. package/packages/cli/src/http.ts +1 -0
  12. package/packages/cli/src/launches.ts +5 -1
  13. package/packages/cli/src/mcp.ts +28 -8
  14. package/packages/cli/src/messaging.ts +14 -0
  15. package/packages/cli/src/team-intake-mcp.ts +26 -2
  16. package/packages/codex/package.json +1 -1
  17. package/packages/node/package.json +1 -1
  18. package/packages/preset/package.json +1 -1
  19. package/packages/protocol/package.json +1 -1
  20. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  21. package/packages/protocol/src/artifacts.ts +43 -0
  22. package/packages/protocol/src/credentials.ts +38 -0
  23. package/packages/protocol/src/index.ts +56 -5
  24. package/packages/protocol/src/launch.ts +7 -2
  25. package/packages/protocol/src/markdown.ts +753 -0
  26. package/packages/protocol/src/media-allowlist.ts +240 -0
  27. package/packages/protocol/src/media.ts +120 -0
  28. package/packages/protocol/src/preview-bundle.ts +221 -0
  29. package/packages/protocol/src/voice-recording.ts +8 -0
  30. package/packages/runtime-openshell/package.json +1 -1
  31. package/packages/runtime-sdk/package.json +1 -1
  32. package/packages/sdk/package.json +1 -1
  33. package/packages/sdk/src/client.ts +17 -3
  34. package/packages/sdk/src/errors.ts +1 -0
  35. package/packages/sdk/src/team-intake.ts +8 -0
  36. package/packages/sdk/src/types.ts +16 -2
  37. package/packages/spoke-sdk/package.json +1 -1
  38. package/src/central/agent/delivery.ts +35 -6
  39. package/src/central/agent/errors.ts +2 -0
  40. package/src/central/agent/events.ts +11 -2
  41. package/src/central/agent/launch-engine.ts +65 -4
  42. package/src/central/agent/launch-routes.ts +8 -0
  43. package/src/central/agent/launch-spec.ts +18 -1
  44. package/src/central/agent/launch-store.ts +2 -2
  45. package/src/central/agent/message-routes.ts +49 -11
  46. package/src/central/agent/messages.ts +17 -4
  47. package/src/central/agent/not-after.ts +28 -0
  48. package/src/central/agent/placement.ts +36 -4
  49. package/src/central/agent/routes.ts +3 -1
  50. package/src/central/agent/webhook-routes.ts +21 -9
  51. package/src/central/agent/webhooks.ts +27 -9
  52. package/src/central/artifacts/host.ts +3 -1
  53. package/src/central/artifacts/maintenance.ts +2 -1
  54. package/src/central/artifacts/preview-bundle.ts +81 -0
  55. package/src/central/artifacts/previews.ts +94 -0
  56. package/src/central/artifacts/routes.ts +12 -1
  57. package/src/central/artifacts/store.ts +198 -8
  58. package/src/central/config.ts +34 -0
  59. package/src/central/fetch-budget.ts +59 -0
  60. package/src/central/fetcher.ts +372 -0
  61. package/src/central/http.ts +277 -22
  62. package/src/central/media-remote.ts +248 -0
  63. package/src/central/media-timeline.ts +317 -0
  64. package/src/central/media-unfurl.ts +119 -0
  65. package/src/central/profiles/content.ts +3 -35
  66. package/src/central/recording/routes.ts +2 -2
  67. package/src/central/recording/schema.ts +3 -1
  68. package/src/central/registry.ts +449 -21
  69. package/src/central/spoke-ws.ts +17 -3
  70. package/src/central/store.ts +21 -0
  71. package/src/central/teams/intake-routes.ts +2 -1
  72. package/src/central/teams/intake.ts +200 -15
  73. package/src/central/unfurl-parse.ts +313 -0
  74. package/src/index.ts +68 -8
  75. package/src/speakable.ts +33 -1
  76. package/src/spoke/conversation.ts +7 -4
  77. package/src/spoke/deliver.ts +19 -0
  78. package/src/spoke/media-bytes.ts +171 -0
  79. package/src/spoke/media-policy.ts +394 -0
  80. package/src/spoke/media.ts +189 -0
  81. package/src/spoke/own-server.ts +30 -0
  82. package/src/spoke/preview-bundle.ts +538 -0
  83. package/src/spoke/spoke.ts +387 -17
  84. package/src/spoke/timeline.ts +59 -5
  85. package/src/spoke/voice-front.ts +335 -0
  86. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  87. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  88. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  89. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  90. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  91. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  92. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  93. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  94. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  95. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  96. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  97. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  98. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  99. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  100. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  101. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  102. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  103. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  104. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  105. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  106. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  107. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  108. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  109. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  110. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  111. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  112. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  113. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  114. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  115. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  116. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  117. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  118. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  119. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  120. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  121. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  122. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  123. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  124. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  125. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  126. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  127. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  128. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  129. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  130. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  131. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  132. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  133. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  134. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  135. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  136. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  137. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  138. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  139. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  140. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  141. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  142. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  143. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  144. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  145. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  146. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  147. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  148. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  149. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  150. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  151. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  152. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  153. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  154. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  155. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  156. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  157. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  158. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  159. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  160. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  161. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  162. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  163. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  164. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  165. package/web/dist/assets/source-BWlvaODa.js +1 -0
  166. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  167. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  168. package/web/dist/index.html +8 -6
  169. package/web/src/voice/turn/LICENSE +28 -0
  170. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  171. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  172. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  173. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  174. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  175. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  176. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  177. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  178. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  179. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  180. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  181. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  182. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  183. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  184. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -0,0 +1,240 @@
1
+ // (RF2, session media §4) The per-user media allowlist: hostnames (`github.com`) or suffixes
2
+ // (`*.githubusercontent.com`) whose web images the console loads at once through GET /api/media/remote. Shared by
3
+ // the hub (PUT /api/settings/media validates with parseMediaAllowlist) and the console (Settings → Media, and the
4
+ // match for a reply's image). Entries are stored normalised: lowercase, no trailing dot, IDNs as punycode. Refused:
5
+ // IP addresses, ports, schemes, paths, userinfo, a bare `*`, and wildcards (or exact hosts) that are only a public
6
+ // suffix (`*.com`, `*.co.uk`, `*.github.io`): a wildcard needs at least two labels after `*.`, none of them a known
7
+ // public suffix. A wildcard matches subdomains only, never the suffix itself.
8
+
9
+ export const MEDIA_ALLOWLIST_LIMITS = { entries: 100, chars: 253 } as const
10
+
11
+ /**
12
+ * Common public suffixes of more than one label (and shared hosting suffixes whose subdomains belong to different
13
+ * owners). Single-label suffixes (`com`, `de` …) are refused by the two-label rule. Not the full Public Suffix List:
14
+ * a small built-in guard against the obvious mistakes.
15
+ */
16
+ const PUBLIC_SUFFIXES = new Set([
17
+ ...["co", "org", "ac", "gov", "net", "me", "ltd", "plc", "sch", "nhs"].map((s) => `${s}.uk`),
18
+ ...["com", "net", "org", "edu", "gov", "id", "asn"].map((s) => `${s}.au`),
19
+ ...["co", "ne", "or", "ac", "go", "gr", "ed", "ad", "lg"].map((s) => `${s}.jp`),
20
+ ...["co", "org", "net", "ac", "govt", "school", "geek", "gen", "kiwi", "maori"].map((s) => `${s}.nz`),
21
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.br`),
22
+ ...["com", "net", "org", "gov", "edu", "ac"].map((s) => `${s}.cn`),
23
+ ...["com", "net", "org", "gob", "edu"].map((s) => `${s}.mx`),
24
+ ...["co", "net", "org", "firm", "gen", "ind", "ac", "edu", "gov", "res"].map((s) => `${s}.in`),
25
+ ...["co", "org", "net", "ac", "gov", "web"].map((s) => `${s}.za`),
26
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.tr`),
27
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.sg`),
28
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.hk`),
29
+ ...["co", "or", "ne", "go", "ac", "re"].map((s) => `${s}.kr`),
30
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.tw`),
31
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.ar`),
32
+ ...["co", "or", "ac", "go"].map((s) => `${s}.id`),
33
+ ...["co", "or", "ac", "go"].map((s) => `${s}.th`),
34
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.my`),
35
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.ph`),
36
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.pk`),
37
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.ua`),
38
+ ...["com", "net", "org", "gov", "edu"].map((s) => `${s}.pl`),
39
+ ...["co", "or", "ac"].map((s) => `${s}.at`),
40
+ ...["co", "org", "ac"].map((s) => `${s}.il`),
41
+ "com.es",
42
+ "org.es",
43
+ "co.it",
44
+ "com.ru",
45
+ "co.ca",
46
+ "eu.org",
47
+ // Shared hosting: every subdomain is someone else's site.
48
+ "github.io",
49
+ "githubpreview.dev",
50
+ "gitlab.io",
51
+ "pages.dev",
52
+ "workers.dev",
53
+ "netlify.app",
54
+ "vercel.app",
55
+ "herokuapp.com",
56
+ "appspot.com",
57
+ "blogspot.com",
58
+ "cloudfront.net",
59
+ "azurewebsites.net",
60
+ "web.app",
61
+ "firebaseapp.com",
62
+ "s3.amazonaws.com",
63
+ "ngrok.io",
64
+ "ngrok-free.app",
65
+ "glitch.me",
66
+ "repl.co",
67
+ "fly.dev",
68
+ "onrender.com",
69
+ ])
70
+
71
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
72
+
73
+ type Entry = { ok: true; entry: string } | { ok: false; reason: string }
74
+
75
+ /** One allowlist entry, normalised (lowercase, no trailing dot, punycode), or why it is refused. */
76
+ export function normalizeAllowlistEntry(raw: string): Entry {
77
+ if (typeof raw !== "string") return { ok: false, reason: "Expected a hostname" }
78
+ const value = raw.trim()
79
+ if (!value) return { ok: false, reason: "Expected a hostname" }
80
+ if (value.length > MEDIA_ALLOWLIST_LIMITS.chars)
81
+ return {
82
+ ok: false,
83
+ reason: `${short(value)}: at most ${MEDIA_ALLOWLIST_LIMITS.chars} characters`,
84
+ }
85
+ if (/[\s/\\:@?#[\]%]/.test(value))
86
+ return {
87
+ ok: false,
88
+ reason: `${short(value)}: a hostname only (no scheme, port, path or address)`,
89
+ }
90
+ const wildcard = value.startsWith("*.")
91
+ const rest = wildcard ? value.slice(2) : value
92
+ if (rest.includes("*"))
93
+ return {
94
+ ok: false,
95
+ reason: `${short(value)}: a wildcard only as a leading "*."`,
96
+ }
97
+ const ascii = toASCII(rest.replace(/\.$/, ""))
98
+ if (ascii === undefined) return { ok: false, reason: `${short(value)}: not a hostname` }
99
+ const labels = ascii.split(".")
100
+ if (labels.some((l) => !LABEL.test(l))) return { ok: false, reason: `${short(value)}: not a hostname` }
101
+ if (/^\d+$/.test(labels[labels.length - 1]!))
102
+ return {
103
+ ok: false,
104
+ reason: `${short(value)}: an IP address or numeric domain is not allowed`,
105
+ }
106
+ if (labels.length < 2)
107
+ return {
108
+ ok: false,
109
+ reason: wildcard ? `${short(value)}: too broad (name at least two labels after "*.")` : `${short(value)}: name a full hostname (like github.com)`,
110
+ }
111
+ if (PUBLIC_SUFFIXES.has(ascii))
112
+ return {
113
+ ok: false,
114
+ reason: `${short(value)}: too broad (a public suffix: name the site itself)`,
115
+ }
116
+ const entry = wildcard ? `*.${ascii}` : ascii
117
+ if (entry.length > MEDIA_ALLOWLIST_LIMITS.chars)
118
+ return {
119
+ ok: false,
120
+ reason: `${short(value)}: at most ${MEDIA_ALLOWLIST_LIMITS.chars} characters`,
121
+ }
122
+ return { ok: true, entry }
123
+ }
124
+
125
+ const short = (value: string) => (value.length > 60 ? `${value.slice(0, 57)}…` : value)
126
+
127
+ /** A host in punycode through the URL parser (which also lowercases); undefined when it is not a valid one. */
128
+ function toASCII(host: string): string | undefined {
129
+ if (!host) return undefined
130
+ try {
131
+ const parsed = new URL(`http://${host}/`)
132
+ return parsed.hostname && parsed.hostname === parsed.host ? parsed.hostname : undefined
133
+ } catch {
134
+ return undefined
135
+ }
136
+ }
137
+
138
+ /** The whole setting (PUT /api/settings/media's `allowlist`): normalised, order kept, duplicates dropped. */
139
+ export function parseMediaAllowlist(value: unknown): { ok: true; allowlist: string[] } | { ok: false; reason: string } {
140
+ if (!Array.isArray(value)) return { ok: false, reason: "Expected allowlist: a list of hostnames" }
141
+ if (value.length > MEDIA_ALLOWLIST_LIMITS.entries)
142
+ return {
143
+ ok: false,
144
+ reason: `At most ${MEDIA_ALLOWLIST_LIMITS.entries} allowed sites`,
145
+ }
146
+ const out: string[] = []
147
+ for (const raw of value) {
148
+ const entry = normalizeAllowlistEntry(raw as string)
149
+ if (!entry.ok) return entry
150
+ if (!out.includes(entry.entry)) out.push(entry.entry)
151
+ }
152
+ return { ok: true, allowlist: out }
153
+ }
154
+
155
+ /**
156
+ * The hostname of an http(s) image URL, normalised like allowlist entries (the URL parser's host: lowercase,
157
+ * punycode, userinfo dropped); undefined for other schemes, unparsable URLs and ports other than 80/443 (the hub's
158
+ * fetcher refuses those).
159
+ */
160
+ export function imageHost(url: string): string | undefined {
161
+ let parsed: URL
162
+ try {
163
+ parsed = new URL(url)
164
+ } catch {
165
+ return undefined
166
+ }
167
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined
168
+ if (parsed.port && parsed.port !== "80" && parsed.port !== "443") return undefined
169
+ return parsed.hostname.replace(/\.$/, "") || undefined
170
+ }
171
+
172
+ /** Whether a host is on the allowlist: an exact entry, or a `*.suffix` entry for a subdomain of it. */
173
+ export function allowlistMatches(allowlist: readonly string[], host: string): boolean {
174
+ const h = host.toLowerCase().replace(/\.$/, "")
175
+ if (!h) return false
176
+ return allowlist.some((entry) => (entry.startsWith("*.") ? h.endsWith(entry.slice(1)) && h.length > entry.length - 1 : h === entry))
177
+ }
178
+
179
+ /** A host with its punycode labels shown in unicode (for display next to the punycode form). */
180
+ export function hostUnicode(host: string): string {
181
+ return host
182
+ .split(".")
183
+ .map((label) => {
184
+ if (!label.toLowerCase().startsWith("xn--")) return label
185
+ return decodePunycode(label.slice(4)) ?? label
186
+ })
187
+ .join(".")
188
+ }
189
+
190
+ /** RFC 3492 decoding of one label's punycode (after "xn--"); undefined when it is not valid. */
191
+ function decodePunycode(input: string): string | undefined {
192
+ const base = 36
193
+ const tMin = 1
194
+ const tMax = 26
195
+ const skew = 38
196
+ const damp = 700
197
+ let n = 128
198
+ let i = 0
199
+ let bias = 72
200
+ const delimiter = input.lastIndexOf("-")
201
+ const output: number[] = []
202
+ for (let j = 0; j < Math.max(delimiter, 0); j++) {
203
+ const c = input.charCodeAt(j)
204
+ if (c >= 0x80) return undefined
205
+ output.push(c)
206
+ }
207
+ const adapt = (delta: number, points: number, first: boolean) => {
208
+ delta = first ? Math.floor(delta / damp) : delta >> 1
209
+ delta += Math.floor(delta / points)
210
+ let k = 0
211
+ while (delta > ((base - tMin) * tMax) >> 1) {
212
+ delta = Math.floor(delta / (base - tMin))
213
+ k += base
214
+ }
215
+ return k + Math.floor(((base - tMin + 1) * delta) / (delta + skew))
216
+ }
217
+ const digit = (c: number) => (c >= 48 && c <= 57 ? c - 22 : c >= 65 && c <= 90 ? c - 65 : c >= 97 && c <= 122 ? c - 97 : base)
218
+ for (let at = delimiter > 0 ? delimiter + 1 : 0; at < input.length;) {
219
+ const old = i
220
+ for (let w = 1, k = base; ; k += base) {
221
+ if (at >= input.length) return undefined
222
+ const d = digit(input.charCodeAt(at++))
223
+ if (d >= base) return undefined
224
+ i += d * w
225
+ const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias
226
+ if (d < t) break
227
+ w *= base - t
228
+ }
229
+ bias = adapt(i - old, output.length + 1, old === 0)
230
+ n += Math.floor(i / (output.length + 1))
231
+ i %= output.length + 1
232
+ if (n > 0x10ffff) return undefined
233
+ output.splice(i++, 0, n)
234
+ }
235
+ try {
236
+ return String.fromCodePoint(...output)
237
+ } catch {
238
+ return undefined
239
+ }
240
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * (S4 M2) Media in sessions: images a session shows that the console pulls on demand through the spoke op
3
+ * `timelineMedia` (feature "timelineMedia", announced, never implied). An image is named by its message and a ref:
4
+ *
5
+ * - `f<n>`: the n-th file attached to a user message (the same files `timelineFile` serves)
6
+ * - `t<part>.<item>`: content item `item` of the tool part at index `part` of an assistant message
7
+ * - `p:<base64url path>`: an image path written in an assistant message's text (`![alt](path)`), exactly as written;
8
+ * the spoke re-reads the message and serves the file only under its path policy (src/spoke/media-policy.ts)
9
+ *
10
+ * The bytes travel in chunks of at most MEDIA_LIMITS.chunkBytes; the first answer (offset 0) carries the image's
11
+ * metadata, each answer the chunk at `offset` and whether it is the last.
12
+ */
13
+
14
+ export const MEDIA_LIMITS = {
15
+ /** Largest image served (decoded bytes). */
16
+ maxBytes: 8 * 1024 * 1024,
17
+ /** Most pixels (width × height) of a raster image. */
18
+ maxPixels: 40_000_000,
19
+ /** Widest or tallest raster image, in pixels. */
20
+ maxSide: 16_384,
21
+ /** Largest chunk of one answer (decoded bytes). */
22
+ chunkBytes: 512 * 1024,
23
+ /** Most media a timeline row lists. */
24
+ perRow: 8,
25
+ } as const
26
+
27
+ /** The image types served: magic bytes must match. SVG is served only as an attachment (never a document). */
28
+ export const MEDIA_MIMES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"] as const
29
+ export type MediaMime = (typeof MEDIA_MIMES)[number]
30
+
31
+ /** Longest path a `p:` ref carries (UTF-8 bytes). */
32
+ export const MEDIA_PATH_MAX = 4096
33
+ /** Longest ref (a `p:` ref of the longest path). */
34
+ export const MEDIA_REF_MAX = 2 + Math.ceil((MEDIA_PATH_MAX * 4) / 3) + 4
35
+
36
+ export type MediaRef =
37
+ | { readonly kind: "file"; readonly index: number }
38
+ | { readonly kind: "tool"; readonly part: number; readonly item: number }
39
+ | { readonly kind: "path"; readonly path: string }
40
+
41
+ /** What a served image is. `width`/`height`: from its header (raster types). */
42
+ export interface MediaInfo {
43
+ readonly mime: MediaMime
44
+ readonly bytes: number
45
+ /** Hex SHA-256 of the bytes. */
46
+ readonly sha256: string
47
+ readonly width?: number
48
+ readonly height?: number
49
+ }
50
+
51
+ export interface MediaChunk {
52
+ readonly offset: number
53
+ /** Base64 of at most MEDIA_LIMITS.chunkBytes bytes. */
54
+ readonly data: string
55
+ readonly last: boolean
56
+ }
57
+
58
+ /** Why an image is not served: not an allowed type, its message is hidden by a revert, too many pixels, a path outside the policy. */
59
+ export const MEDIA_REFUSALS = ["type", "hidden", "dimensions", "path"] as const
60
+ export type MediaRefusal = (typeof MEDIA_REFUSALS)[number]
61
+
62
+ /**
63
+ * A timelineMedia answer: `media` and `chunk` when served; `tooLarge` when it exceeds MEDIA_LIMITS.maxBytes;
64
+ * `refused` with the reason; nothing at all when there is no such image.
65
+ */
66
+ export interface TimelineMediaResult {
67
+ readonly media?: MediaInfo
68
+ readonly chunk?: MediaChunk
69
+ readonly tooLarge?: { readonly bytes: number; readonly max: number }
70
+ readonly refused?: MediaRefusal
71
+ }
72
+
73
+ const FILE_REF = /^f(0|[1-9]\d{0,2})$/
74
+ const TOOL_REF = /^t(0|[1-9]\d{0,3})\.(0|[1-9]\d{0,2})$/
75
+ const PATH_REF = /^p:([A-Za-z0-9_-]+)$/
76
+
77
+ function base64url(bytes: Uint8Array): string {
78
+ let binary = ""
79
+ for (const byte of bytes) binary += String.fromCharCode(byte)
80
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
81
+ }
82
+
83
+ function fromBase64url(text: string): Uint8Array | undefined {
84
+ if (text.length % 4 === 1) return undefined
85
+ try {
86
+ const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"))
87
+ return Uint8Array.from(binary, (c) => c.charCodeAt(0))
88
+ } catch {
89
+ return undefined
90
+ }
91
+ }
92
+
93
+ /** The `p:` ref of a path as written in a message. */
94
+ export function mediaPathRef(path: string): string {
95
+ return `p:${base64url(new TextEncoder().encode(path))}`
96
+ }
97
+
98
+ /** A ref's parts, or undefined when it is not a well-formed ref. */
99
+ export function parseMediaRef(ref: string): MediaRef | undefined {
100
+ if (typeof ref !== "string" || ref.length > MEDIA_REF_MAX) return undefined
101
+ const file = FILE_REF.exec(ref)
102
+ if (file) return { kind: "file", index: Number(file[1]) }
103
+ const tool = TOOL_REF.exec(ref)
104
+ if (tool) return { kind: "tool", part: Number(tool[1]), item: Number(tool[2]) }
105
+ const path = PATH_REF.exec(ref)
106
+ if (!path) return undefined
107
+ const bytes = fromBase64url(path[1]!)
108
+ if (!bytes || bytes.length === 0 || bytes.length > MEDIA_PATH_MAX) return undefined
109
+ let text: string
110
+ try {
111
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes)
112
+ } catch {
113
+ return undefined
114
+ }
115
+ if (text.includes("\u0000") || mediaPathRef(text) !== ref) return undefined
116
+ return { kind: "path", path: text }
117
+ }
118
+
119
+ /** Whether `offset` is a valid chunk offset of a timelineMedia request. */
120
+ export const isMediaOffset = (offset: unknown): boolean => Number.isInteger(offset) && (offset as number) >= 0 && (offset as number) <= MEDIA_LIMITS.maxBytes
@@ -0,0 +1,221 @@
1
+ /**
2
+ * (Session media §3, item 4b) Previews of local HTML files. The console names a page only by a path written in an
3
+ * assistant message (`htmlTargets`: a code span or a same-site path link ending in .html/.htm); the spoke re-reads the
4
+ * message, opens the page under the media path policy (src/spoke/media-policy.ts) and collects the files it refers to
5
+ * (src/spoke/preview-bundle.ts). Feature "previewBundle" (announced by the OpenCode spoke, never implied).
6
+ *
7
+ * Paging (a frame holds at most MAX_FRAME characters): the first request (`{sessionID, messageID, path}`) answers the
8
+ * bundle's manifest: `bundle` (its identity, a hex SHA-256 of the entry and every file's path, type and hash),
9
+ * `entry`, `files` and `skipped`. Files are inlined (`data`, base64) in manifest order while their decoded bytes fit
10
+ * in PREVIEW_BUNDLE_LIMITS.inlineBytes; every other file is read with `{bundle, file, offset}` requests, one chunk of at
11
+ * most chunkBytes each (`chunk: {offset, data, last}`). The spoke keeps the walked bundle for a minute; when it has to
12
+ * walk again and the bundle changed, a chunk request answers `stale: true` and the hub starts over.
13
+ */
14
+ import type { Inline, MdBlock } from "./markdown.ts"
15
+
16
+ export const PREVIEW_BUNDLE_FEATURE = "previewBundle"
17
+
18
+ export const PREVIEW_BUNDLE_LIMITS = {
19
+ /** Most files in a bundle, the entry included. */
20
+ files: 200,
21
+ /** Largest file (the entry over it: tooLarge; an asset over it: skipped). */
22
+ fileBytes: 5 * 1024 * 1024,
23
+ /** Largest bundle (assets past it are skipped). */
24
+ totalBytes: 20 * 1024 * 1024,
25
+ /** Largest chunk of a chunk answer (decoded bytes). */
26
+ chunkBytes: 512 * 1024,
27
+ /** Decoded bytes inlined in the first answer. */
28
+ inlineBytes: 384 * 1024,
29
+ /** Longest path in a bundle (the artifact store's limit). */
30
+ bundlePathMax: 200,
31
+ /** Longest page path a request names. */
32
+ pathMax: 4096,
33
+ /** Longest reference listed as skipped (longer ones are cut). */
34
+ refMax: 300,
35
+ /** Most skipped references listed (`skippedMore` counts the rest). */
36
+ skipped: 200,
37
+ } as const
38
+
39
+ /** Why a referenced file is not in the bundle. */
40
+ export const PREVIEW_SKIP_REASONS = ["missing", "refused", "too_large", "limit", "name", "type"] as const
41
+ export type PreviewSkipReason = (typeof PREVIEW_SKIP_REASONS)[number]
42
+
43
+ /** Why the page itself is not served: not in the policy (or not in the message), not an HTML file, hidden by a revert. */
44
+ export const PREVIEW_BUNDLE_REFUSALS = ["path", "type", "hidden"] as const
45
+ export type PreviewBundleRefusal = (typeof PREVIEW_BUNDLE_REFUSALS)[number]
46
+
47
+ /**
48
+ * Media types by file extension, all of them artifact store types (ARTIFACT_MEDIA_TYPES: the store takes no other and
49
+ * never sniffs). A file of any other type (fonts, video, …) is left out of a bundle (skipped "type").
50
+ */
51
+ export const PREVIEW_MEDIA_TYPES: Readonly<Record<string, string>> = {
52
+ html: "text/html",
53
+ htm: "text/html",
54
+ css: "text/css",
55
+ js: "text/javascript",
56
+ mjs: "text/javascript",
57
+ json: "application/json",
58
+ txt: "text/plain",
59
+ csv: "text/csv",
60
+ md: "text/markdown",
61
+ pdf: "application/pdf",
62
+ png: "image/png",
63
+ jpg: "image/jpeg",
64
+ jpeg: "image/jpeg",
65
+ gif: "image/gif",
66
+ webp: "image/webp",
67
+ svg: "image/svg+xml",
68
+ }
69
+
70
+ /** The media type a bundle file gets from its name (undefined: not one a bundle carries). */
71
+ export function previewMediaType(path: string): string | undefined {
72
+ const name = path.slice(path.lastIndexOf("/") + 1)
73
+ const dot = name.lastIndexOf(".")
74
+ const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : ""
75
+ return Object.hasOwn(PREVIEW_MEDIA_TYPES, ext) ? PREVIEW_MEDIA_TYPES[ext] : undefined
76
+ }
77
+
78
+ const MEDIA_TYPES = new Set(Object.values(PREVIEW_MEDIA_TYPES))
79
+
80
+ /** A path inside a bundle: relative, no `.`/`..` segments or `//`, the artifact host's characters, ≤ 200. */
81
+ export const isBundlePath = (v: unknown): v is string =>
82
+ typeof v === "string" && /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))(?!.*\/\/)(?!.*\/$)[A-Za-z0-9._/-]{1,200}$/.test(v)
83
+
84
+ export interface PreviewBundleFile {
85
+ readonly path: string
86
+ readonly mediaType: string
87
+ readonly bytes: number
88
+ /** Hex SHA-256 of the bytes. */
89
+ readonly sha256: string
90
+ /** Base64 of the bytes, when inlined in the first answer. */
91
+ readonly data?: string
92
+ }
93
+
94
+ export interface PreviewBundleSkip {
95
+ /** The reference as written (without its query or fragment; cut at PREVIEW_BUNDLE_LIMITS.refMax). */
96
+ readonly path: string
97
+ readonly reason: PreviewSkipReason
98
+ }
99
+
100
+ export interface PreviewBundleRequest {
101
+ readonly op: "previewBundle"
102
+ readonly sessionID: string
103
+ readonly messageID: string
104
+ /** The page as written in the message (a code span or link target). */
105
+ readonly path: string
106
+ /** A chunk request: the bundle's identity, the file's index in `files`, and the chunk's offset (all three or none). */
107
+ readonly bundle?: string
108
+ readonly file?: number
109
+ readonly offset?: number
110
+ }
111
+
112
+ /**
113
+ * A previewBundle answer: the manifest (bundle, entry, files, skipped), a chunk, `stale`, `tooLarge` (the page itself
114
+ * is over fileBytes), `refused`, or nothing at all (no such message).
115
+ */
116
+ export interface PreviewBundleResult {
117
+ readonly bundle?: string
118
+ readonly entry?: string
119
+ readonly files?: readonly PreviewBundleFile[]
120
+ readonly skipped?: readonly PreviewBundleSkip[]
121
+ readonly skippedMore?: number
122
+ readonly chunk?: { readonly offset: number; readonly data: string; readonly last: boolean }
123
+ readonly stale?: true
124
+ readonly tooLarge?: { readonly bytes: number; readonly max: number }
125
+ readonly refused?: PreviewBundleRefusal
126
+ }
127
+
128
+ const HEX64 = /^[0-9a-f]{64}$/
129
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
130
+ const int = (v: unknown, min: number, max: number): v is number => Number.isSafeInteger(v) && (v as number) >= min && (v as number) <= max
131
+
132
+ /** A page path as a request carries it: 1–pathMax characters, no NUL or line break. */
133
+ export const isPreviewPath = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= PREVIEW_BUNDLE_LIMITS.pathMax && !/[\u0000\r\n]/.test(v)
134
+
135
+ /** Whether a previewBundle request is well-formed (index.ts parseServerMessage). */
136
+ export function isPreviewBundleRequest(v: Record<string, unknown>): boolean {
137
+ if (typeof v.sessionID !== "string" || !v.sessionID) return false
138
+ if (typeof v.messageID !== "string" || !v.messageID || v.messageID.length > 300) return false
139
+ if (!isPreviewPath(v.path)) return false
140
+ const chunk = [v.bundle, v.file, v.offset].filter((x) => x !== undefined).length
141
+ if (chunk === 0) return true
142
+ return chunk === 3 && typeof v.bundle === "string" && HEX64.test(v.bundle) && int(v.file, 0, PREVIEW_BUNDLE_LIMITS.files - 1) && int(v.offset, 0, PREVIEW_BUNDLE_LIMITS.fileBytes - 1)
143
+ }
144
+
145
+ /** The problem with a previewBundle answer's shape (undefined: none). */
146
+ export function previewBundleResultProblem(v: unknown): string | undefined {
147
+ if (!isObj(v)) return "must be an object"
148
+ const manifest = v.bundle !== undefined || v.entry !== undefined || v.files !== undefined || v.skipped !== undefined
149
+ const kinds = [manifest, v.chunk !== undefined, v.stale !== undefined, v.tooLarge !== undefined, v.refused !== undefined].filter(Boolean).length
150
+ if (kinds > 1) return "carries more than one of a manifest, chunk, stale, tooLarge and refused"
151
+ if (v.refused !== undefined && !(PREVIEW_BUNDLE_REFUSALS as readonly unknown[]).includes(v.refused)) return "refused must be path, type or hidden"
152
+ if (v.stale !== undefined && v.stale !== true) return "stale must be true"
153
+ if (v.tooLarge !== undefined && !(isObj(v.tooLarge) && int(v.tooLarge.bytes, 0, Number.MAX_SAFE_INTEGER) && int(v.tooLarge.max, 0, Number.MAX_SAFE_INTEGER))) return "tooLarge needs bytes and max"
154
+ if (v.chunk !== undefined) {
155
+ const c = v.chunk
156
+ if (!isObj(c) || !int(c.offset, 0, PREVIEW_BUNDLE_LIMITS.fileBytes) || typeof c.data !== "string" || typeof c.last !== "boolean") return "chunk needs offset, data and last"
157
+ if (c.data.length > Math.ceil(PREVIEW_BUNDLE_LIMITS.chunkBytes / 3) * 4) return "chunk is larger than chunkBytes"
158
+ }
159
+ if (!manifest) return undefined
160
+ if (typeof v.bundle !== "string" || !HEX64.test(v.bundle)) return "bundle must be a hex SHA-256"
161
+ if (!Array.isArray(v.files) || v.files.length < 1 || v.files.length > PREVIEW_BUNDLE_LIMITS.files) return `files must list 1–${PREVIEW_BUNDLE_LIMITS.files} files`
162
+ const paths = new Set<string>()
163
+ for (const f of v.files as unknown[]) {
164
+ if (!isObj(f) || !isBundlePath(f.path)) return "every file needs a relative bundle path"
165
+ if (paths.has(f.path)) return `path ${f.path} appears twice`
166
+ paths.add(f.path)
167
+ if (typeof f.mediaType !== "string" || !MEDIA_TYPES.has(f.mediaType)) return "mediaType is not allowed"
168
+ if (!int(f.bytes, 0, PREVIEW_BUNDLE_LIMITS.fileBytes)) return `bytes must be 0–${PREVIEW_BUNDLE_LIMITS.fileBytes}`
169
+ if (typeof f.sha256 !== "string" || !HEX64.test(f.sha256)) return "sha256 must be hex"
170
+ if (f.data !== undefined && typeof f.data !== "string") return "data must be base64"
171
+ }
172
+ if (typeof v.entry !== "string" || !paths.has(v.entry)) return "entry must be one of the files"
173
+ if (v.skipped !== undefined) {
174
+ if (!Array.isArray(v.skipped) || v.skipped.length > PREVIEW_BUNDLE_LIMITS.skipped) return `skipped lists at most ${PREVIEW_BUNDLE_LIMITS.skipped}`
175
+ for (const s of v.skipped as unknown[]) {
176
+ if (!isObj(s) || typeof s.path !== "string" || s.path.length > PREVIEW_BUNDLE_LIMITS.refMax || !(PREVIEW_SKIP_REASONS as readonly unknown[]).includes(s.reason)) return "a skipped file needs its path and a reason"
177
+ }
178
+ }
179
+ if (v.skippedMore !== undefined && !int(v.skippedMore, 1, Number.MAX_SAFE_INTEGER)) return "skippedMore must be a positive count"
180
+ return undefined
181
+ }
182
+
183
+ /**
184
+ * Whether a written target names a local HTML page: ends in .html/.htm (any case), no URL scheme but file:, no `~`
185
+ * (never expanded), at most pathMax characters, no NUL or line break.
186
+ */
187
+ export function isHtmlTarget(target: string): boolean {
188
+ if (!isPreviewPath(target) || target.startsWith("~")) return false
189
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(target) && !/^file:/i.test(target)) return false
190
+ return /\.html?$/i.test(target)
191
+ }
192
+
193
+ /**
194
+ * The local HTML pages a parsed text names, in order, without duplicates: code spans and link targets (same-site
195
+ * links' `href`, other links' raw `target`: `./page.html`) through paragraphs, headings, quotes, lists and tables; never in code blocks. The console shows Preview on
196
+ * exactly these, the spoke serves only these (it also leaves out indented code and reads a text's first 64 KiB).
197
+ */
198
+ export function htmlTargets(blocks: readonly MdBlock[], out: string[] = []): string[] {
199
+ const add = (t: string) => {
200
+ if (isHtmlTarget(t) && !out.includes(t)) out.push(t)
201
+ }
202
+ const inline = (nodes: readonly Inline[]) => {
203
+ for (const node of nodes) {
204
+ if (node.type === "code") add(node.text)
205
+ else if (node.type === "link") {
206
+ add(node.href)
207
+ inline(node.children)
208
+ } else if (node.type === "label") {
209
+ add(node.target)
210
+ inline(node.children)
211
+ } else if ("children" in node) inline(node.children)
212
+ }
213
+ }
214
+ for (const block of blocks) {
215
+ if (block.type === "paragraph" || block.type === "heading") inline(block.children)
216
+ else if (block.type === "quote") htmlTargets(block.blocks, out)
217
+ else if (block.type === "list") for (const item of block.items) htmlTargets(item.blocks, out)
218
+ else if (block.type === "table") for (const row of [block.head, ...block.rows]) for (const cell of row) inline(cell)
219
+ }
220
+ return out
221
+ }
@@ -7,6 +7,13 @@ export const RECORDING_HEADER = "x-hub-recording" // on /api/transcri
7
7
  export const RECORDING_SEGMENT_HEADER = "x-hub-segment" // on /api/transcribe: the segment number
8
8
  export const RECORDING_REF_HEADER = "x-hub-tts-ref" // on /api/speak: "<messageID>:<ordinal>:<seq>:<i>"
9
9
  export const RECORDING_AUDIO_RESULT_HEADER = "x-hub-recording-audio" // transcribe answer: RecordingAudioResult
10
+ /**
11
+ * Console kinds and values added after version 1, listed in the start answer (`features`): a console sends them only
12
+ * to a hub that lists them (an older hub refuses a whole batch with an unknown kind or value).
13
+ * "turn.predict": the turn.predict kind and utterance.sent reasons "smart" / "smart-fallback" (Smart Turn).
14
+ */
15
+ export const RECORDING_FEATURES = ["turn.predict"] as const
16
+ export type RecordingFeature = (typeof RECORDING_FEATURES)[number]
10
17
 
11
18
  export const RECORDING_LIMITS = {
12
19
  retentionMs: 7 * 24 * 3_600_000,
@@ -82,6 +89,7 @@ export type ConsoleEventKind =
82
89
  | "heard" | "say.received" | "tts.sentence.queued" | "tts.request" | "tts.first.byte" | "tts.sentence.played"
83
90
  | "tts.sentence.ended" | "barge.in" | "cue.played" | "mic" | "control" | "turn.timing" | "decision.voice"
84
91
  | "clock.sync" | "error"
92
+ | "turn.predict" // feature "turn.predict"
85
93
  export type HubEventKind =
86
94
  | "recording.started" | "recording.ended" | "conversation" | "stt.request" | "stt.result" | "tts.synth"
87
95
  | "utterance.received" | "utterance.delivered" | "turn.started" | "first.token" | "say.sent" | "reply.full"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-openshell",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-sdk",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "The Runtime interface `hub node` runtimes implement (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-sdk",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Typed client for the hub's /api/agent (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",