@huynle/hub 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +20 -4
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +625 -395
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/grants-mcp.ts +65 -0
  8. package/packages/cli/src/mcp-operations.ts +49 -0
  9. package/packages/cli/src/mcp.ts +6 -0
  10. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  11. package/packages/cli/src/teams-mcp.ts +5 -3
  12. package/packages/codex/package.json +1 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/src/index.ts +13 -1
  17. package/packages/protocol/src/teams.ts +4 -0
  18. package/packages/protocol/src/voice-recording.ts +39 -2
  19. package/packages/runtime-openshell/image/entry.sh +3 -1
  20. package/packages/runtime-openshell/image/start.sh +3 -1
  21. package/packages/runtime-openshell/package.json +1 -1
  22. package/packages/runtime-sdk/package.json +1 -1
  23. package/packages/sdk/package.json +1 -1
  24. package/packages/sdk/src/client.ts +13 -0
  25. package/packages/sdk/src/teams.ts +2 -0
  26. package/packages/spoke-sdk/package.json +1 -1
  27. package/src/central/agent/decisions.ts +7 -1
  28. package/src/central/agent/delivery.ts +39 -9
  29. package/src/central/agent/grant-routes.ts +14 -1
  30. package/src/central/agent/grants.ts +23 -0
  31. package/src/central/agent/launch-engine.ts +3 -3
  32. package/src/central/agent/message-limits.ts +3 -0
  33. package/src/central/agent/routes.ts +11 -0
  34. package/src/central/agent/subscriptions.ts +3 -1
  35. package/src/central/config.ts +9 -2
  36. package/src/central/http.ts +135 -24
  37. package/src/central/recording/export.ts +57 -3
  38. package/src/central/recording/index.ts +234 -16
  39. package/src/central/recording/ingest.ts +11 -2
  40. package/src/central/recording/routes.ts +61 -7
  41. package/src/central/recording/schema.ts +66 -2
  42. package/src/central/recording/settings.ts +19 -5
  43. package/src/central/recording/store.ts +19 -0
  44. package/src/central/recording/taps.ts +45 -1
  45. package/src/central/speech/openai.ts +4 -4
  46. package/src/central/speech/openrouter.ts +4 -4
  47. package/src/central/speech/provider.ts +32 -1
  48. package/src/central/speech/service.ts +6 -5
  49. package/src/central/teams/hop-stalls.ts +72 -0
  50. package/src/central/teams/policy.ts +10 -0
  51. package/src/central/teams/routes.ts +2 -0
  52. package/src/central/teams/seats.ts +3 -3
  53. package/src/central/teams/staffing.ts +5 -0
  54. package/src/central/teams/store.ts +5 -2
  55. package/src/central/teams/validation.ts +8 -5
  56. package/src/spoke/spoke.ts +19 -0
  57. package/web/dist/assets/{ArtifactPage-Bz992ZX6.js → ArtifactPage-C5MMqXLN.js} +1 -1
  58. package/web/dist/assets/{ArtifactPane-BJZNixJC.js → ArtifactPane-CXSCmhyJ.js} +1 -1
  59. package/web/dist/assets/{ArtifactWorkbench-Dti3VP07.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
  60. package/web/dist/assets/{Diagram-B4HdUxw0.js → Diagram-BGmyRb-R.js} +1 -1
  61. package/web/dist/assets/{Library-CpzD2HDW.js → Library-CFmMbXEa.js} +2 -2
  62. package/web/dist/assets/{ProfilesDestination-DlQeogea.js → ProfilesDestination-7jpOc2HM.js} +1 -1
  63. package/web/dist/assets/{TeamsPage-4xdWCaI-.js → TeamsPage-C1NGrUR-.js} +1 -1
  64. package/web/dist/assets/{Watchtower-CpQdIRns.js → Watchtower-DnvlJaU0.js} +2 -2
  65. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  66. package/web/dist/assets/index-Da8SKXMw.js +33 -0
  67. package/web/dist/assets/{index-BC0O1gfR.css → index-Dyq-jBeE.css} +1 -1
  68. package/web/dist/assets/live-BmqLASSo.js +5 -0
  69. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  70. package/web/dist/assets/{renderer2d-IaJiloPi.js → renderer2d-B2Nh39UN.js} +1 -1
  71. package/web/dist/assets/{renderer3d-ZiD1uKGW.js → renderer3d-CTYbljHH.js} +1 -1
  72. package/web/dist/assets/{safe-area-C7IAlkgY.js → safe-area-CkhEclGo.js} +1 -1
  73. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  74. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  75. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  76. package/web/dist/index.html +5 -5
  77. package/web/dist/assets/index-BS6lA6fN.js +0 -33
  78. package/web/dist/assets/live-CVUFIdUX.js +0 -5
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -0,0 +1,65 @@
1
+ // MCP grant tools: the server owns authority. Requests use the existing tap Decision route; /mine never widens
2
+ // the user-only grant administration routes. A user credential may create directly but has no /mine holder.
3
+ import type { HubClient } from "../../sdk/src/client.ts"
4
+ import type { GrantRequestSpec, GrantSpec } from "../../sdk/src/types.ts"
5
+ import { grantError, grantText, grantsText, GRANT_USER_HINT, requestedText } from "./grants"
6
+ import { CliError, EXIT } from "./http"
7
+ import { ToolArgError } from "./tool-args"
8
+
9
+ type Args = Record<string, unknown>
10
+ export interface GrantTool {
11
+ readonly name: string
12
+ readonly description: string
13
+ readonly properties: Record<string, unknown>
14
+ readonly required?: readonly string[]
15
+ readonly readOnly: boolean
16
+ readonly organizes?: boolean
17
+ run(hub: HubClient, args: Args, signal: AbortSignal): Promise<string>
18
+ }
19
+
20
+ const STRING = { type: "string" } as const
21
+ const GRANT = { type: "object", description: "The grant spec accepted by POST /api/agent/grants: holder (optional for a request), scope.projects, allow, permissions, allowAlways, maxActionsPerHour and expires. The server validates all fields.", additionalProperties: true } as const
22
+ const need = (args: Args, key: string): string => {
23
+ if (typeof args[key] !== "string" || !(args[key] as string).trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
24
+ return args[key] as string
25
+ }
26
+ const spec = (args: Args): Record<string, unknown> => {
27
+ if (typeof args.grant !== "object" || args.grant === null || Array.isArray(args.grant)) throw new ToolArgError('"grant" is required (an object)')
28
+ return args.grant as Record<string, unknown>
29
+ }
30
+
31
+ export const grantTools: readonly GrantTool[] = [
32
+ {
33
+ name: "grant_request", description: "Ask the user for a grant: opens one tap Decision for @me (approve or deny). Sessions and apps with messages:send only; one open request and at most five per hour. Only the user's answer creates authority.",
34
+ properties: { grant: GRANT, why: STRING }, required: ["grant", "why"], readOnly: false,
35
+ run: async (hub, args, signal) => requestedText(await hub.requestGrant(spec(args) as GrantRequestSpec, need(args, "why"), { signal })),
36
+ },
37
+ {
38
+ name: "grant_list", description: "List grants (active, expired or revoked) whose holder is this session or app, including grants for roles it holds now. Apps need messages:read. A user credential has no holder: use the console or hub grant list for administration.",
39
+ properties: {}, readOnly: true,
40
+ run: async (hub, _args, signal) => grantsText((await hub.heldGrants({ signal })).grants),
41
+ },
42
+ {
43
+ name: "grant_show", description: "Show one grant only if this credential currently holds it. Apps need messages:read; another holder's ID answers not_found.",
44
+ properties: { id: STRING }, required: ["id"], readOnly: true,
45
+ run: async (hub, args, signal) => grantText((await hub.heldGrant(need(args, "id"), { signal })).grant),
46
+ },
47
+ {
48
+ name: "grant_revoke", description: "Irreversibly revoke a grant held by this session or app (including a held role shared with other holders). Apps need messages:send; another holder's ID answers not_found.",
49
+ properties: { id: STRING }, required: ["id"], readOnly: false,
50
+ run: async (hub, args, signal) => grantText((await hub.revokeHeldGrant(need(args, "id"), { signal })).grant),
51
+ },
52
+ {
53
+ name: "grant_create", description: `Create a grant directly ONLY with the user's device credential. Sessions and apps must use grant_request and wait for the user's tap. ${GRANT_USER_HINT}.`,
54
+ properties: { grant: GRANT }, required: ["grant"], readOnly: false,
55
+ run: async (hub, args, signal) => {
56
+ const actor = (await hub.whoami({ signal })).actor
57
+ if (actor.kind !== "user") throw new CliError(EXIT.forbidden, "only the user can create a grant, not a session or app", GRANT_USER_HINT)
58
+ try {
59
+ return grantText((await hub.createGrant(spec(args) as unknown as GrantSpec, { signal })).grant)
60
+ } catch (cause) {
61
+ throw grantError(cause)
62
+ }
63
+ },
64
+ },
65
+ ]
@@ -0,0 +1,49 @@
1
+ // User-only MCP access to the existing console session-model route and owner-scoped machine/node snapshots.
2
+ // No agent or app model-setting authority: the server's model route remains console user-only.
3
+ import type { HubClient } from "../../sdk/src/client.ts"
4
+ import { CliError, EXIT } from "./http"
5
+ import { ToolArgError } from "./tool-args"
6
+
7
+ type Args = Record<string, unknown>
8
+ interface Tool {
9
+ readonly name: string
10
+ readonly description: string
11
+ readonly properties: Record<string, unknown>
12
+ readonly required?: readonly string[]
13
+ readonly readOnly: boolean
14
+ run(hub: HubClient, args: Args, signal: AbortSignal): Promise<string>
15
+ }
16
+ const STRING = { type: "string" } as const
17
+ const need = (args: Args, key: string) => {
18
+ const v = args[key]
19
+ if (typeof v !== "string" || !v.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
20
+ return v
21
+ }
22
+ const userOnly = async (hub: HubClient, signal: AbortSignal) => {
23
+ if ((await hub.whoami({ signal })).actor.kind !== "user") throw new CliError(EXIT.forbidden, "only the user can use this tool", "Use the user's device credential")
24
+ }
25
+
26
+ export const operationTools: readonly Tool[] = [
27
+ {
28
+ name: "session_model_set", description: "Set one existing session's model (providerID, modelID and optional variant), exactly as the console Model chip does. User device credential only; the console route checks ownership, catalog, availability and audit. An app or session cannot set it.",
29
+ properties: { session: STRING, model: { type: "object", properties: { providerID: STRING, modelID: STRING, variant: STRING }, required: ["providerID", "modelID"] } },
30
+ required: ["session", "model"], readOnly: false,
31
+ run: async (hub, args, signal) => {
32
+ await userOnly(hub, signal)
33
+ const model = args.model
34
+ if (typeof model !== "object" || model === null || Array.isArray(model)) throw new ToolArgError('"model" is required (providerID, modelID, variant?)')
35
+ const result = await hub.call<{ model: unknown }>("PUT", `/api/sessions/${encodeURIComponent(need(args, "session"))}/model`, { body: { model }, signal })
36
+ return JSON.stringify(result)
37
+ },
38
+ },
39
+ {
40
+ name: "machines", description: "The user's own enrolled machines, online status and processes. User device credential only; wraps GET /api/machines. No machine tokens or secrets.",
41
+ properties: {}, readOnly: true,
42
+ run: async (hub, _args, signal) => { await userOnly(hub, signal); return JSON.stringify(await hub.call("GET", "/api/machines", { signal })) },
43
+ },
44
+ {
45
+ name: "launch_nodes", description: "The user's connected launch nodes: machine, workspace roots, capacity/used/free slots, runtimes (harnesses) and labels. User device credential only; no tokens, connection IDs or mutation.",
46
+ properties: {}, readOnly: true,
47
+ run: async (hub, _args, signal) => { await userOnly(hub, signal); return JSON.stringify(await hub.call("GET", "/api/agent/launch-nodes", { signal })) },
48
+ },
49
+ ]
@@ -25,8 +25,11 @@ import { launchedText, launchesText, launchText, stoppedLaunchText } from "./lau
25
25
  import { artifactTools } from "./artifacts-mcp"
26
26
  import { artifactJobTools } from "./artifact-jobs-mcp"
27
27
  import { teamIntakeTools } from "./team-intake-mcp"
28
+ import { subscriptionTools } from "./subscriptions-mcp"
29
+ import { operationTools } from "./mcp-operations"
28
30
  import { teamTools } from "./teams-mcp"
29
31
  import { profileLifecycleTools } from "./profiles-mcp"
32
+ import { grantTools } from "./grants-mcp"
30
33
  import { ToolArgError } from "./tool-args"
31
34
  import { watchlistTools } from "./watchlist"
32
35
 
@@ -755,10 +758,13 @@ const TOOLS: readonly Tool[] = [
755
758
  ...webhookTools,
756
759
  ...profileTools,
757
760
  ...profileLifecycleTools,
761
+ ...grantTools,
758
762
  ...workspaceTools,
759
763
  ...artifactTools,
760
764
  ...artifactJobTools,
761
765
  ...teamIntakeTools,
766
+ ...subscriptionTools,
767
+ ...operationTools,
762
768
  ...teamTools,
763
769
  ...watchlistTools,
764
770
  {
@@ -0,0 +1,76 @@
1
+ // MCP wrappers over the existing subscription routes; authorization and delivery caps remain on the server.
2
+ import type { HubClient } from "../../sdk/src/client.ts"
3
+ import type { SubscribeInput } from "../../sdk/src/types.ts"
4
+ import { subscribedText, subscriptionsText } from "./subscriptions"
5
+ import { ToolArgError } from "./tool-args"
6
+
7
+ type Args = Record<string, unknown>
8
+ export interface SubscriptionTool {
9
+ readonly name: string
10
+ readonly description: string
11
+ readonly properties: Record<string, unknown>
12
+ readonly required?: readonly string[]
13
+ readonly readOnly: boolean
14
+ readonly organizes?: boolean
15
+ run(hub: HubClient, args: Args, signal: AbortSignal): Promise<string>
16
+ }
17
+ const STRING = { type: "string" } as const
18
+ const need = (args: Args, key: string): string => {
19
+ const value = args[key]
20
+ if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
21
+ return value
22
+ }
23
+ const optional = (args: Args, key: string): string | undefined => {
24
+ const value = args[key]
25
+ if (value === undefined || value === null) return undefined
26
+ if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
27
+ return value.trim() || undefined
28
+ }
29
+ const array = (args: Args, key: string): string[] | undefined => {
30
+ const value = args[key]
31
+ if (value === undefined || value === null) return undefined
32
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new ToolArgError(`"${key}" must be a list of strings`)
33
+ return value
34
+ }
35
+ const number = (args: Args, key: string, min: number, max: number): number | undefined => {
36
+ const value = args[key]
37
+ if (value === undefined || value === null) return undefined
38
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new ToolArgError(`"${key}" must be a whole number from ${min} to ${max}`)
39
+ return value
40
+ }
41
+
42
+ export const subscriptionTools: readonly SubscriptionTool[] = [
43
+ {
44
+ name: "subscribe", description: "Follow journal events for a subscriber session. A session subscribes itself; users and apps name subscriber. Apps need events:subscribe, sessions:read, relevant type scopes and messages:send unless silent. Hub checks visibility, rates and loops.",
45
+ properties: {
46
+ to: { type: ["string", "array"], items: STRING, description: "One or more session/role/team addresses, or children." },
47
+ subscriber: STRING, types: { type: "array", items: STRING }, delivery: { type: "string", enum: ["queue", "steer", "silent"] },
48
+ batch: { type: "number", description: "Seconds per batch (1–3600)." }, expires: { type: "number", description: "Seconds until expiry." },
49
+ maxPerHour: { type: "integer", minimum: 1, maximum: 120 }, note: STRING,
50
+ }, required: ["to"], readOnly: false,
51
+ run: async (hub, args, signal) => {
52
+ const to = typeof args.to === "string" && args.to.trim() ? args.to : array(args, "to")
53
+ if (!to || (Array.isArray(to) && !to.length)) throw new ToolArgError('"to" needs at least one address')
54
+ const delivery = optional(args, "delivery")
55
+ if (delivery && !["queue", "steer", "silent"].includes(delivery)) throw new ToolArgError('"delivery" is queue, steer or silent')
56
+ const subscriber = optional(args, "subscriber")
57
+ const types = array(args, "types")
58
+ const batch = number(args, "batch", 1, 3600)
59
+ const expires = number(args, "expires", 1, 30 * 86400)
60
+ const maxPerHour = number(args, "maxPerHour", 1, 120)
61
+ const note = optional(args, "note")
62
+ const input: SubscribeInput = { to, ...(subscriber ? { subscriber } : {}), ...(types ? { types } : {}), ...(delivery ? { delivery: delivery as SubscribeInput["delivery"] } : {}), ...(batch ? { batch } : {}), ...(expires ? { expires } : {}), ...(maxPerHour ? { maxPerHour } : {}), ...(note ? { note } : {}) }
63
+ return subscribedText(await hub.subscribe(input, { signal }))
64
+ },
65
+ },
66
+ {
67
+ name: "subscriptions", description: "List subscriptions visible to this credential (session: its own; app: created by it; user: all). Optional session filter; route enforces scopes and tenancy.",
68
+ properties: { session: STRING }, readOnly: true,
69
+ run: async (hub, args, signal) => subscriptionsText(await hub.subscriptions({ ...(optional(args, "session") ? { session: optional(args, "session") } : {}), signal })),
70
+ },
71
+ {
72
+ name: "unsubscribe", description: "Remove a subscription this credential owns. An unknown or another caller's ID answers not_found.",
73
+ properties: { id: STRING }, required: ["id"], readOnly: false, organizes: true,
74
+ run: async (hub, args, signal) => { const id = need(args, "id"); await hub.unsubscribe(id, { signal }); return `unsubscribed ${id}` },
75
+ },
76
+ ]
@@ -51,6 +51,7 @@ const SPEC_FIELDS = {
51
51
  title: { ...STRING, description: "1–120 characters." },
52
52
  description: STRING,
53
53
  mode: { type: "string", enum: ["open", "restricted"], description: "open: every member may msg/ask every other member. restricted: only the drawn paths (edges)." },
54
+ hopLimit: { type: "integer", minimum: 1, maximum: 100, description: "Agent-only wake hop limit between this team's seats; may not exceed the hub-wide agents.hopLimit. Unset uses the global limit." },
54
55
  roles: { type: "array", items: ROLE, description: "1–32 roles." },
55
56
  entryRole: { ...STRING, description: "The role that receives submitted work (e.g. pm) and supervises members." },
56
57
  edges: { type: "array", items: EDGE, description: "Communication paths between roles (restricted mode)." },
@@ -176,6 +177,7 @@ function specLines(spec: TeamSpec): string[] {
176
177
  ` ${r.name}${r.name === spec.entryRole ? " (entry)" : ""}${r.label ? ` "${one(r.label)}"` : ""}${r.externalContact ? " external contact" : ""}${r.minMembers !== undefined || r.maxMembers !== undefined ? ` members ${r.minMembers ?? 0}–${r.maxMembers ?? 16}` : ""}${r.profile ? ` profile ${r.profile.profileID}${r.profile.version ? `@${r.profile.version}` : ""}` : ""}`
177
178
  return [
178
179
  `mode: ${spec.mode}${spec.mode === "open" ? " (every member may msg/ask every other)" : " (only the paths below)"}`,
180
+ ...(spec.hopLimit !== undefined ? [`hop limit: ${spec.hopLimit}`] : []),
179
181
  `entry role: ${spec.entryRole}`,
180
182
  "roles:",
181
183
  ...spec.roles.map(role),
@@ -196,7 +198,7 @@ function rosterLines(roster: TeamRoster): string[] {
196
198
  const flags = [r.vacant ? "vacant" : "", r.understaffed ? `understaffed (min ${r.minMembers})` : "", r.launching ? `${r.launching} launching` : ""].filter(Boolean)
197
199
  lines.push(` ${r.role}${r.entry ? " (entry)" : ""} ${r.seats.length}/${r.maxMembers} ${r.online} online${flags.length ? ` ${flags.join(", ")}` : ""}`)
198
200
  for (const s of r.seats)
199
- lines.push(` ${s.staffing.id} ${s.staffing.sessionID} ${s.presence} gen ${s.staffing.generation} r${s.staffing.revision}${s.profile ? ` profile ${s.profile.status}${s.profile.matches ? "" : " (mismatch)"}` : ""}`)
201
+ lines.push(` ${s.staffing.id} ${s.staffing.sessionID} ${s.presence}${s.stalled ? ` stalled: ${s.stalled}` : ""} gen ${s.staffing.generation} r${s.staffing.revision}${s.profile ? ` profile ${s.profile.status}${s.profile.matches ? "" : " (mismatch)"}` : ""}`)
200
202
  }
201
203
  const failed = roster.requests.filter((q) => q.state !== "staffed").slice(-5)
202
204
  for (const q of failed) lines.push(` launch ${q.id} ${q.role} ${q.state}${q.reason ? `: ${one(q.reason)}` : ""}`)
@@ -347,7 +349,7 @@ export const teamTools: readonly TeamTool[] = [
347
349
  need(args, "name")
348
350
  need(args, "title")
349
351
  const key = optional(args, "idempotencyKey")
350
- const spec = picked(args, ["name", "title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as unknown as TeamSpec
352
+ const spec = picked(args, ["name", "title", "description", "mode", "hopLimit", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as unknown as TeamSpec
351
353
  const { team, created } = await hub.teams.create({ ...spec, edges: spec.edges ?? [] }, { signal, ...(key ? { idempotencyKey: key } : {}) })
352
354
  return [`${created ? "created" : "already created (same key)"}: ${teamLine(team)}`, ...specLines(team)].join("\n")
353
355
  },
@@ -362,7 +364,7 @@ export const teamTools: readonly TeamTool[] = [
362
364
  idempotent: true,
363
365
  run: async (hub, args, signal) => {
364
366
  const expectedRevision = revision(args)
365
- const patch = picked(args, ["title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as TeamPatch
367
+ const patch = picked(args, ["title", "description", "mode", "hopLimit", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as TeamPatch
366
368
  if (Object.keys(patch).length === 0) throw new ArgError("give at least one field to change")
367
369
  const id = need(args, "team")
368
370
  const { team } = await hub.teams.update(id, patch, expectedRevision, { signal })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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.10.0",
3
+ "version": "0.11.0",
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.10.0",
3
+ "version": "0.11.0",
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.10.0",
3
+ "version": "0.11.0",
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",
@@ -480,8 +480,10 @@ export const IMPLIED_OPENCODE_FEATURES = [
480
480
  * "inboxCarried": its session.inbox.delivered marks a user item the hub carried (`carried: true`), so every other
481
481
  * user item is a prompt typed in the terminal. Spokes of harnesses that report the hub's prompts as origin "hub"
482
482
  * need not announce it.
483
+ * "setModel": it sets the session's own model (setModel op) the way the harness keeps it for every later turn of
484
+ * that session (OpenCode: session.switchModel, stored on the session), whoever prompts it next.
483
485
  */
484
- const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert", "dismissError", "timelineMedia", PREVIEW_BUNDLE_FEATURE, "voiceFront", "conciergeFront", "inboxCarried"] as const
486
+ const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert", "dismissError", "timelineMedia", PREVIEW_BUNDLE_FEATURE, "voiceFront", "conciergeFront", "inboxCarried", "setModel"] as const
485
487
 
486
488
  /**
487
489
  * The core ops. An OpenCode spoke serves them all without announcing them; a spoke of another harness announces
@@ -700,6 +702,12 @@ export type ServerRequest =
700
702
  | { readonly op: "modelPrefs"; readonly sessionID: string }
701
703
  /** `worker` is obsolete: older hubs send it, current spokes ignore it. */
702
704
  | { readonly op: "setModelPrefs"; readonly sessionID: string; readonly voice?: ModelSelection | null; readonly worker?: ModelSelection | null }
705
+ /**
706
+ * (feature "setModel") Set the session's own model: every later turn of the session runs on it, whoever prompts it
707
+ * (the console, the terminal, a voice relay). One of the session's catalog models (models op); no variant: the
708
+ * model's default. The Voice model (setModelPrefs) is separate and never changes it.
709
+ */
710
+ | { readonly op: "setModel"; readonly sessionID: string; readonly model: ModelSelection }
703
711
  | { readonly op: "timeline"; readonly sessionID: string; readonly limit?: number; readonly cursor?: string; readonly order?: "asc" | "desc"; readonly before?: string }
704
712
  | { readonly op: "timelinePart"; readonly sessionID: string; readonly messageID: string; readonly partID: string }
705
713
  /** One image attached to a user message (its position in the message's files). */
@@ -920,6 +928,8 @@ export interface ResultFor {
920
928
  models: { readonly models: readonly AvailableModel[] }
921
929
  modelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
922
930
  setModelPrefs: { readonly prefs: SessionModelPrefs; readonly defaults: SessionModelPrefs }
931
+ /** `model`: the session's model now (source "chosen"). */
932
+ setModel: { readonly model: SessionModel }
923
933
  timeline: TimelinePage
924
934
  timelinePart: { readonly detail: unknown }
925
935
  /** No `file` when the message has no image at that index; `tooLarge` when its bytes would not fit in a frame. */
@@ -1268,6 +1278,8 @@ function isServerRequest(v: unknown): v is ServerRequest {
1268
1278
  (v.worker === undefined || v.worker === null || isModelSelection(v.worker)) &&
1269
1279
  (v.voice !== undefined || v.worker !== undefined)
1270
1280
  )
1281
+ case "setModel":
1282
+ return nonEmpty(v.sessionID) && isModelSelection(v.model)
1271
1283
  case "timeline":
1272
1284
  return (
1273
1285
  nonEmpty(v.sessionID) &&
@@ -19,6 +19,7 @@ export const TEAM_LIMITS = {
19
19
  sessionsPerRole: 16,
20
20
  /** Open intake items per team. */
21
21
  openIntakePerTeam: 500,
22
+ hopLimit: 100,
22
23
  } as const
23
24
 
24
25
  export const TEAM_SCOPES = ["teams:read", "teams:manage", "teams:submit"] as const
@@ -54,6 +55,8 @@ export interface TeamDefinition extends Revisioned {
54
55
  readonly description?: string
55
56
  readonly state: RecordState
56
57
  readonly mode: TeamMode
58
+ /** Optional limit for messages between this team's seated sessions. */
59
+ readonly hopLimit?: number
57
60
  readonly roles: readonly TeamRole[]
58
61
  /** The intake role; receives submitted work and supervises members (via workspace supervision). */
59
62
  readonly entryRole: string
@@ -182,6 +185,7 @@ export function teamInputProblem(v: unknown): string | undefined {
182
185
  if (!isLabel(v.title)) return "title must be 1–120 characters of text"
183
186
  if (v.description !== undefined && !isText(v.description, DESCRIPTION_MAX)) return `description must be at most ${DESCRIPTION_MAX} characters`
184
187
  if (v.mode !== "open" && v.mode !== "restricted") return "mode must be open or restricted"
188
+ if (v.hopLimit !== undefined && (!Number.isSafeInteger(v.hopLimit) || (v.hopLimit as number) < 1 || (v.hopLimit as number) > TEAM_LIMITS.hopLimit)) return `hopLimit must be a whole number from 1 to ${TEAM_LIMITS.hopLimit}`
185
189
  if (!Array.isArray(v.roles) || v.roles.length === 0 || v.roles.length > TEAM_LIMITS.rolesPerTeam) return `roles must list 1–${TEAM_LIMITS.rolesPerTeam} roles`
186
190
  const names = new Set<string>()
187
191
  for (const role of v.roles) {
@@ -2,7 +2,8 @@ export const RECORDING_FORMAT = "hub-voice-recording" as const
2
2
  export const RECORDING_FORMAT_VERSION = 1
3
3
  export const RECORDING_AUDIO_TYPE = "audio/wav" // 16 kHz mono PCM16, exactly as sent to STT
4
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
5
+ export const RECORDING_REPLY_TYPE = "audio/mpeg" // Developer mode: a reply sentence's TTS audio, as synthesized
6
+ export const RECORDING_MEDIA_TYPES = [RECORDING_AUDIO_TYPE, RECORDING_PAGE_TYPE, RECORDING_REPLY_TYPE] as const
6
7
  export const RECORDING_HEADER = "x-hub-recording" // on /api/transcribe and /api/speak: the recording ID
7
8
  export const RECORDING_SEGMENT_HEADER = "x-hub-segment" // on /api/transcribe: the segment number
8
9
  export const RECORDING_REF_HEADER = "x-hub-tts-ref" // on /api/speak: "<messageID>:<ordinal>:<seq>:<i>"
@@ -11,8 +12,12 @@ export const RECORDING_AUDIO_RESULT_HEADER = "x-hub-recording-audio" // transcri
11
12
  * Console kinds and values added after version 1, listed in the start answer (`features`): a console sends them only
12
13
  * to a hub that lists them (an older hub refuses a whole batch with an unknown kind or value).
13
14
  * "turn.predict": the turn.predict kind and utterance.sent reasons "smart" / "smart-fallback" (Smart Turn).
15
+ * "capture.v2": the kinds vad.near, vad.summary, stt.slow and mark, the utterance.command command "mark", and the
16
+ * stt.received fields junkReason, held and speechMs (an older hub refuses an unknown field too).
17
+ * "dev.v1": Developer mode's kinds vad.trace, audio.clip, device.info, resource.timing and console.log (sent only
18
+ * when the start answer also says `developer: true`), and the clip upload route.
14
19
  */
15
- export const RECORDING_FEATURES = ["turn.predict"] as const
20
+ export const RECORDING_FEATURES = ["turn.predict", "capture.v2", "dev.v1"] as const
16
21
  export type RecordingFeature = (typeof RECORDING_FEATURES)[number]
17
22
 
18
23
  export const RECORDING_LIMITS = {
@@ -36,8 +41,29 @@ export const RECORDING_LIMITS = {
36
41
  hubFlushMs: 2_000, hubFlushEvents: 100,
37
42
  } as const
38
43
 
44
+ /**
45
+ * Developer mode (feature "dev.v1"): the extras' own limits. Their audio (clips, reply audio) counts against the same
46
+ * caps but stops first: never within the headroom kept for segments below the user's and the recording's caps.
47
+ */
48
+ export const DEV_LIMITS = {
49
+ clipsPerRecording: 200,
50
+ clipPreMs: 3000,
51
+ clipPostMs: 2000,
52
+ clipBytes: 192 * 1024, // 5 s at 16 kHz PCM16 = 160,044 B
53
+ /** At most one clip per this much time (console), the rest are not cut. */
54
+ clipGapMs: 2000,
55
+ repliesPerRecording: 2000,
56
+ replyBytes: 1024 * 1024, // one sentence's MP3
57
+ userHeadroomBytes: 50 * 1024 * 1024, // extras stop at userBytes − this; segments go on to userBytes
58
+ recordingHeadroomBytes: 15 * 1024 * 1024,
59
+ tracesPerMinute: 30,
60
+ logsPerMinute: 20,
61
+ } as const
62
+
39
63
  export interface RecordingSettings {
40
64
  readonly enabled: boolean // default false
65
+ /** Developer mode (implies enabled; turning recording off turns it off). Absent: off. */
66
+ readonly developer?: boolean
41
67
  readonly revision: number
42
68
  readonly updatedAt?: number
43
69
  }
@@ -75,6 +101,11 @@ export interface RecordingSummary {
75
101
  readonly events: number
76
102
  readonly audio: boolean // false once a cap stopped audio
77
103
  readonly redactions: number
104
+ /** Developer mode: this recording keeps the extras (until Developer mode is turned off). */
105
+ readonly developer?: true
106
+ /** Developer mode: audio clips and reply audio stored. */
107
+ readonly clips?: number
108
+ readonly replies?: number
78
109
  }
79
110
 
80
111
  export interface RecordingUsage { readonly bytes: number; readonly recordings: number; readonly capBytes: number }
@@ -90,11 +121,17 @@ export type ConsoleEventKind =
90
121
  | "tts.sentence.ended" | "barge.in" | "cue.played" | "mic" | "control" | "turn.timing" | "decision.voice"
91
122
  | "clock.sync" | "error"
92
123
  | "turn.predict" // feature "turn.predict"
124
+ | "vad.near" | "vad.summary" | "stt.slow" | "mark" // feature "capture.v2"
125
+ | "vad.trace" | "audio.clip" | "device.info" | "resource.timing" | "console.log" // feature "dev.v1" (Developer mode)
93
126
  export type HubEventKind =
94
127
  | "recording.started" | "recording.ended" | "conversation" | "stt.request" | "stt.result" | "tts.synth"
95
128
  | "utterance.received" | "utterance.delivered" | "turn.started" | "first.token" | "say.sent" | "reply.full"
96
129
  | "turn.ended" | "tool" | "interrupt" | "wake.arrived" | "concierge.tool" | "decision.opened" | "decision.answered"
97
130
  | "cap.reached" | "error"
131
+ | "turn.detail" | "request" | "reply.audio" // Developer mode
132
+
133
+ /** Console kinds of Developer mode: stored only in a developer recording. */
134
+ export const DEV_CONSOLE_KINDS: readonly ConsoleEventKind[] = ["vad.trace", "audio.clip", "device.info", "resource.timing", "console.log"]
98
135
 
99
136
  /** As stored and exported (events.jsonl). */
100
137
  export interface RecordingEvent {
@@ -29,7 +29,9 @@ poll="${HUB_POLL_INTERVAL:-0.5}"
29
29
  say() { echo "[hub-sandbox] $*" >&2; }
30
30
 
31
31
  mkdir -p "$hub"
32
- echo "$$" >"$hub/entry.pid"
32
+ pid_file=$(mktemp "$hub/entry.pid.XXXXXX") || exit 1
33
+ printf '%s\n' "$$" >"$pid_file"
34
+ mv -f "$pid_file" "$hub/entry.pid"
33
35
  rm -f "$hub/exited"
34
36
 
35
37
  # 1. Wait for the start op.
@@ -20,5 +20,7 @@ else
20
20
  nohup "$here/hub-sandbox-entry" >>"$log" 2>&1 </dev/null &
21
21
  fi
22
22
  # The entry rewrites this with its own pid; written here too so a start right after sees it.
23
- echo "$!" >"$hub/entry.pid"
23
+ pid_file=$(mktemp "$hub/entry.pid.XXXXXX") || exit 1
24
+ printf '%s\n' "$!" >"$pid_file"
25
+ mv -f "$pid_file" "$hub/entry.pid"
24
26
  echo "hub-sandbox: started (pid $!, log $log)"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-openshell",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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.10.0",
3
+ "version": "0.11.0",
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.10.0",
3
+ "version": "0.11.0",
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",
@@ -780,6 +780,19 @@ export class HubClient {
780
780
  return this.call("GET", `${AGENT}/grants`, signalOf(options))
781
781
  }
782
782
 
783
+ /** Grants held by this session/app; a user credential has no holder. */
784
+ heldGrants(options: CallOptions = {}): Promise<{ grants: Grant[] }> {
785
+ return this.call("GET", `${AGENT}/grants/mine`, signalOf(options))
786
+ }
787
+
788
+ heldGrant(id: string, options: CallOptions = {}): Promise<{ grant: Grant }> {
789
+ return this.call("GET", `${AGENT}/grants/mine/${segment(id)}`, signalOf(options))
790
+ }
791
+
792
+ revokeHeldGrant(id: string, options: CallOptions = {}): Promise<{ grant: Grant }> {
793
+ return this.call("DELETE", `${AGENT}/grants/mine/${segment(id)}`, signalOf(options))
794
+ }
795
+
783
796
  grant(id: string, options: CallOptions = {}): Promise<{ grant: Grant }> {
784
797
  return this.call("GET", `${AGENT}/grants/${segment(id)}`, signalOf(options))
785
798
  }
@@ -53,6 +53,7 @@ export interface TeamSpec {
53
53
  readonly title: string
54
54
  readonly description?: string
55
55
  readonly mode: TeamMode
56
+ readonly hopLimit?: number
56
57
  readonly roles: readonly TeamRole[]
57
58
  /** The intake role (e.g. the project manager): receives submitted work and supervises members. */
58
59
  readonly entryRole: string
@@ -149,6 +150,7 @@ export interface TeamWorkspaceLink {
149
150
  export interface TeamRosterSeat {
150
151
  readonly staffing: TeamStaffing
151
152
  readonly presence: TeamSessionPresence
153
+ readonly stalled?: "hop cap"
152
154
  readonly memberID?: string
153
155
  readonly supervisorMemberID?: string
154
156
  readonly profile?: {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
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",
@@ -124,7 +124,13 @@ export function appAnswerRefusal(decision: Pick<Decision, "tier" | "action" | "a
124
124
  }
125
125
 
126
126
  /** What answering a Decision does besides replying: an escalated permission request (P3-T8) or a grant request (P3-T9). */
127
- export type DecisionAction = PermissionAction | GrantRequestAction | ActAction
127
+ export type DecisionAction = PermissionAction | GrantRequestAction | ActAction | TeamHopRenewAction
128
+
129
+ /** Hub-created, tap-only action: an agent cannot authorize a team's hop budget renewal. */
130
+ export interface TeamHopRenewAction {
131
+ readonly kind: "teamHopRenew"
132
+ readonly teamID: string
133
+ }
128
134
 
129
135
  /**
130
136
  * (CON-06) A Concierge proposal (concierge/proposals.ts): answering `go` runs the act verb on the target AS THE USER