@huynle/hub 0.8.1 → 0.8.2

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 (67) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +87 -52
  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 +83 -6
  22. package/packages/protocol/src/wire.ts +1 -0
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/client.ts +3 -1
  27. package/packages/spoke-sdk/package.json +1 -1
  28. package/packages/spoke-sdk/src/connection.ts +13 -1
  29. package/src/central/agent/acting-routes.ts +3 -2
  30. package/src/central/agent/acting.ts +67 -12
  31. package/src/central/agent/delivery.ts +12 -3
  32. package/src/central/agent/escalation.ts +30 -9
  33. package/src/central/agent/launch-spec.ts +38 -19
  34. package/src/central/agent/routes.ts +7 -0
  35. package/src/central/concierge/context.ts +3 -1
  36. package/src/central/concierge/prompt.ts +7 -4
  37. package/src/central/http.ts +50 -21
  38. package/src/central/registry.ts +23 -3
  39. package/src/central/revert-windows.ts +66 -0
  40. package/src/central/spoke-ws.ts +6 -3
  41. package/src/central/teams/staffing.ts +2 -2
  42. package/src/index.ts +26 -14
  43. package/src/spoke/deliver.ts +6 -0
  44. package/src/spoke/fork-revert.ts +94 -9
  45. package/src/spoke/hub-tool.ts +2 -2
  46. package/src/spoke/locations.ts +80 -0
  47. package/src/spoke/profile-apply.ts +24 -14
  48. package/src/spoke/session-context.ts +76 -6
  49. package/src/spoke/spoke.ts +116 -36
  50. package/src/spoke/timeline.ts +30 -3
  51. package/src/spoke/tracker.ts +17 -1
  52. package/src/status.ts +2 -1
  53. package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-R0ZTpHca.js} +1 -1
  54. package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-C9KrBna8.js} +1 -1
  55. package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-sP2mZccC.js} +1 -1
  56. package/web/dist/assets/{Library-LXHsP5Fw.js → Library-BXbJvTUS.js} +2 -2
  57. package/web/dist/assets/{ProfilesDestination-DhVVcAep.js → ProfilesDestination-D7XyLTJ7.js} +1 -1
  58. package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-Dgxt8UI3.js} +1 -1
  59. package/web/dist/assets/{Watchtower-DxaJYQp_.js → Watchtower-DJHx6cWw.js} +2 -2
  60. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  61. package/web/dist/assets/{live-BDq0AABg.js → live-OgQWPrSY.js} +1 -1
  62. package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-DjEipyto.js} +1 -1
  63. package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-B648jKGO.js} +1 -1
  64. package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-CNLyboIj.js} +1 -1
  65. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  66. package/web/dist/index.html +1 -1
  67. package/web/dist/assets/index-lIOfSYck.js +0 -34
@@ -7,6 +7,7 @@
7
7
  // (+ artifacts:read) for an app token. Content never travels in requests or job listings, only in the context.
8
8
  import type { ArtifactJob, ArtifactJobContext, ArtifactJobKind, ArtifactJobRequest } from "../../sdk/src/artifact-jobs.ts"
9
9
  import type { Artifacts } from "../../sdk/src/artifacts.ts"
10
+ import { ToolArgError } from "./tool-args"
10
11
 
11
12
  /** UTF-8 bytes of the inline proposal text one tool call may carry. */
12
13
  export const MCP_JOB_TEXT_BYTES = 256 * 1024
@@ -33,27 +34,27 @@ const KINDS: readonly ArtifactJobKind[] = ["ask", "propose-edit", "run-action"]
33
34
 
34
35
  const need = (args: Args, key: string): string => {
35
36
  const value = args[key]
36
- if (typeof value !== "string" || !value.trim()) throw new Error(`"${key}" is required (a non-empty string)`)
37
+ if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
37
38
  return value
38
39
  }
39
40
  const optional = (args: Args, key: string): string | undefined => {
40
41
  const value = args[key]
41
42
  if (value === undefined || value === null) return undefined
42
- if (typeof value !== "string") throw new Error(`"${key}" must be a string`)
43
+ if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
43
44
  return value.trim() ? value : undefined
44
45
  }
45
46
  const integer = (args: Args, key: string): number => {
46
47
  const value = args[key]
47
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error(`"${key}" is required (a whole number)`)
48
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new ToolArgError(`"${key}" is required (a whole number)`)
48
49
  return value
49
50
  }
50
51
 
51
52
  function handlerOf(args: Args): ArtifactJobRequest["handler"] {
52
53
  const session = optional(args, "session")
53
54
  const machine = optional(args, "machine")
54
- if (session && machine) throw new Error('give either "session" (an existing session) or "machine" + "repo" (a new handler session), not both')
55
+ if (session && machine) throw new ToolArgError('give either "session" (an existing session) or "machine" + "repo" (a new handler session), not both')
55
56
  if (session) return { session }
56
- if (!machine) throw new Error('"machine" and "repo" are required to launch a handler session (or name an existing one with "session")')
57
+ if (!machine) throw new ToolArgError('"machine" and "repo" are required to launch a handler session (or name an existing one with "session")')
57
58
  const repo = need(args, "repo")
58
59
  const harness = optional(args, "harness")
59
60
  const model = optional(args, "model")
@@ -114,7 +115,7 @@ export const artifactJobTools: readonly ArtifactJobTool[] = [
114
115
  readOnly: false,
115
116
  run: async (hub, args, signal) => {
116
117
  const kind = optional(args, "kind") as ArtifactJobKind | undefined
117
- if (kind && !KINDS.includes(kind)) throw new Error(`"kind" must be one of ${KINDS.join(", ")}`)
118
+ if (kind && !KINDS.includes(kind)) throw new ToolArgError(`"kind" must be one of ${KINDS.join(", ")}`)
118
119
  const action = optional(args, "action")
119
120
  const apply = optional(args, "apply") as "manual" | "auto" | undefined
120
121
  const key = optional(args, "key")
@@ -181,10 +182,10 @@ export const artifactJobTools: readonly ArtifactJobTool[] = [
181
182
  const error = optional(args, "error")
182
183
  const answer = optional(args, "answer")
183
184
  const text = args.text
184
- if (text !== undefined && typeof text !== "string") throw new Error('"text" must be a string')
185
- if (typeof text === "string" && Buffer.byteLength(text, "utf8") > MCP_JOB_TEXT_BYTES) throw new Error(`"text" is larger than ${MCP_JOB_TEXT_BYTES} bytes`)
185
+ if (text !== undefined && typeof text !== "string") throw new ToolArgError('"text" must be a string')
186
+ if (typeof text === "string" && Buffer.byteLength(text, "utf8") > MCP_JOB_TEXT_BYTES) throw new ToolArgError(`"text" is larger than ${MCP_JOB_TEXT_BYTES} bytes`)
186
187
  const keep = args.keep
187
- if (keep !== undefined && (!Array.isArray(keep) || !keep.every((k) => typeof k === "string"))) throw new Error('"keep" must be a list of paths')
188
+ if (keep !== undefined && (!Array.isArray(keep) || !keep.every((k) => typeof k === "string"))) throw new ToolArgError('"keep" must be a list of paths')
188
189
  const path = optional(args, "path")
189
190
  const entry = optional(args, "entry")
190
191
  const result = error
@@ -5,6 +5,7 @@
5
5
  // tool ever reads local files). Needs scope artifacts:read / artifacts:write for an app token; sessions create and
6
6
  // read workspace-shared artifacts only (revising stays with the user in v1).
7
7
  import type { Artifact, ArtifactKind, ArtifactRevision, Artifacts, BlobRef } from "../../sdk/src/artifacts.ts"
8
+ import { ToolArgError } from "./tool-args"
8
9
 
9
10
  /** UTF-8 bytes of inline text one tool call may carry (bigger content: stage it, then pass `blobs`). */
10
11
  export const MCP_INLINE_TEXT_BYTES = 256 * 1024
@@ -45,24 +46,24 @@ const FILE_ARGS = {
45
46
 
46
47
  const need = (args: Args, key: string): string => {
47
48
  const value = args[key]
48
- if (typeof value !== "string" || !value.trim()) throw new Error(`"${key}" is required (a non-empty string)`)
49
+ if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
49
50
  return value
50
51
  }
51
52
  const optional = (args: Args, key: string): string | undefined => {
52
53
  const value = args[key]
53
54
  if (value === undefined || value === null) return undefined
54
- if (typeof value !== "string") throw new Error(`"${key}" must be a string`)
55
+ if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
55
56
  return value.trim() ? value : undefined
56
57
  }
57
58
 
58
59
  /** The files of a publish/revise: inline text and/or blob references (one of them required). */
59
60
  function files(args: Args, kind: ArtifactKind | undefined): { blobs?: BlobRef[]; upload?: { mediaType: string; data: string; path?: string }[]; entry?: string } {
60
61
  const text = args.text
61
- if (text !== undefined && typeof text !== "string") throw new Error('"text" must be a string')
62
+ if (text !== undefined && typeof text !== "string") throw new ToolArgError('"text" must be a string')
62
63
  const blobs = args.blobs
63
- if (blobs !== undefined && (!Array.isArray(blobs) || !blobs.every((b) => typeof b === "object" && b !== null))) throw new Error('"blobs" must be a list of blob references')
64
- if (text === undefined && (blobs === undefined || blobs.length === 0)) throw new Error('give text or blobs (stage bigger content first and pass its reference)')
65
- if (text !== undefined && Buffer.byteLength(text, "utf8") > MCP_INLINE_TEXT_BYTES) throw new Error(`text is larger than ${MCP_INLINE_TEXT_BYTES} bytes: stage it with uploadBlob and pass blobs`)
64
+ if (blobs !== undefined && (!Array.isArray(blobs) || !blobs.every((b) => typeof b === "object" && b !== null))) throw new ToolArgError('"blobs" must be a list of blob references')
65
+ if (text === undefined && (blobs === undefined || blobs.length === 0)) throw new ToolArgError('give text or blobs (stage bigger content first and pass its reference)')
66
+ if (text !== undefined && Buffer.byteLength(text, "utf8") > MCP_INLINE_TEXT_BYTES) throw new ToolArgError(`text is larger than ${MCP_INLINE_TEXT_BYTES} bytes: stage it with uploadBlob and pass blobs`)
66
67
  const path = optional(args, "path")
67
68
  const entry = optional(args, "entry")
68
69
  const mediaType = optional(args, "mediaType") ?? (kind && DEFAULT_MEDIA[kind]) ?? "text/plain"
@@ -126,7 +127,7 @@ export const artifactTools: readonly ArtifactTool[] = [
126
127
  run: async (hub, args, signal) => {
127
128
  const title = need(args, "title")
128
129
  const kind = need(args, "kind") as ArtifactKind
129
- if (!KINDS.includes(kind)) throw new Error(`"kind" must be one of ${KINDS.join(", ")}`)
130
+ if (!KINDS.includes(kind)) throw new ToolArgError(`"kind" must be one of ${KINDS.join(", ")}`)
130
131
  const body = files(args, kind)
131
132
  const description = optional(args, "description")
132
133
  const visibility = optional(args, "visibility") as "private" | "workspace" | undefined
@@ -26,7 +26,8 @@ import { artifactTools } from "./artifacts-mcp"
26
26
  import { artifactJobTools } from "./artifact-jobs-mcp"
27
27
  import { teamIntakeTools } from "./team-intake-mcp"
28
28
  import { teamTools } from "./teams-mcp"
29
- import { ProfileArgError, profileLifecycleTools } from "./profiles-mcp"
29
+ import { profileLifecycleTools } from "./profiles-mcp"
30
+ import { ToolArgError } from "./tool-args"
30
31
  import { watchlistTools } from "./watchlist"
31
32
 
32
33
  /** The protocol revisions the server speaks, newest first: a client's supported one is echoed, else the newest. */
@@ -48,7 +49,7 @@ type Id = string | number | null
48
49
  type Args = Record<string, unknown>
49
50
 
50
51
  /** A bad tool argument (an isError result, not a protocol error). */
51
- class ArgError extends Error {}
52
+ const ArgError = ToolArgError
52
53
 
53
54
  const need = (args: Args, key: string): string => {
54
55
  const value = args[key]
@@ -1143,7 +1144,7 @@ export function createMcpServer(options: McpServerOptions): McpServer {
1143
1144
  return controller.signal.aborted ? undefined : ok(id, toolResult(clean(text, token), false))
1144
1145
  } catch (cause) {
1145
1146
  if (controller.signal.aborted) return undefined // cancelled: no answer (MCP cancellation)
1146
- const text = cause instanceof ArgError || cause instanceof ProfileArgError ? `invalid: ${cause.message}` : cause instanceof CliError ? errorText(cause) : `${tool.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`
1147
+ const text = cause instanceof ToolArgError ? `invalid: ${cause.message}` : cause instanceof CliError ? errorText(cause) : `${tool.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`
1147
1148
  return ok(id, toolResult(clean(text, token), true))
1148
1149
  } finally {
1149
1150
  if (inFlight.get(key(id)) === controller) inFlight.delete(key(id))
@@ -14,6 +14,7 @@
14
14
  // version can be restored with fromVersion); archive/restore (no new versions or pins) and pins (applied to a running
15
15
  // session) keep the default destructiveHint. Changes send expectedRevision, so repeating one is idempotent.
16
16
  import { sanitizeText } from "../../sdk/src/envelope.ts"
17
+ import { ToolArgError } from "./tool-args"
17
18
  import type { Profiles } from "../../sdk/src/client.ts"
18
19
  import type { ProfileCommand, ProfileContent, ProfileSkill, ProfileVersionSummary, ProfileView } from "../../sdk/src/types.ts"
19
20
 
@@ -53,9 +54,8 @@ const META_FIELDS = {
53
54
  } as const
54
55
  const SCOPES = "App tokens need scope profiles:read (reads) or profiles:manage (changes; profile_version_create with content fields and the skill/command set/remove tools read the latest version first, so they also need profiles:read); pins need profiles:assign and sessions:read (an app also needs a prompt grant on the session). Session tokens never change profiles. A profile confers no authority."
55
56
 
56
- /** A bad tool argument: mcp.ts answers it as `invalid: …` (an isError result). */
57
- export class ProfileArgError extends Error {}
58
- const ArgError = ProfileArgError
57
+ /** A bad tool argument: mcp.ts answers it as `invalid: …` (an isError result; tool-args.ts). */
58
+ const ArgError = ToolArgError
59
59
 
60
60
  const need = (args: Args, key: string): string => {
61
61
  const value = args[key]
@@ -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.2",
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.2",
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.2",
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.2",
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,6 +31,7 @@ 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"
36
37
  export { HUB_URL_MAX_CHARS, isHubURL, MAX_FRAME, MAX_RETRY_AFTER_MS }
@@ -93,7 +94,8 @@ export interface AvailableModel {
93
94
  * A timeline page. Forward mode (`cursor`): oldest rows first, `cursor` for the next newer page. Newest-first
94
95
  * mode (`order: "desc"` or `before`): the newest rows (before `before`) in chronological order, `older` for the
95
96
  * 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`.
97
+ * spokes add optional `pos` (ordering position), `callID`, `childSessionID`, `title`, `ordinal` and, on error rows,
98
+ * `declined` (a denied permission stopped the turn).
97
99
  */
98
100
  export interface TimelinePage {
99
101
  readonly items: readonly unknown[]
@@ -154,6 +156,14 @@ export interface SessionInfo {
154
156
  * Older hubs ignore it.
155
157
  */
156
158
  readonly revert?: SessionRevertInfo
159
+ /**
160
+ * The latest revert committed in this session as this process saw it (feature "revert"): the history from
161
+ * `messageID` on was removed. Hub message rows (agent messages the session sent or got) from `from` (the reverted
162
+ * message's time, when known) until `until` (when the revert was staged; later ones were held for after it) belonged
163
+ * to that history: the hub keeps the window and leaves them out. `at` (commit time) changes with every commit.
164
+ * Older hubs ignore it.
165
+ */
166
+ readonly revertCommitted?: RevertCommitted
157
167
  /**
158
168
  * (CON-04, feature "sessionContext") Sessions the hub gave a locked context (the Concierge): the agent the spoke
159
169
  * last saw the session run on, and the context revision it holds now (absent: none, e.g. after a restart). The hub
@@ -192,6 +202,21 @@ export interface SessionRevertInfo {
192
202
  /** Restored files (at most REVERT_FILES_MAX). */
193
203
  readonly files: readonly RevertFile[]
194
204
  readonly filesTotal: number
205
+ /** When this process saw it staged (epoch ms; optional, older spokes send none). */
206
+ readonly stagedAt?: number
207
+ }
208
+
209
+ /** A committed revert's removed window (SessionInfo.revertCommitted). */
210
+ export interface RevertCommitted {
211
+ readonly messageID: string
212
+ readonly from?: number
213
+ readonly until: number
214
+ readonly at: number
215
+ }
216
+
217
+ /** A committed revert's window as a spoke reports it. */
218
+ export function isRevertCommitted(v: unknown): v is RevertCommitted {
219
+ return isObj(v) && nonEmpty(v.messageID) && v.messageID.length <= 300 && (v.from === undefined || num(v.from)) && num(v.until) && num(v.at)
195
220
  }
196
221
 
197
222
  /** An attachment of a user message, by its position in the message's files (fetched through timelineFile). */
@@ -829,8 +854,29 @@ export interface Res {
829
854
  readonly ok: boolean
830
855
  readonly result?: unknown
831
856
  readonly error?: string
857
+ /**
858
+ * A refusal's kind, so the hub need not match `error` texts (optional and additive: older hubs ignore it, older
859
+ * spokes send none). See RES_ERROR_CODES; a peer may send codes the other does not know (they are ignored).
860
+ */
861
+ readonly code?: string
832
862
  }
833
863
 
864
+ /**
865
+ * Refusal codes a spoke puts on `{ok: false}` answers: "busy" (the session runs, e.g. a revert while a turn is
866
+ * running; the hub answers 409 busy) and "not_found" (the session, or the message an op names, is not there; 404).
867
+ */
868
+ export const RES_ERROR_CODES = ["busy", "not_found"] as const
869
+ export type ResErrorCode = (typeof RES_ERROR_CODES)[number]
870
+ /** Longest `code` a res frame carries. */
871
+ export const RES_CODE_MAX = 40
872
+
873
+ /** The spoke's refusal of a revert while the session runs (code "busy"). */
874
+ export const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
875
+ /** The spoke's refusal of a restore or commit while the session runs (code "busy"). */
876
+ export const REVERT_END_BUSY = "The session is working; wait for it to finish (or stop it) before restoring or committing the revert"
877
+ /** A fork or revert naming a message that is not one of the session's user messages (code "not_found"). */
878
+ export const NOT_A_USER_MESSAGE = "Not a user message of this session"
879
+
834
880
  export interface Bye {
835
881
  readonly t: "bye"
836
882
  readonly reason: string
@@ -915,6 +961,7 @@ function isSession(v: unknown): v is SessionInfo {
915
961
  (v.open === undefined || typeof v.open === "boolean") &&
916
962
  (v.terminal === undefined || isTerminal(v.terminal)) &&
917
963
  (v.revert === undefined || isSessionRevert(v.revert)) &&
964
+ (v.revertCommitted === undefined || isRevertCommitted(v.revertCommitted)) &&
918
965
  (v.agent === undefined || (nonEmpty(v.agent) && v.agent.length <= AGENT_NAME_MAX)) &&
919
966
  (v.contextRevision === undefined || (Number.isSafeInteger(v.contextRevision) && (v.contextRevision as number) >= 0)) &&
920
967
  (v.locked === undefined || v.locked === true)
@@ -948,7 +995,8 @@ export function isSessionRevert(v: unknown): v is SessionRevertInfo {
948
995
  v.files.length <= REVERT_FILES_MAX &&
949
996
  v.files.every(isRevertFile) &&
950
997
  count(v.filesTotal) &&
951
- (v.filesTotal as number) >= v.files.length
998
+ (v.filesTotal as number) >= v.files.length &&
999
+ (v.stagedAt === undefined || num(v.stagedAt))
952
1000
  )
953
1001
  }
954
1002
 
@@ -1187,8 +1235,11 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
1187
1235
  (m.launchID === undefined || (str(m.launchID) && ID.test(m.launchID)))
1188
1236
  ? (m as unknown as Hello)
1189
1237
  : undefined
1190
- case "sessions":
1191
- return Array.isArray(m.sessions) && m.sessions.every(isSession) ? (m as unknown as Sessions) : undefined
1238
+ case "sessions": {
1239
+ if (!Array.isArray(m.sessions)) return undefined
1240
+ const sessions = m.sessions.map(lenientSession)
1241
+ return sessions.every(isSession) ? ({ ...m, sessions } as unknown as Sessions) : undefined
1242
+ }
1192
1243
  case "terminals":
1193
1244
  return m.herdr === undefined || isHerdrInfo(m.herdr) ? (m as unknown as Terminals) : undefined
1194
1245
  case "event":
@@ -1198,7 +1249,7 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
1198
1249
  case "req":
1199
1250
  return nonEmpty(m.id) && isSpokeRequest(m.req) ? (m as unknown as Req<SpokeRequest>) : undefined
1200
1251
  case "res":
1201
- return isRes(m) ? (m as unknown as Res) : undefined
1252
+ return isRes(m) ? lenientRes(m) : undefined
1202
1253
  case "bye":
1203
1254
  return isBye(m) ? (m as unknown as Bye) : undefined
1204
1255
  default:
@@ -1206,6 +1257,32 @@ export function parseSpokeMessage(raw: string | Buffer): SpokeMessage | undefine
1206
1257
  }
1207
1258
  }
1208
1259
 
1260
+ /** A res frame without an invalid `code` (over-long, empty or not a string): ignored, as for older peers. */
1261
+ function lenientRes(m: Record<string, unknown>): Res {
1262
+ if (m.code === undefined || (nonEmpty(m.code) && m.code.length <= RES_CODE_MAX)) return m as unknown as Res
1263
+ const { code: _dropped, ...rest } = m
1264
+ return rest as unknown as Res
1265
+ }
1266
+
1267
+ /**
1268
+ * A reported session without malformed additive revert fields (`revertCommitted`, `revert.stagedAt`): they are
1269
+ * dropped (older hubs ignore them too), never failing the whole sessions frame.
1270
+ */
1271
+ function lenientSession(v: unknown): unknown {
1272
+ if (!isObj(v)) return v
1273
+ let out: Record<string, unknown> = v
1274
+ if (out.revertCommitted !== undefined && !isRevertCommitted(out.revertCommitted)) {
1275
+ const { revertCommitted: _dropped, ...rest } = out
1276
+ out = rest
1277
+ }
1278
+ const revert = out.revert
1279
+ if (isObj(revert) && revert.stagedAt !== undefined && !num(revert.stagedAt)) {
1280
+ const { stagedAt: _dropped, ...rest } = revert
1281
+ out = { ...out, revert: rest }
1282
+ }
1283
+ return out
1284
+ }
1285
+
1209
1286
  /** Parses a frame received by the spoke. Returns undefined for anything invalid. */
1210
1287
  export function parseServerMessage(raw: string | Buffer): ServerMessage | undefined {
1211
1288
  const m = parse(raw)
@@ -1216,7 +1293,7 @@ export function parseServerMessage(raw: string | Buffer): ServerMessage | undefi
1216
1293
  case "req":
1217
1294
  return nonEmpty(m.id) && isServerRequest(m.req) ? (m as unknown as Req<ServerRequest>) : undefined
1218
1295
  case "res":
1219
- return isRes(m) ? (m as unknown as Res) : undefined
1296
+ return isRes(m) ? lenientRes(m) : undefined
1220
1297
  case "bye":
1221
1298
  return isBye(m) ? (m as unknown as Bye) : undefined
1222
1299
  default:
@@ -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.2",
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.2",
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.2",
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.2",
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",