@huynle/hub 0.8.7 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +75 -16
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/wrapper.ts +2 -2
  6. package/packages/cli/dist/hub-cli.js +1413 -523
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
  9. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
  10. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
  11. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
  12. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
  13. package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
  14. package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
  15. package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
  16. package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
  17. package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
  18. package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
  19. package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
  20. package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
  21. package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
  22. package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
  23. package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
  24. package/packages/cli/src/bootstrap/release/assets.ts +4 -1
  25. package/packages/cli/src/cli.ts +23 -10
  26. package/packages/cli/src/http.ts +1 -0
  27. package/packages/cli/src/launches.ts +5 -1
  28. package/packages/cli/src/mcp.ts +28 -8
  29. package/packages/cli/src/messaging.ts +14 -0
  30. package/packages/cli/src/node.ts +94 -12
  31. package/packages/cli/src/setup/commands.ts +27 -7
  32. package/packages/cli/src/setup/runtime.ts +31 -4
  33. package/packages/cli/src/team-intake-mcp.ts +26 -2
  34. package/packages/codex/package.json +1 -1
  35. package/packages/codex/src/wrapper.ts +2 -2
  36. package/packages/node/package.json +1 -1
  37. package/packages/node/src/config.ts +49 -17
  38. package/packages/node/src/git.ts +16 -2
  39. package/packages/node/src/harnesses.ts +54 -9
  40. package/packages/node/src/host.ts +61 -15
  41. package/packages/node/src/opencode-service.ts +111 -0
  42. package/packages/node/src/opencode-warm.ts +11 -5
  43. package/packages/node/src/run.ts +50 -5
  44. package/packages/preset/package.json +1 -1
  45. package/packages/protocol/package.json +1 -1
  46. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  47. package/packages/protocol/src/artifacts.ts +43 -0
  48. package/packages/protocol/src/credentials.ts +38 -0
  49. package/packages/protocol/src/index.ts +59 -6
  50. package/packages/protocol/src/launch.ts +46 -6
  51. package/packages/protocol/src/markdown.ts +753 -0
  52. package/packages/protocol/src/media-allowlist.ts +240 -0
  53. package/packages/protocol/src/media.ts +120 -0
  54. package/packages/protocol/src/preview-bundle.ts +221 -0
  55. package/packages/protocol/src/voice-recording.ts +8 -0
  56. package/packages/runtime-openshell/package.json +1 -1
  57. package/packages/runtime-sdk/package.json +1 -1
  58. package/packages/sdk/package.json +1 -1
  59. package/packages/sdk/src/client.ts +17 -3
  60. package/packages/sdk/src/errors.ts +1 -0
  61. package/packages/sdk/src/team-intake.ts +8 -0
  62. package/packages/sdk/src/types.ts +16 -2
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/src/central/agent/delivery.ts +35 -6
  65. package/src/central/agent/errors.ts +2 -0
  66. package/src/central/agent/events.ts +11 -2
  67. package/src/central/agent/launch-engine.ts +65 -4
  68. package/src/central/agent/launch-routes.ts +8 -0
  69. package/src/central/agent/launch-spec.ts +22 -3
  70. package/src/central/agent/launch-store.ts +2 -2
  71. package/src/central/agent/message-routes.ts +49 -11
  72. package/src/central/agent/messages.ts +17 -4
  73. package/src/central/agent/not-after.ts +28 -0
  74. package/src/central/agent/placement.ts +36 -4
  75. package/src/central/agent/routes.ts +3 -1
  76. package/src/central/agent/webhook-routes.ts +21 -9
  77. package/src/central/agent/webhooks.ts +27 -9
  78. package/src/central/artifacts/host.ts +3 -1
  79. package/src/central/artifacts/maintenance.ts +2 -1
  80. package/src/central/artifacts/preview-bundle.ts +81 -0
  81. package/src/central/artifacts/previews.ts +94 -0
  82. package/src/central/artifacts/routes.ts +12 -1
  83. package/src/central/artifacts/store.ts +198 -8
  84. package/src/central/config.ts +34 -0
  85. package/src/central/fetch-budget.ts +59 -0
  86. package/src/central/fetcher.ts +372 -0
  87. package/src/central/http.ts +277 -22
  88. package/src/central/install/CONTRACT.md +4 -4
  89. package/src/central/install/install.ps1 +37 -5
  90. package/src/central/install/render.ts +13 -8
  91. package/src/central/media-remote.ts +248 -0
  92. package/src/central/media-timeline.ts +317 -0
  93. package/src/central/media-unfurl.ts +119 -0
  94. package/src/central/profiles/content.ts +3 -35
  95. package/src/central/recording/routes.ts +2 -2
  96. package/src/central/recording/schema.ts +3 -1
  97. package/src/central/registry.ts +449 -21
  98. package/src/central/spoke-ws.ts +17 -3
  99. package/src/central/store.ts +21 -0
  100. package/src/central/teams/intake-routes.ts +2 -1
  101. package/src/central/teams/intake.ts +200 -15
  102. package/src/central/unfurl-parse.ts +313 -0
  103. package/src/index.ts +68 -8
  104. package/src/speakable.ts +33 -1
  105. package/src/spoke/conversation.ts +7 -4
  106. package/src/spoke/deliver.ts +19 -0
  107. package/src/spoke/media-bytes.ts +171 -0
  108. package/src/spoke/media-policy.ts +394 -0
  109. package/src/spoke/media.ts +189 -0
  110. package/src/spoke/own-server.ts +30 -0
  111. package/src/spoke/preview-bundle.ts +538 -0
  112. package/src/spoke/spoke.ts +387 -17
  113. package/src/spoke/timeline.ts +59 -5
  114. package/src/spoke/voice-front.ts +335 -0
  115. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  116. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  117. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  118. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  119. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  120. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  121. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  122. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  123. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  124. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  125. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  126. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  127. package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
  128. package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
  129. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
  130. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  131. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  132. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  133. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  134. package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
  135. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  136. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  137. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  138. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  139. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  140. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  141. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  142. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  143. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  144. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  145. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  146. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  147. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  148. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  149. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  150. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  151. package/web/dist/assets/Library-CcjZXsfF.js +2 -0
  152. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  153. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  154. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  155. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  156. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  157. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  158. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  159. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  160. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  161. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  162. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  163. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
  164. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  165. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  166. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  167. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  168. package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
  169. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  170. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  171. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  172. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  173. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  174. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  175. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  176. package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
  177. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  178. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  179. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  180. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  181. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  182. package/web/dist/assets/index-LNMZCqgE.js +36 -0
  183. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  184. package/web/dist/assets/live-ycZ_aQZO.js +5 -0
  185. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  186. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  187. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  188. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
  189. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
  190. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
  191. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  192. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  193. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  194. package/web/dist/assets/source-BWlvaODa.js +1 -0
  195. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  196. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  197. package/web/dist/index.html +8 -6
  198. package/web/src/voice/turn/LICENSE +28 -0
  199. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  200. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  201. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  202. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  203. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  204. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  205. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  206. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  207. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  208. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  209. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  210. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  211. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  212. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  213. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -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
+ }
@@ -18,8 +18,11 @@
18
18
 
19
19
  import { featureList, HUB_URL_MAX_CHARS, ID, isByeFrame as isBye, isHubURL, isObj, isResFrame as isRes, isWelcomeFrame, MAX_FRAME, MAX_RETRY_AFTER_MS, nonEmpty, num, parseFrame as parse, str, token, UNSAFE_CHARS } from "./wire.ts"
20
20
  import type { NodeMessage, NodeServerMessage } from "./node.ts"
21
+ import { isWindowsAbsolutePath } from "./launch.ts"
21
22
  import { PROFILE_APPLY_FEATURE, profileApplyRequestProblem, type ProfileApplyRequest, type ProfileApplyResult } from "./profiles.ts"
22
23
  import { SESSION_CONTEXT_FEATURE, sessionContextProblem, type SessionContextRequest, type SessionContextResult } from "./concierge.ts"
24
+ import { isMediaOffset, parseMediaRef, type TimelineMediaResult } from "./media.ts"
25
+ import { isPreviewBundleRequest, PREVIEW_BUNDLE_FEATURE, type PreviewBundleRequest, type PreviewBundleResult } from "./preview-bundle.ts"
23
26
 
24
27
  export * from "./harness-events.ts"
25
28
  export * from "./launch.ts"
@@ -32,6 +35,9 @@ export * from "./concierge.ts"
32
35
  export * from "./profiles.ts"
33
36
  export * from "./artifacts.ts"
34
37
  export * from "./embedded-media.ts"
38
+ export * from "./media.ts"
39
+ export * from "./media-allowlist.ts"
40
+ export * from "./preview-bundle.ts"
35
41
  export * from "./teams.ts"
36
42
  export * from "./graph.ts"
37
43
  export * from "./voice-recording.ts"
@@ -96,7 +102,9 @@ export interface AvailableModel {
96
102
  * mode (`order: "desc"` or `before`): the newest rows (before `before`) in chronological order, `older` for the
97
103
  * page before them. Both cursors are opaque. Items are the spoke's rows (src/spoke/timeline.ts TimelineItem); newer
98
104
  * spokes add optional `pos` (ordering position), `callID`, `childSessionID`, `title`, `ordinal` and, on error rows,
99
- * `declined` (a denied permission stopped the turn).
105
+ * `declined` (a denied permission stopped the turn). Spokes with "timelineMedia" (media.ts) add `ref`, `bytes`,
106
+ * `width`, `height` to user rows' `files`, and `media` (the same shape, ≤ MEDIA_LIMITS.perRow) to tool rows (their
107
+ * image file items, refs `t<part>.<item>`) and assistant text rows (local image paths, refs `p:…`).
100
108
  */
101
109
  export interface TimelinePage {
102
110
  readonly items: readonly unknown[]
@@ -172,6 +180,11 @@ export interface SessionInfo {
172
180
  */
173
181
  readonly agent?: string
174
182
  readonly contextRevision?: number
183
+ /**
184
+ * (feature "voiceFront") Set on a hidden voice-front session: the ID of the main session it fronts. The hub routes
185
+ * that session's conversation through it and never lists it. Older hubs ignore it.
186
+ */
187
+ readonly voiceFrontOf?: string
175
188
  /**
176
189
  * (FX-07) The session is locked as a Concierge session on this spoke (sent from the first sessions frame after a
177
190
  * restart, before `agent` or `contextRevision` are known). The hub treats it as a Concierge session.
@@ -449,8 +462,12 @@ export const IMPLIED_OPENCODE_FEATURES = [
449
462
  * reaches its own server).
450
463
  * "dismissError": it forgets a session's last run failure when the user dismisses it (dismissError op), so a
451
464
  * reconnect does not seed it again.
465
+ * "timelineMedia": it serves timeline images by ref in chunks (timelineMedia op, media.ts), and its timeline rows
466
+ * carry media refs (user files' `ref`, tool and assistant rows' `media`).
467
+ * "previewBundle": it answers previewBundle (preview-bundle.ts): a local HTML page an assistant message names and the
468
+ * files it refers to, under the media path policy, paged under the frame limit.
452
469
  */
453
- const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert", "dismissError"] as const
470
+ 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
471
 
455
472
  /**
456
473
  * The core ops. An OpenCode spoke serves them all without announcing them; a spoke of another harness announces
@@ -641,8 +658,16 @@ export type ServerRequest =
641
658
  | { readonly op: "summary"; readonly sessionID: string }
642
659
  /** One changed file's patch in the session's directory. */
643
660
  | { 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 }
661
+ /**
662
+ * Switch the session to the voice orchestrator agent (on) or back to its previous agent (off). `front` (feature
663
+ * "voiceFront", sent only to spokes that list it): leave the session's agent and model untouched and put a hidden
664
+ * voice-front session in front of it instead (docs/plans/2026-11-06-voice-front-p1-contract.md).
665
+ */
666
+ /**
667
+ * `front` (feature "voiceFront"): put a hidden voice front in front of the session. `frontID`: the front the hub
668
+ * knows for it (a resend to a restarted spoke): re-linked if it still exists, instead of creating another.
669
+ */
670
+ | { readonly op: "conversation"; readonly sessionID: string; readonly on: boolean; readonly front?: boolean; readonly frontID?: string }
646
671
  | { readonly op: "models"; readonly sessionID: string }
647
672
  | { readonly op: "modelPrefs"; readonly sessionID: string }
648
673
  | { readonly op: "setModelPrefs"; readonly sessionID: string; readonly voice?: ModelSelection | null; readonly worker?: ModelSelection | null }
@@ -650,6 +675,16 @@ export type ServerRequest =
650
675
  | { readonly op: "timelinePart"; readonly sessionID: string; readonly messageID: string; readonly partID: string }
651
676
  /** One image attached to a user message (its position in the message's files). */
652
677
  | { readonly op: "timelineFile"; readonly sessionID: string; readonly messageID: string; readonly index: number }
678
+ /**
679
+ * (S4 M2, feature "timelineMedia") One image of a timeline row, by its ref (media.ts): a user attachment, a tool
680
+ * content item or an image path in an assistant message. `offset`: the chunk to answer (default 0).
681
+ */
682
+ | { readonly op: "timelineMedia"; readonly sessionID: string; readonly messageID: string; readonly ref: string; readonly offset?: number }
683
+ /**
684
+ * (Session media §3 4b, feature "previewBundle") A local HTML page named in an assistant message's text and the
685
+ * files it refers to: the manifest first, then chunks of the files not inlined (preview-bundle.ts).
686
+ */
687
+ | PreviewBundleRequest
653
688
  /** Enable or disable voice for one session (adds/removes it from the machine config's session list). */
654
689
  | { readonly op: "expose"; readonly sessionID: string; readonly on: boolean }
655
690
  /**
@@ -708,6 +743,11 @@ export type ServerRequest =
708
743
  | { readonly op: "revertRestore"; readonly sessionID: string }
709
744
  /** Make a staged revert permanent: the hidden messages are removed (feature "revert"). */
710
745
  | { readonly op: "revertCommit"; readonly sessionID: string }
746
+ /**
747
+ * (feature "voiceFront") Delete a voice-front session no conversation uses (the hub found it stale). The spoke
748
+ * deletes only a session on the voice-front agent that it has not linked; anything else is left alone.
749
+ */
750
+ | { readonly op: "removeFront"; readonly sessionID: string }
711
751
  /** Forget the session's last run failure: the user dismissed it (feature "dismissError"). */
712
752
  | { readonly op: "dismissError"; readonly sessionID: string }
713
753
 
@@ -723,6 +763,8 @@ export function featureForOp(op: ServerRequest["op"]): SpokeFeature {
723
763
  case "revertRestore":
724
764
  case "revertCommit":
725
765
  return "revert"
766
+ case "removeFront":
767
+ return "voiceFront"
726
768
  default:
727
769
  return op
728
770
  }
@@ -824,7 +866,8 @@ export interface ResultFor {
824
866
  stop: { readonly stopped: readonly string[] }
825
867
  summary: SessionSummary
826
868
  diff: { readonly patch?: FilePatch }
827
- conversation: { readonly agent: string }
869
+ /** `front`: the voice-front session now in front of this one (on with `front`; absent when the spoke fell back). */
870
+ conversation: { readonly agent: string; readonly front?: { readonly sessionID: string } }
828
871
  models: { readonly models: readonly AvailableModel[] }
829
872
  modelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
830
873
  setModelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
@@ -832,6 +875,8 @@ export interface ResultFor {
832
875
  timelinePart: { readonly detail: unknown }
833
876
  /** No `file` when the message has no image at that index; `tooLarge` when its bytes would not fit in a frame. */
834
877
  timelineFile: { readonly file?: PromptImage; readonly tooLarge?: boolean }
878
+ timelineMedia: TimelineMediaResult
879
+ previewBundle: PreviewBundleResult
835
880
  expose: { readonly exposed: boolean }
836
881
  create: { readonly sessionID: string; readonly openedIn?: "herdr" }
837
882
  /** `applied`: sessions whose token the spoke stored; `skipped`: the others, with the reason. */
@@ -850,6 +895,7 @@ export interface ResultFor {
850
895
  revertCommit: Record<string, never>
851
896
  /** `dismissed`: the spoke had a failure of the session to forget. */
852
897
  dismissError: { readonly dismissed: boolean }
898
+ removeFront: { readonly removed: boolean }
853
899
  }
854
900
 
855
901
  // ---------- both directions ----------
@@ -1182,17 +1228,23 @@ function isServerRequest(v: unknown): v is ServerRequest {
1182
1228
  return nonEmpty(v.sessionID) && promptBody(v)
1183
1229
  case "timelineFile":
1184
1230
  return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && Number.isInteger(v.index) && Number(v.index) >= 0 && Number(v.index) < 1000
1231
+ case "timelineMedia":
1232
+ return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && str(v.ref) && parseMediaRef(v.ref) !== undefined && (v.offset === undefined || isMediaOffset(v.offset))
1233
+ case "previewBundle":
1234
+ return isPreviewBundleRequest(v)
1185
1235
  case "diff":
1186
1236
  return nonEmpty(v.sessionID) && nonEmpty(v.file) && v.file.length <= 4096
1187
1237
  case "rename":
1188
1238
  return nonEmpty(v.sessionID) && str(v.title) && v.title.length <= 1000
1189
1239
  case "conversation":
1240
+ return nonEmpty(v.sessionID) && typeof v.on === "boolean" && (v.frontID === undefined || (nonEmpty(v.frontID) && v.frontID.length <= 300))
1190
1241
  case "expose":
1191
1242
  return nonEmpty(v.sessionID) && typeof v.on === "boolean"
1192
1243
  case "create":
1193
1244
  return (
1194
1245
  nonEmpty(v.directory) &&
1195
- v.directory.startsWith("/") &&
1246
+ // POSIX as before; a Windows machine's directory in canonical form (C:/…, Phase 3).
1247
+ (v.directory.startsWith("/") || isWindowsAbsolutePath(v.directory)) &&
1196
1248
  v.directory.length <= 4096 &&
1197
1249
  (v.title === undefined || (str(v.title) && v.title.length <= 200)) &&
1198
1250
  (v.herdrWorkspace === undefined || (nonEmpty(v.herdrWorkspace) && v.herdrWorkspace.length <= 100)) &&
@@ -1218,6 +1270,7 @@ function isServerRequest(v: unknown): v is ServerRequest {
1218
1270
  case "revertRestore":
1219
1271
  case "revertCommit":
1220
1272
  case "dismissError":
1273
+ case "removeFront":
1221
1274
  return nonEmpty(v.sessionID)
1222
1275
  default:
1223
1276
  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. */
@@ -144,17 +149,52 @@ export const LABELS_MAX = 32
144
149
  export const LABEL_VALUE_MAX = 256
145
150
  const LABEL_KEY = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,62}$/
146
151
 
147
- /** An absolute path of at most PATH_MAX characters without `.`/`..` segments, empty segments or unsafe characters. */
152
+ /**
153
+ * An absolute path of at most PATH_MAX characters without `.`/`..` segments, empty segments or unsafe characters:
154
+ * POSIX `/a/b` (a trailing slash allowed, as before), or a canonical Windows path `C:/a/b` (upper-case drive, `/`
155
+ * separators, the root `C:/`; no backslash). UNC (`//server`, `\\server`) and device paths (`//?/`, `\\?\`) are never
156
+ * absolute here. The Windows form is the bootstrap contracts' rule (packages/cli/src/bootstrap/contracts/validate.ts
157
+ * isAbsolutePath); native forms are canonicalized at the node's edge (canonicalNativePath).
158
+ */
148
159
  export function isAbsolutePath(v: unknown): v is string {
149
- if (typeof v !== "string" || !v.startsWith("/") || v.length > PATH_MAX || UNSAFE_CHARS.test(v)) return false
150
- if (v === "/") return true
151
- return v
160
+ if (typeof v !== "string" || v.length > PATH_MAX || UNSAFE_CHARS.test(v)) return false
161
+ if (WINDOWS_ROOT.test(v)) return !v.includes("\\") && normalSegments(v.slice(2))
162
+ if (!v.startsWith("/")) return false
163
+ return normalSegments(v)
164
+ }
165
+
166
+ /** `rest` (starting with "/") is "/" or "/"-separated segments, none empty, "." or ".." (one trailing "/" allowed). */
167
+ function normalSegments(rest: string): boolean {
168
+ if (!rest.startsWith("/")) return false
169
+ if (rest === "/") return true
170
+ return rest
152
171
  .slice(1)
153
172
  .replace(/\/$/, "")
154
173
  .split("/")
155
174
  .every((part) => part !== "" && part !== "." && part !== "..")
156
175
  }
157
176
 
177
+ const WINDOWS_ROOT = /^[A-Z]:(?=\/)/
178
+
179
+ /** Whether `v` is a canonical Windows absolute path (`C:/…`). */
180
+ export const isWindowsAbsolutePath = (v: unknown): v is string => typeof v === "string" && WINDOWS_ROOT.test(v) && isAbsolutePath(v)
181
+
182
+ /**
183
+ * The canonical form of a path a host reported in its own form (node.json, realpath, git output), or undefined when
184
+ * it has none: Windows drive paths get an upper-case drive and `/` separators (a `\\?\` or `\??\` final-path
185
+ * prefix is dropped); repeated and trailing slashes are dropped. UNC, device and relative paths have no canonical
186
+ * form. Mirrors packages/cli/src/bootstrap/discovery/util.ts normalizeAbs.
187
+ */
188
+ export function canonicalNativePath(value: unknown): string | undefined {
189
+ if (typeof value !== "string") return undefined
190
+ let p = value.replace(/^(\\\\\?\\|\\\?\?\\)(?=[A-Za-z]:\\)/, "")
191
+ if (/^[A-Za-z]:[\\/]/.test(p)) p = p[0]!.toUpperCase() + p.slice(1).replace(/\\/g, "/")
192
+ else if (!p.startsWith("/") || p.startsWith("//")) return undefined
193
+ p = p.replace(/\/{2,}/g, "/")
194
+ if (p.endsWith("/") && p !== "/" && !/^[A-Z]:\/$/.test(p)) p = p.slice(0, -1)
195
+ return isAbsolutePath(p) ? p : undefined
196
+ }
197
+
158
198
  /** A branch name `git check-ref-format --branch` accepts, from a conservative character set, never starting with `-`. */
159
199
  export function isRefName(v: unknown): v is string {
160
200
  if (typeof v !== "string" || v.length === 0 || v.length > REF_MAX) return false