@huynle/hub 0.9.2 → 0.10.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 (141) hide show
  1. package/CHANGELOG.md +296 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +152 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +532 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -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/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +69 -11
  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/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +516 -56
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +209 -238
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-BC0O1gfR.css +1 -0
  123. package/web/dist/assets/index-BS6lA6fN.js +33 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -6,6 +6,8 @@
6
6
  // phase 3): the only credential the hub lets a session act with, which the spoke never puts in any environment.
7
7
  // toolIdentity picks the token per verb and never falls back from one kind to the other. Messages from other sessions are shown in the
8
8
  // hub's envelope (packages/sdk/src/envelope.ts), sanitised, and marked as not coming from the user.
9
+ // Each result's metadata carries a one-line title (src/spoke/hub-tool-title.ts) that reads like an incoming notice.
10
+ import { withHubToolTitle } from "./hub-tool-title"
9
11
  import {
10
12
  HubClient,
11
13
  HubError,
@@ -27,7 +29,8 @@ import {
27
29
  type TeamIntakeStatusInput,
28
30
  wakeSkipNote,
29
31
  } from "../../packages/sdk/src/index.ts"
30
- import { ME_LINE, reachText, whoSections } from "./who-teams"
32
+ import { ME_LINE, reachText, titleText, whoSections, YOU_MARK, youLine } from "./who-teams"
33
+ import { HUB_TOOL_SUBSCRIPTION_VERBS, SUBSCRIPTION_TOOL_DESCRIPTION, SUBSCRIPTION_TOOL_INPUT, subscriptionVerb } from "./hub-tool-subscriptions"
31
34
 
32
35
  export const HUB_TOOL = "hub"
33
36
 
@@ -47,11 +50,13 @@ export const HUB_TOOL_SELF_VERBS = ["name", "role_claim", "role_release"] as con
47
50
  * concierge_find looks the user's sessions up by @name or title words through GET /api/agent/sessions?q=, an ordinary
48
51
  * read that any session (and the user) may make: it is a Concierge verb only for its framed, untrusted output. (FX-07)
49
52
  * Text without a letter or digit is answered here without a request (the hub would match nothing).
53
+ * (Concierge grant requests) concierge_request_grant asks the user for a scoped grant (POST …/grant-requests: a tap
54
+ * Decision; the hub clamps it).
50
55
  */
51
- export const HUB_TOOL_CONCIERGE_VERBS = ["concierge_board", "concierge_decisions", "concierge_decision", "concierge_notes", "concierge_notes_set", "concierge_watch", "concierge_unwatch", "concierge_mute", "concierge_unmute", "concierge_arm", "concierge_propose", "concierge_find", "concierge_form"] as const
56
+ export const HUB_TOOL_CONCIERGE_VERBS = ["concierge_board", "concierge_decisions", "concierge_decision", "concierge_notes", "concierge_notes_set", "concierge_watch", "concierge_unwatch", "concierge_mute", "concierge_unmute", "concierge_arm", "concierge_propose", "concierge_find", "concierge_form", "concierge_request_grant"] as const
52
57
  export type HubToolConciergeVerb = (typeof HUB_TOOL_CONCIERGE_VERBS)[number]
53
58
 
54
- export const HUB_TOOL_VERBS = ["msg", "ask", "reply", "decline", "inbox", "who", "status", "show", "whoami", "intake", "intake_update", ...HUB_TOOL_SELF_VERBS, ...HUB_TOOL_ACT_VERBS, ...HUB_TOOL_CONCIERGE_VERBS] as const
59
+ export const HUB_TOOL_VERBS = ["msg", "ask", "reply", "decline", "inbox", "who", "status", "show", "whoami", "intake", "intake_update", ...HUB_TOOL_SUBSCRIPTION_VERBS, ...HUB_TOOL_SELF_VERBS, ...HUB_TOOL_ACT_VERBS, ...HUB_TOOL_CONCIERGE_VERBS] as const
55
60
  export type HubToolVerb = (typeof HUB_TOOL_VERBS)[number]
56
61
 
57
62
  /** Which of the session's tokens a verb uses: "act" for act and Concierge verbs, "shell" for every other verb. */
@@ -75,8 +80,9 @@ export const HUB_TOOL_DESCRIPTION =
75
80
  "msg (tell a session something: to, text; urgent steers into its running turn; wake:false does not start a turn), " +
76
81
  "ask (ask a question: to, text; wait = seconds to block for the answer, at most 600; without wait the answer arrives later as a hub message), " +
77
82
  "reply (answer a message: id, text), decline (decline an ask: id, text = the reason), " +
78
- "inbox (open asks to you, your open asks, recent messages), who (named and role-holding sessions; project, role, team; grouped by team with your reach to each), " +
83
+ "inbox (open asks to you, your open asks, recent messages), who (sessions: online ones, and named or role-holding ones while offline, yours first and marked; project, role, team; grouped by team with your reach to each), " +
79
84
  "status (machines, projects, sessions and what needs attention, with request IDs), show (one session: session; its state and blockers with the request and form IDs approve, deny and answer take), whoami (this session's hub identity). " +
85
+ SUBSCRIPTION_TOOL_DESCRIPTION +
80
86
  "This session itself (never another): name (set your @name: name, e.g. @billing-lead; your previous name stays an alias for 24 h; a taken name fails with name_taken), " +
81
87
  "role_claim (hold a role so @@role reaches you: role, project scopes it to a project, exclusive to be its only holder; reserved roles such as orchestrator are the user's to assign), role_release (role, project). " +
82
88
  "Team intake, for the session in a team's entry role: intake (team = the team's ID; id = an intake ID for one item, else the list), " +
@@ -94,11 +100,11 @@ export const HUB_TOOL_INPUT = {
94
100
  type: "object",
95
101
  properties: {
96
102
  verb: { type: "string", enum: [...HUB_TOOL_VERBS], description: "What to do." },
97
- to: { type: "string", description: "msg/ask: the recipient address (@name, @@role, @@role#team, @name.project, ses_…, @me)." },
103
+ to: { type: "string", description: "msg/ask: the recipient address (@name, @@role, @@role#team, @name.project, ses_…, @me); subscribe: what to follow (a session, @@role, #team or children; several separated by commas)." },
98
104
  text: { type: "string", description: "msg/ask/reply: the message text; decline: the reason; prompt: the prompt." },
99
- id: { type: "string", description: "reply/decline: the ID of the message you answer (from its [hub] header); approve/deny: the permission request's ID; answer: the form's ID; intake/intake_update: the intake ID (itk_…)." },
105
+ id: { type: "string", description: "reply/decline: the ID of the message you answer (from its [hub] header); approve/deny: the permission request's ID; answer: the form's ID; intake/intake_update: the intake ID (itk_…); unsubscribe: the subscription's ID (sub_…)." },
100
106
  wait: { type: "number", description: "ask: seconds to wait for the answer (0–600, default 0)." },
101
- expires: { type: "number", description: "ask: seconds until the ask expires (the hub's default when unset)." },
107
+ expires: { type: "number", description: "ask: seconds until the ask expires (the hub's default when unset); subscribe: seconds until the subscription ends (default a day, at most 30 days)." },
102
108
  urgent: { type: "boolean", description: "msg/ask: steer into the recipient's running turn." },
103
109
  wake: { type: "boolean", description: "msg/ask/reply: false admits the message without starting a turn." },
104
110
  all: { type: "boolean", description: "msg/ask: send to every holder of a role address (no wait)." },
@@ -109,7 +115,7 @@ export const HUB_TOOL_INPUT = {
109
115
  why: { type: "string", description: "approve/deny/answer/prompt/stop: why (audited; deny passes it to the harness)." },
110
116
  answer: { type: "object", description: "answer: the form's answer, field key → value (text, number, true/false or a list of texts)." },
111
117
  cancel: { type: "boolean", description: "answer: dismiss the form instead of answering." },
112
- delivery: { type: "string", enum: ["queue", "steer"], description: "prompt: how it enters a busy session." },
118
+ delivery: { type: "string", enum: ["queue", "steer", "silent"], description: "prompt: how it enters a busy session (queue or steer); subscribe: queue (default), steer or silent." },
113
119
  project: { type: "string", description: "who: only this project; role_claim/role_release: the project the role is scoped to." },
114
120
  role: { type: "string", description: "who: only holders of this role; role_claim/role_release: the role (reviewer or @@reviewer)." },
115
121
  team: { type: "string", description: "who: only this team (one of yours, when you are in teams); intake/intake_update: the team's ID (team_…)." },
@@ -117,7 +123,14 @@ export const HUB_TOOL_INPUT = {
117
123
  reason: { type: "string", description: "intake_update: why it failed or was cancelled (one line)." },
118
124
  revision: { type: "number", description: "intake_update: the revision you read (optional: the current one is read first)." },
119
125
  propose: { type: "string", enum: ["stop", "deny", "prompt", "answer"], description: "concierge_propose: the act to propose to the user." },
126
+ ...SUBSCRIPTION_TOOL_INPUT,
120
127
  recommend: { type: "boolean", description: "concierge_propose and approve: true only when YOU recommend doing it (shown as recommended, with why). Leave it out when the user only asked you to propose or try." },
128
+ projects: { type: "array", items: { type: "string" }, description: "concierge_request_grant: the watched projects (as your sessions name them) the grant covers." },
129
+ verbs: { type: "array", items: { type: "string" }, description: "concierge_request_grant: approve, deny, answer (prompt, stop only with userAsked)." },
130
+ permissions: { type: "array", items: { type: "string" }, description: "concierge_request_grant: the permissions approve may allow (read, edit, …)." },
131
+ never: { type: "array", items: { type: "string" }, description: "concierge_request_grant: extra never rules (the user's are added by the hub)." },
132
+ hours: { type: "number", description: "concierge_request_grant: how long the grant lasts after approval (default 8, at most 24)." },
133
+ userAsked: { type: "boolean", description: "concierge_request_grant: true only when the user explicitly asked for prompt or stop." },
121
134
  },
122
135
  required: ["verb"],
123
136
  additionalProperties: false,
@@ -193,7 +206,8 @@ export async function runHubTool(spoke: () => HubToolSpoke | undefined, value: u
193
206
  const secrets = [identity.token, toolIdentity(current, context.sessionID, kind === "act" ? "shell" : "act")?.token]
194
207
  const redact = (result: HubToolResult): HubToolResult => secrets.reduce((out, token) => (token ? scrub(out, token) : out), result)
195
208
  try {
196
- return redact(await dispatch(client, verb as HubToolVerb, input, context))
209
+ // The title (metadata only) is redacted with the rest: the text it quotes is the agent's.
210
+ return redact(withHubToolTitle(input, await dispatch(client, verb as HubToolVerb, input, context)))
197
211
  } catch (cause) {
198
212
  if (cause instanceof InputError) return failure(cause.message, "invalid")
199
213
  const needsUser = needsUserOf(cause)
@@ -255,20 +269,22 @@ async function dispatch(client: HubClient, verb: HubToolVerb, input: Input, cont
255
269
  const answer = await client.who({ ...(project ? { project } : {}), ...(role ? { role } : {}), ...(team ? { team } : {}), ...signal })
256
270
  const { sessions } = answer
257
271
  const teams = (answer.teams ?? []).map((t) => ({ ...t, name: sanitizeLabel(t.name), yourRoles: (t.yourRoles ?? []).map((r) => sanitizeLabel(r)) }))
258
- const rows = sessions.map((s) => ({ ...s, teams: (s.teams ?? []).map((t) => ({ ...t, team: sanitizeLabel(t.team) })) }))
259
- const lines: string[] = []
260
- for (const section of whoSections(rows, { teams, ...(answer.scope ? { scope: answer.scope } : {}) })) {
272
+ const rows = sessions.map((s) => ({ ...s, ...(s.name ? { name: sanitizeLabel(s.name) } : {}), ...(s.project ? { project: sanitizeLabel(s.project) } : {}), teams: (s.teams ?? []).map((t) => ({ ...t, team: sanitizeLabel(t.team) })) }))
273
+ const meta = { ...(teams.length ? { teams: teams.length } : {}), ...(answer.scope ? { scope: answer.scope } : {}) }
274
+ if (!rows.length) return { content: "No sessions to show.", metadata: { verb, sessions: 0, ...meta } }
275
+ const you = context.sessionID
276
+ const lines = [youLine(rows.find((s) => s.sessionID === you), you, "the name verb sets one")]
277
+ for (const section of whoSections(rows, { teams, you, ...(answer.scope ? { scope: answer.scope } : {}) })) {
261
278
  if (section.title) lines.push(section.title)
262
279
  for (const { row: s, teamRoles, reach } of section.rows) {
263
280
  const roles = (teamRoles ?? s.roles.map((r) => r.role)).map((r) => `@@${sanitizeLabel(r)}`).join(" ")
264
- const tail = teamRoles && s.sessionID === context.sessionID ? "you" : reachText(reach)
265
- const parts = [s.name ? `@${sanitizeLabel(s.name)}` : s.sessionID, s.name ? s.sessionID : "", s.project ? sanitizeLabel(s.project) : "", s.state ?? "", s.online ? "online" : "offline", s.harness ? sanitizeLabel(s.harness) : "", roles, tail]
281
+ const label = [s.name ? `@${s.name}` : s.sessionID, s.sessionID === you ? YOU_MARK : ""].filter(Boolean).join(" ")
282
+ const parts = [label, s.name ? s.sessionID : titleText(s.title ? sanitizeLabel(s.title) : undefined), s.project ?? "", s.state ?? "", s.online ? "online" : "offline", s.harness ? sanitizeLabel(s.harness) : "", roles, reachText(reach)]
266
283
  lines.push((section.title ? " " : "") + parts.filter(Boolean).join(" · "))
267
284
  }
268
285
  }
269
- if (answer.scope === "team" && lines.length) lines.push(ME_LINE)
270
- const meta = { ...(teams.length ? { teams: teams.length } : {}), ...(answer.scope ? { scope: answer.scope } : {}) }
271
- return { content: lines.length ? lines.join("\n") : "No named or role-holding sessions.", metadata: { verb, sessions: sessions.length, ...meta } }
286
+ if (answer.scope === "team") lines.push(ME_LINE)
287
+ return { content: lines.join("\n"), metadata: { verb, sessions: sessions.length, ...meta } }
272
288
  }
273
289
  case "show": {
274
290
  const addr = typeof input.session === "string" ? input.session.trim() : ""
@@ -312,6 +328,10 @@ async function dispatch(client: HubClient, verb: HubToolVerb, input: Input, cont
312
328
  case "intake":
313
329
  case "intake_update":
314
330
  return intake(client, verb, input, signal)
331
+ case "subscribe":
332
+ case "unsubscribe":
333
+ case "subscriptions":
334
+ return subscriptionVerb(client, verb, input, signal)
315
335
  case "name":
316
336
  case "role_claim":
317
337
  case "role_release":
@@ -329,6 +349,7 @@ async function dispatch(client: HubClient, verb: HubToolVerb, input: Input, cont
329
349
  case "concierge_propose":
330
350
  case "concierge_find":
331
351
  case "concierge_form":
352
+ case "concierge_request_grant":
332
353
  return conciergeVerb(client, verb, input, signal)
333
354
  case "whoami": {
334
355
  const { actor, user } = await client.whoami(signal)
@@ -499,7 +520,7 @@ function sent(verb: string, result: Sent): HubToolResult {
499
520
  const { message } = result
500
521
  return {
501
522
  content: `${verb === "decline" ? "Declined" : verb === "reply" ? "Replied" : "Sent"} ${message.id} to ${partyText(message)} (${deliveryNote(result)}).`,
502
- metadata: { verb, id: message.id, ...deliveryMetadata(result) },
523
+ metadata: { verb, id: message.id, to: partyText(message), ...deliveryMetadata(result) },
503
524
  }
504
525
  }
505
526
 
@@ -764,6 +785,23 @@ async function conciergeVerb(client: HubClient, verb: HubToolConciergeVerb, inpu
764
785
  : "Nothing is armed."
765
786
  return { content, metadata: { verb, decisionID: r.armed?.decisionID ?? null } }
766
787
  }
788
+ case "concierge_request_grant": {
789
+ const list = (key: string, required: boolean) => {
790
+ const value = input[key]
791
+ if (value === undefined && !required) return {}
792
+ if (!Array.isArray(value) || (required && value.length === 0)) throw new InputError(`hub ${verb}: "${key}" is a list${required ? " (at least one)" : ""}`)
793
+ return { [key]: value }
794
+ }
795
+ const body: Record<string, unknown> = { ...list("projects", true), ...list("verbs", true), ...list("permissions", false), ...list("never", false) }
796
+ if (input.hours !== undefined) body.hours = input.hours
797
+ if (input.userAsked !== undefined) body.userAsked = input.userAsked
798
+ body.why = str(input, "why", verb)
799
+ const { decision } = await client.call<{ decision: { id: string; tier: string } }>("POST", `${CONCIERGE_PATH}/grant-requests`, { body, ...signal })
800
+ return {
801
+ content: `Requested the grant: Decision ${decision.id} (${decision.tier}) waits for the user's tap on the card in the Concierge panel. Do not retry and do not arm it; tell the user it waits for their tap.`,
802
+ metadata: { verb, decisionID: decision.id, tier: decision.tier },
803
+ }
804
+ }
767
805
  case "concierge_propose": {
768
806
  const propose = input.propose
769
807
  if (propose !== "stop" && propose !== "deny" && propose !== "prompt" && propose !== "answer") throw new InputError(`hub ${verb}: "propose" is stop, deny, prompt or answer`)
@@ -16,7 +16,7 @@
16
16
  // LOCKED sessions (the Concierge's, R1 of the CON-04 verify): a context with lockAgent locks its session for good.
17
17
  // A locked session, WHATEVER agent it runs on and with or without a context (cleared, or not re-sent yet after a
18
18
  // restart), keeps only CONCIERGE_TOOLS (intersected with its context's allow-list), never gets conversation mode,
19
- // voice style or orchestrator parts. Locks survive `clear`, the context bound and a restart: they are persisted in
19
+ // voice style or a voice front. Locks survive `clear`, the context bound and a restart: they are persisted in
20
20
  // the plugin's state dir (lockFile), so a restarted OpenCode locks the session before the hub reconnects. The agent
21
21
  // a locked session was last seen on is reported to the hub (SessionInfo.agent), which suspends a Concierge found on
22
22
  // another agent.
@@ -32,6 +32,8 @@ interface Context {
32
32
  readonly tail: readonly string[]
33
33
  readonly deny: readonly string[]
34
34
  readonly allow?: ReadonlySet<string>
35
+ /** (P2) The compact board for the session's voice front (never in the session's own prompt). */
36
+ readonly frontBoard?: string
35
37
  }
36
38
 
37
39
  /** Parts at the end of the system prompt (stable between turns), in this order. */
@@ -277,7 +279,8 @@ export class SessionContexts {
277
279
  const byKey = new Map(req.parts.map((p) => [p.key, p.text]))
278
280
  const pick = (keys: readonly SessionContextKey[]) => keys.flatMap((key) => (byKey.has(key) ? [byKey.get(key)!] : []))
279
281
  const allow = req.tools?.allow
280
- this.contexts.set(req.sessionID, { revision: req.revision, parts: pick(SYSTEM_KEYS), tail: pick(TAIL_KEYS), deny: [...(req.tools?.deny ?? [])], ...(allow ? { allow: new Set(allow) } : {}) })
282
+ const frontBoard = byKey.get("frontBoard")
283
+ this.contexts.set(req.sessionID, { revision: req.revision, parts: pick(SYSTEM_KEYS), tail: pick(TAIL_KEYS), deny: [...(req.tools?.deny ?? [])], ...(allow ? { allow: new Set(allow) } : {}), ...(frontBoard !== undefined ? { frontBoard } : {}) })
281
284
  this.bound(this.contexts)
282
285
  return { revision: req.revision }
283
286
  }
@@ -301,6 +304,11 @@ export class SessionContexts {
301
304
  return [...(this.contexts.get(sessionID)?.tail ?? [])]
302
305
  }
303
306
 
307
+ /** (P2) The compact board the hub sent for the session's voice front (frontBoard part), if any. */
308
+ frontBoard(sessionID: string): string | undefined {
309
+ return this.contexts.get(sessionID)?.frontBoard
310
+ }
311
+
304
312
  /** A Concierge session: only CONCIERGE_TOOLS, its agent and model kept, no conversation mode. */
305
313
  locked(sessionID: string): boolean {
306
314
  if (!this.locks.has(sessionID)) this.refresh()