@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,144 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ Three things in one release: **media in sessions** (screenshots, images, Mermaid diagrams, safe HTML previews, and
6
+ web images and link previews for the sites you allow), **app delivery** for hub apps (idempotent messages, delivery
7
+ deadlines, and team-intake webhooks), and two **voice experiments** that were prepared as 0.8.8 (Smart Turn and the
8
+ Voice front, both off by default). Session images, local image paths, HTML previews and the Voice front need the
9
+ 0.9.0 plugin on the machine (the OpenCode harness; other harnesses keep file chips and today's Voice mode); Mermaid
10
+ diagrams, web images and link previews work in any reply. No migration is needed.
11
+ [docs/session-view.md → Media in sessions](docs/session-view.md#media-in-sessions),
12
+ [docs/media-security.md](docs/media-security.md),
13
+ [docs/plans/2026-11-06-app-delivery-contracts.md](docs/plans/2026-11-06-app-delivery-contracts.md).
14
+
15
+ ### Media in sessions
16
+ - **Tool screenshots and attachments in the transcript.** A tool group shows its images as a strip of thumbnails
17
+ under its head (also while folded; 8, then "+N"), so a screenshot is no longer just a `[image/png, 1.2 MB — not
18
+ shown]` placeholder, and your attachments load the same way. Images keep their space while loading, load once on screen, and on a phone
19
+ over cellular, 2g or Save-Data an image over 512 KB waits for a tap ("Screenshot · 1.8 MB · Tap to load").
20
+ - **Image files a reply names** (`![chart](./out/chart.png)`) show inline, read from the agent's machine only when
21
+ the path is in that message's text and inside the session's folder (its `.worktrees/` too) or a file the session
22
+ itself read, wrote or edited; never dotfiles (`.git/`, `.env`, `.ssh/`) it did not touch, never `~`.
23
+ - **Full-screen viewer** for any image or diagram: pinch, wheel or double tap to zoom, drag to pan, ←/→ between a
24
+ message's images, Download, Escape to close.
25
+ - **A failed image says why** on its chip: the machine is offline, not in this session's files, too large, not an
26
+ image, this harness can't share images, or try again (one quiet retry, then Retry).
27
+ - **Artifact images in replies**: `![alt](/artifacts/<id>)` (or a revision) shows the artifact's image inline with a
28
+ caption link, in session replies, the Concierge panel and artifact Markdown.
29
+ - **Mermaid diagrams**: ```` ```mermaid ```` and ```` ```mmd ```` blocks draw as diagrams (in the session, the
30
+ Concierge panel and artifacts) with a Diagram / Source toggle, Copy source and Download SVG, themed to light or
31
+ dark. They draw once the fence has closed, when scrolled into view; a broken or unsupported diagram shows its code
32
+ with the reason, one over 50 KB stays code. Voice says "a diagram" instead of reading the source.
33
+ - **HTML previews**: an ```` ```html ```` block, or a code span or link naming a local `.html` page, gets a
34
+ **Preview** button. The page (with its scripts, styles and images) opens beside the session in the isolated
35
+ artifact host, where external resources are blocked; a local page lists the files it left out and has
36
+ **Refresh**. Previews are private artifacts: **Keep** one to make it a normal artifact, else it is archived a day
37
+ after you last opened it and deleted a week later. Artifacts → **Show: Previews** lists them.
38
+ - **Web images**: Settings → **Media** holds the sites whose images load by themselves (`github.com`,
39
+ `*.githubusercontent.com`; empty at first). Any other site's image is a card naming the domain, with **Load
40
+ image**, **Always allow <domain>** and "Load all from this domain in this session". The hub fetches the image, not
41
+ your browser.
42
+ - **Link previews**: a link to an allowed site gets a card (title, description, image) under its paragraph, at most
43
+ 3 per message; other links get a small preview button that fetches only when you tap it. No favicons.
44
+
45
+ ### Security
46
+ - **Agent media is untrusted everywhere.** The console's CSP is unchanged. Raster images show only as `blob:` URLs of
47
+ bytes the hub checked; SVG (files, artifacts, diagrams) only as a `data:` URL in an `<img>`, never in the page and
48
+ never as a `blob:` (the viewer offers SVG as a download only); diagrams with active content are not shown; raw HTML
49
+ in a reply still never renders, and HTML runs only in the isolated artifact host.
50
+ - **The spoke decides which local files can be shown**, never the console: the path must be in the message's own
51
+ text; realpath under the session's folder or in its touched set (completed read/write/edit only); no unvisited
52
+ dotfiles; regular files whose open handle is the checked file (Linux also through `/proc/self/fd`, macOS 11+ with
53
+ `O_NOFOLLOW_ANY`); magic bytes PNG/JPEG/WebP/GIF/SVG, ≤ 8 MiB, ≤ 40 MP, ≤ 16,384 px per side. The hub re-sniffs
54
+ every image and checks its length and SHA-256 before answering.
55
+ - **A safe outbound fetcher** for web images and link previews: signed-in users only (session and app tokens are
56
+ refused, so agents cannot use the hub as a fetcher), http/https on default ports without userinfo, private,
57
+ loopback, link-local (cloud metadata), CGNAT, reserved, ULA and mapped/translated/tunnelled IPv6 addresses refused,
58
+ the checked address pinned (DNS rebinding), redirects re-checked (3 at most), no cookies, 3 s / 8 s timeouts,
59
+ bodies capped and never decompressed (gzip bombs), web images PNG/JPEG/WebP/GIF only (no SVG from the web), 30
60
+ fetches a minute and 4 at once per user. The audit log keeps the host and outcome, never the URL (it may hold the
61
+ leaked data).
62
+ - **Allowlist rules**: no IP addresses, ports, paths, bare `*` or public suffixes alone (`*.com`, `*.github.io`); a
63
+ URL with userinfo never loads by itself. Settings warns that an allowed site hosting other people's content can
64
+ still receive data an agent puts in an image URL.
65
+ - Known residual risks (Windows/macOS < 11 directory-swap race, hard links inside the workspace, per-pod limits in
66
+ cluster mode, a Bun wildcard-certificate quirk) are listed in [docs/media-security.md](docs/media-security.md#residual-risks).
67
+
68
+ ### For operators
69
+ - **`HUB_REMOTE_FETCH`** (`on` | `off`): `off` turns off web images and link previews (cards say so). Default `on`,
70
+ but **off when `HTTP_PROXY`/`HTTPS_PROXY` is set** and `NO_PROXY` is not `*` (a proxy could undo the fetcher's
71
+ address pinning), with a startup warning; set `NO_PROXY=*` for the hub process or `HUB_REMOTE_FETCH=on` knowingly.
72
+ - **`HUB_FETCH_DENY_CIDRS`**: extra CIDRs the fetcher never connects to, on top of the built-in ranges. In Kubernetes
73
+ add your pod and service CIDRs (e.g. `10.42.0.0/16,10.43.0.0/16`); an invalid entry stops the hub at start.
74
+ - New routes (README route table): `GET /api/sessions/:id/timeline/messages/:message/media/:ref` (`…/files/:index`
75
+ is its alias on new spokes), `GET /api/media/remote?url=`, `GET /api/media/unfurl?url=`, `GET`/`PUT
76
+ /api/settings/media`, `POST /api/sessions/:id/previews`; artifacts gain `origin` (`GET /api/agent/artifacts?origin=preview|all`,
77
+ previews hidden by default) and `POST /api/agent/artifacts/:id/keep`.
78
+ - The hub now makes outbound HTTP(S) requests for users (when allowed): permit egress to the public internet, or set
79
+ `HUB_REMOTE_FETCH=off`. Caches and rate limits are in memory per pod in cluster mode.
80
+ - CI and releases run on the org's self-hosted runner (`CI_RUNNER`). The release's Windows smoke test of hubctl runs
81
+ on a self-hosted Windows runner when the `WINDOWS_RUNNER` variable is set (JSON labels), else GitHub-hosted
82
+ `windows-latest`.
83
+
84
+ ### For harness authors
85
+ - **Two new optional spoke ops**, announced as features of their own and never implied (also not for OpenCode
86
+ hellos): `timelineMedia` (one timeline image by ref `f<n>` / `t<part>.<item>` / `p:<base64url path>`, in 512 KiB
87
+ chunks, `MEDIA_LIMITS`) and `previewBundle` (a local HTML page and its files: a manifest, then chunks,
88
+ `PREVIEW_BUNDLE_LIMITS`). Timeline rows of a spoke with `timelineMedia` carry `ref`/`bytes`/`width`/`height` on
89
+ user files and `media` on tool and assistant rows. With HarnessSpoke, a `timelineMedia` or `previewBundle` handler
90
+ announces it; the conformance suite checks both answers' shapes. Spokes without them get `422 unsupported
91
+ {feature, harness}` from the hub and file chips in the console. Contract:
92
+ [docs/spoke-sdk.md](docs/spoke-sdk.md#media-ops-timelinemedia-and-previewbundle).
93
+ - The console's Markdown parser moved to `packages/protocol/src/markdown.ts` (re-exported by the web app), so the
94
+ hub, the spokes and the console agree on what is an image source, an HTML block or a page link.
95
+
96
+ ### App delivery (for hub apps)
97
+ - **`Idempotency-Key` on `POST /api/agent/msg` and `/ask`**: a retry with the same key and body replays the first
98
+ answer (200) instead of sending twice; the same key with a different body is `409 conflict`.
99
+ - **`notAfter` delivery deadlines** (epoch ms, at most 30 days ahead; the CLI and MCP also take ISO 8601) on msg/ask,
100
+ launches and team intake. A deadline already passed is `422 expired` and nothing is stored; a pending message
101
+ that misses it ends `expired` with reason `not_after`, a queued launch ends with outcome `expired`, a queued
102
+ intake item is cancelled with `reasonCode: "not_after"`. It is a delivery/start deadline, never a work deadline.
103
+ - **`team.intake` webhooks for apps**: an app holding `teams:submit` gets signed `team.intake` webhooks for the items
104
+ it submitted.
105
+ - **A machine-checkable contract**: `packages/protocol/schema/app-delivery/v1.schema.json` (JSON Schema, shipped in
106
+ the npm package) is checked against real route answers, events and webhook bodies in the test suite, so the two
107
+ cannot drift. A test fixture runs a real hub with simulated machines for app integration tests.
108
+
109
+ ### Voice front (experimental)
110
+ - **A hidden, fast voice agent in front of the session** (Settings → Voice & speech → Voice front). It starts every
111
+ reply by saying what it understood and what it is doing, then relays to the session (steering a running turn,
112
+ queueing an unrelated request, or stopping it). The session's agent and model are never switched. Its result,
113
+ questions, permissions and failures are spoken once; no spoken "On it." phrases (the tones stay). Concierge
114
+ conversations keep today's path. If the front is unavailable, Voice mode talks to the session directly.
115
+ - The front runs on the machine's voice model (`voiceModel` in `~/.config/hub/config.json`, or the session's voice
116
+ model). Pick a fast model; free models can hit usage limits.
117
+ - Spoken and pushed failure reasons now hide credentials ("[redacted: api key]"), for workers too.
118
+
119
+ ### Experiments
120
+ - **Smart Turn (experimental) turn detection in Voice mode** (Settings → Voice → Turn detection, per device; the
121
+ timers stay the default): Pipecat Smart Turn v3.2 (BSD-2-Clause) scores the turn's audio on the device at each
122
+ pause; a confident "finished" sends at once, otherwise Voice mode waits (re-scoring) up to 3 s. The Voice bar shows
123
+ the score; the recording mode records it (`turn.predict`, a hub feature an older hub never receives). Tunable with
124
+ `voiceTurn.*` (URL or localStorage). The model (8.7 MB) loads only when the mode is on.
125
+
126
+ ### Fixes
127
+ - **Hub messages now show in the OpenCode TUI.** A message, ask, reply or prompt the hub delivers to a session used
128
+ to be invisible in the terminal UI: you only saw the agent react to it. It now shows as a one-line notice where it
129
+ arrived (`hub · ask from @api: Which release order?`). The agent still sees only the full message. Needs the
130
+ 0.9.0 plugin on the machine.
131
+
132
+ ## 0.8.7
133
+
134
+ The one-liner now installs OpenCode on Windows when a machine does not have it yet (the reviewed OpenCode 2.2.0 pin for
135
+ windows-x64 is in the signed release manifest). No migration is needed.
136
+
137
+ ### Machine setup
138
+ - **hubctl installs OpenCode on Windows** too: the reviewed pin for OpenCode 2.2.0 `windows-x64`
139
+ (`opencode-windows-x64-baseline.zip`) is in the signed release manifest, so a Windows machine without OpenCode gets
140
+ it from the one-liner, like macOS and Linux.
141
+
3
142
  ## 0.8.6
4
143
 
5
144
  A Windows fix release: OpenCode with the hub plugin no longer flashes a console window every few seconds on Windows,
package/README.md CHANGED
@@ -61,6 +61,8 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
61
61
  - **Launches**: you, a script or an app start a session on one of your machines (`hub launch --branch feat/x
62
62
  --prompt "…"`): a `hub node` daemon prepares a git worktree, the session gets its name, roles, labels and first
63
63
  prompt, and ends by itself when it is done, with signed webhooks for apps ([docs/launches.md](docs/launches.md)).
64
+ Test an app against a real hub with simulated machines: the app fixture
65
+ ([docs/app-integration-testing.md](docs/app-integration-testing.md)).
64
66
  - **More harnesses**: Claude Code (`hub claude`, through its hooks), Codex (`hub codex`, through its app-server)
65
67
  and any terminal agent described by a data-only preset (`hub preset run`); the console shows only the controls
66
68
  each harness's spoke serves, with an evidence badge and honest delivery labels
@@ -83,6 +85,10 @@ Formerly **opencode-voice** and, briefly, **opencode-hub**
83
85
  [intake](docs/teams-intake.md)).
84
86
  - **Watchtower** (formerly Bird's-eye view): a 3D, 2D or list picture of machines, sessions, teams and live
85
87
  message flow, with a team editor ([docs/watchtower.md](docs/watchtower.md)).
88
+ - **Media in sessions**: tool screenshots, attachments and image files a reply names show in the transcript (with a
89
+ full-screen viewer), Mermaid diagrams draw, HTML blocks and local pages preview in the isolated host, and web images
90
+ and link previews load through the hub for sites you allow, without loosening the console's CSP
91
+ ([docs/session-view.md](docs/session-view.md#media-in-sessions), security: [docs/media-security.md](docs/media-security.md)).
86
92
  - **Session details**: context usage, todos, MCP status, branch and per-file diffs, on demand; unread
87
93
  marks synced per user.
88
94
  - **Self-hostable**: a hub on your computer with nothing extra to install, or your own hub for phones
@@ -390,7 +396,14 @@ conversation mode and utterances (403 `not_exposed` for sessions not shared with
390
396
  |---|---|
391
397
  | `POST /api/sessions/:id/turn {text, images?}` | sends a turn; while the session tree is running it steers instead (202 `{mode: "steer", inboxID}`). An idle turn answers its `TurnResult` (`{outcome, reply, message?}`) when it ends; with `Prefer: respond-async` (the console sends it) a turn still running after 1.5 s answers 202 `{mode: "turn"}` (`Preference-Applied: respond-async`) and its result follows as an SSE `turn` event `{sessionID, outcome, reply, message?}`, so no proxy cuts a long request. The session stays busy until the turn ends either way. `images` is up to 4 `{mime, data, name?}` (PNG, JPEG, WebP or GIF; canonical base64 whose bytes match the type; each at most 512 KB decoded, together at most 750,000 base64 characters); `text` may be empty when images are attached. They reach OpenCode as inline file attachments. 400 empty, over 100,000 characters or invalid images (with the reason); 409 "A turn is already running for this session" |
392
398
  | `GET /api/sessions/:id/timeline?limit=N&order=desc` · `?limit=N&before=<older>` | the conversation as timeline rows, newest first: the latest `limit` rows (1–200, default 50) in chronological order, plus `older` (the cursor of the rows before them) while there are more. `before=<older>` returns the page before that cursor. Cursors are row keys, so they stay valid while new messages arrive. A page starts at a message boundary when it can (it may be up to twice `limit` then). 400 for an unknown cursor (history reverted or compacted: start again from the newest page). The forward mode `?limit=N&cursor=` (oldest rows first, `cursor` for the next page) still works; it cannot be combined with `before` or `order=desc`. The spoke reads OpenCode's history for a first page and serves the following pages from a short-lived per-session cache (30 s, dropped when the session changes) |
393
- | `GET /api/sessions/:id/timeline/messages/:message/files/:index` | one image attached to a user message, as bytes with its type (`cache-control: private, no-store`). Timeline user items list their attachments as `files: [{index, mime, name?}]` (never the bytes). Every timeline row carries `pos` (ordering position: session message order, then part order; derived from the message's creation time, kept increasing, so it does not change as pages move); tool rows `callID`, subagent tool rows `childSessionID` and `title` (the task description), assistant text rows `ordinal`. Only PNG, JPEG, WebP and GIF are served (404 otherwise or when missing); 413 when the image would not fit in a protocol frame (about 650 KB) |
399
+ | `GET /api/sessions/:id/timeline/messages/:message/files/:index` | one image attached to a user message, as bytes with its type (`cache-control: private, no-store`). On a spoke that announces `timelineMedia` it is an alias of `…/media/f<index>` below (its headers, cache and limits). Timeline user items list their attachments as `files: [{index, mime, name?}]` (never the bytes). Every timeline row carries `pos` (ordering position: session message order, then part order; derived from the message's creation time, kept increasing, so it does not change as pages move); tool rows `callID`, subagent tool rows `childSessionID` and `title` (the task description), assistant text rows `ordinal`. Only PNG, JPEG, WebP and GIF are served (404 otherwise or when missing); 413 when the image would not fit in a protocol frame (about 650 KB) |
400
+ | `GET /api/sessions/:id/timeline/messages/:message/media/:ref` | (feature `timelineMedia`) one timeline image by ref: `f<n>` (a user attachment), `t<part>.<item>` (a tool content item), `p:<base64url path>` (an image path in an assistant message, served under the spoke's path policy). Pulled from the spoke in 512 KiB chunks and collected whole (≤ 8 MiB, 60 s in all) before anything is answered; the hub re-sniffs the magic bytes (PNG, JPEG, WebP, GIF, SVG) and verifies the length and SHA-256 (a mismatch is 502 `integrity`, never a partial body or a 304). Headers: `ETag: "sha256-<hex>"`, `cache-control: private, no-cache`, `nosniff`, `content-security-policy: default-src 'none'; sandbox`, CORP same-origin, `referrer-policy: no-referrer`, `content-disposition` inline (SVG always `attachment`; `?download=1` for any type). Cached per user on the pod (64 MiB): `f`/`t` images answer `If-None-Match` with 304 without contacting the spoke; `p:` refs are revalidated with one first-chunk call; entries live at most 10 min and are dropped when the session's committed revert (as its spoke reports it, seen from any pod) differs from when they were cached; a staged revert bypasses the cache, a deleted session clears it. The message ID may not contain `/` or `..`. 400 bad ref, 404 `not_found`/`path`/`hidden`, 409 offline, 413 `too_large {bytes,max}`/`dimensions`, 415 `type`, 422 `unsupported {feature, harness}`, 429 `rate_limited` (4 in flight per user, 2 per spoke connection; `Retry-After`), 502 `integrity`, 504 `timeout` |
401
+ | `GET /api/media/remote?url=` | (signed-in users only: session and app tokens get 403) a web image through the hub's safe fetcher ([docs/media-security.md](docs/media-security.md#what-each-layer-does)): http/https on default ports, no userinfo, private and reserved addresses refused (plus `HUB_FETCH_DENY_CIDRS`), the checked address pinned, at most 3 redirects each re-checked, no cookies, 3 s to headers and 8 s in all, never decompressed. Only PNG, JPEG, WebP or GIF by magic bytes (never SVG), ≤ 8 MiB, ≤ 40 MP, ≤ 16,384 px per side, with the media headers of the timeline route and an `ETag` (304 on `If-None-Match`). Cached per user for the site's lifetime clamped to 1–24 h, failures for 5 min. 400 `bad_url`, 403 `refused`/`disabled` (`HUB_REMOTE_FETCH=off`), 413 `too_large`, 415 `not_image`, 429 `rate_limited` (30 fetches a minute and 4 at once per user, shared with `GET /api/media/unfurl`; `Retry-After`), 502 `upstream`, 504 `timeout`. Audited as `media.remote` with the host and outcome, never the URL |
402
+ | `GET /api/settings/media` | (signed-in users only: session and app tokens get 403) `{allowlist: string[], remoteFetch: boolean}`: the caller's media allowlist (hostnames like `github.com` or suffixes like `*.githubusercontent.com`, stored lowercase without a trailing dot, IDNs as punycode; empty by default) and whether this hub fetches web images (`HUB_REMOTE_FETCH`). In a session reply (and the Concierge panel) a web image from an allowlisted site loads at once through `GET /api/media/remote`; any other site's is a card naming the domain with **Load image**, **Always allow <domain>** and "Load all from this domain in this session" (that last one is kept in the page only), and nothing is fetched until a tap. The hub's own origin is never sent to the remote route |
403
+ | `PUT /api/settings/media {allowlist}` | replaces the list, answers like GET. 400 (naming the entry) for more than 100 entries, entries over 253 characters, IP addresses, ports, schemes, paths, userinfo, a bare `*`, a wildcard anywhere but a leading `*.`, and public suffixes alone (`*.com`, `*.co.uk`, `*.github.io`, `co.uk`; a small built-in list). A `*.` entry matches subdomains only. Settings → Media edits it and warns that an allowed site hosting other people's content can still receive data an agent puts in an image URL |
404
+ | `GET /api/media/unfurl?url=` | (signed-in users only: session and app tokens get 403) a link's preview through the hub's safe fetcher: `{url, title?, description?, siteName?, image?}` (`url` is the final URL after redirects; plain text, entities decoded, title ≤ 200, description ≤ 300, site name ≤ 100 characters; `image` resolved against the final URL, http/https on the default port without userinfo, else left out). Reads at most the first 512 KiB; only a 2xx `text/html` or `application/xhtml+xml` answer counts (else `415 not_html`). `cache-control: private, max-age` ≤ 1 h; the hub caches previews per user for the site's lifetime clamped to 1–24 h and failures for 5 min. Shares the per-user budget of `GET /api/media/remote` (30 fetches a minute and 4 at once for both together, `429` with `Retry-After`); `403 disabled` with `HUB_REMOTE_FETCH=off`; the audit log keeps the host and outcome, never the URL. In a session reply (and the Concierge panel) a link to an allowlisted site gets a preview card under its paragraph (at most 3 per message, never for links in code or same-origin links; the card's image loads through `GET /api/media/remote` only when the image's host is allowlisted too); any other link gets a small preview button that fetches only on a tap |
405
+ | `POST /api/sessions/:id/previews {messageID, block}` · `{messageID, path}` | (console only) an HTML preview of the message's `block`-th (0-based) ```` ```html ````/```` ```htm ```` block, or of a local `.html`/`.htm` page the message names with the files it refers to (spoke feature `previewBundle`, read under the media path policy; ≤ 200 files, 5 MiB each, 20 MiB in all). The hub reads the content from the message, never from the request, and stores it as a private `html` artifact with `origin: "preview"`: `{artifact, revision, reused, host}` (+ `skipped`, `skippedMore` for a page), 201 stored, 200 unchanged (`reused`); `POST host` opens it in the isolated artifact host. 400, 404 `not_found`/`too_old`/`refused`, 409 offline/`changed`, 413 `too_large`, 422 `unsupported` (no artifacts, or a spoke without `previewBundle`), 502 `bad_bundle`. Previews are archived 24 h after their last view and purged 7 days later. Contract: [docs/artifacts.md](docs/artifacts.md#html-previews) |
406
+ | `GET /api/agent/artifacts?origin=preview\|all` · `POST /api/agent/artifacts/:id/keep {expectedRevision}` | the library leaves previews out unless `origin` asks for them (`preview`: only previews, `all`: both); **Keep** turns a preview into a normal artifact (`409` on a stale `expectedRevision`; no change for other artifacts). Users and apps with `artifacts:write`, never sessions. See [docs/artifacts.md](docs/artifacts.md#http-api-apiagent) |
394
407
  | `POST /api/sessions/:id/rename` `{auto: true}` | Auto-name: OpenCode regenerates the title with its Title agent (`session.update` with an empty title on the process's own server). 200 `{title}` (or `{title, unchanged: true}` when OpenCode kept it), 202 `{pending: true}` when it is still generating after 20 s (the catalog updates when it lands), 409 `plugin_outdated` when no process reporting the session supports it, 502 when the process cannot reach its server or the title model failed |
395
408
  | `POST /api/sessions/:id/rename` | `{title}` → `{title}`: renames the session in OpenCode (`session.update`), through the process that owns it, else any other process that reports it. The title is trimmed and must be 1–200 characters (400 otherwise). Works with Voice off; the new title shows in every console at once (the spoke reports it before answering) |
396
409
  | `POST /api/sessions/:id/fork {messageID}` · `GET …/revert/preview?messageID=` · `POST …/revert {messageID, files?}` · `POST …/revert/restore` · `POST …/revert/commit` | fork before one of the session's user messages (201 `{sessionID, text, attachments}`), or stage, undo and commit a revert from it (409 `busy` while it runs); console only, audited; needs a spoke announcing `fork`/`revert` (409 `unsupported` otherwise). Contract: [docs/session-view.md](docs/session-view.md#fork-and-revert-a-message) |
@@ -509,8 +522,8 @@ cookie and a hub bearer token (400).
509
522
  | `GET /api/agent/who?project=&role=` | ✓ | ✓ | ✓ | named, role-holding and online sessions |
510
523
  | `POST /api/agent/name {name, session?}` | ✓ | own session | `sessions:manage` | names a session (`409 name_taken`) |
511
524
  | `POST /api/agent/roles/claim {role, exclusive?, project?, session?}` · `…/roles/release` | ✓ | own session | `sessions:manage` + `roles` grant | claims (with a new `generation`) or releases a role; never a reserved role for an app |
512
- | `POST /api/agent/msg {to, text, wake?, urgent?, all?}` | ✓ | ✓ | `messages:send` | tells a session (`all`: every holder of a role); never yourself |
513
- | `POST /api/agent/ask {to, text, expiresIn?, wait?}` | ✓ | ✓ | `messages:send` | an ask, open until answered, declined or expired; `wait` (≤ 60 s) returns the `answer` |
525
+ | `POST /api/agent/msg {to, text, wake?, urgent?, all?}`; `Idempotency-Key` | ✓ | ✓ | `messages:send` | tells a session (`all`: every holder of a role); never yourself |
526
+ | `POST /api/agent/ask {to, text, expiresIn?, wait?}`; `Idempotency-Key` | ✓ | ✓ | `messages:send` | an ask, open until answered, declined or expired; `wait` (≤ 60 s) returns the `answer` |
514
527
  | `POST /api/agent/ask {to: "@me", text, options?, recommendation?, urgency?, tier?, deadline?, onTimeout?, about?, wait?}` | – | ✓ | `messages:send` | opens a **Decision** for the user (below): `{message, decision, answer?}` |
515
528
  | `GET /api/agent/decisions?state=` · `…/decisions/:id` | ✓ (all) | own | `messages:read` (own) | the Decisions the caller asked |
516
529
  | `POST /api/agent/reply {replyTo, text}` · `…/decline {replyTo, why}` | ✓ | ✓ | `messages:send` | answers a message you received (to its real sender); not a Decision's ask |
@@ -524,6 +537,15 @@ whether the hub sent it to wake the recipient, and when not, `wakeSkipped` says
524
537
  `hop_cap`, `budget`; see **Limits**). The CLI and MCP tools print it, e.g. `queued (read at its next turn) — wake
525
538
  skipped: hop limit reached`. Hubs before 0.8.1 send neither field.
526
539
 
540
+ `msg` and `ask` (also to `@me`, also with `all`) accept an `Idempotency-Key` header (1–200 of `A-Z a-z 0-9 _ . : -`),
541
+ so a client that retries after a timeout never delivers, pushes or opens a Decision twice. Keys are per sender (the
542
+ user, each app token, each session). The same key and body within 24 h answer `200` with the original body
543
+ (`{message, delivery?, …}`, `{sent: [...]}`, or `{message, decision, …}`) and `idempotent-replayed: true`, sending
544
+ nothing; another body under the key is `409 conflict`, and so is a repeat while the first is still running. A
545
+ malformed key is `400 invalid`. The keys are kept in the hub's state store, so a replay works across restarts.
546
+ SDK: `hub.msg(to, text, {idempotencyKey})`, `hub.ask(…, {idempotencyKey})`; CLI: `hub msg|ask … --key K`; MCP:
547
+ the `idempotencyKey` argument of `msg` and `ask`.
548
+
527
549
  The act verbs, grants, kill switches and the agent audit are in [Agent actions and grants](#agent-actions-and-grants).
528
550
 
529
551
  **Decisions and `@me`.** `@me` addresses the user: `msg @me` is stored in the user's inbox (push kind `message`);
@@ -573,6 +595,14 @@ you. Addresses resolve among your own sessions only; another user's sessions do
573
595
  `--no-wake` admits a message without starting a turn and `--urgent` steers it into a running turn. Texts are at
574
596
  most 10,000 characters (a decline's reason 1,000).
575
597
 
598
+ **Not after.** `msg` and `ask` take `notAfter`: never deliver the message after that time. REST and the SDK take
599
+ epoch milliseconds; the CLI (`--not-after`) and `hub mcp` (`notAfter`) also take an ISO 8601 date-time such as
600
+ `2027-01-15T12:00:00Z`. It must lie in the future and at most 30 days ahead (else `invalid`, exit 2); one that has
601
+ already passed answers `422 expired` (exit 8) and nothing is stored, delivered or pushed. A message pending for an
602
+ offline or busy session expires at the earlier of its 24 h hold and `notAfter` (its sender is told as below, and the
603
+ `message.changed` event says why: `reason` `not_after` or `pending_timeout`); an ask expires at the earlier of its
604
+ `expiresIn` and `notAfter`. Messages to `@me` are stored at once, so for them `notAfter` is only checked when sent.
605
+
576
606
  **Delivery.** A message reaches an OpenCode session as a hub message in its conversation (the spoke's `deliver`,
577
607
  OpenCode's synthetic message; it shows in the console's conversation as a message row). An idle session gets a
578
608
  turn; a busy one gets a plain `msg` after its running turn and an `ask` steered into it. A `--no-wake` message never
@@ -840,8 +870,8 @@ hub name <@name> [--session <addr>]
840
870
  hub intent <addr> [text… | --clear] what the session is for (one line, ≤ 280 characters)
841
871
  hub role claim <role[.project]> [--exclusive] [--session <addr>]
842
872
  hub role release <role[.project]> [--session <addr>]
843
- hub msg <addr> "text" [--all] [--no-wake] [--urgent] tell: fire and forget
844
- hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--all] [--no-wake] [--urgent]
873
+ hub msg <addr> "text" [--all] [--no-wake] [--urgent] [--key K] tell: fire and forget
874
+ hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--all] [--no-wake] [--urgent] [--key K]
845
875
  hub reply <msgID> "text" [--no-wake] to the message's real sender, never via the role
846
876
  hub decline <msgID> --why "reason"
847
877
  hub ask @me "question" [--option id=label[:safe]]... [--recommend id [--why "…"]] [--urgency low|normal|high]
@@ -888,7 +918,8 @@ message, any candidates or retry time, a missing feature, and the hint on stderr
888
918
  the running turn, `queued` for its next turn, `retained` while it has a question open, `pending` while offline (or,
889
919
  for a `--no-wake` message, while the recipient's turn runs), or
890
920
  `stored` in a user's or app's inbox); `--all` sends to every session the address names (each holder of a role),
891
- `--no-wake` admits without starting a turn, `--urgent` steers into a running turn. A text (or `--why`) of `-` is
921
+ `--no-wake` admits without starting a turn, `--urgent` steers into a running turn, `--key K` sends an
922
+ `Idempotency-Key` (a retry with the same key and text answers the first send again and sends nothing). A text (or `--why`) of `-` is
892
923
  read from stdin, so long or quoted bodies need no escaping: `git diff | hub msg @reviewer -`. `--expires` and
893
924
  `--wait` take seconds or `30s`, `5m`, `2h`, `1d` (asks expire after 1 minute to 7 days, default 24 h). `ask --wait N`
894
925
  waits up to N seconds for the answer (the CLI loops on the hub's 60 s long poll); a bare `--wait` waits until the
@@ -925,8 +956,10 @@ repository on the node and must be under that node's workspace roots. `--label k
925
956
  (`hub launches list --label brain.task=abc` finds it again); `--node-label k=v` and `--machine` pick nodes, required
926
957
  unless `--prefer`. `--prompt -` reads the first prompt from stdin. `--key` makes retries safe: the same key and
927
958
  flags answer the existing launch (`… was already launched with key …`), the same key with other flags is invalid
928
- (exit 2). It prints the launch and its state (`queued`, `placing`, `provisioning`, `running` with the session,
929
- `ended` with an outcome: `idle`, `needs_user`, `failed`, `timeout`, `stopped`, `lost`); `hub launches show` adds the
959
+ (exit 2). `--not-after <ms|ISO time>` sets a deadline: a launch still waiting for a machine then is never started
960
+ and ends `expired` (already passed: nothing is launched, exit 8). It prints the launch and its state (`queued`,
961
+ `placing`, `provisioning`, `running` with the session, `ended` with an outcome: `idle`, `needs_user`, `failed`,
962
+ `timeout`, `stopped`, `lost`, `expired`); `hub launches show` adds the
930
963
  workspace, steps and artifacts (branch, head, commits). `hub launches stop` ends a launch (`stopped`); one that
931
964
  already ended exits 8 (`gone`). The user may always launch; an app token needs scope `sessions:launch` **and** a
932
965
  grant with verb `launch` covering the repo (exit 5 otherwise; the kill switch pauses it); sessions launch only
@@ -989,7 +1022,7 @@ is set and the credential is not that session. The CLI removes `BUN_BE_BUN` from
989
1022
  | 5 | `forbidden`, `paused` |
990
1023
  | 6 | `rate_limited` (with `retryAfter`) |
991
1024
  | 7 | `unsupported`, `offline`, `busy` |
992
- | 8 | `gone`; an `ask --wait` that expired |
1025
+ | 8 | `gone`, `expired` (a `--not-after` already passed); an `ask --wait` that expired |
993
1026
  | 9 | `unauthorized`, `token_expired` (a session token: start a new shell), or not logged in |
994
1027
 
995
1028
  ### hub mcp (MCP server)
@@ -1015,7 +1048,8 @@ required and absolute; `mode`, `branch`, `base`, `remote`, `key`, `name`, `roles
1015
1048
  (`to`, `text`, `addr`, `id`, `requestID`, `formID`, `why`, `n`, `wake`, `urgent`, `all`, `project`, `role`, …). The act
1016
1049
  tools need an app token with scope `sessions:act` and a grant, the launch tools scope `sessions:launch` and a
1017
1050
  launch grant; `needs_user` results include the evaluation. `ask` takes `wait` of at
1018
- most 60 s (one long poll; a longer wait is cut to 60) and `expires` in seconds or as `2h`; `ask @me` reports the
1051
+ most 60 s (one long poll; a longer wait is cut to 60) and `expires` in seconds or as `2h`; `msg` and `ask` take
1052
+ `notAfter` as epoch milliseconds or an ISO 8601 date-time; `ask @me` reports the
1019
1053
  Decision it opened. Answers are the CLI's
1020
1054
  short text; messages and answers from other sessions come in the hub's envelope and are marked as not from the
1021
1055
  user. Hub errors are tool results with `isError: true` and the hub's `code: message` (plus candidates and hint),
@@ -1256,6 +1290,8 @@ holds the startup (`bin/hub.ts` serves `web/` from disk; the compiled binaries e
1256
1290
  | `HUB_VAPID_SUBJECT` | the https public URL, else `mailto:hub@localhost` | contact sent to push services |
1257
1291
  | `HUB_RELEASE_BASE` | `https://github.com/huynle/hub-releases/releases/download/v<version>` | where `/install.sh` and `/install.ps1` download hubctl ([Set up a machine](#set-up-a-machine-one-line)): https, or plain http on loopback or a private-network IP address; the hub refuses to start with any other value. Without it a development version (not a plain `x.y.z`) answers 503 |
1258
1292
  | `HUB_CSP` | `enforce` | `report-only` sends the console's Content-Security-Policy as `Content-Security-Policy-Report-Only` (violations are only logged in the browser console); see [Content-Security-Policy](#content-security-policy) |
1293
+ | `HUB_REMOTE_FETCH` | `on`; `off` when `HTTP_PROXY`/`HTTPS_PROXY` (either case) is set and `NO_PROXY` is not `*` | `on` or `off`. A proxy could re-resolve hostnames and undo the fetcher's address pinning, so with a proxy in the environment remote fetch stays off (with a startup warning) unless you set `NO_PROXY=*` for the hub process (direct fetches) or `HUB_REMOTE_FETCH=on` (accept proxying knowingly). `off` turns off the hub fetching web images and link previews for the console (`GET /api/media/remote` and `GET /api/media/unfurl` answer `403 disabled`). Signed-in users only (session and app tokens are refused); http/https on default ports, private and reserved addresses refused, 8 MiB, never decompressed (compressed responses are refused), PNG/JPEG/WebP/GIF only, 30 a minute and 4 at once per user (images and link previews together); the audit log keeps the host, never the URL. Any other value stops the hub at start. Threat model and residual risks: [docs/media-security.md](docs/media-security.md) |
1294
+ | `HUB_FETCH_DENY_CIDRS` | | comma list of extra IPv4 or IPv6 CIDRs the hub never fetches from (`GET /api/media/remote`, `GET /api/media/unfurl`), on top of the built-in loopback, private, link-local (cloud metadata), CGNAT, multicast, reserved, ULA and mapped ranges (e.g. your Kubernetes pod and service CIDRs: `10.42.0.0/16,10.43.0.0/16`); an invalid entry stops the hub at start |
1259
1295
  | `OPENROUTER_API_KEY` | | default key for the `openrouter` speech provider (never sent to other providers) |
1260
1296
  | `HUB_STT_MODEL`, `HUB_TTS_MODEL`, `HUB_VOICE`, `HUB_MAX_*` | see `src/central/config.ts` | speech defaults; the phone's speech settings override them |
1261
1297
 
@@ -1277,11 +1313,14 @@ Nothing inline and no `eval`: scripts and styles come only from the built bundle
1277
1313
  that runs before the first paint is `/theme-boot.js` (not an inline script), components use classes or style
1278
1314
  properties (never a `style="…"` attribute in markup), and the stylesheets 3d-force-graph would inject for the
1279
1315
  Watchtower are stripped at build time and shipped as `web/src/app/watchtower/force-graph.css`. `data:`/`blob:`
1280
- images are attachments and pasted images, `blob:` media is Voice playback. `connect-src 'self'` covers the API, the
1316
+ images are attachments, pasted images and session media (screenshots, local images, artifact images and web images
1317
+ the hub fetched, as `blob:` URLs of checked raster bytes; SVG and Mermaid diagrams only as `data:` URLs in an
1318
+ `<img>`, never DOM; [docs/media-security.md](docs/media-security.md)), `blob:` media is Voice playback. Mermaid draws
1319
+ in a same-origin worker (`worker-src 'self'`). `connect-src 'self'` covers the API, the
1281
1320
  SSE stream and same-origin WebSockets. The only frames are the isolated artifact host's (`/artifact-host/v1/…`, same
1282
1321
  origin), which keeps its own sandboxing policy ([docs/artifact-host.md](docs/artifact-host.md)); nothing may frame the
1283
1322
  console. Sign-in (OIDC) is a navigation, not a form post, so `form-action 'self'` does not affect it. The Silero VAD
1284
- worker compiles its WebAssembly inside the worker, so no `'wasm-unsafe-eval'` is needed.
1323
+ and Smart Turn workers compile their WebAssembly inside the worker, so no `'wasm-unsafe-eval'` is needed.
1285
1324
 
1286
1325
  API responses carry no console policy (JSON is never rendered as a page; downloads, the sign-in result pages and the
1287
1326
  artifact host send their own). If a deployment hits a violation (the browser console says `Refused to …`), set
@@ -1770,6 +1809,24 @@ it again, say "pause listening", or leave the page to turn it off. Leaving the p
1770
1809
  it ends in `. ? !`, or has 3+ words and doesn't end in "and", "but", "so", "um", "the" and the like.
1771
1810
  Otherwise it waits up to 0.8 s. If you speak again during the wait, the next segment's transcript
1772
1811
  is joined to it. Empty and filler-only transcripts are dropped. Utterances are capped at 60 s.
1812
+ - **Turn detection (experimental, Settings → Voice → Turn detection, per device)**: "Timers (current)" is the
1813
+ default and works as above. "Smart Turn (experimental)" decides the end of a turn from the audio: on-device
1814
+ [Pipecat Smart Turn v3](https://huggingface.co/pipecat-ai/smart-turn-v3) (v3.2 int8, BSD-2-Clause, 8.7 MB, loaded on
1815
+ first use as a hashed asset; license in `web/src/voice/turn/LICENSE`) in its own Web Worker
1816
+ (`web/src/voice/smart-turn-worker.ts`, onnxruntime-web as for Silero, so the console policy needs nothing new). The
1817
+ console keeps the last 8 s of the turn's 16 kHz mic audio, pauses included (from the turn's first speech start),
1818
+ computes Whisper's log-mel features in TS (`web/src/core/smart-turn-features.ts`, matched to transformers'
1819
+ `WhisperFeatureExtractor` within 1e-5) and scores it at each segment end: a score ≥ 0.5 sends 150 ms after the
1820
+ score once the transcripts are in; a lower one holds and is re-scored every 400 ms of quiet, with a fallback after
1821
+ 3 s. Speech resuming cancels the send as with the timers; commands, the 60 s cap and noise transcripts are
1822
+ unchanged. A model that cannot load, or a score slower than the slow cap, leaves that turn to the timers (one
1823
+ console warning). The slow cap is 1.5 s, or twice the median of the recent score times when the device is slower,
1824
+ at most 5 s; the model runs once on silence right after it loads, so the first turn is not the slow one. The Voice bar shows the latest score ("turn 6%", "turn 91% → sending"), or why there is none ("turn: model
1825
+ loading", "turn: timers (slow)"). A turn buffer without speech (silence scores about 0.99 "complete") is never
1826
+ scored. Tuning per device: Settings →
1827
+ Voice → Advanced (threshold, grace, fallback, slow cap; clamped to their ranges, each with the value in use), or `voiceTurn.threshold`, `voiceTurn.grace`, `voiceTurn.recheck`,
1828
+ `voiceTurn.fallback`, `voiceTurn.slow` as URL query parameters (one visit) or localStorage keys. The recording
1829
+ mode records each score as `turn.predict` ([docs/voice-recording.md](docs/voice-recording.md)).
1773
1830
  - **Commands**: an utterance that is only "stop", "cancel", "never mind", "stop that" or "hold on"
1774
1831
  calls `POST …/interrupt` and stops speech. "Pause listening" turns the mode off.
1775
1832
  - **Speech out**: root replies arrive one sentence at a time while they are written (`say` with `seq`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
5
5
  "license": "MIT",
6
6
  "author": "Huy Le",
@@ -47,6 +47,7 @@
47
47
  "src",
48
48
  "packages/protocol/src",
49
49
  "packages/protocol/package.json",
50
+ "packages/protocol/schema",
50
51
  "packages/sdk/src",
51
52
  "packages/sdk/package.json",
52
53
  "packages/cli/src",
@@ -70,6 +71,7 @@
70
71
  "packages/codex/package.json",
71
72
  "bin",
72
73
  "web/dist",
74
+ "web/src/voice/turn/LICENSE",
73
75
  "docker",
74
76
  "Dockerfile",
75
77
  "scripts/hub-local.sh",
@@ -124,7 +126,9 @@
124
126
  "@types/three": "0.186.0",
125
127
  "@types/web-push": "^3.6.4",
126
128
  "3d-force-graph": "1.80.1",
129
+ "ajv": "^8.20.0",
127
130
  "onnxruntime-web": "1.23.2",
131
+ "pele": "1.13.0",
128
132
  "playwright-core": "^1.64.1",
129
133
  "solid-js": "^1.9.17",
130
134
  "typescript": "^5.9.3",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-claude-code",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",