@huynle/hub 0.8.1 → 0.8.3

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 (97) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +32 -1
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +116 -53
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  8. package/packages/cli/src/artifacts-mcp.ts +8 -7
  9. package/packages/cli/src/mcp.ts +4 -3
  10. package/packages/cli/src/profiles-mcp.ts +3 -3
  11. package/packages/cli/src/team-intake-mcp.ts +8 -7
  12. package/packages/cli/src/teams-mcp.ts +2 -1
  13. package/packages/cli/src/tool-args.ts +6 -0
  14. package/packages/cli/src/watchlist.ts +5 -4
  15. package/packages/codex/package.json +1 -1
  16. package/packages/node/package.json +1 -1
  17. package/packages/preset/package.json +1 -1
  18. package/packages/protocol/package.json +1 -1
  19. package/packages/protocol/src/concierge.ts +4 -1
  20. package/packages/protocol/src/embedded-media.ts +59 -0
  21. package/packages/protocol/src/index.ts +92 -7
  22. package/packages/protocol/src/voice-recording.ts +107 -0
  23. package/packages/protocol/src/wire.ts +1 -0
  24. package/packages/runtime-openshell/package.json +1 -1
  25. package/packages/runtime-sdk/package.json +1 -1
  26. package/packages/sdk/package.json +1 -1
  27. package/packages/sdk/src/client.ts +3 -1
  28. package/packages/spoke-sdk/package.json +1 -1
  29. package/packages/spoke-sdk/src/connection.ts +13 -1
  30. package/src/central/agent/acting-routes.ts +3 -2
  31. package/src/central/agent/acting.ts +67 -12
  32. package/src/central/agent/delivery.ts +12 -3
  33. package/src/central/agent/escalation.ts +30 -9
  34. package/src/central/agent/launch-spec.ts +38 -19
  35. package/src/central/agent/routes.ts +14 -1
  36. package/src/central/artifacts/blob-store.ts +6 -1
  37. package/src/central/artifacts/blob-support.ts +3 -1
  38. package/src/central/concierge/board-render.ts +34 -11
  39. package/src/central/concierge/context.ts +3 -1
  40. package/src/central/concierge/index.ts +1 -1
  41. package/src/central/concierge/notifier.ts +2 -1
  42. package/src/central/concierge/prompt.ts +7 -4
  43. package/src/central/config.ts +4 -0
  44. package/src/central/csp.ts +55 -0
  45. package/src/central/http.ts +134 -29
  46. package/src/central/profiles/content.ts +16 -0
  47. package/src/central/recording/clock.ts +76 -0
  48. package/src/central/recording/concierge-taps.ts +104 -0
  49. package/src/central/recording/export.ts +215 -0
  50. package/src/central/recording/index.ts +658 -0
  51. package/src/central/recording/ingest.ts +209 -0
  52. package/src/central/recording/retention.ts +71 -0
  53. package/src/central/recording/routes.ts +281 -0
  54. package/src/central/recording/schema.ts +232 -0
  55. package/src/central/recording/settings.ts +35 -0
  56. package/src/central/recording/store.ts +316 -0
  57. package/src/central/recording/taps.ts +105 -0
  58. package/src/central/recording/zip.ts +138 -0
  59. package/src/central/registry.ts +91 -6
  60. package/src/central/revert-windows.ts +66 -0
  61. package/src/central/spoke-ws.ts +8 -3
  62. package/src/central/teams/staffing.ts +2 -2
  63. package/src/index.ts +26 -14
  64. package/src/spoke/deliver.ts +6 -0
  65. package/src/spoke/fork-revert.ts +94 -9
  66. package/src/spoke/hub-tool.ts +2 -2
  67. package/src/spoke/locations.ts +80 -0
  68. package/src/spoke/profile-apply.ts +24 -14
  69. package/src/spoke/session-context.ts +96 -6
  70. package/src/spoke/spoke.ts +119 -36
  71. package/src/spoke/timeline.ts +30 -3
  72. package/src/spoke/tracker.ts +23 -2
  73. package/src/status.ts +33 -6
  74. package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
  75. package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
  76. package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
  77. package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
  78. package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
  79. package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-C-5lYeeM.js} +1 -1
  80. package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
  81. package/web/dist/assets/index-6mdQ7eUY.js +34 -0
  82. package/web/dist/assets/index-C5OmwA1y.css +1 -0
  83. package/web/dist/assets/{live-BDq0AABg.js → live-CDWcyavI.js} +1 -1
  84. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  85. package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
  86. package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
  87. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  88. package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
  89. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  90. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  91. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  92. package/web/dist/index.html +5 -16
  93. package/web/dist/theme-boot.js +12 -0
  94. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +0 -1
  95. package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
  96. package/web/dist/assets/index-BabrNDB3.css +0 -1
  97. package/web/dist/assets/index-lIOfSYck.js +0 -34
@@ -5,6 +5,7 @@
5
5
  // sessions submit under team policy and, in the team's entry role, report status.
6
6
  import { sanitizeText } from "../../sdk/src/envelope.ts"
7
7
  import { reportIntakeStatus, TEAM_INTAKE_STATUS_INPUTS, type TeamIntake, type TeamIntakeApi, type TeamIntakePriority, type TeamIntakeStatus, type TeamIntakeStatusInput } from "../../sdk/src/team-intake.ts"
8
+ import { ToolArgError } from "./tool-args"
8
9
 
9
10
  type Args = Record<string, unknown>
10
11
 
@@ -29,24 +30,24 @@ const UPDATES: readonly TeamIntakeStatusInput[] = TEAM_INTAKE_STATUS_INPUTS
29
30
 
30
31
  const need = (args: Args, key: string): string => {
31
32
  const value = args[key]
32
- if (typeof value !== "string" || !value.trim()) throw new Error(`"${key}" is required (a non-empty string)`)
33
+ if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
33
34
  return value
34
35
  }
35
36
  const optional = (args: Args, key: string): string | undefined => {
36
37
  const value = args[key]
37
38
  if (value === undefined || value === null) return undefined
38
- if (typeof value !== "string") throw new Error(`"${key}" must be a string`)
39
+ if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
39
40
  return value.trim() ? value : undefined
40
41
  }
41
42
  const strings = (args: Args, key: string): string[] | undefined => {
42
43
  const value = args[key]
43
44
  if (value === undefined || value === null) return undefined
44
- if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new Error(`"${key}" must be a list of strings`)
45
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new ToolArgError(`"${key}" must be a list of strings`)
45
46
  return value
46
47
  }
47
48
  const oneOf = <T extends string>(args: Args, key: string, allowed: readonly T[]): T | undefined => {
48
49
  const value = optional(args, key)
49
- if (value !== undefined && !allowed.includes(value as T)) throw new Error(`"${key}" must be one of ${allowed.join(", ")}`)
50
+ if (value !== undefined && !allowed.includes(value as T)) throw new ToolArgError(`"${key}" must be one of ${allowed.join(", ")}`)
50
51
  return value as T | undefined
51
52
  }
52
53
 
@@ -55,7 +56,7 @@ function dueOf(args: Args): number | undefined {
55
56
  const value = args.due
56
57
  if (value === undefined || value === null || value === "") return undefined
57
58
  const ms = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN
58
- if (!Number.isSafeInteger(ms) || ms <= 0) throw new Error(`"due" must be an ISO date/time or epoch milliseconds`)
59
+ if (!Number.isSafeInteger(ms) || ms <= 0) throw new ToolArgError(`"due" must be an ISO date/time or epoch milliseconds`)
59
60
  return ms
60
61
  }
61
62
 
@@ -146,10 +147,10 @@ export const teamIntakeTools: readonly TeamIntakeTool[] = [
146
147
  idempotent: true,
147
148
  run: async (hub, args, signal) => {
148
149
  const status = oneOf(args, "status", UPDATES)
149
- if (!status) throw new Error(`"status" is required: one of ${UPDATES.join(", ")}`)
150
+ if (!status) throw new ToolArgError(`"status" is required: one of ${UPDATES.join(", ")}`)
150
151
  const expectedRevision = args.expectedRevision
151
152
  if (expectedRevision !== undefined && expectedRevision !== null && (typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0))
152
- throw new Error(`"expectedRevision" must be the revision you read (or left out)`)
153
+ throw new ToolArgError(`"expectedRevision" must be the revision you read (or left out)`)
153
154
  const reason = optional(args, "reason")
154
155
  const { intake, assignment } = await reportIntakeStatus(hub.teams, need(args, "team"), need(args, "intake"), { status, ...(typeof expectedRevision === "number" ? { expectedRevision } : {}), ...(reason ? { reason } : {}) }, { signal })
155
156
  return `${intake.id} is ${intake.status}${intake.progress ? ` (${intake.progress})` : ""}, revision ${intake.revision}${assignment ? `; assignment ${assignment.id} ${assignment.status}` : ""}`
@@ -10,6 +10,7 @@
10
10
  // the default destructiveHint. Changes of existing records send expectedRevision, so repeating one is idempotent.
11
11
  // Decision answers and grant administration stay console-only: there is no tool for them here.
12
12
  import { sanitizeText } from "../../sdk/src/envelope.ts"
13
+ import { ToolArgError } from "./tool-args"
13
14
  import type { LaunchSpec } from "../../sdk/src/types.ts"
14
15
  import type { Team, TeamDraft, TeamEdge, TeamPatch, TeamRole, TeamRoster, Teams, TeamSpec } from "../../sdk/src/teams.ts"
15
16
 
@@ -116,7 +117,7 @@ const LAUNCH = {
116
117
  } as const
117
118
  const SCOPES = "App tokens need scope teams:read (reads) or teams:manage (changes); seats, status and staffing also need sessions:read. Session tokens read only teams they are seated in and never change definitions. Team roles confer no authority."
118
119
 
119
- class ArgError extends Error {}
120
+ const ArgError = ToolArgError
120
121
 
121
122
  const need = (args: Args, key: string): string => {
122
123
  const value = args[key]
@@ -0,0 +1,6 @@
1
+ // (R-04) The one error class for a bad MCP tool argument. Every tool family (mcp.ts, teams-mcp.ts,
2
+ // team-intake-mcp.ts, artifacts-mcp.ts, artifact-jobs-mcp.ts, profiles-mcp.ts, watchlist.ts) throws it, and mcp.ts
3
+ // answers it as `invalid: …` (an isError result), never `<tool> failed: …`.
4
+
5
+ /** A bad tool argument: mcp.ts answers it as `invalid: <message>`. */
6
+ export class ToolArgError extends Error {}
@@ -4,6 +4,7 @@
4
4
  // act token (show and the verbs). Other session tokens get not found (exit 3).
5
5
  import { sanitizeLabel } from "../../sdk/src/envelope.ts"
6
6
  import type { Concierge, WatchlistChanged, WatchlistConfig, WatchlistUpdate, WatchlistView } from "../../sdk/src/index.ts"
7
+ import { ToolArgError } from "./tool-args"
7
8
 
8
9
  type Args = Record<string, unknown>
9
10
 
@@ -52,13 +53,13 @@ const STRINGS = { type: "array", items: { type: "string" } } as const
52
53
 
53
54
  const need = (args: Args, key: string): string => {
54
55
  const value = args[key]
55
- if (typeof value !== "string" || !value.trim()) throw new Error(`"${key}" is required (a non-empty string)`)
56
+ if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
56
57
  return value
57
58
  }
58
59
  const flag = (args: Args, key: string, fallback: boolean): boolean => {
59
60
  const value = args[key]
60
61
  if (value === undefined || value === null) return fallback
61
- if (typeof value !== "boolean") throw new Error(`"${key}" must be true or false`)
62
+ if (typeof value !== "boolean") throw new ToolArgError(`"${key}" must be true or false`)
62
63
  return value
63
64
  }
64
65
 
@@ -92,9 +93,9 @@ export const watchlistTools: readonly WatchlistTool[] = [
92
93
  organizes: true,
93
94
  run: async (hub, args, signal) => {
94
95
  const expected = args.expectedRevision
95
- if (typeof expected !== "number" || !Number.isSafeInteger(expected) || expected < 0) throw new Error(`"expectedRevision" is required (the revision you read)`)
96
+ if (typeof expected !== "number" || !Number.isSafeInteger(expected) || expected < 0) throw new ToolArgError(`"expectedRevision" is required (the revision you read)`)
96
97
  const unknown = Object.keys(args).filter((k) => k !== "expectedRevision" && !(UPDATE_FIELDS as readonly string[]).includes(k))
97
- if (unknown.length) throw new Error(`Unknown argument${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${safe(k).slice(0, 40)}"`).join(", ")}: use ${UPDATE_FIELDS.join(", ")} and expectedRevision`)
98
+ if (unknown.length) throw new ToolArgError(`Unknown argument${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${safe(k).slice(0, 40)}"`).join(", ")}: use ${UPDATE_FIELDS.join(", ")} and expectedRevision`)
98
99
  const patch: Record<string, unknown> = {}
99
100
  for (const field of UPDATE_FIELDS) if (args[field] !== undefined && args[field] !== null) patch[field] = args[field]
100
101
  const r = await hub.concierge.watchlist.update(patch as WatchlistUpdate, expected, { signal })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-node",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-preset",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-protocol",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "description": "Hub ↔ spoke wire protocol (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -98,7 +98,10 @@ export const CONCIERGE_LIMITS = {
98
98
 
99
99
  // ---- Session context (CON-04, spoke feature "sessionContext") ----
100
100
 
101
- /** The keys of the parts a session context carries, appended to the session's system prompt in this order. */
101
+ /**
102
+ * The keys of the parts a session context carries. The spoke appends instructions then notes to the session's system
103
+ * prompt (stable between turns) and sends the board after the conversation (it changes: V-01, the prompt cache).
104
+ */
102
105
  export const SESSION_CONTEXT_KEYS = ["instructions", "board", "notes"] as const
103
106
  export type SessionContextKey = (typeof SESSION_CONTEXT_KEYS)[number]
104
107
 
@@ -0,0 +1,59 @@
1
+ /**
2
+ * (M1) Embedded media in tool-call details. Harnesses keep tool screenshots inside the tool output: OpenCode 2.2.3 as
3
+ * `data:` URLs (`read` of a PNG, MCP image results), MCP and Claude Code as raw base64 image parts. A tool-call
4
+ * detail shows none of these bytes: each is replaced by a short placeholder naming its type and approximate size,
5
+ * e.g. "[image/png, 1.2 MB — not shown]", so the rest of the detail (input, text output) fits the detail limit and
6
+ * no image bytes travel to the console. Inline media display is a separate, later feature.
7
+ */
8
+
9
+ /** A base64 `data:` URL: optional mime, optional parameters, then the payload. */
10
+ const DATA_URL = /data:([\w.+-]+\/[\w.+-]+)?((?:;[\w.+-]+=[^;,\s"'<>]*)*);base64,([A-Za-z0-9+/_-]+={0,2})/gi
11
+ const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/
12
+ const MIME_KEYS = ["mime", "mimeType", "media_type", "mediaType"] as const
13
+
14
+ /** The approximate decoded size of `length` base64 characters, for people ("10 B", "900 KB", "1.2 MB"). */
15
+ function sizeOf(base64: string): string {
16
+ const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0
17
+ const bytes = Math.max(0, Math.floor((base64.length * 3) / 4) - padding)
18
+ if (bytes < 1024) return `${bytes} B`
19
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
20
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
21
+ }
22
+
23
+ /** The placeholder shown instead of embedded bytes. */
24
+ export function mediaPlaceholder(mime: string | undefined, base64: string): string {
25
+ return `[${mime || "data"}, ${sizeOf(base64)} — not shown]`
26
+ }
27
+
28
+ function redactText(text: string): string {
29
+ if (!text.includes("base64,")) return text
30
+ return text.replace(DATA_URL, (_match, mime: string | undefined, _params: string, data: string) => mediaPlaceholder(mime?.toLowerCase(), data))
31
+ }
32
+
33
+ /** The mime of an object that carries raw base64 image bytes in `data`, else undefined. */
34
+ function rawImageMime(object: Record<string, unknown>): string | undefined {
35
+ if (typeof object.data !== "string" || !BASE64.test(object.data)) return undefined
36
+ const mime = MIME_KEYS.map((key) => object[key]).find((value): value is string => typeof value === "string")
37
+ if (mime?.toLowerCase().startsWith("image/")) return mime.toLowerCase()
38
+ if (object.type === "image" || object.type === "base64") return mime || "image"
39
+ return undefined
40
+ }
41
+
42
+ /**
43
+ * A copy of `value` (JSON-like) with every embedded base64 payload replaced by a placeholder: `data:…;base64,…` URLs
44
+ * in any string, and raw base64 `data` of image parts. Everything else is kept as it is; `value` is not changed.
45
+ */
46
+ export function redactEmbeddedMedia<T>(value: T): T {
47
+ return redact(value) as T
48
+ }
49
+
50
+ function redact(value: unknown): unknown {
51
+ if (typeof value === "string") return redactText(value)
52
+ if (Array.isArray(value)) return value.map(redact)
53
+ if (!value || typeof value !== "object") return value
54
+ const object = value as Record<string, unknown>
55
+ const mime = rawImageMime(object)
56
+ const out: Record<string, unknown> = {}
57
+ for (const [key, item] of Object.entries(object)) out[key] = key === "data" && mime ? mediaPlaceholder(mime, item as string) : redact(item)
58
+ return out
59
+ }
@@ -31,8 +31,10 @@ export * from "./hub-workspace.ts"
31
31
  export * from "./concierge.ts"
32
32
  export * from "./profiles.ts"
33
33
  export * from "./artifacts.ts"
34
+ export * from "./embedded-media.ts"
34
35
  export * from "./teams.ts"
35
36
  export * from "./graph.ts"
37
+ export * from "./voice-recording.ts"
36
38
  export { HUB_URL_MAX_CHARS, isHubURL, MAX_FRAME, MAX_RETRY_AFTER_MS }
37
39
 
38
40
  export const PROTOCOL_VERSION = 1
@@ -93,7 +95,8 @@ export interface AvailableModel {
93
95
  * A timeline page. Forward mode (`cursor`): oldest rows first, `cursor` for the next newer page. Newest-first
94
96
  * mode (`order: "desc"` or `before`): the newest rows (before `before`) in chronological order, `older` for the
95
97
  * page before them. Both cursors are opaque. Items are the spoke's rows (src/spoke/timeline.ts TimelineItem); newer
96
- * spokes add optional `pos` (ordering position), `callID`, `childSessionID`, `title` and `ordinal`.
98
+ * spokes add optional `pos` (ordering position), `callID`, `childSessionID`, `title`, `ordinal` and, on error rows,
99
+ * `declined` (a denied permission stopped the turn).
97
100
  */
98
101
  export interface TimelinePage {
99
102
  readonly items: readonly unknown[]
@@ -154,6 +157,14 @@ export interface SessionInfo {
154
157
  * Older hubs ignore it.
155
158
  */
156
159
  readonly revert?: SessionRevertInfo
160
+ /**
161
+ * The latest revert committed in this session as this process saw it (feature "revert"): the history from
162
+ * `messageID` on was removed. Hub message rows (agent messages the session sent or got) from `from` (the reverted
163
+ * message's time, when known) until `until` (when the revert was staged; later ones were held for after it) belonged
164
+ * to that history: the hub keeps the window and leaves them out. `at` (commit time) changes with every commit.
165
+ * Older hubs ignore it.
166
+ */
167
+ readonly revertCommitted?: RevertCommitted
157
168
  /**
158
169
  * (CON-04, feature "sessionContext") Sessions the hub gave a locked context (the Concierge): the agent the spoke
159
170
  * last saw the session run on, and the context revision it holds now (absent: none, e.g. after a restart). The hub
@@ -192,6 +203,21 @@ export interface SessionRevertInfo {
192
203
  /** Restored files (at most REVERT_FILES_MAX). */
193
204
  readonly files: readonly RevertFile[]
194
205
  readonly filesTotal: number
206
+ /** When this process saw it staged (epoch ms; optional, older spokes send none). */
207
+ readonly stagedAt?: number
208
+ }
209
+
210
+ /** A committed revert's removed window (SessionInfo.revertCommitted). */
211
+ export interface RevertCommitted {
212
+ readonly messageID: string
213
+ readonly from?: number
214
+ readonly until: number
215
+ readonly at: number
216
+ }
217
+
218
+ /** A committed revert's window as a spoke reports it. */
219
+ export function isRevertCommitted(v: unknown): v is RevertCommitted {
220
+ return isObj(v) && nonEmpty(v.messageID) && v.messageID.length <= 300 && (v.from === undefined || num(v.from)) && num(v.until) && num(v.at)
195
221
  }
196
222
 
197
223
  /** An attachment of a user message, by its position in the message's files (fetched through timelineFile). */
@@ -421,8 +447,10 @@ export const IMPLIED_OPENCODE_FEATURES = [
421
447
  * a revert of a session from one of its user messages (revertPreview, revert, revertRestore, revertCommit ops) and
422
448
  * reports a staged one as SessionInfo.revert. Announced only when the spoke's harness can (OpenCode: a process that
423
449
  * reaches its own server).
450
+ * "dismissError": it forgets a session's last run failure when the user dismisses it (dismissError op), so a
451
+ * reconnect does not seed it again.
424
452
  */
425
- const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert"] as const
453
+ const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert", "dismissError"] as const
426
454
 
427
455
  /**
428
456
  * The core ops. An OpenCode spoke serves them all without announcing them; a spoke of another harness announces
@@ -675,6 +703,8 @@ export type ServerRequest =
675
703
  | { readonly op: "revertRestore"; readonly sessionID: string }
676
704
  /** Make a staged revert permanent: the hidden messages are removed (feature "revert"). */
677
705
  | { readonly op: "revertCommit"; readonly sessionID: string }
706
+ /** Forget the session's last run failure: the user dismissed it (feature "dismissError"). */
707
+ | { readonly op: "dismissError"; readonly sessionID: string }
678
708
 
679
709
  /**
680
710
  * The feature a spoke must serve (spokeFeatures) for the hub to send `op`: the op itself for a core op (CORE_OPS,
@@ -813,6 +843,8 @@ export interface ResultFor {
813
843
  revert: { readonly revert: SessionRevertInfo; readonly text: string; readonly attachments: readonly MessageAttachment[] }
814
844
  revertRestore: Record<string, never>
815
845
  revertCommit: Record<string, never>
846
+ /** `dismissed`: the spoke had a failure of the session to forget. */
847
+ dismissError: { readonly dismissed: boolean }
816
848
  }
817
849
 
818
850
  // ---------- both directions ----------
@@ -829,8 +861,29 @@ export interface Res {
829
861
  readonly ok: boolean
830
862
  readonly result?: unknown
831
863
  readonly error?: string
864
+ /**
865
+ * A refusal's kind, so the hub need not match `error` texts (optional and additive: older hubs ignore it, older
866
+ * spokes send none). See RES_ERROR_CODES; a peer may send codes the other does not know (they are ignored).
867
+ */
868
+ readonly code?: string
832
869
  }
833
870
 
871
+ /**
872
+ * Refusal codes a spoke puts on `{ok: false}` answers: "busy" (the session runs, e.g. a revert while a turn is
873
+ * running; the hub answers 409 busy) and "not_found" (the session, or the message an op names, is not there; 404).
874
+ */
875
+ export const RES_ERROR_CODES = ["busy", "not_found"] as const
876
+ export type ResErrorCode = (typeof RES_ERROR_CODES)[number]
877
+ /** Longest `code` a res frame carries. */
878
+ export const RES_CODE_MAX = 40
879
+
880
+ /** The spoke's refusal of a revert while the session runs (code "busy"). */
881
+ export const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
882
+ /** The spoke's refusal of a restore or commit while the session runs (code "busy"). */
883
+ export const REVERT_END_BUSY = "The session is working; wait for it to finish (or stop it) before restoring or committing the revert"
884
+ /** A fork or revert naming a message that is not one of the session's user messages (code "not_found"). */
885
+ export const NOT_A_USER_MESSAGE = "Not a user message of this session"
886
+
834
887
  export interface Bye {
835
888
  readonly t: "bye"
836
889
  readonly reason: string
@@ -915,6 +968,7 @@ function isSession(v: unknown): v is SessionInfo {
915
968
  (v.open === undefined || typeof v.open === "boolean") &&
916
969
  (v.terminal === undefined || isTerminal(v.terminal)) &&
917
970
  (v.revert === undefined || isSessionRevert(v.revert)) &&
971
+ (v.revertCommitted === undefined || isRevertCommitted(v.revertCommitted)) &&
918
972
  (v.agent === undefined || (nonEmpty(v.agent) && v.agent.length <= AGENT_NAME_MAX)) &&
919
973
  (v.contextRevision === undefined || (Number.isSafeInteger(v.contextRevision) && (v.contextRevision as number) >= 0)) &&
920
974
  (v.locked === undefined || v.locked === true)
@@ -948,7 +1002,8 @@ export function isSessionRevert(v: unknown): v is SessionRevertInfo {
948
1002
  v.files.length <= REVERT_FILES_MAX &&
949
1003
  v.files.every(isRevertFile) &&
950
1004
  count(v.filesTotal) &&
951
- (v.filesTotal as number) >= v.files.length
1005
+ (v.filesTotal as number) >= v.files.length &&
1006
+ (v.stagedAt === undefined || num(v.stagedAt))
952
1007
  )
953
1008
  }
954
1009
 
@@ -1157,6 +1212,7 @@ function isServerRequest(v: unknown): v is ServerRequest {
1157
1212
  return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && typeof v.files === "boolean"
1158
1213
  case "revertRestore":
1159
1214
  case "revertCommit":
1215
+ case "dismissError":
1160
1216
  return nonEmpty(v.sessionID)
1161
1217
  default:
1162
1218
  return false
@@ -1187,8 +1243,11 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
1187
1243
  (m.launchID === undefined || (str(m.launchID) && ID.test(m.launchID)))
1188
1244
  ? (m as unknown as Hello)
1189
1245
  : undefined
1190
- case "sessions":
1191
- return Array.isArray(m.sessions) && m.sessions.every(isSession) ? (m as unknown as Sessions) : undefined
1246
+ case "sessions": {
1247
+ if (!Array.isArray(m.sessions)) return undefined
1248
+ const sessions = m.sessions.map(lenientSession)
1249
+ return sessions.every(isSession) ? ({ ...m, sessions } as unknown as Sessions) : undefined
1250
+ }
1192
1251
  case "terminals":
1193
1252
  return m.herdr === undefined || isHerdrInfo(m.herdr) ? (m as unknown as Terminals) : undefined
1194
1253
  case "event":
@@ -1198,7 +1257,7 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
1198
1257
  case "req":
1199
1258
  return nonEmpty(m.id) && isSpokeRequest(m.req) ? (m as unknown as Req<SpokeRequest>) : undefined
1200
1259
  case "res":
1201
- return isRes(m) ? (m as unknown as Res) : undefined
1260
+ return isRes(m) ? lenientRes(m) : undefined
1202
1261
  case "bye":
1203
1262
  return isBye(m) ? (m as unknown as Bye) : undefined
1204
1263
  default:
@@ -1206,6 +1265,32 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
1206
1265
  }
1207
1266
  }
1208
1267
 
1268
+ /** A res frame without an invalid `code` (over-long, empty or not a string): ignored, as for older peers. */
1269
+ function lenientRes(m: Record<string, unknown>): Res {
1270
+ if (m.code === undefined || (nonEmpty(m.code) && m.code.length <= RES_CODE_MAX)) return m as unknown as Res
1271
+ const { code: _dropped, ...rest } = m
1272
+ return rest as unknown as Res
1273
+ }
1274
+
1275
+ /**
1276
+ * A reported session without malformed additive revert fields (`revertCommitted`, `revert.stagedAt`): they are
1277
+ * dropped (older hubs ignore them too), never failing the whole sessions frame.
1278
+ */
1279
+ function lenientSession(v: unknown): unknown {
1280
+ if (!isObj(v)) return v
1281
+ let out: Record<string, unknown> = v
1282
+ if (out.revertCommitted !== undefined && !isRevertCommitted(out.revertCommitted)) {
1283
+ const { revertCommitted: _dropped, ...rest } = out
1284
+ out = rest
1285
+ }
1286
+ const revert = out.revert
1287
+ if (isObj(revert) && revert.stagedAt !== undefined && !num(revert.stagedAt)) {
1288
+ const { stagedAt: _dropped, ...rest } = revert
1289
+ out = { ...out, revert: rest }
1290
+ }
1291
+ return out
1292
+ }
1293
+
1209
1294
  /** Parses a frame received by the spoke. Returns undefined for anything invalid. */
1210
1295
  export function parseServerMessage(raw: string | Buffer): ServerMessage | undefined {
1211
1296
  const m = parse(raw)
@@ -1216,7 +1301,7 @@ export function parseServerMessage(raw: string | Buffer): ServerMessage | undefi
1216
1301
  case "req":
1217
1302
  return nonEmpty(m.id) && isServerRequest(m.req) ? (m as unknown as Req<ServerRequest>) : undefined
1218
1303
  case "res":
1219
- return isRes(m) ? (m as unknown as Res) : undefined
1304
+ return isRes(m) ? lenientRes(m) : undefined
1220
1305
  case "bye":
1221
1306
  return isBye(m) ? (m as unknown as Bye) : undefined
1222
1307
  default:
@@ -0,0 +1,107 @@
1
+ export const RECORDING_FORMAT = "hub-voice-recording" as const
2
+ export const RECORDING_FORMAT_VERSION = 1
3
+ export const RECORDING_AUDIO_TYPE = "audio/wav" // 16 kHz mono PCM16, exactly as sent to STT
4
+ export const RECORDING_PAGE_TYPE = "application/x-ndjson" // event pages (blobs)
5
+ export const RECORDING_MEDIA_TYPES = [RECORDING_AUDIO_TYPE, RECORDING_PAGE_TYPE] as const
6
+ export const RECORDING_HEADER = "x-hub-recording" // on /api/transcribe and /api/speak: the recording ID
7
+ export const RECORDING_SEGMENT_HEADER = "x-hub-segment" // on /api/transcribe: the segment number
8
+ export const RECORDING_REF_HEADER = "x-hub-tts-ref" // on /api/speak: "<messageID>:<ordinal>:<seq>:<i>"
9
+ export const RECORDING_AUDIO_RESULT_HEADER = "x-hub-recording-audio" // transcribe answer: RecordingAudioResult
10
+
11
+ export const RECORDING_LIMITS = {
12
+ retentionMs: 7 * 24 * 3_600_000,
13
+ userBytes: 500 * 1024 * 1024, // audio + event pages, all of a user's recordings
14
+ recordingBytes: 150 * 1024 * 1024, // one recording (≈ 80 min of speech)
15
+ segmentBytes: 2 * 1024 * 1024, // one WAV (60.3 s at 16 kHz = 1,929,644 B)
16
+ segmentsPerRecording: 5_000,
17
+ eventsPerBatch: 200,
18
+ batchBytes: 256 * 1024,
19
+ keepaliveBatchBytes: 60 * 1024, // fetch keepalive's 64 KiB budget, with headroom
20
+ eventsPerRecording: 50_000,
21
+ eventBytesPerRecording: 32 * 1024 * 1024,
22
+ recordingsStored: 200, // per user
23
+ activePerUser: 4,
24
+ maxDurationMs: 4 * 3_600_000, // then the hub ends it ("max_duration"); the console starts the next one
25
+ idleEndMs: 10 * 60_000, // no event and conversation off: the sweep ends it ("stale")
26
+ sweepMs: 15 * 60_000,
27
+ textChars: 2_000, utteranceChars: 4_000, replyChars: 32_000, messageChars: 300,
28
+ consoleBufferEvents: 2_000, flushMs: 5_000, flushEvents: 100,
29
+ hubFlushMs: 2_000, hubFlushEvents: 100,
30
+ } as const
31
+
32
+ export interface RecordingSettings {
33
+ readonly enabled: boolean // default false
34
+ readonly revision: number
35
+ readonly updatedAt?: number
36
+ }
37
+
38
+ export type RecordingKind = "voice" | "concierge"
39
+ export type RecordingState = "active" | "ended" | "deleting"
40
+ export type RecordingEndReason = "stopped" | "conversation_off" | "disabled" | "max_duration" | "stale" | "limit" | "superseded" | "session_deleted"
41
+ export type RecordingAudioResult = "stored" | "skipped-cap" | "failed" | "off"
42
+
43
+ export interface RecordingClient { // sent by the console at start; all optional, bounded labels
44
+ readonly app?: string // console version
45
+ readonly platform?: string // ≤ 60 chars, e.g. "android-chrome", "ios-safari", "desktop-chrome"
46
+ readonly detector?: "silero" | "energy"
47
+ readonly sensitivity?: "low" | "normal" | "high"
48
+ readonly cues?: boolean
49
+ readonly background?: boolean
50
+ readonly streaming?: boolean // MediaSource playback
51
+ readonly timings?: Readonly<Record<string, number>> // VOICE_TIMINGS at start
52
+ }
53
+
54
+ export interface RecordingSummary {
55
+ readonly id: string // "rec_" + 20 base32 chars
56
+ readonly kind: RecordingKind
57
+ readonly sessionID: string
58
+ readonly sessionTitle?: string // ≤ 80, at start (the user's own session)
59
+ readonly deviceID: string
60
+ readonly deviceName?: string
61
+ readonly state: RecordingState
62
+ readonly startedAt: number // hub epoch ms
63
+ readonly endedAt?: number
64
+ readonly endReason?: RecordingEndReason
65
+ readonly expiresAt: number // startedAt + retentionMs
66
+ readonly bytes: number // audio + pages
67
+ readonly segments: number // stored audio segments
68
+ readonly events: number
69
+ readonly audio: boolean // false once a cap stopped audio
70
+ readonly redactions: number
71
+ }
72
+
73
+ export interface RecordingUsage { readonly bytes: number; readonly recordings: number; readonly capBytes: number }
74
+
75
+ export interface RecordingAnchor { readonly perf: number; readonly epoch: number } // performance.now() and Date.now() at the same moment
76
+
77
+ /** Console → hub (POST …/events). `seq` from 1, gapless per recording; `t` = performance.now() (ms). */
78
+ export interface ConsoleEvent { readonly seq: number; readonly t: number; readonly kind: ConsoleEventKind; readonly [field: string]: unknown }
79
+ export type ConsoleEventKind =
80
+ | "state" | "detector" | "speech.start" | "speech.end" | "segment.closed" | "stt.sent" | "stt.received"
81
+ | "utterance.held" | "utterance.merged" | "utterance.sent" | "utterance.accepted" | "utterance.failed" | "utterance.command"
82
+ | "heard" | "say.received" | "tts.sentence.queued" | "tts.request" | "tts.first.byte" | "tts.sentence.played"
83
+ | "tts.sentence.ended" | "barge.in" | "cue.played" | "mic" | "control" | "turn.timing" | "decision.voice"
84
+ | "clock.sync" | "error"
85
+ export type HubEventKind =
86
+ | "recording.started" | "recording.ended" | "conversation" | "stt.request" | "stt.result" | "tts.synth"
87
+ | "utterance.received" | "utterance.delivered" | "turn.started" | "first.token" | "say.sent" | "reply.full"
88
+ | "turn.ended" | "tool" | "interrupt" | "wake.arrived" | "concierge.tool" | "decision.opened" | "decision.answered"
89
+ | "cap.reached" | "error"
90
+
91
+ /** As stored and exported (events.jsonl). */
92
+ export interface RecordingEvent {
93
+ readonly v: 1
94
+ readonly src: "console" | "hub"
95
+ readonly kind: ConsoleEventKind | HubEventKind
96
+ readonly seq?: number // console events
97
+ readonly id?: string // hub events: "<podTag>.<n>", unique per recording
98
+ readonly t?: number // console: page clock (performance.now ms)
99
+ readonly at?: number // hub: hub epoch ms (also on console events: when the hub received the batch)
100
+ readonly ts?: number // export only: aligned hub-epoch ms (see Clocks)
101
+ readonly redacted?: readonly string[] // credential kinds removed from this event's text
102
+ readonly truncated?: true
103
+ readonly [field: string]: unknown
104
+ }
105
+
106
+ /** A spoken sentence's identity: its say and its index among splitSentences(say.text). */
107
+ export interface TtsRef { readonly messageID: string; readonly ordinal: number; readonly seq?: number; readonly i: number }
@@ -17,6 +17,7 @@ export const featureList = (v: unknown): v is string[] => Array.isArray(v) && v.
17
17
  /** Control (C0, C1), format (bidi, zero-width, BOM) and line/paragraph separator characters. */
18
18
  export const UNSAFE_CHARS = /[\p{Cc}\p{Cf}\u2028\u2029]/u
19
19
 
20
+ /** A res frame (an optional `code` is not checked here: an invalid one is dropped, never fatal; see parse). */
20
21
  export const isResFrame = (v: Record<string, unknown>): boolean => nonEmpty(v.id) && typeof v.ok === "boolean" && (v.error === undefined || str(v.error))
21
22
  /** Longest reconnect delay a bye's retryAfterMs may ask for (5 minutes). */
22
23
  export const MAX_RETRY_AFTER_MS = 300_000
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-openshell",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-sdk",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "description": "The Runtime interface `hub node` runtimes implement (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-sdk",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "description": "Typed client for the hub's /api/agent (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -320,6 +320,8 @@ export interface ActOptions extends CallOptions {
320
320
  export interface ApproveOptions extends ActOptions {
321
321
  /** Approve for good (OpenCode saves the rule); agents need a grant with allowAlways. */
322
322
  readonly always?: boolean
323
+ /** (R-03) The Concierge advises approving: only then does its escalation Decision recommend approve. */
324
+ readonly recommend?: boolean
323
325
  }
324
326
 
325
327
  export interface PromptOptions extends ActOptions {
@@ -688,7 +690,7 @@ export class HubClient {
688
690
 
689
691
  /** Approves a pending permission request in a session's tree. */
690
692
  approve(addr: string, requestID: string, options: ApproveOptions = {}): Promise<Approved> {
691
- const body = { requestID, ...(options.always ? { always: true } : {}), ...whyField(options) }
693
+ const body = { requestID, ...(options.always ? { always: true } : {}), ...(options.recommend ? { recommend: true } : {}), ...whyField(options) }
692
694
  return this.call("POST", `${AGENT}/sessions/${segment(addr)}/approve`, { body, ...signalOf(options) })
693
695
  }
694
696
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "private": true,
5
5
  "description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@ import {
11
11
  type ServerRequest,
12
12
  type SpokeMessage,
13
13
  type SpokeRequest,
14
+ type ResErrorCode,
14
15
  type Welcome,
15
16
  } from "../../protocol/src/index.ts"
16
17
 
@@ -67,6 +68,17 @@ const defaultSocket = (url: string, headers: Record<string, string>): SocketLike
67
68
 
68
69
  const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
69
70
 
71
+ /** An op's refusal with a protocol code (Res.code, RES_ERROR_CODES): the hub maps it without matching the text. */
72
+ export class CodedError extends Error {
73
+ constructor(
74
+ message: string,
75
+ readonly code: ResErrorCode,
76
+ ) {
77
+ super(message)
78
+ this.name = "CodedError"
79
+ }
80
+ }
81
+
70
82
  interface Pending {
71
83
  resolve: (value: unknown) => void
72
84
  reject: (error: Error) => void
@@ -245,7 +257,7 @@ export class SpokeConnection {
245
257
  try {
246
258
  reply = { t: "res", id, ok: true, result: await this.options.handle(req) }
247
259
  } catch (cause) {
248
- reply = { t: "res", id, ok: false, error: message(cause) }
260
+ reply = { t: "res", id, ok: false, error: message(cause), ...(cause instanceof CodedError ? { code: cause.code } : {}) }
249
261
  }
250
262
  this.send(reply)
251
263
  }