@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
@@ -63,11 +63,51 @@ export interface AppToken {
63
63
  [field: string]: unknown
64
64
  }
65
65
 
66
+ /**
67
+ * (AF1) A registered app client of this user (hub-wide when an admin registered it with audience "hub", else
68
+ * personal). Only the sha256 of its secret is stored. Unique per user by id (hcl_…); IDs are hub-unique.
69
+ */
70
+ export interface AppClientRecord {
71
+ id: string
72
+ secretHash: string
73
+ name?: string
74
+ redirectURIs?: string[]
75
+ scopes?: string[]
76
+ audience?: "hub" | "user"
77
+ created?: number
78
+ updated?: number
79
+ disabled?: true
80
+ [field: string]: unknown
81
+ }
82
+
83
+ /**
84
+ * (AF1) This user's connection to an app client: its scopes and its token family (sha256 hashes only). `previous`
85
+ * is the 30 s refresh grace: the just-rotated refresh token's hash, its access token's hash and the new pair sealed
86
+ * with a key derived from that refresh token. Unique per user by id (16 hex, the app's tokenID).
87
+ */
88
+ export interface ConnectionRecord {
89
+ id: string
90
+ clientID: string
91
+ scopes?: string[]
92
+ created?: number
93
+ lastUsed?: number
94
+ accessHash?: string
95
+ accessExpiresAt?: number
96
+ refreshHash?: string
97
+ refreshExpiresAt?: number
98
+ previous?: { readonly refreshHash: string; readonly accessHash: string; readonly until: number; readonly accessUntil: number; readonly sealed: string }
99
+ [field: string]: unknown
100
+ }
101
+
66
102
  export interface AgentData {
67
103
  names?: AgentName[]
68
104
  roles?: AgentRole[]
69
105
  sessions?: AgentSession[]
70
106
  appTokens?: AppToken[]
107
+ /** (AF1) Registered app clients (connect-app). */
108
+ appClients?: AppClientRecord[]
109
+ /** (AF1) Connections of this user to app clients. */
110
+ connections?: ConnectionRecord[]
71
111
  /** Last holder generation handed out per roleKey(role, project). */
72
112
  generations?: Record<string, number>
73
113
  /** The user's agent kill switch (agent/acting-switch.ts): every agent action answers paused. */
@@ -89,11 +129,13 @@ const LEGACY_ROLE_KEY = "\u0000"
89
129
  type Entry = Record<string, unknown>
90
130
 
91
131
  /** Each list: the string fields an entry needs, and its identity (entries with the same one are duplicates). */
92
- const LISTS: Record<"names" | "roles" | "sessions" | "appTokens", { required: readonly string[]; identity: (e: Entry) => string }> = {
132
+ const LISTS: Record<"names" | "roles" | "sessions" | "appTokens" | "appClients" | "connections", { required: readonly string[]; identity: (e: Entry) => string }> = {
93
133
  names: { required: ["name", "sessionID"], identity: (e) => e.name as string },
94
134
  roles: { required: ["role", "sessionID"], identity: (e) => `${roleKey(e.role as string, typeof e.project === "string" ? e.project : undefined)}\u0000${e.sessionID}` },
95
135
  sessions: { required: ["sessionID"], identity: (e) => e.sessionID as string },
96
136
  appTokens: { required: ["id", "tokenHash"], identity: (e) => e.id as string },
137
+ appClients: { required: ["id", "secretHash"], identity: (e) => e.id as string },
138
+ connections: { required: ["id", "clientID"], identity: (e) => e.id as string },
97
139
  }
98
140
 
99
141
  const isPlain = (v: unknown): v is Entry => typeof v === "object" && v !== null && !Array.isArray(v)
@@ -46,9 +46,9 @@ export class MemoryCounters implements Counters {
46
46
  this.counts = new ExpiringMap(this.now)
47
47
  }
48
48
 
49
- async incr(key: string, ttlMs: number): Promise<number> {
49
+ async incr(key: string, ttlMs: number, by = 1): Promise<number> {
50
50
  const current = this.counts.get(key)
51
- const count = (current?.count ?? 0) + 1
51
+ const count = (current?.count ?? 0) + by
52
52
  this.counts.set(key, { count, expiresAt: current?.expiresAt ?? this.now() + ttlMs })
53
53
  return count
54
54
  }
@@ -143,8 +143,11 @@ export interface EventLog {
143
143
 
144
144
  /** Fixed-window counters (rate limits, budgets): INCR + PEXPIRE NX in a cluster. */
145
145
  export interface Counters {
146
- /** Adds one and returns the count; the first increment of a window starts its `ttlMs`. */
147
- incr(key: string, ttlMs: number): Promise<number>
146
+ /**
147
+ * Adds `by` (default one; a non-zero whole number, negative to undo an increment) and returns the count; the first
148
+ * increment of a window starts its `ttlMs`, later ones never extend it.
149
+ */
150
+ incr(key: string, ttlMs: number, by?: number): Promise<number>
148
151
  /** The current count (0 when unset or expired). */
149
152
  get(key: string): Promise<number>
150
153
  }
@@ -1,10 +1,10 @@
1
- // Counters in Valkey: fixed windows as INCR + PEXPIRE NX in one Lua script, so the first increment of a window
1
+ // Counters in Valkey: fixed windows as INCRBY + PEXPIRE NX in one Lua script, so the first increment of a window
2
2
  // starts its expiry and later ones never extend it, atomically. Keys go under a prefix (default "ctr:").
3
3
  // Expiry is server-side, so it follows the server's clock, not an injected one.
4
4
  import type { Counters } from "./types"
5
5
  import { ValkeyConnection, type ValkeySource } from "./valkey-client"
6
6
 
7
- const INCR_WINDOW = `local n = redis.call('INCR', KEYS[1])
7
+ const INCR_WINDOW = `local n = redis.call('INCRBY', KEYS[1], ARGV[2])
8
8
  redis.call('PEXPIRE', KEYS[1], ARGV[1], 'NX')
9
9
  return n`
10
10
 
@@ -22,9 +22,10 @@ export class ValkeyCounters implements Counters {
22
22
  this.prefix = options.prefix ?? "ctr:"
23
23
  }
24
24
 
25
- async incr(key: string, ttlMs: number): Promise<number> {
25
+ async incr(key: string, ttlMs: number, by = 1): Promise<number> {
26
26
  if (!(Number.isInteger(ttlMs) && ttlMs > 0)) throw new Error("Counters.incr: ttl must be a positive whole number of milliseconds")
27
- return Number(await this.valkey.send("EVAL", [INCR_WINDOW, "1", this.prefix + key, String(ttlMs)]))
27
+ if (!(Number.isInteger(by) && by !== 0)) throw new Error("Counters.incr: by must be a non-zero whole number")
28
+ return Number(await this.valkey.send("EVAL", [INCR_WINDOW, "1", this.prefix + key, String(ttlMs), String(by)]))
28
29
  }
29
30
 
30
31
  async get(key: string): Promise<number> {
@@ -0,0 +1,16 @@
1
+ // The client address of a request, for per-address rate limits (connect-app, AF1). The TCP peer, unless the peer is
2
+ // a trusted reverse proxy (HUB_TRUSTED_PROXIES): then the right-most X-Forwarded-For entry that is not itself a
3
+ // trusted proxy (the address the outermost trusted proxy saw). Without trusted proxies the header is never read, so
4
+ // a client cannot pick its own address. Only for limits: never for authentication or authorization.
5
+ import { type Cidr, inCidrs } from "./fetcher"
6
+
7
+ export function clientAddress(request: Request, peer: string | undefined, trusted: readonly Cidr[]): string {
8
+ const direct = (peer ?? "").trim() || "unknown"
9
+ if (!trusted.length || !inCidrs(direct, trusted)) return direct
10
+ const hops = (request.headers.get("x-forwarded-for") ?? "")
11
+ .split(",")
12
+ .map((hop) => hop.trim())
13
+ .filter(Boolean)
14
+ for (let i = hops.length - 1; i >= 0; i--) if (!inCidrs(hops[i]!, trusted)) return hops[i]!.slice(0, 64)
15
+ return hops[0]?.slice(0, 64) ?? direct
16
+ }
@@ -24,8 +24,12 @@
24
24
  // surrogates are removed from all of the text.
25
25
  import { CONCIERGE_BOARD_LIMITS, INTENT_MAX, type ConciergeBoard, type ConciergeBoardDecision, type ConciergeBoardSection, type ConciergeBoardSession } from "../../../packages/protocol/src/concierge"
26
26
  import { isHubSessionID } from "../../../packages/protocol/src/harness-events"
27
+ import { CONTEXT_CARD_LIMITS } from "../../../packages/protocol/src/context-cards"
27
28
  import { sanitizeLabel } from "../agent/envelope"
28
29
 
30
+ /** (Concierge grant requests) The verbs a board may name for a project's grant (anything else is dropped). */
31
+ export const PROJECT_GRANT_VERBS: readonly string[] = ["approve", "deny", "answer", "prompt", "stop"]
32
+
29
33
  const MIN = 60_000
30
34
  /** An age line when the board is at least this old. */
31
35
  const AGE_AFTER_MS = MIN
@@ -129,7 +133,11 @@ const cutLine = (line: string) => cutUnits(line, LINE_MAX)
129
133
 
130
134
  function sessionLine(s: ConciergeBoardSession, at: number): string {
131
135
  const parts: string[] = [s.name ? `${s.sessionID} ${frame(s.name, LABEL_CHARS)}` : s.sessionID]
132
- if (s.project) parts.push(`project ${frame(s.project, LABEL_CHARS)}`)
136
+ // (Concierge grant requests) Whether a @@concierge grant covers the project (the hub's lookup), with its verbs.
137
+ const verbs = s.projectGrant?.filter((v) => PROJECT_GRANT_VERBS.includes(v))
138
+ const covered = verbs === undefined ? "" : verbs.length ? ` inGrant=yes (${verbs.join(", ")})` : " inGrant=no"
139
+ if (s.project) parts.push(`project ${frame(s.project, LABEL_CHARS)}${covered}`)
140
+ else if (covered) parts.push(`project${covered}`)
133
141
  if (s.machine) parts.push(`on ${frame(s.machine, LABEL_CHARS)}`)
134
142
  parts.push(s.since !== undefined ? `${s.state} ${span(at - s.since)}` : s.state)
135
143
  if (!s.blockers.reported) parts.push("blockers not reported")
@@ -144,6 +152,7 @@ function sessionLine(s: ConciergeBoardSession, at: number): string {
144
152
  if (s.intent) parts.push(`intent (${s.intent.source}) ${frame(s.intent.text, INTENT_MAX)}`)
145
153
  // (CON-11) "no intent": the title shows only because no intent is set (one the user cleared reads the same).
146
154
  else if (s.title) parts.push(`no intent · title ${frame(s.title, CONCIERGE_BOARD_LIMITS.titleChars)}`)
155
+ if (s.pickup) parts.push(`pick up ${frame(s.pickup, CONTEXT_CARD_LIMITS.headline)}`)
147
156
  parts.push(`watching: ${s.reasons.join(", ")}`)
148
157
  return cutLine(`- ${parts.join(" · ")}`)
149
158
  }
@@ -211,3 +220,45 @@ export function renderBoard(board: ConciergeBoard, options: RenderOptions = {}):
211
220
  }
212
221
  return cutUnits(text, max).replace(LONE_SURROGATE, "")
213
222
  }
223
+
224
+ /** (P2) The compact board for the Concierge's voice front, at most this many characters. */
225
+ export const FRONT_BOARD_MAX = 6_000
226
+ /** Intent shown on the compact board (characters). */
227
+ const FRONT_INTENT_CHARS = 100
228
+
229
+ /**
230
+ * (P2 Concierge front, contract "P2: Concierge mode") The compact board for the front's note: the watched sessions
231
+ * with ID, @name, title (or intent), project, machine, state and what they wait for, then the machines. Enough to pick
232
+ * a relay target and answer simple status questions; no Decision IDs, watch reasons, grant facts or results (those
233
+ * are the Concierge's). Untrusted text is framed as on the full board. Lines past FRONT_BOARD_MAX are dropped from the
234
+ * end (the lowest priority) and counted.
235
+ */
236
+ export function renderFrontBoard(board: ConciergeBoard): string {
237
+ const machines =
238
+ board.machines.status !== "ok"
239
+ ? "Machines: unavailable"
240
+ : cutLine(`Machines: ${board.machines.items.length ? board.machines.items.map((m) => `${frame(m.name, 40)} ${m.online ? "online" : "offline"}`).join(", ") : "none"}`)
241
+ if (board.sessions.status !== "ok") return [`The user's watched sessions: ${unavailable(board.sessions)}`, machines].join("\n")
242
+ const items = board.sessions.items.filter((s) => isHubSessionID(s.sessionID))
243
+ if (!items.length) return ["The user's watched sessions: none", machines].join("\n")
244
+ const total = Math.max(board.counts.watched, items.length)
245
+ const head = `The user's watched sessions (${total}${total > items.length ? `, ${items.length} shown` : ""}):`
246
+ const lines = items.map((s) => {
247
+ const parts = [s.name ? `${s.sessionID} ${frame(s.name, LABEL_CHARS)}` : s.sessionID]
248
+ if (s.title) parts.push(frame(s.title, CONCIERGE_BOARD_LIMITS.titleChars))
249
+ else if (s.intent) parts.push(`intent ${frame(s.intent.text, FRONT_INTENT_CHARS)}`)
250
+ if (s.project) parts.push(`project ${frame(s.project, LABEL_CHARS)}`)
251
+ if (s.machine) parts.push(`on ${frame(s.machine, LABEL_CHARS)}`)
252
+ parts.push(s.since !== undefined ? `${s.state} ${span(board.at - s.since)}` : s.state)
253
+ if (s.blockers.reported && s.blockers.count > 0) parts.push(`waits for the user: ${s.blockers.kinds.length ? s.blockers.kinds.map((k) => sanitizeLabel(k, 40)).join(", ") : String(s.blockers.count)}`)
254
+ return cutLine(`- ${parts.join(" · ")}`)
255
+ })
256
+ const compose = (shown: string[]) => [head, ...shown, ...(shown.length < lines.length ? [`(${lines.length - shown.length} more not shown)`] : []), machines].join("\n")
257
+ let shown = lines
258
+ let text = compose(shown)
259
+ while (text.length > FRONT_BOARD_MAX && shown.length > 0) {
260
+ shown = shown.slice(0, -1)
261
+ text = compose(shown)
262
+ }
263
+ return cutUnits(text, FRONT_BOARD_MAX).replace(LONE_SURROGATE, "")
264
+ }
@@ -52,6 +52,8 @@ export interface BoardRouteDeps {
52
52
  /** Launched sessions' labels (the latest launch wins), for label filters. */
53
53
  readonly labels?: (userID: string) => Promise<ReadonlyMap<string, Readonly<Record<string, string>>>>
54
54
  readonly intents?: Pick<SessionIntents, "many">
55
+ /** Context cards: the shown roots' primary card headlines (docs/context-cards.md). */
56
+ readonly cards?: { primaries(userID: string, sessionIDs: Iterable<string>): Promise<ReadonlyMap<string, { readonly headline: string }>> }
55
57
  readonly results?: Pick<ResultTracker, "many">
56
58
  readonly identity?: ConciergeIdentity
57
59
  /** (CON-06) The Decision armed now by the current Concierge; a failure shows none armed. */
@@ -64,6 +66,12 @@ export interface BoardRouteDeps {
64
66
  * tier, Acting.coverage). Absent, or no current Concierge: every permission reads inGrant=no, auto=no.
65
67
  */
66
68
  readonly coverage?: (userID: string, conciergeSessionID: string, sessionID: string, requestID: string) => Promise<{ readonly inGrant: boolean; readonly auto: boolean }>
69
+ /**
70
+ * (Concierge grant requests) The verbs an active grant the current Concierge holds allows in a folder (Grants.usable
71
+ * per verb): the board's project-level inGrant, so the Concierge knows where it may offer to request one. Absent,
72
+ * no current Concierge or a failure: not shown.
73
+ */
74
+ readonly projectGrant?: (userID: string, conciergeSessionID: string, directory: string) => Promise<readonly string[]>
67
75
  readonly now?: Clock
68
76
  }
69
77
 
@@ -126,11 +134,13 @@ export async function conciergeBoard(deps: BoardRouteDeps, revisions: BoardRevis
126
134
  const machineName = new Map(ownMachines.map((m) => [m.id, m.name]))
127
135
  const slugs = new Map(facts.sessions.filter((f) => f.projectSlug !== undefined).map((f) => [f.sessionID, f.projectSlug!]))
128
136
  const coverage = await coverageOf(deps, userID, live)
129
- const [intents, results, decisions, armed] = await Promise.all([
137
+ const projectGrants = await projectGrantsOf(deps, userID, live)
138
+ const [intents, results, decisions, armed, primaries] = await Promise.all([
130
139
  deps.intents ? deps.intents.many(userID, shown) : new Map(),
131
140
  deps.results ? deps.results.many(userID, shown) : new Map(),
132
141
  open,
133
142
  deps.armed ? deps.armed(userID).catch((cause: unknown) => (console.error(`[hub] concierge board: armed failed: ${(cause as Error).message}`), undefined)) : undefined,
143
+ deps.cards ? deps.cards.primaries(userID, shown).catch((cause: unknown) => (console.error(`[hub] concierge board: context cards failed: ${(cause as Error).message}`), undefined)) : undefined,
134
144
  ])
135
145
  const rootFacts = new Map<string, BoardRoot>()
136
146
  for (const id of shown) {
@@ -144,7 +154,8 @@ export async function conciergeBoard(deps: BoardRouteDeps, revisions: BoardRevis
144
154
  if (reported) {
145
155
  const s = reported.session
146
156
  const blockers = s.blockers.map((b) => ({ kind: b.kind, id: b.id, ...(b.action !== undefined ? { action: b.action } : {}), ...(b.kind === "permission" ? { inGrant: coverage.get(b.id)?.inGrant === true, auto: coverage.get(b.id)?.auto === true } : {}) }))
147
- rootFacts.set(id, { ...common, machine: reported.machine, state: STATES.has(s.state) ? (s.state as ConciergeBoardState) : "idle", online: true, title: s.title, blockers })
157
+ const projectGrant = projectGrants.get(s.project ?? s.directory)
158
+ rootFacts.set(id, { ...common, machine: reported.machine, state: STATES.has(s.state) ? (s.state as ConciergeBoardState) : "idle", online: true, title: s.title, blockers, ...(projectGrant !== undefined ? { projectGrant } : {}) })
148
159
  continue
149
160
  }
150
161
  const machine = record?.machineID !== undefined ? machineName.get(record.machineID) : undefined
@@ -168,6 +179,7 @@ export async function conciergeBoard(deps: BoardRouteDeps, revisions: BoardRevis
168
179
  rootOf,
169
180
  intents,
170
181
  results,
182
+ ...(primaries?.size ? { cards: new Map([...primaries].map(([id, card]) => [id, card.headline])) } : {}),
171
183
  decisions: !deps.decisions ? { status: "unsupported", feature: "decisions" } : decisions.ok ? (decisions.list as BoardDecisionInput[]) : { status: "failed" },
172
184
  hidden,
173
185
  machines,
@@ -203,6 +215,17 @@ async function coverageOf(deps: BoardRouteDeps, userID: string, live: ReadonlyMa
203
215
  return out
204
216
  }
205
217
 
218
+ /** (Concierge grant requests) The verbs the current Concierge's grants allow in each shown live session's project folder. */
219
+ async function projectGrantsOf(deps: BoardRouteDeps, userID: string, live: ReadonlyMap<string, { session: CatalogSession }>): Promise<Map<string, readonly string[]>> {
220
+ const out = new Map<string, readonly string[]>()
221
+ if (!deps.projectGrant || !deps.identity) return out
222
+ const conciergeID = await deps.identity.current(userID).then((c) => c.sessionID, () => undefined)
223
+ if (conciergeID === undefined) return out
224
+ const folders = new Set([...live.values()].map(({ session }) => session.project ?? session.directory).filter((d): d is string => typeof d === "string" && d.length > 0))
225
+ await Promise.all([...folders].map((dir) => deps.projectGrant!(userID, conciergeID, dir).then((verbs) => void out.set(dir, verbs), () => undefined)))
226
+ return out
227
+ }
228
+
206
229
  /** Muted sessions named on the line (the rest counted). */
207
230
  const MUTED_SHOWN = 20
208
231
 
@@ -19,6 +19,7 @@
19
19
  // digest} (a SHA-256 of the content without `at`), version-checked, so it changes only when the content changes and
20
20
  // concurrent renders of one change bump it once. Nothing else is written.
21
21
  import { createHash } from "node:crypto"
22
+ import { CONTEXT_CARD_LIMITS } from "../../../packages/protocol/src/context-cards"
22
23
  import {
23
24
  CONCIERGE_BOARD_LIMITS,
24
25
  CONCIERGE_BOARD_PENDING_MAX,
@@ -39,7 +40,7 @@ import { partyLabel } from "../agent/envelope"
39
40
  import { isPlaceholderTitle } from "../agent/names"
40
41
  import { updateVersioned } from "../agent/versioned"
41
42
  import type { StateStore } from "../backend/types"
42
- import { boardText } from "./board-render"
43
+ import { boardText, PROJECT_GRANT_VERBS } from "./board-render"
43
44
 
44
45
  export const BOARD_COLLECTION = "concierge-board"
45
46
  const REVISION_KEY = "revision"
@@ -63,8 +64,12 @@ export interface BoardRoot {
63
64
  readonly title?: string
64
65
  /** Open blockers in the tree when this hub's catalog reports it; absent: not reported. Summaries are never read. */
65
66
  readonly blockers?: readonly { readonly kind: string; readonly id?: string; readonly action?: string; readonly inGrant?: boolean; readonly auto?: boolean }[]
67
+ /** (Concierge grant requests) The verbs a @@concierge grant allows in its project folder; absent: unknown. */
68
+ readonly projectGrant?: readonly string[]
66
69
  }
67
70
 
71
+ const grantVerbs = (verbs: readonly string[]) => PROJECT_GRANT_VERBS.filter((v) => verbs.includes(v))
72
+
68
73
  /** The Decision fields the board reads (never question, options, recommendation or answer). */
69
74
  export type BoardDecisionInput = Pick<Decision, "id" | "from" | "about" | "urgency" | "tier" | "at" | "deadline" | "state">
70
75
 
@@ -88,6 +93,8 @@ export interface BoardInput {
88
93
  readonly intents: ReadonlyMap<string, SessionIntent>
89
94
  /** Last results of the shown roots. */
90
95
  readonly results: ReadonlyMap<string, LastResult>
96
+ /** Primary context card headlines of the shown roots (docs/context-cards.md). */
97
+ readonly cards?: ReadonlyMap<string, string>
91
98
  /** The user's open Decisions, or why there are none to show. */
92
99
  readonly decisions: readonly BoardDecisionInput[] | { readonly status: "unsupported"; readonly feature: string } | { readonly status: "failed" }
93
100
  /** The Concierge's own sessions (current and previous). */
@@ -136,6 +143,7 @@ function sessionItem(entry: WatchlistEntry, input: BoardInput, decisions: readon
136
143
  const intentText = stored ? label(stored.text, INTENT_MAX) : undefined
137
144
  const title = intentText === undefined && r && !isPlaceholderTitle(r.title) ? label(r.title, CONCIERGE_BOARD_LIMITS.titleChars) : undefined
138
145
  const blockers = r?.blockers
146
+ const pickup = label(input.cards?.get(entry.sessionID), CONTEXT_CARD_LIMITS.headline)
139
147
  const name = label(r?.name, LABEL_CHARS)
140
148
  const project = label(r?.project, LABEL_CHARS)
141
149
  const machine = label(r?.machine, LABEL_CHARS)
@@ -144,12 +152,14 @@ function sessionItem(entry: WatchlistEntry, input: BoardInput, decisions: readon
144
152
  ...(name ? { name } : {}),
145
153
  ...(project ? { project } : {}),
146
154
  ...(machine ? { machine } : {}),
155
+ ...(r?.projectGrant !== undefined ? { projectGrant: grantVerbs(r.projectGrant) } : {}),
147
156
  state,
148
157
  ...(since !== undefined ? { since } : {}),
149
158
  blockers: blockers ? { reported: true, count: blockers.length, kinds: [...new Set(blockers.map((b) => (BLOCKER_KIND.test(b.kind) ? b.kind : "other")))].sort(), ...pendingField(blockers) } : { reported: false },
150
159
  decisions,
151
160
  ...(intentText !== undefined && stored ? { intent: { text: intentText, source: stored.source } } : {}),
152
161
  ...(title !== undefined ? { title } : {}),
162
+ ...(pickup !== undefined ? { pickup } : {}),
153
163
  ...(result ? { lastResult: { at: result.at, outcome: result.outcome } } : {}),
154
164
  reasons: [...entry.reasons],
155
165
  }
@@ -21,7 +21,7 @@
21
21
  import { CONCIERGE_TOOLS, SESSION_CONTEXT_FEATURE, SESSION_CONTEXT_LIMITS, type SessionContextRequest, type SessionContextResult } from "../../../packages/protocol/src/concierge"
22
22
  import type { Clock, Timers } from "../backend/types"
23
23
  import type { NotesStore } from "./notes"
24
- import { conciergeInstructions, grantLine, type GrantLineInput, NOTES_HEADER, RETIRED_INSTRUCTIONS } from "./prompt"
24
+ import { conciergeInstructions, grantLines, type GrantLineInput, NOTES_HEADER, type RequestedGrantInput, RETIRED_INSTRUCTIONS } from "./prompt"
25
25
  import type { SettingsStore } from "./settings"
26
26
 
27
27
  export { CONCIERGE_TOOLS } from "../../../packages/protocol/src/concierge"
@@ -46,14 +46,22 @@ export interface ContextGrant {
46
46
  readonly permissions?: { readonly allow: readonly string[]; readonly never: readonly string[] }
47
47
  }
48
48
 
49
+ /** (P2) The spoke feature that takes the frontBoard part (the Concierge's voice front). */
50
+ export const CONCIERGE_FRONT_FEATURE = "conciergeFront"
51
+
49
52
  export interface ContextDeps {
50
- readonly registry: { sessionTargets(sessionID: string): string[]; supports(connID: string, feature: typeof SESSION_CONTEXT_FEATURE): boolean; connTenant(connID: string): string | undefined }
53
+ readonly registry: { sessionTargets(sessionID: string): string[]; supports(connID: string, feature: typeof SESSION_CONTEXT_FEATURE | typeof CONCIERGE_FRONT_FEATURE): boolean; connTenant(connID: string): string | undefined }
51
54
  readonly send: (connID: string, req: SessionContextRequest) => Promise<SessionContextResult>
52
- /** The rendered board of the user (renderBoard). */
53
- readonly board: (userID: string) => Promise<string>
55
+ /**
56
+ * The rendered board of the user (renderBoard); (P2) with `front`, also the compact board for the voice front
57
+ * (renderFrontBoard), sent as the frontBoard part to spokes that take it.
58
+ */
59
+ readonly board: (userID: string) => Promise<string | { readonly rendered: string; readonly front?: string }>
54
60
  readonly notes: Pick<NotesStore, "get">
55
61
  readonly settings: Pick<SettingsStore, "get">
56
62
  readonly grant: (userID: string, grantID: string) => Promise<ContextGrant | undefined>
63
+ /** (Concierge grant requests) The user's @@concierge grants: the active ones besides the Settings grant are named. */
64
+ readonly requested?: (userID: string) => Promise<readonly ContextGrant[]>
57
65
  /** (CON-09a) Speech is set up for the user (the instructions mention voice and voice confirm). Absent: false. */
58
66
  readonly voice?: (userID: string) => boolean
59
67
  readonly now?: Clock
@@ -105,13 +113,26 @@ export class ContextPusher {
105
113
  return { state: "ended", id: grant.id, at: grant.revokedAt ?? grant.expiresAt }
106
114
  }
107
115
 
108
- private async build(userID: string, sessionID: string): Promise<Body> {
109
- const [settings, notes, board] = await Promise.all([this.deps.settings.get(userID), this.deps.notes.get(userID), this.deps.board(userID)])
116
+ /** (Concierge grant requests) The active requested grants (not the Settings grant); a failed read names none. */
117
+ private async requestedGrants(userID: string, settingsGrantID: string | undefined): Promise<RequestedGrantInput[]> {
118
+ if (!this.deps.requested) return []
119
+ const grants = await this.deps.requested(userID).catch(() => [] as readonly ContextGrant[])
120
+ const active = grants.filter((g) => g.state === "active" && g.id !== settingsGrantID)
121
+ return active.map((g) => ({ id: g.id, allow: g.allow, projects: g.scope.projects, expiresAt: g.expiresAt }))
122
+ }
123
+
124
+ private async build(userID: string, sessionID: string, connID: string): Promise<Body> {
125
+ const [settings, notes, boards] = await Promise.all([this.deps.settings.get(userID), this.deps.notes.get(userID), this.deps.board(userID)])
126
+ const board = typeof boards === "string" ? boards : boards.rendered
127
+ const front = typeof boards === "string" ? undefined : boards.front
110
128
  const grant = await this.grantInput(userID, settings.grantID)
129
+ const requested = await this.requestedGrants(userID, settings.grantID)
111
130
  const parts: SessionContextRequest["parts"][number][] = [
112
131
  { key: "instructions", text: cut(conciergeInstructions({ voice: this.deps.voice?.(userID) === true, timeZone: settings.timeZone })) },
113
- { key: "board", text: cut(`${grantLine(grant, settings.timeZone)}\n${board}`) },
132
+ { key: "board", text: cut(`${grantLines(grant, requested, settings.timeZone)}\n${board}`) },
114
133
  ...(notes.text ? [{ key: "notes" as const, text: cut(`${NOTES_HEADER}\n${notes.text}`) }] : []),
134
+ // (P2) Only a spoke that takes it: an older one refuses an unknown part key.
135
+ ...(front !== undefined && this.deps.registry.supports(connID, CONCIERGE_FRONT_FEATURE) ? [{ key: "frontBoard" as const, text: cut(front) }] : []),
115
136
  ]
116
137
  return { op: "sessionContext", sessionID, parts, tools: { allow: [...CONCIERGE_TOOLS] }, lockAgent: true }
117
138
  }
@@ -137,7 +158,7 @@ export class ContextPusher {
137
158
  try {
138
159
  const connID = this.target(userID, sessionID)
139
160
  if (connID === undefined) return false
140
- const body = await this.build(userID, sessionID)
161
+ const body = await this.build(userID, sessionID, connID)
141
162
  const digest = JSON.stringify(body)
142
163
  const key = `${userID}\n${sessionID}`
143
164
  const known = this.sent.get(key)