@huynle/hub 0.7.4 → 0.8.1

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 (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -20,6 +20,11 @@
20
20
  // - close(): a Decision whose subject went away (a permission answered elsewhere) is closed, not answered; the asker
21
21
  // hears why.
22
22
  // - Every open and change (answer, timeout, escalation, expiry, close) goes to `changed` (the console's 'decision' event).
23
+ // - (CON-06) `via`: an answer is a tap unless it says voice or text. Voice/text answers only a confirm-tier
24
+ // Decision, and only when the hub's `via` check admits it (the Concierge tiers: armed, presence, the user's
25
+ // device); otherwise forbidden "Answer this decision with a tap". Never a model: only the user's device answers.
26
+ // - (CON-06) `as: "concierge"`: set when the asker is one of the user's Concierge sessions (current or past), so
27
+ // the console can say "by @@concierge". The `act` action kind is a Concierge proposal (concierge/proposals.ts).
23
28
  import type { Clock, StateStore, Timers } from "../backend/types"
24
29
  import type { AgentActor, UserActor } from "./actor"
25
30
  import type { Delivery } from "./delivery"
@@ -37,6 +42,22 @@ export type DecisionState = "open" | "answered" | "expired" | "closed"
37
42
  export const URGENCIES: readonly Urgency[] = ["low", "normal", "high"]
38
43
  export const TIERS: readonly Tier[] = ["confirm", "tap"]
39
44
  export const DECISION_STATES: readonly DecisionState[] = ["open", "answered", "expired", "closed"]
45
+ /** (CON-06) How the user answered: tap (the default, absent), or voice/text in the Concierge panel. */
46
+ export type AnswerVia = "tap" | "voice" | "text"
47
+ export const ANSWER_VIAS: readonly AnswerVia[] = ["tap", "voice", "text"]
48
+ /** The refusal of a voice/text answer the hub does not admit. */
49
+ export const TAP_ONLY = "Answer this decision with a tap"
50
+ /** The refusal of a voice/text answer that picks a lasting option (coordinator decision, 93265or7). */
51
+ export const ALWAYS_TAP_ONLY = "Answer 'always' with a tap"
52
+
53
+ /**
54
+ * Whether an option is not once-off: `always` (saves a lasting rule, e.g. an escalated permission approved for
55
+ * good), or approving a grant request (creates a lasting grant). Voice and text pick only once-off options.
56
+ */
57
+ export function lastingOption(decision: Pick<Decision, "action" | "options">, option: string | undefined): boolean {
58
+ if (option === "always") return true
59
+ return decision.action?.kind === "grant" && !decision.options.some((o) => o.id === option && o.safe)
60
+ }
40
61
 
41
62
  /** Options a Decision offers. */
42
63
  export const OPTIONS_MAX = 8
@@ -67,10 +88,27 @@ export interface DecisionAnswer {
67
88
  readonly at: number
68
89
  /** The device the user answered from. */
69
90
  readonly deviceID?: string
91
+ /** (CON-06) voice or text; absent = tap. */
92
+ readonly via?: "voice" | "text"
70
93
  }
71
94
 
72
95
  /** What answering a Decision does besides replying: an escalated permission request (P3-T8) or a grant request (P3-T9). */
73
- export type DecisionAction = PermissionAction | GrantRequestAction
96
+ export type DecisionAction = PermissionAction | GrantRequestAction | ActAction
97
+
98
+ /**
99
+ * (CON-06) A Concierge proposal (concierge/proposals.ts): answering `go` runs the act verb on the target AS THE USER
100
+ * through the acting engine (tenancy re-checked); `hold` does nothing.
101
+ */
102
+ export interface ActAction {
103
+ readonly kind: "act"
104
+ readonly verb: "stop" | "deny" | "prompt" | "answer"
105
+ /** The session ID it was proposed for (resolved when proposed). */
106
+ readonly target: string
107
+ /** The verb's validated input (requestID, formID, answer, text). */
108
+ readonly input: Readonly<Record<string, unknown>>
109
+ /** (FX-05) deny: the permission it would deny, for the console's card. */
110
+ readonly request?: DecisionRequest
111
+ }
74
112
 
75
113
  /** An escalated permission request: the answer replies to it as the user. */
76
114
  export interface PermissionAction {
@@ -82,6 +120,56 @@ export interface PermissionAction {
82
120
  readonly sessionID: string
83
121
  /** The agent asked to approve it always. */
84
122
  readonly always: boolean
123
+ /** (FX-05) What it asks, for the console's card (absent when the hub did not know the request's details). */
124
+ readonly request?: DecisionRequest
125
+ /** (FX-05) Why the hub sent it to the user (the evaluation's reason). */
126
+ readonly reason?: string
127
+ }
128
+
129
+ /**
130
+ * (FX-05) A permission request as a Decision shows it (bounded): its kind, what it applies to and the patterns an
131
+ * "always" would save. What was not stored is recorded, so a shortened command never reads as complete: the totals
132
+ * when items were dropped, each cut item with its full length, and `truncated` when the hub received the request
133
+ * already shortened (src/status.ts permissionRequest). The console renders it as text; the Concierge's Decision reads
134
+ * leave it out (concierge/board-routes.ts conciergeDecisionView).
135
+ */
136
+ export interface DecisionRequest {
137
+ readonly action: string
138
+ readonly resources: readonly string[]
139
+ readonly save?: readonly string[]
140
+ /** How many resources the request had, when more than were stored. */
141
+ readonly resourcesTotal?: number
142
+ /** How many save patterns it had, when more than were stored. */
143
+ readonly savesTotal?: number
144
+ /** Stored items cut at DECISION_REQUEST_TEXT characters, with their full length. */
145
+ readonly cut?: readonly { readonly field: "resources" | "save"; readonly index: number; readonly length: number }[]
146
+ /** The hub received the request already shortened. */
147
+ readonly truncated?: true
148
+ }
149
+
150
+ /** Resources (and save patterns) a Decision keeps of a request, and the characters of each. */
151
+ export const DECISION_REQUEST_ITEMS = 20
152
+ export const DECISION_REQUEST_TEXT = 2_000
153
+
154
+ /** A permission request, bounded for a Decision, with what was not stored. */
155
+ export function decisionRequest(request: { readonly action: string; readonly resources: readonly string[]; readonly save?: readonly string[]; readonly truncated?: true }): DecisionRequest {
156
+ const cut: { field: "resources" | "save"; index: number; length: number }[] = []
157
+ const bound = (field: "resources" | "save", list: readonly string[]) =>
158
+ list.slice(0, DECISION_REQUEST_ITEMS).map((r, index) => {
159
+ if (r.length > DECISION_REQUEST_TEXT) cut.push({ field, index, length: r.length })
160
+ return r.slice(0, DECISION_REQUEST_TEXT)
161
+ })
162
+ const resources = bound("resources", request.resources)
163
+ const save = request.save?.length ? bound("save", request.save) : undefined
164
+ return {
165
+ action: request.action.slice(0, DECISION_REQUEST_TEXT),
166
+ resources,
167
+ ...(save ? { save } : {}),
168
+ ...(request.resources.length > resources.length ? { resourcesTotal: request.resources.length } : {}),
169
+ ...(save && request.save!.length > save.length ? { savesTotal: request.save!.length } : {}),
170
+ ...(cut.length ? { cut } : {}),
171
+ ...(request.truncated ? { truncated: true as const } : {}),
172
+ }
85
173
  }
86
174
 
87
175
  /** A grant an agent requested (grant-requests.ts): approving creates it as the user. */
@@ -121,6 +209,8 @@ export interface Decision {
121
209
  readonly closed?: { readonly at: number; readonly reason: string }
122
210
  /** Run (as the answering user) when the user answers; set by the hub only, never by an asker. */
123
211
  readonly action?: DecisionAction
212
+ /** (CON-06) Asked by one of the user's Concierge sessions: the console says "by @@concierge". */
213
+ readonly as?: "concierge"
124
214
  }
125
215
 
126
216
  /** What the hub (never an asker's request) adds to an ask. */
@@ -148,8 +238,13 @@ export interface AskInput {
148
238
  export interface AnswerInput {
149
239
  readonly option?: string
150
240
  readonly text?: string
241
+ /** (CON-06) Absent = tap. */
242
+ readonly via?: AnswerVia
151
243
  }
152
244
 
245
+ /** (CON-06) Admits a voice/text answer (throws forbidden otherwise). */
246
+ export type ViaCheck = (userID: string, decision: Decision, via: "voice" | "text", by: UserActor) => Promise<void>
247
+
153
248
  export type NotifyReason = "opened" | "escalated"
154
249
 
155
250
  /**
@@ -177,6 +272,10 @@ export interface DecisionsDeps {
177
272
  readonly changed?: (userID: string, decision: Decision) => void
178
273
  /** Runs Decisions' actions (escalations); a Decision with an action cannot be answered without it. */
179
274
  readonly act?: DecisionActor
275
+ /** (CON-06) Admits voice/text answers; without it every voice/text answer is refused. */
276
+ readonly via?: ViaCheck
277
+ /** (CON-06) Whether a session is one of the user's Concierge sessions (current or past): the Decision's `as`. */
278
+ readonly concierge?: (userID: string, sessionID: string) => Promise<boolean>
180
279
  }
181
280
 
182
281
  const DECISIONS = "decisions"
@@ -274,6 +373,7 @@ export class Decisions {
274
373
  const v = validate(input)
275
374
  const about = [...new Set(input.about ?? (actor.kind === "session" ? [actor.sessionID] : []))]
276
375
  const { message } = await this.deps.delivery.send(userID, actor, "@me", "ask", input.question, { expiresInMs: v.expiresInMs })
376
+ const asConcierge = actor.kind === "session" && (await this.isConcierge(userID, actor.sessionID))
277
377
  const decision: Decision = {
278
378
  id: this.newID(),
279
379
  messageID: message.id,
@@ -291,6 +391,7 @@ export class Decisions {
291
391
  at: message.at,
292
392
  expiresAt: message.expiresAt ?? message.at + v.expiresInMs,
293
393
  ...(extra.action ? { action: extra.action } : {}),
394
+ ...(asConcierge ? { as: "concierge" as const } : {}),
294
395
  }
295
396
  const written = await this.deps.state.put(userID, DECISIONS, decision.id, decision, { ifVersion: 0 })
296
397
  if (!written.ok) throw new Error("decision id collision")
@@ -331,9 +432,18 @@ export class Decisions {
331
432
  if (!current || by.userID !== userID) throw notFound()
332
433
  if (current.state !== "open") throw gone()
333
434
  if (current.tier === "tap" && by.via !== "cookie") throw new AgentError("forbidden", "This decision needs a tap in the signed-in console", { hint: "Answer it in the console" })
435
+ const via = input.via ?? "tap"
436
+ if (!ANSWER_VIAS.includes(via)) throw invalid(`via is one of ${ANSWER_VIAS.join(", ")}`)
334
437
  const choice = this.choice(current, input)
438
+ if (via !== "tap") {
439
+ // Voice/text: confirm tier only, and only what the hub's check admits (never without one).
440
+ if (current.tier !== "confirm" || !this.deps.via) throw new AgentError("forbidden", TAP_ONLY, { hint: "Tap an option" })
441
+ // Only once-off options by voice or text: a lasting one (always, a grant) needs a tap.
442
+ if (lastingOption(current, choice.option)) throw new AgentError("forbidden", ALWAYS_TAP_ONLY, { hint: "Tap the option" })
443
+ await this.deps.via(userID, current, via, by)
444
+ }
335
445
  const at = this.now()
336
- const answer: DecisionAnswer = { ...choice, answeredBy: "user", at, deviceID: by.deviceID }
446
+ const answer: DecisionAnswer = { ...choice, answeredBy: "user", at, deviceID: by.deviceID, ...(via !== "tap" ? { via } : {}) }
337
447
  const decision = await this.settle(userID, id, answer)
338
448
  if (!decision) throw gone()
339
449
  const note = decision.action ? await this.runAction(userID, decision, choice, by) : undefined
@@ -399,6 +509,19 @@ export class Decisions {
399
509
 
400
510
  // ---- internals ----------------------------------------------------------------------------------------
401
511
 
512
+ /**
513
+ * Whether the session is one of the user's Concierge sessions; a failed read is no label (never fails the ask).
514
+ * (FX-07) A read that timed out (`busy`) refuses the ask instead: the label could not be checked (fail closed).
515
+ */
516
+ private async isConcierge(userID: string, sessionID: string): Promise<boolean> {
517
+ try {
518
+ return (await this.deps.concierge?.(userID, sessionID)) === true
519
+ } catch (cause) {
520
+ if (cause instanceof AgentError && cause.code === "busy") throw cause
521
+ return false
522
+ }
523
+ }
524
+
402
525
  /** Tells the changed listener (its failure never fails the caller). */
403
526
  private emit(userID: string, decision: Decision): void {
404
527
  try {