@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
@@ -39,6 +39,28 @@ export const ISOLATED_KINDS: readonly ArtifactKind[] = ["html", "app"]
39
39
 
40
40
  export type ArtifactVisibility = "private" | "workspace"
41
41
 
42
+ /**
43
+ * Where an artifact came from when the hub made it for the console (absent: a normal artifact). "preview": an HTML
44
+ * preview of a code block in a session message (session media §3); hidden from the default library, archived and
45
+ * purged by preview retention, and turned into a normal artifact by Keep (which clears it).
46
+ */
47
+ export const ARTIFACT_ORIGINS = ["preview"] as const
48
+ export type ArtifactOrigin = (typeof ARTIFACT_ORIGINS)[number]
49
+
50
+ /**
51
+ * A preview's source in one message of a session: the n-th (0-based) ```html block (`block`), or a local HTML page the
52
+ * message names (`path`, as written; session media §3 4b). Exactly one of the two. Provenance only; grants nothing.
53
+ */
54
+ export interface ArtifactProvenance {
55
+ readonly sessionID: string
56
+ readonly messageID: string
57
+ readonly block?: number
58
+ readonly path?: string
59
+ }
60
+
61
+ /** Longest page path a preview's provenance carries (PREVIEW_BUNDLE_LIMITS.pathMax). */
62
+ const PROVENANCE_PATH_MAX = 4096
63
+
42
64
  export interface Artifact extends Revisioned {
43
65
  readonly v: 1
44
66
  readonly id: string
@@ -54,6 +76,10 @@ export interface Artifact extends Revisioned {
54
76
  readonly latestRevisionID: string
55
77
  readonly tags?: readonly string[]
56
78
  readonly metadata?: Readonly<Record<string, string>>
79
+ /** Absent: a normal artifact. */
80
+ readonly origin?: ArtifactOrigin
81
+ /** With origin "preview": the code block or page it previews. */
82
+ readonly provenance?: ArtifactProvenance
57
83
  readonly createdAt: number
58
84
  readonly updatedAt: number
59
85
  }
@@ -129,9 +155,26 @@ export function artifactInputProblem(v: unknown): string | undefined {
129
155
  if (v.visibility !== undefined && v.visibility !== "private" && v.visibility !== "workspace") return "visibility must be private or workspace"
130
156
  if (v.visibility === "workspace" && !isContractID("workspace", v.workspaceID)) return "workspace visibility needs a workspaceID"
131
157
  if (v.workspaceID !== undefined && !isContractID("workspace", v.workspaceID)) return "workspaceID must be a workspace ID"
158
+ if (v.origin !== undefined && !ARTIFACT_ORIGINS.includes(v.origin as ArtifactOrigin)) return `origin must be one of ${ARTIFACT_ORIGINS.join(", ")}`
159
+ if (v.provenance !== undefined) {
160
+ const problem = provenanceProblem(v.provenance)
161
+ if (problem) return problem
162
+ }
132
163
  return tagsProblem(v.tags) ?? metadataProblem(v.metadata)
133
164
  }
134
165
 
166
+ const PROVENANCE_ID = /^[A-Za-z0-9_.:-]{1,200}$/
167
+ /** Session and message IDs are the harness's (opaque, bounded); `block` a 0-based index, or `path` a page path. */
168
+ export function provenanceProblem(v: unknown): string | undefined {
169
+ if (!isObj(v)) return "provenance must be {sessionID, messageID, block} or {sessionID, messageID, path}"
170
+ if (typeof v.sessionID !== "string" || !PROVENANCE_ID.test(v.sessionID)) return "provenance.sessionID must be a session ID"
171
+ if (typeof v.messageID !== "string" || !PROVENANCE_ID.test(v.messageID)) return "provenance.messageID must be a message ID"
172
+ if ((v.block === undefined) === (v.path === undefined)) return "provenance needs exactly one of block and path"
173
+ if (v.block !== undefined && (typeof v.block !== "number" || !Number.isSafeInteger(v.block) || v.block < 0 || v.block > 9999)) return "provenance.block must be a whole number 0–9999"
174
+ if (v.path !== undefined && (typeof v.path !== "string" || v.path.length < 1 || v.path.length > PROVENANCE_PATH_MAX || /[\u0000\r\n]/.test(v.path))) return `provenance.path must be 1–${PROVENANCE_PATH_MAX} characters on one line`
175
+ return undefined
176
+ }
177
+
135
178
  export function blobRefProblem(v: unknown): string | undefined {
136
179
  if (!isObj(v)) return "blob must be an object"
137
180
  if (!isBlobHash(v.hash)) return "hash must be sha256:<64 hex>"
@@ -0,0 +1,38 @@
1
+ // Literal credentials in free text (tokens, keys, private keys): found, or replaced with "[redacted: <kind>]". Voice
2
+ // recordings scrub every text field with it; spoken and pushed failure reasons and voice-front wakes do too.
3
+
4
+ /** Distinctive credential formats; prose about secrets ("set API_KEY=…") is not matched. */
5
+ export const CREDENTIALS: readonly (readonly [string, RegExp])[] = [
6
+ ["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
7
+ ["hub app token", /\bhat_[0-9a-f]{16}_[A-Za-z0-9_-]{43,}/],
8
+ ["github token", /\bgh[pousr]_[A-Za-z0-9]{36,}/],
9
+ ["github token", /\bgithub_pat_[A-Za-z0-9_]{22,}/],
10
+ ["api key", /\bsk-[A-Za-z0-9_-]{20,}/],
11
+ ["api key", /\bAIza[0-9A-Za-z_-]{35}/],
12
+ ["aws access key", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
13
+ ["slack token", /\bxox[abposr]-[A-Za-z0-9-]{10,}/],
14
+ ["jwt", /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/],
15
+ ["bearer token", /\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{20,}/],
16
+ ]
17
+
18
+ /** The kind of literal credential in a text, or undefined. */
19
+ export function credentialFinding(text: string): string | undefined {
20
+ for (const [kind, pattern] of CREDENTIALS) if (pattern.test(text)) return kind
21
+ return undefined
22
+ }
23
+
24
+ /**
25
+ * (REC-01) Replaces every literal credential in a text with `[redacted: <kind>]` (the CREDENTIALS table, applied
26
+ * globally) and lists the kinds removed, each once, in table order. Voice recordings scrub every text field with it.
27
+ */
28
+ export function redactCredentials(text: string): { text: string; kinds: string[] } {
29
+ const kinds: string[] = []
30
+ let out = text
31
+ for (const [kind, pattern] of CREDENTIALS) {
32
+ const global = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`)
33
+ const next = out.replace(global, `[redacted: ${kind}]`)
34
+ if (next !== out && !kinds.includes(kind)) kinds.push(kind)
35
+ out = next
36
+ }
37
+ return { text: out, kinds }
38
+ }
@@ -20,6 +20,8 @@ import { featureList, HUB_URL_MAX_CHARS, ID, isByeFrame as isBye, isHubURL, isOb
20
20
  import type { NodeMessage, NodeServerMessage } from "./node.ts"
21
21
  import { PROFILE_APPLY_FEATURE, profileApplyRequestProblem, type ProfileApplyRequest, type ProfileApplyResult } from "./profiles.ts"
22
22
  import { SESSION_CONTEXT_FEATURE, sessionContextProblem, type SessionContextRequest, type SessionContextResult } from "./concierge.ts"
23
+ import { isMediaOffset, parseMediaRef, type TimelineMediaResult } from "./media.ts"
24
+ import { isPreviewBundleRequest, PREVIEW_BUNDLE_FEATURE, type PreviewBundleRequest, type PreviewBundleResult } from "./preview-bundle.ts"
23
25
 
24
26
  export * from "./harness-events.ts"
25
27
  export * from "./launch.ts"
@@ -32,6 +34,9 @@ export * from "./concierge.ts"
32
34
  export * from "./profiles.ts"
33
35
  export * from "./artifacts.ts"
34
36
  export * from "./embedded-media.ts"
37
+ export * from "./media.ts"
38
+ export * from "./media-allowlist.ts"
39
+ export * from "./preview-bundle.ts"
35
40
  export * from "./teams.ts"
36
41
  export * from "./graph.ts"
37
42
  export * from "./voice-recording.ts"
@@ -96,7 +101,9 @@ export interface AvailableModel {
96
101
  * mode (`order: "desc"` or `before`): the newest rows (before `before`) in chronological order, `older` for the
97
102
  * page before them. Both cursors are opaque. Items are the spoke's rows (src/spoke/timeline.ts TimelineItem); newer
98
103
  * spokes add optional `pos` (ordering position), `callID`, `childSessionID`, `title`, `ordinal` and, on error rows,
99
- * `declined` (a denied permission stopped the turn).
104
+ * `declined` (a denied permission stopped the turn). Spokes with "timelineMedia" (media.ts) add `ref`, `bytes`,
105
+ * `width`, `height` to user rows' `files`, and `media` (the same shape, ≤ MEDIA_LIMITS.perRow) to tool rows (their
106
+ * image file items, refs `t<part>.<item>`) and assistant text rows (local image paths, refs `p:…`).
100
107
  */
101
108
  export interface TimelinePage {
102
109
  readonly items: readonly unknown[]
@@ -172,6 +179,11 @@ export interface SessionInfo {
172
179
  */
173
180
  readonly agent?: string
174
181
  readonly contextRevision?: number
182
+ /**
183
+ * (feature "voiceFront") Set on a hidden voice-front session: the ID of the main session it fronts. The hub routes
184
+ * that session's conversation through it and never lists it. Older hubs ignore it.
185
+ */
186
+ readonly voiceFrontOf?: string
175
187
  /**
176
188
  * (FX-07) The session is locked as a Concierge session on this spoke (sent from the first sessions frame after a
177
189
  * restart, before `agent` or `contextRevision` are known). The hub treats it as a Concierge session.
@@ -449,8 +461,12 @@ export const IMPLIED_OPENCODE_FEATURES = [
449
461
  * reaches its own server).
450
462
  * "dismissError": it forgets a session's last run failure when the user dismisses it (dismissError op), so a
451
463
  * reconnect does not seed it again.
464
+ * "timelineMedia": it serves timeline images by ref in chunks (timelineMedia op, media.ts), and its timeline rows
465
+ * carry media refs (user files' `ref`, tool and assistant rows' `media`).
466
+ * "previewBundle": it answers previewBundle (preview-bundle.ts): a local HTML page an assistant message names and the
467
+ * files it refers to, under the media path policy, paged under the frame limit.
452
468
  */
453
- const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert", "dismissError"] as const
469
+ const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert", "dismissError", "timelineMedia", PREVIEW_BUNDLE_FEATURE, "voiceFront"] as const
454
470
 
455
471
  /**
456
472
  * The core ops. An OpenCode spoke serves them all without announcing them; a spoke of another harness announces
@@ -641,8 +657,16 @@ export type ServerRequest =
641
657
  | { readonly op: "summary"; readonly sessionID: string }
642
658
  /** One changed file's patch in the session's directory. */
643
659
  | { readonly op: "diff"; readonly sessionID: string; readonly file: string }
644
- /** Switch the session to the voice orchestrator agent (on) or back to its previous agent (off). */
645
- | { readonly op: "conversation"; readonly sessionID: string; readonly on: boolean }
660
+ /**
661
+ * Switch the session to the voice orchestrator agent (on) or back to its previous agent (off). `front` (feature
662
+ * "voiceFront", sent only to spokes that list it): leave the session's agent and model untouched and put a hidden
663
+ * voice-front session in front of it instead (docs/plans/2026-11-06-voice-front-p1-contract.md).
664
+ */
665
+ /**
666
+ * `front` (feature "voiceFront"): put a hidden voice front in front of the session. `frontID`: the front the hub
667
+ * knows for it (a resend to a restarted spoke): re-linked if it still exists, instead of creating another.
668
+ */
669
+ | { readonly op: "conversation"; readonly sessionID: string; readonly on: boolean; readonly front?: boolean; readonly frontID?: string }
646
670
  | { readonly op: "models"; readonly sessionID: string }
647
671
  | { readonly op: "modelPrefs"; readonly sessionID: string }
648
672
  | { readonly op: "setModelPrefs"; readonly sessionID: string; readonly voice?: ModelSelection | null; readonly worker?: ModelSelection | null }
@@ -650,6 +674,16 @@ export type ServerRequest =
650
674
  | { readonly op: "timelinePart"; readonly sessionID: string; readonly messageID: string; readonly partID: string }
651
675
  /** One image attached to a user message (its position in the message's files). */
652
676
  | { readonly op: "timelineFile"; readonly sessionID: string; readonly messageID: string; readonly index: number }
677
+ /**
678
+ * (S4 M2, feature "timelineMedia") One image of a timeline row, by its ref (media.ts): a user attachment, a tool
679
+ * content item or an image path in an assistant message. `offset`: the chunk to answer (default 0).
680
+ */
681
+ | { readonly op: "timelineMedia"; readonly sessionID: string; readonly messageID: string; readonly ref: string; readonly offset?: number }
682
+ /**
683
+ * (Session media §3 4b, feature "previewBundle") A local HTML page named in an assistant message's text and the
684
+ * files it refers to: the manifest first, then chunks of the files not inlined (preview-bundle.ts).
685
+ */
686
+ | PreviewBundleRequest
653
687
  /** Enable or disable voice for one session (adds/removes it from the machine config's session list). */
654
688
  | { readonly op: "expose"; readonly sessionID: string; readonly on: boolean }
655
689
  /**
@@ -708,6 +742,11 @@ export type ServerRequest =
708
742
  | { readonly op: "revertRestore"; readonly sessionID: string }
709
743
  /** Make a staged revert permanent: the hidden messages are removed (feature "revert"). */
710
744
  | { readonly op: "revertCommit"; readonly sessionID: string }
745
+ /**
746
+ * (feature "voiceFront") Delete a voice-front session no conversation uses (the hub found it stale). The spoke
747
+ * deletes only a session on the voice-front agent that it has not linked; anything else is left alone.
748
+ */
749
+ | { readonly op: "removeFront"; readonly sessionID: string }
711
750
  /** Forget the session's last run failure: the user dismissed it (feature "dismissError"). */
712
751
  | { readonly op: "dismissError"; readonly sessionID: string }
713
752
 
@@ -723,6 +762,8 @@ export function featureForOp(op: ServerRequest["op"]): SpokeFeature {
723
762
  case "revertRestore":
724
763
  case "revertCommit":
725
764
  return "revert"
765
+ case "removeFront":
766
+ return "voiceFront"
726
767
  default:
727
768
  return op
728
769
  }
@@ -824,7 +865,8 @@ export interface ResultFor {
824
865
  stop: { readonly stopped: readonly string[] }
825
866
  summary: SessionSummary
826
867
  diff: { readonly patch?: FilePatch }
827
- conversation: { readonly agent: string }
868
+ /** `front`: the voice-front session now in front of this one (on with `front`; absent when the spoke fell back). */
869
+ conversation: { readonly agent: string; readonly front?: { readonly sessionID: string } }
828
870
  models: { readonly models: readonly AvailableModel[] }
829
871
  modelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
830
872
  setModelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
@@ -832,6 +874,8 @@ export interface ResultFor {
832
874
  timelinePart: { readonly detail: unknown }
833
875
  /** No `file` when the message has no image at that index; `tooLarge` when its bytes would not fit in a frame. */
834
876
  timelineFile: { readonly file?: PromptImage; readonly tooLarge?: boolean }
877
+ timelineMedia: TimelineMediaResult
878
+ previewBundle: PreviewBundleResult
835
879
  expose: { readonly exposed: boolean }
836
880
  create: { readonly sessionID: string; readonly openedIn?: "herdr" }
837
881
  /** `applied`: sessions whose token the spoke stored; `skipped`: the others, with the reason. */
@@ -850,6 +894,7 @@ export interface ResultFor {
850
894
  revertCommit: Record<string, never>
851
895
  /** `dismissed`: the spoke had a failure of the session to forget. */
852
896
  dismissError: { readonly dismissed: boolean }
897
+ removeFront: { readonly removed: boolean }
853
898
  }
854
899
 
855
900
  // ---------- both directions ----------
@@ -1182,11 +1227,16 @@ function isServerRequest(v: unknown): v is ServerRequest {
1182
1227
  return nonEmpty(v.sessionID) && promptBody(v)
1183
1228
  case "timelineFile":
1184
1229
  return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && Number.isInteger(v.index) && Number(v.index) >= 0 && Number(v.index) < 1000
1230
+ case "timelineMedia":
1231
+ return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && str(v.ref) && parseMediaRef(v.ref) !== undefined && (v.offset === undefined || isMediaOffset(v.offset))
1232
+ case "previewBundle":
1233
+ return isPreviewBundleRequest(v)
1185
1234
  case "diff":
1186
1235
  return nonEmpty(v.sessionID) && nonEmpty(v.file) && v.file.length <= 4096
1187
1236
  case "rename":
1188
1237
  return nonEmpty(v.sessionID) && str(v.title) && v.title.length <= 1000
1189
1238
  case "conversation":
1239
+ return nonEmpty(v.sessionID) && typeof v.on === "boolean" && (v.frontID === undefined || (nonEmpty(v.frontID) && v.frontID.length <= 300))
1190
1240
  case "expose":
1191
1241
  return nonEmpty(v.sessionID) && typeof v.on === "boolean"
1192
1242
  case "create":
@@ -1218,6 +1268,7 @@ function isServerRequest(v: unknown): v is ServerRequest {
1218
1268
  case "revertRestore":
1219
1269
  case "revertCommit":
1220
1270
  case "dismissError":
1271
+ case "removeFront":
1221
1272
  return nonEmpty(v.sessionID)
1222
1273
  default:
1223
1274
  return false
@@ -9,8 +9,8 @@ import { isObj, UNSAFE_CHARS } from "./wire.ts"
9
9
  export const LAUNCH_STATES = ["queued", "placing", "provisioning", "running", "ended"] as const
10
10
  export type LaunchState = (typeof LAUNCH_STATES)[number]
11
11
 
12
- /** How an ended launch ended. */
13
- export const LAUNCH_OUTCOMES = ["idle", "needs_user", "failed", "timeout", "stopped", "lost"] as const
12
+ /** How an ended launch ended. expired (H3): its notAfter passed while it was still queued or placing (never started). */
13
+ export const LAUNCH_OUTCOMES = ["idle", "needs_user", "failed", "timeout", "stopped", "lost", "expired"] as const
14
14
  export type LaunchOutcome = (typeof LAUNCH_OUTCOMES)[number]
15
15
 
16
16
  /** `worktree`: `<repo>/.worktrees/<branch>` (reused when present); `checkout`: the repo itself; `clone`: a fresh clone at `repo`. */
@@ -89,6 +89,11 @@ export interface LaunchSpec {
89
89
  readonly end?: LaunchEnd
90
90
  /** (HWA-15) Pin and apply this profile version to the session before its prompt (spokes that announce profileApply). */
91
91
  readonly agentProfile?: LaunchAgentProfile
92
+ /**
93
+ * (H3) Epoch ms: a launch still queued or placing then is never started; it ends with outcome expired. At most 30
94
+ * days ahead; already passed at submit: 422 expired, nothing stored. No effect once it is provisioning.
95
+ */
96
+ readonly notAfter?: number
92
97
  }
93
98
 
94
99
  /** What a node collected from a launch's workspace. */