@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.
- package/CHANGELOG.md +193 -0
- package/README.md +32 -1
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +116 -53
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
- package/packages/cli/src/artifacts-mcp.ts +8 -7
- package/packages/cli/src/mcp.ts +4 -3
- package/packages/cli/src/profiles-mcp.ts +3 -3
- package/packages/cli/src/team-intake-mcp.ts +8 -7
- package/packages/cli/src/teams-mcp.ts +2 -1
- package/packages/cli/src/tool-args.ts +6 -0
- package/packages/cli/src/watchlist.ts +5 -4
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/concierge.ts +4 -1
- package/packages/protocol/src/embedded-media.ts +59 -0
- package/packages/protocol/src/index.ts +92 -7
- package/packages/protocol/src/voice-recording.ts +107 -0
- package/packages/protocol/src/wire.ts +1 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +3 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/connection.ts +13 -1
- package/src/central/agent/acting-routes.ts +3 -2
- package/src/central/agent/acting.ts +67 -12
- package/src/central/agent/delivery.ts +12 -3
- package/src/central/agent/escalation.ts +30 -9
- package/src/central/agent/launch-spec.ts +38 -19
- package/src/central/agent/routes.ts +14 -1
- package/src/central/artifacts/blob-store.ts +6 -1
- package/src/central/artifacts/blob-support.ts +3 -1
- package/src/central/concierge/board-render.ts +34 -11
- package/src/central/concierge/context.ts +3 -1
- package/src/central/concierge/index.ts +1 -1
- package/src/central/concierge/notifier.ts +2 -1
- package/src/central/concierge/prompt.ts +7 -4
- package/src/central/config.ts +4 -0
- package/src/central/csp.ts +55 -0
- package/src/central/http.ts +134 -29
- package/src/central/profiles/content.ts +16 -0
- package/src/central/recording/clock.ts +76 -0
- package/src/central/recording/concierge-taps.ts +104 -0
- package/src/central/recording/export.ts +215 -0
- package/src/central/recording/index.ts +658 -0
- package/src/central/recording/ingest.ts +209 -0
- package/src/central/recording/retention.ts +71 -0
- package/src/central/recording/routes.ts +281 -0
- package/src/central/recording/schema.ts +232 -0
- package/src/central/recording/settings.ts +35 -0
- package/src/central/recording/store.ts +316 -0
- package/src/central/recording/taps.ts +105 -0
- package/src/central/recording/zip.ts +138 -0
- package/src/central/registry.ts +91 -6
- package/src/central/revert-windows.ts +66 -0
- package/src/central/spoke-ws.ts +8 -3
- package/src/central/teams/staffing.ts +2 -2
- package/src/index.ts +26 -14
- package/src/spoke/deliver.ts +6 -0
- package/src/spoke/fork-revert.ts +94 -9
- package/src/spoke/hub-tool.ts +2 -2
- package/src/spoke/locations.ts +80 -0
- package/src/spoke/profile-apply.ts +24 -14
- package/src/spoke/session-context.ts +96 -6
- package/src/spoke/spoke.ts +119 -36
- package/src/spoke/timeline.ts +30 -3
- package/src/spoke/tracker.ts +23 -2
- package/src/status.ts +33 -6
- package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
- package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
- package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
- package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-C-5lYeeM.js} +1 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
- package/web/dist/assets/index-6mdQ7eUY.js +34 -0
- package/web/dist/assets/index-C5OmwA1y.css +1 -0
- package/web/dist/assets/{live-BDq0AABg.js → live-CDWcyavI.js} +1 -1
- package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
- package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
- package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
- package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
- package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
- package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
- package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
- package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
- package/web/dist/index.html +5 -16
- package/web/dist/theme-boot.js +12 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +0 -1
- package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
- package/web/dist/assets/index-BabrNDB3.css +0 -1
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -98,7 +98,10 @@ export const CONCIERGE_LIMITS = {
|
|
|
98
98
|
|
|
99
99
|
// ---- Session context (CON-04, spoke feature "sessionContext") ----
|
|
100
100
|
|
|
101
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|
|
@@ -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.
|
|
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
|
}
|