@huynle/hub 0.7.4 → 0.8.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 (221) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  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/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +632 -49
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,245 @@
1
+ // Profile application for harness adapters (HWA-15; contract packages/protocol/src/profiles.ts). Shared by the
2
+ // OpenCode (HWA-16), Claude Code (HWA-17) and Codex/preset (HWA-18) adapters; checked by the conformance suite.
3
+ //
4
+ // Precedence (the documented rule): a profile ADDS a session-scoped layer and never replaces local configuration.
5
+ // - Local instructions (project AGENTS.md/CLAUDE.md, the user's own harness config) load unchanged. Profile
6
+ // instructions come after them, framed by profileInstructionsBlock, which says local instructions take
7
+ // precedence where they conflict.
8
+ // - A local skill or command with the same name wins: the profile's is skipped with a note (status partial).
9
+ // - Parts the adapter cannot apply are skipped with a note (partial); nothing applicable is `unsupported`.
10
+ // - An adapter whose harness reads the profile only at session start answers `restart_required`.
11
+ // - Adapters write session-scoped state only, never global harness configuration.
12
+ // Secrets: profiles carry `{{secret:NAME}}` references only (central never resolves them). The adapter resolves
13
+ // them on the spoke's machine when a command is rendered at invocation (renderProfileCommand; default source
14
+ // envSecrets: HUB_SECRET_<NAME> in the spoke's environment). Resolved values are never written to disk, sent to
15
+ // the hub, or put in notes; an adapter that cannot render at invocation skips commands that reference secrets.
16
+ import { createHash } from "node:crypto"
17
+ import {
18
+ canonicalProfileJSON,
19
+ PROFILE_APPLY_LIMITS,
20
+ type ProfileApplyRequest,
21
+ type ProfileApplyResult,
22
+ type ProfileApplyStatus,
23
+ type ProfileCommand,
24
+ type ProfileSkill,
25
+ profileTemplateParts,
26
+ type ProfileVersionContent,
27
+ } from "../../protocol/src/index.ts"
28
+
29
+ /** sha256 hex of the canonical JSON of a version's content: what the hub sends as `digest`. */
30
+ export function profileDigest(content: ProfileVersionContent): string {
31
+ return createHash("sha256").update(canonicalProfileJSON(content)).digest("hex")
32
+ }
33
+
34
+ /** What a harness adapter can apply to a running session. */
35
+ export interface ProfileCapabilities {
36
+ readonly instructions: boolean
37
+ readonly skills: boolean
38
+ readonly commands: boolean
39
+ /** It can switch the session's model. */
40
+ readonly model: boolean
41
+ /** Applied parts take effect in the running session; false: only after the session restarts. */
42
+ readonly live: boolean
43
+ /** It renders commands when they are invoked (so secret references resolve then, on this machine). */
44
+ readonly renderAtInvocation: boolean
45
+ }
46
+
47
+ /** Names the session already has locally (they win over the profile's). */
48
+ export interface ProfileLocalNames {
49
+ readonly skills?: Iterable<string>
50
+ readonly commands?: Iterable<string>
51
+ }
52
+
53
+ export interface ProfilePlan {
54
+ readonly status: ProfileApplyStatus
55
+ /** The framed instructions block to add after local instructions (absent: none to apply). */
56
+ readonly instructions?: string
57
+ readonly skills: readonly ProfileSkill[]
58
+ readonly commands: readonly ProfileCommand[]
59
+ readonly model?: string
60
+ readonly notes: readonly string[]
61
+ }
62
+
63
+ /** A note within the protocol's bounds: plain text of at most noteChars characters. */
64
+ export function profileNote(text: string): string {
65
+ const clean = text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, " ")
66
+ return clean.length <= PROFILE_APPLY_LIMITS.noteChars ? clean : `${clean.slice(0, PROFILE_APPLY_LIMITS.noteChars - 1)}…`
67
+ }
68
+
69
+ /** At most PROFILE_APPLY_LIMITS.notes notes (the last one says how many were left out). */
70
+ export function boundedNotes(notes: readonly string[]): string[] {
71
+ const max = PROFILE_APPLY_LIMITS.notes
72
+ const clean = notes.map(profileNote)
73
+ return clean.length <= max ? clean : [...clean.slice(0, max - 1), `… ${clean.length - max + 1} more notes`]
74
+ }
75
+
76
+ /** The profile instructions as adapters add them: after local instructions, which take precedence. */
77
+ export function profileInstructionsBlock(req: Pick<ProfileApplyRequest, "profileID" | "version" | "content">): string {
78
+ return [
79
+ `<!-- hub profile ${req.profileID} v${req.version} (session-scoped) -->`,
80
+ `Hub agent profile ${req.profileID} version ${req.version}, applied to this session only. Where it conflicts with local project or user instructions, the local instructions take precedence.`,
81
+ "",
82
+ req.content.instructions ?? "",
83
+ ].join("\n")
84
+ }
85
+
86
+ /** Secret names a template references. */
87
+ export function templateSecrets(template: string): string[] {
88
+ const parts = profileTemplateParts(template)
89
+ if (typeof parts === "string") return []
90
+ return [...new Set(parts.flatMap((p) => ("secret" in p ? [p.secret] : [])))]
91
+ }
92
+
93
+ /** What to apply of a profile version, by the precedence rule above. */
94
+ export function planProfileApplication(req: ProfileApplyRequest, options: { readonly capabilities: ProfileCapabilities; readonly local?: ProfileLocalNames }): ProfilePlan {
95
+ const { capabilities: can } = options
96
+ const { content } = req
97
+ const localSkills = new Set(options.local?.skills ?? [])
98
+ const localCommands = new Set(options.local?.commands ?? [])
99
+ const notes: string[] = []
100
+ let offered = 0
101
+ let usable = 0
102
+
103
+ let instructions: string | undefined
104
+ if (content.instructions !== undefined && content.instructions.trim() !== "") {
105
+ offered++
106
+ if (can.instructions) {
107
+ instructions = profileInstructionsBlock(req)
108
+ usable++
109
+ } else notes.push("instructions not supported by this harness adapter")
110
+ }
111
+
112
+ const skills: ProfileSkill[] = []
113
+ if (content.skills.length) {
114
+ offered++
115
+ if (!can.skills) notes.push("skills not supported by this harness adapter")
116
+ else
117
+ for (const skill of content.skills) {
118
+ if (localSkills.has(skill.name)) notes.push(`skill ${skill.name} skipped: a local skill of that name takes precedence`)
119
+ else skills.push(skill)
120
+ }
121
+ if (skills.length) usable++
122
+ }
123
+
124
+ const commands: ProfileCommand[] = []
125
+ if (content.commands.length) {
126
+ offered++
127
+ if (!can.commands) notes.push("commands not supported by this harness adapter")
128
+ else
129
+ for (const command of content.commands) {
130
+ const secrets = templateSecrets(command.template)
131
+ if (localCommands.has(command.name)) notes.push(`command ${command.name} skipped: a local command of that name takes precedence`)
132
+ else if (secrets.length && !can.renderAtInvocation) notes.push(`command ${command.name} skipped: it needs secrets (${secrets.join(", ")}) and this adapter cannot render at invocation`)
133
+ else commands.push(command)
134
+ }
135
+ if (commands.length) usable++
136
+ }
137
+
138
+ let model: string | undefined
139
+ if (content.model !== undefined) {
140
+ offered++
141
+ if (can.model) {
142
+ model = content.model
143
+ usable++
144
+ } else notes.push("model ignored: this harness adapter cannot change the model")
145
+ }
146
+
147
+ const status: ProfileApplyStatus = offered > 0 && usable === 0 ? "unsupported" : !can.live ? "restart_required" : notes.length ? "partial" : "applied"
148
+ return { status, ...(instructions !== undefined ? { instructions } : {}), skills, commands, ...(model !== undefined ? { model } : {}), notes: boundedNotes(notes) }
149
+ }
150
+
151
+ /** What an adapter's apply may report; its status (if any) and notes replace or extend the plan's. */
152
+ export interface ProfileApplyOutcome {
153
+ readonly status?: ProfileApplyStatus
154
+ readonly notes?: readonly string[]
155
+ }
156
+
157
+ export interface ProfileApplyHandlerOptions {
158
+ readonly capabilities: ProfileCapabilities
159
+ /** The session's local skill and command names (they win over the profile's). */
160
+ readonly local?: (sessionID: string) => ProfileLocalNames | undefined
161
+ /** Writes the plan to the session's own (session-scoped) state. Throwing answers `rejected` with the reason. */
162
+ readonly apply: (plan: ProfilePlan, req: ProfileApplyRequest) => Promise<ProfileApplyOutcome | void>
163
+ /** Sessions whose last applied digest is remembered (default 1000). */
164
+ readonly remembered?: number
165
+ }
166
+
167
+ /** The answer to a profileApply request, echoing what it was asked to apply. */
168
+ export function profileApplyResult(req: ProfileApplyRequest, status: ProfileApplyStatus, notes: readonly string[] = []): ProfileApplyResult {
169
+ const bounded = boundedNotes(notes)
170
+ return { sessionID: req.sessionID, profileID: req.profileID, version: req.version, digest: req.digest, status, ...(bounded.length ? { notes: bounded } : {}) }
171
+ }
172
+
173
+ /**
174
+ * The `profileApply` op handler for HarnessSpoke (`handlers: { profileApply: handler.handle }`, which announces the
175
+ * feature): checks the digest, plans by precedence, calls the adapter, and answers a valid result. A repeated
176
+ * request for the digest a session already applied (applied or partial) answers the same result without
177
+ * re-applying; restart_required, rejected and unsupported are tried again.
178
+ */
179
+ export class ProfileApplyHandler {
180
+ private readonly options: ProfileApplyHandlerOptions
181
+ private readonly done = new Map<string, ProfileApplyResult>()
182
+
183
+ constructor(options: ProfileApplyHandlerOptions) {
184
+ this.options = options
185
+ }
186
+
187
+ readonly handle = async (req: ProfileApplyRequest): Promise<ProfileApplyResult> => {
188
+ if (profileDigest(req.content) !== req.digest) return profileApplyResult(req, "rejected", ["digest does not match the content; nothing applied"])
189
+ const previous = this.done.get(req.sessionID)
190
+ if (previous && previous.profileID === req.profileID && previous.version === req.version && previous.digest === req.digest) return previous
191
+ const plan = planProfileApplication(req, { capabilities: this.options.capabilities, ...(this.options.local ? { local: this.options.local(req.sessionID) ?? {} } : {}) })
192
+ if (plan.status === "unsupported") return profileApplyResult(req, "unsupported", plan.notes)
193
+ let outcome: ProfileApplyOutcome | void
194
+ try {
195
+ outcome = await this.options.apply(plan, req)
196
+ } catch (cause) {
197
+ return profileApplyResult(req, "rejected", [`adapter failed: ${cause instanceof Error ? cause.message : String(cause)}`])
198
+ }
199
+ const result = profileApplyResult(req, outcome?.status ?? plan.status, [...plan.notes, ...(outcome?.notes ?? [])])
200
+ this.done.delete(req.sessionID)
201
+ if (result.status === "applied" || result.status === "partial") {
202
+ this.done.set(req.sessionID, result)
203
+ while (this.done.size > (this.options.remembered ?? 1000)) this.done.delete(this.done.keys().next().value!)
204
+ }
205
+ return result
206
+ }
207
+
208
+ /** Forgets a session (it ended, or its applied state was lost). */
209
+ forget(sessionID: string): void {
210
+ this.done.delete(sessionID)
211
+ }
212
+ }
213
+
214
+ /** A command's secret references are not available on this machine (names only, never values). */
215
+ export class ProfileSecretMissingError extends Error {
216
+ constructor(readonly missing: readonly string[]) {
217
+ super(`missing secret(s) ${missing.join(", ")} on this machine`)
218
+ this.name = "ProfileSecretMissingError"
219
+ }
220
+ }
221
+
222
+ /** Secrets from the spoke's environment: `{{secret:NAME}}` reads `<prefix>NAME` (default HUB_SECRET_NAME). */
223
+ export function envSecrets(env: Readonly<Record<string, string | undefined>> = process.env, prefix = "HUB_SECRET_"): (name: string) => string | undefined {
224
+ return (name) => {
225
+ const value = env[`${prefix}${name}`]
226
+ return value === undefined || value === "" ? undefined : value
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Renders a command at invocation on the spoke: each `{{param}}` once, literally (a value is never parsed again),
232
+ * each `{{secret:NAME}}` from `secret`. Exactly the template's parameters must be given.
233
+ */
234
+ export function renderProfileCommand(template: string, params: Readonly<Record<string, string>>, secret: (name: string) => string | undefined): string {
235
+ const parts = profileTemplateParts(template)
236
+ if (typeof parts === "string") throw new Error(`template: ${parts}`)
237
+ const wanted = [...new Set(parts.flatMap((p) => ("param" in p ? [p.param] : [])))]
238
+ const missing = wanted.filter((name) => !Object.hasOwn(params, name))
239
+ if (missing.length) throw new Error(`missing parameter(s) ${missing.join(", ")}`)
240
+ const unknown = Object.keys(params).filter((name) => !wanted.includes(name))
241
+ if (unknown.length) throw new Error(`unknown parameter(s) ${unknown.map((n) => (/^[a-z][a-z0-9_]{0,39}$/.test(n) ? n : "?")).join(", ")}`)
242
+ const absent = [...new Set(parts.flatMap((p) => ("secret" in p && secret(p.secret) === undefined ? [p.secret] : [])))]
243
+ if (absent.length) throw new ProfileSecretMissingError(absent)
244
+ return parts.map((p) => ("text" in p ? p.text : "param" in p ? params[p.param]! : secret(p.secret)!)).join("")
245
+ }
@@ -73,6 +73,13 @@ export interface PromptInput {
73
73
  readonly images?: readonly PromptImage[]
74
74
  /** True for the user's own prompts; agent prompts never reset the hop chain (decision wvqeuis0 §8). */
75
75
  readonly resetHops: boolean
76
+ /**
77
+ * (CON-09a) false: the user did not write this text (a Concierge proposal run as the user), so it is never the
78
+ * source of the session's first-prompt intent. (CON-10) "first": it is the session's first prompt by construction
79
+ * (a launch's prompt, even when the launch already gave the session its name as title). Absent: it is the first
80
+ * prompt only when the session has no real title yet.
81
+ */
82
+ readonly intent?: false | "first"
76
83
  }
77
84
 
78
85
  /** Typed input steered into the running tree. */
@@ -14,6 +14,7 @@ import { DELIVER_ID_MAX, DELIVERY_MODES, type DeliveryMode, type FormAnswer, for
14
14
  import type { Acting } from "./acting"
15
15
  import { AgentError } from "./errors"
16
16
  import type { AgentRoute } from "./routes"
17
+ import { AGENT_JSON_BODY_MAX, boundedText } from "./body"
17
18
 
18
19
  // A literal: routes.ts imports this module (AGENT_PREFIX is not initialised yet when it loads).
19
20
  const PREFIX = "/api/agent/sessions/:addr"
@@ -27,7 +28,7 @@ const invalid = (message: string) => new AgentError("invalid", message)
27
28
  type Body = Record<string, unknown>
28
29
 
29
30
  async function bodyOf(request: Request, optional = false): Promise<Body> {
30
- const raw = await request.text()
31
+ const raw = await boundedText(request, AGENT_JSON_BODY_MAX)
31
32
  if (!raw.trim() && optional) return {}
32
33
  const value: unknown = JSON.parse(raw)
33
34
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalid("Body must be a JSON object")
@@ -6,6 +6,7 @@
6
6
  // (agent/acting.ts) calls ensureActing() first and again before the spoke request; it answers 423 paused.
7
7
  // Reading and messaging keep working (messaging has its own switch, AgentData.messagingPaused).
8
8
  // - hubNever(): hub.json `agents.never` (already validated by config.ts), checked before every grant.
9
+ import { boundedJson } from "./body"
9
10
  import type { Store } from "../store"
10
11
  import type { AgentActor } from "./actor"
11
12
  import type { AgentAudit } from "./audit"
@@ -82,7 +83,7 @@ export function actingSwitchRoutes(acting: ActingSwitch): AgentRoute[] {
82
83
  path: `${AGENT_PREFIX}/acting`,
83
84
  allow: {},
84
85
  async handle({ request, user, actor }) {
85
- const body: unknown = await request.json()
86
+ const body: unknown = await boundedJson(request)
86
87
  const enabled = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as Record<string, unknown>).enabled : undefined
87
88
  if (typeof enabled !== "boolean") throw new AgentError("invalid", "enabled must be true or false")
88
89
  await acting.setUserActing(user.id, actor, enabled)
@@ -19,6 +19,9 @@
19
19
  // - Every outcome after the address resolves is in the agent audit: decision (allow, needs_user, forbidden,
20
20
  // paused, rate_limited) and outcome (ok, gone, offline, unsupported, escalated, refused, failed).
21
21
  // - Races: the request answered elsewhere first is gone (410).
22
+ // - (CON-06) preflight: a Concierge proposal is checked exactly like the act verb would check the agent, short of
23
+ // its grant (kill switch, tenancy, the Concierge guard, pending request/form, self-tree, prompt rules); nothing is
24
+ // consumed, audited or sent. When the user says go, the verb runs as the user (no grant, tenancy re-checked).
22
25
  import {
23
26
  DELIVER_TEXT_MAX,
24
27
  type Delivered,
@@ -35,7 +38,8 @@ import {
35
38
  import { type Actions, type Unsupported, unsupportedMessage } from "../actions"
36
39
  import { BusyError, type Registry } from "../registry"
37
40
  import { SpokeError } from "../spoke-ws"
38
- import type { PermissionRequest } from "../../status"
41
+ import type { FormFieldSpec, PermissionRequest } from "../../status"
42
+ import { formAnswerFieldProblem } from "./form-check"
39
43
  import type { ActingSwitch } from "./acting-switch"
40
44
  import type { AgentActor } from "./actor"
41
45
  import type { AgentAudit, AuditDecision, AuditOutcome, AuditTarget } from "./audit"
@@ -51,7 +55,9 @@ import { evaluatePermission, formatPattern, type GrantVerb, parsePattern, type P
51
55
  export type ActingRegistry = Pick<
52
56
  Registry,
53
57
  "sessionTenant" | "session" | "rootOf" | "permissionRequest" | "permissionTargets" | "formTargets" | "owner" | "supports" | "harnessOf"
54
- >
58
+ > &
59
+ /** (CON-13) The tree's live status: a pending form's fields (its schema). Without it no answer is checked. */
60
+ Partial<Pick<Registry, "status">>
55
61
 
56
62
  /** Why a request needs the user, as the policy saw it. */
57
63
  export interface Evaluation {
@@ -97,8 +103,20 @@ export interface ActingDeps {
97
103
  readonly newMessageID?: () => string
98
104
  /** Longest prompt a user sends (the console's limit); agents' prompts are at most DELIVER_TEXT_MAX. */
99
105
  readonly maxPromptChars?: number
106
+ /** (CON-04) Sessions only the user acts on (the Concierge's): agents get forbidden {policy: "concierge"}. */
107
+ readonly protect?: (userID: string, sessionID: string) => Promise<boolean>
108
+ /**
109
+ * (CON-13 decision, design §11.4) Whether the acting session is a Concierge session (current or past). Such an
110
+ * actor never executes a tap-tier approval (shell, outside folders, MCP and unknown tools, near-never) or an
111
+ * "always", even inside its grant: needs_user, a tap Decision. A failed read counts as yes (fail closed).
112
+ */
113
+ readonly conciergeActor?: (userID: string, sessionID: string) => Promise<boolean>
100
114
  }
101
115
 
116
+ /** (CON-13 decision) Why the Concierge escalates a covered request. */
117
+ export const CONCIERGE_TAP_REASON = "Shell, outside folders and tools always need the user's tap"
118
+ export const CONCIERGE_ALWAYS_REASON = "The Concierge never answers “always”: the user decides"
119
+
102
120
  /** Who acts, for which user. */
103
121
  export interface Caller {
104
122
  readonly actor: AgentActor
@@ -162,6 +180,8 @@ export interface PromptInput {
162
180
  /** Agents' prompts always wake the session: false is refused. */
163
181
  readonly wake?: boolean
164
182
  readonly why?: string
183
+ /** (CON-09a) false: not the user's own text (a proposal run as the user): never the session's first-prompt intent. */
184
+ readonly intent?: false
165
185
  }
166
186
 
167
187
  /** An agent's prompt, admitted by the spoke under its provenance header. */
@@ -176,6 +196,28 @@ export interface AgentPrompted {
176
196
  /** The user's prompt: steered into the running tree, or a new turn (its reply streams to the console). */
177
197
  export type UserPrompted = { readonly sessionID: string; readonly mode: "steer"; readonly inboxID: string } | { readonly sessionID: string; readonly mode: "turn" }
178
198
 
199
+ /** (CON-13) A pending form's schema, as an agent may read it: the fields it takes (bounded by the spoke). */
200
+ export interface PendingForm {
201
+ readonly sessionID: string
202
+ readonly formID: string
203
+ /** The session in the tree that asked (a subagent's form is answered through its root). */
204
+ readonly askedBy: string
205
+ readonly title: string
206
+ readonly question?: true
207
+ readonly fields: readonly FormFieldSpec[]
208
+ }
209
+
210
+ /** (CON-06) The verbs a Concierge proposal covers, and what preflight answers about the target. */
211
+ export type ProposalVerb = "stop" | "deny" | "prompt" | "answer"
212
+ export interface Preflighted {
213
+ readonly sessionID: string
214
+ readonly rootID: string
215
+ /** The target's @name. */
216
+ readonly name?: string
217
+ /** deny: the pending request's details (when known). */
218
+ readonly request?: PermissionRequest
219
+ }
220
+
179
221
  /** The console's prompt limit (http.ts MAX_PROMPT_CHARS; Central passes it). */
180
222
  const DEFAULT_MAX_PROMPT_CHARS = 100_000
181
223
 
@@ -258,9 +300,10 @@ export class Acting {
258
300
  const origin = this.pendingPermission(target, input.requestID)
259
301
  const always = input.always === true
260
302
  const decision: PermissionDecision = always ? "always" : "once"
303
+ const concierge = await this.isConcierge(caller)
261
304
  return this.act(caller, "approve", target, { requestID: input.requestID }, input.why, {
262
305
  selfTree: true,
263
- choose: (usable) => this.choosePermission(usable, origin.request, always),
306
+ choose: (usable) => this.choosePermission(usable, origin.request, always, concierge),
264
307
  escalation: (evaluation) => ({
265
308
  verb: "approve",
266
309
  target: target.sessionID,
@@ -281,6 +324,39 @@ export class Acting {
281
324
  })
282
325
  }
283
326
 
327
+ /**
328
+ * (CON-06) Checks an agent's proposed act as the verb would, short of its grant: kill switch (paused), tenancy
329
+ * (not_found/offline), the Concierge guard (forbidden {policy: concierge}), the pending request or form
330
+ * (not_found), self-tree (forbidden), the prompt rules (invalid). Consumes, audits and sends nothing.
331
+ */
332
+ async preflight(caller: Caller, verb: ProposalVerb, addr: string, ids: { readonly requestID?: string; readonly formID?: string; readonly text?: string; readonly answer?: FormAnswer }): Promise<Preflighted> {
333
+ const { actor, userID } = caller
334
+ if (actor.kind !== "user") this.deps.acting.ensureActing(userID)
335
+ if (verb === "prompt" && actor.kind !== "user") {
336
+ const text = (ids.text ?? "").trim()
337
+ if (!text) throw invalid("Empty prompt")
338
+ if (text.length > DELIVER_TEXT_MAX) throw invalid(`Prompt longer than ${DELIVER_TEXT_MAX} characters`)
339
+ }
340
+ const target = this.locate(userID, addr)
341
+ if (actor.kind !== "user") await this.guard(userID, target)
342
+ let request: PermissionRequest | undefined
343
+ if (verb === "deny") request = this.pendingPermission(target, ids.requestID ?? "").request
344
+ if (verb === "answer" && !this.deps.registry.formTargets(target.rootID, ids.formID ?? "").length) throw new AgentError("not_found", `No pending form ${ids.formID} in this session's tree`)
345
+ if (verb === "answer" && ids.answer !== undefined && actor.kind !== "user") this.checkAnswer(target, ids.formID ?? "", ids.answer)
346
+ if ((verb === "deny" || verb === "answer") && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
347
+ throw forbidden("A session never answers requests in its own session tree")
348
+ }
349
+ if (verb === "prompt" && actor.kind !== "user") {
350
+ if (target.parentID) throw invalid("Prompts go to root sessions, not subagent sessions")
351
+ if (actor.kind === "session" && actor.sessionID === target.sessionID) throw invalid("A session never prompts itself")
352
+ }
353
+ let name: string | undefined
354
+ try {
355
+ name = this.deps.directory.resolveOne(userID, target.sessionID).name
356
+ } catch {}
357
+ return { sessionID: target.sessionID, rootID: target.rootID, ...(name ? { name } : {}), ...(request ? { request } : {}) }
358
+ }
359
+
284
360
  /** Rejects a pending permission request (any request: a reject is always safe), with an optional reason. */
285
361
  async deny(caller: Caller, addr: string, input: DenyInput): Promise<Denied> {
286
362
  const target = this.locate(caller.userID, addr)
@@ -306,6 +382,8 @@ export class Acting {
306
382
  if (!this.deps.registry.formTargets(target.rootID, input.formID).length) throw new AgentError("not_found", `No pending form ${input.formID} in this session's tree`)
307
383
  return this.act(caller, "answer", target, { formID: input.formID }, input.why, {
308
384
  selfTree: true,
385
+ // (CON-13) Checked after the grant's checks (a holder without the verb learns nothing of the form), before it runs.
386
+ check: () => (input.cancel || caller.actor.kind === "user" ? undefined : this.checkAnswer(target, input.formID, input.answer)),
309
387
  run: async (grant) => {
310
388
  const form = input.cancel
311
389
  ? { rootID: target.rootID, formID: input.formID, cancel: true as const }
@@ -318,6 +396,88 @@ export class Acting {
318
396
  })
319
397
  }
320
398
 
399
+ /**
400
+ * (CON-13) A pending form's schema in the addressed session's tree: its title and fields (keys, types, options,
401
+ * required…), never anything else of the session. Checked like the answer verb short of its grant: kill switch,
402
+ * tenancy, the Concierge guard, the pending form, self-tree. Consumes, audits and sends nothing.
403
+ */
404
+ async form(caller: Caller, addr: string, formID: string): Promise<PendingForm> {
405
+ const { actor, userID } = caller
406
+ if (actor.kind !== "user") this.deps.acting.ensureActing(userID)
407
+ const target = this.locate(userID, addr)
408
+ if (actor.kind !== "user") await this.guard(userID, target)
409
+ if (!this.deps.registry.formTargets(target.rootID, formID).length) throw new AgentError("not_found", `No pending form ${formID} in this session's tree`)
410
+ if (actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) throw forbidden("A session never answers requests in its own session tree")
411
+ const found = this.formBlocker(target, formID)
412
+ return {
413
+ sessionID: target.sessionID,
414
+ formID,
415
+ askedBy: found?.sessionID ?? target.sessionID,
416
+ title: found?.blocker.summary ?? "question",
417
+ ...(found?.blocker.question ? { question: true as const } : {}),
418
+ fields: found?.blocker.fields ?? [],
419
+ }
420
+ }
421
+
422
+ /**
423
+ * (CON-13) Whether the agent's grant would approve this pending request once, as approve() decides it: kill switch,
424
+ * tenancy, the Concierge guard, pending in the tree, self-tree, a usable grant with approve, then every never rule
425
+ * and the allow rules (choosePermission). Never acts, consumes, audits or escalates; any refusal is false. The
426
+ * hourly cap is not counted (the act may still be rate limited). The board's `inGrant` (the model never decides).
427
+ */
428
+ async covers(caller: Caller, addr: string, requestID: string): Promise<boolean> {
429
+ const { actor, userID } = caller
430
+ if (actor.kind === "user") return false
431
+ try {
432
+ this.deps.acting.ensureActing(userID)
433
+ const target = this.locate(userID, addr)
434
+ await this.guard(userID, target)
435
+ const { request } = this.pendingPermission(target, requestID)
436
+ if (actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) return false
437
+ const usable = await this.deps.grants.usable(actor, "approve", { directory: target.directory })
438
+ if (!usable.length) return false
439
+ this.choosePermission(usable, request, false)
440
+ return true
441
+ } catch {
442
+ return false
443
+ }
444
+ }
445
+
446
+ /**
447
+ * (CON-13 decision) The board's facts for a pending request: `inGrant` (covers(): the policy's truth) and `auto`:
448
+ * inGrant and not the tap risk tier, i.e. the Concierge may approve it once without the user.
449
+ */
450
+ async coverage(caller: Caller, addr: string, requestID: string): Promise<{ inGrant: boolean; auto: boolean }> {
451
+ const inGrant = await this.covers(caller, addr, requestID)
452
+ if (!inGrant) return { inGrant, auto: false }
453
+ try {
454
+ const target = this.locate(caller.userID, addr)
455
+ const { request } = this.pendingPermission(target, requestID)
456
+ if (!request) return { inGrant, auto: false }
457
+ const usable = await this.deps.grants.usable(caller.actor, "approve", { directory: target.directory })
458
+ const never = [...this.deps.acting.hubNever(), ...usable.flatMap((g) => patterns(g.permissions.never))]
459
+ return { inGrant, auto: riskTier(request, never) !== "tap" }
460
+ } catch {
461
+ return { inGrant, auto: false }
462
+ }
463
+ }
464
+
465
+ /** The form's blocker in the target's tree (with the session that shows it), when the registry reports it. */
466
+ private formBlocker(target: Located, formID: string) {
467
+ for (const s of this.deps.registry.status?.(target.rootID) ?? []) {
468
+ const blocker = s.blockers.find((b) => b.kind === "form" && b.id === formID)
469
+ if (blocker) return { sessionID: s.id, blocker }
470
+ }
471
+ return undefined
472
+ }
473
+
474
+ /** (CON-13) 400 invalid with the precise problem when an agent's answer does not fit the form's fields. */
475
+ private checkAnswer(target: Located, formID: string, answer: FormAnswer): void {
476
+ const fields = this.formBlocker(target, formID)?.blocker.fields
477
+ const problem = fields ? formAnswerFieldProblem(fields, answer) : undefined
478
+ if (problem) throw new AgentError("invalid", problem, { hint: "Read the form's fields first (concierge_form, or show) and answer with their keys and option values" })
479
+ }
480
+
321
481
  /** Stops the addressed session's tree. */
322
482
  async stop(caller: Caller, addr: string, input: StopInput): Promise<StoppedSession> {
323
483
  const target = this.locate(caller.userID, addr)
@@ -361,16 +521,16 @@ export class Acting {
361
521
  }
362
522
  return this.act(caller, "prompt", target, {}, input.why, {
363
523
  run: async (grant) => {
364
- if (!grant) return this.userPrompt(target.sessionID, text, images)
524
+ if (!grant) return this.userPrompt(target.sessionID, text, images, input.intent)
365
525
  return this.agentPrompt(caller, addr, target.sessionID, text, grant, input.delivery)
366
526
  },
367
527
  })
368
528
  }
369
529
 
370
- private async userPrompt(sessionID: string, text: string, images: readonly PromptImage[] | undefined): Promise<UserPrompted> {
530
+ private async userPrompt(sessionID: string, text: string, images: readonly PromptImage[] | undefined, intent?: false): Promise<UserPrompted> {
371
531
  let started
372
532
  try {
373
- started = await this.deps.actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
533
+ started = await this.deps.actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true, ...(intent === false ? { intent } : {}) })
374
534
  } catch (cause) {
375
535
  if (cause instanceof BusyError) throw new AgentError("rate_limited", "Another turn is starting in this session", { hint: "Try again", extra: { retryAfter: 1 } })
376
536
  throw cause
@@ -427,6 +587,8 @@ export class Acting {
427
587
  /** The grant to act under (default: the newest usable); throws NeedsUser or forbidden. */
428
588
  readonly choose?: (usable: Grant[]) => Grant
429
589
  readonly escalation?: (evaluation: Evaluation) => Omit<Escalation, "userID" | "actor">
590
+ /** (CON-13) Input checks that need the target (throw invalid): after the grant's checks, before it is consumed. */
591
+ readonly check?: () => void
430
592
  readonly run: (grant: Grant | undefined) => Promise<T>
431
593
  },
432
594
  ): Promise<T> {
@@ -446,16 +608,21 @@ export class Acting {
446
608
  try {
447
609
  if (actor.kind !== "user") {
448
610
  acting.ensureActing(userID)
611
+ await this.guard(userID, target)
449
612
  const usable = await grants.usable(actor, verb, { directory: target.directory })
450
613
  if (!usable.length) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
451
614
  if (plan.selfTree && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
452
615
  throw forbidden("A session never answers requests in its own session tree")
453
616
  }
454
617
  grant = plan.choose ? plan.choose(usable) : usable[0]!
618
+ plan.check?.()
455
619
  await grants.consume(grant)
456
620
  acting.ensureActing(userID)
457
621
  }
458
- const result = await plan.run(grant)
622
+ // (CON-13) What the spoke said when it refused, never a bare 500.
623
+ const result = await plan.run(grant).catch((cause: unknown) => {
624
+ throw refusedBySession(cause)
625
+ })
459
626
  await record("allow", "ok", why)
460
627
  return result
461
628
  } catch (cause) {
@@ -474,6 +641,13 @@ export class Acting {
474
641
  }
475
642
  }
476
643
 
644
+ /** (CON-04) Sessions only the user acts on (the Concierge's): agents are forbidden {policy: concierge}. */
645
+ private async guard(userID: string, target: Located): Promise<void> {
646
+ if (this.deps.protect && ((await this.deps.protect(userID, target.rootID)) || (await this.deps.protect(userID, target.sessionID)))) {
647
+ throw new AgentError("forbidden", "Only the user acts on the Concierge", { extra: { policy: "concierge" } })
648
+ }
649
+ }
650
+
477
651
  /** Hands the escalation to the hook: undefined when there is none or it failed. */
478
652
  private async escalate(caller: Caller, escalation: Omit<Escalation, "userID" | "actor"> | undefined): Promise<{ decisionID?: string } | undefined> {
479
653
  if (!this.deps.escalate || !escalation) return undefined
@@ -485,10 +659,25 @@ export class Acting {
485
659
  }
486
660
  }
487
661
 
488
- /** The grant that covers the request, after every never rule; NeedsUser when none does. */
489
- private choosePermission(usable: Grant[], request: PermissionRequest | undefined, always: boolean): Grant {
662
+ /** (CON-13 decision) The actor is a Concierge session (current or past); a failed read counts as yes. */
663
+ private async isConcierge(caller: Caller): Promise<boolean> {
664
+ const { actor } = caller
665
+ if (actor.kind !== "session" || !this.deps.conciergeActor) return false
666
+ try {
667
+ return await this.deps.conciergeActor(caller.userID, actor.sessionID)
668
+ } catch {
669
+ return true
670
+ }
671
+ }
672
+
673
+ /**
674
+ * The grant that covers the request, after every never rule; NeedsUser when none does. (CON-13 decision)
675
+ * `concierge`: a Concierge actor's "always", or a covered request of the tap risk tier, is NeedsUser at tap tier.
676
+ */
677
+ private choosePermission(usable: Grant[], request: PermissionRequest | undefined, always: boolean, concierge = false): Grant {
490
678
  const hubNever = this.deps.acting.hubNever()
491
679
  const ids = usable.map((g) => g.id)
680
+ if (concierge && always) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_ALWAYS_REASON, tier: "tap", grants: ids })
492
681
  if (!request) throw new NeedsUser({ verdict: "outside", reason: "The request's details are unknown", tier: "tap", grants: ids })
493
682
  const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
494
683
  for (const form of neverForms(request)) {
@@ -504,7 +693,10 @@ export class Acting {
504
693
  let reason: string | undefined
505
694
  for (const { grant, allow, never } of rules) {
506
695
  const verdict = evaluatePermission(request, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
507
- if (verdict.verdict === "allow") return grant
696
+ if (verdict.verdict === "allow") {
697
+ if (concierge && riskTier(request, allNever) === "tap") throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_TAP_REASON, tier: "tap", grants: ids })
698
+ return grant
699
+ }
508
700
  if (verdict.verdict === "outside") reason ??= verdict.reason
509
701
  }
510
702
  throw new NeedsUser({ verdict: "outside", reason: reason ?? "Outside the grant", tier: riskTier(request, allNever), grants: ids })
@@ -570,12 +762,25 @@ function bounded(text: string, max: number): string {
570
762
  }
571
763
 
572
764
  const gone = () => new AgentError("gone", "Already answered, expired or ended")
765
+
766
+ /**
767
+ * (CON-13) The session's harness refused the act (OpenCode said why, e.g. "Unknown form field: Questions"): 400
768
+ * invalid with its words (bounded, one line), never a 500 "Internal error" a model would repeat to the user. The
769
+ * audit keeps it as allowed · failed. A disconnect stays offline; hub errors pass through.
770
+ */
771
+ export function refusedBySession(cause: unknown): unknown {
772
+ if (cause instanceof AgentError || cause instanceof NeedsUser || cause instanceof BusyError) return cause
773
+ if (cause instanceof SpokeError && /disconnected/i.test(cause.message)) return new AgentError("offline", "The session's spoke disconnected")
774
+ const said = sanitizeText(cause instanceof Error ? cause.message : String(cause)).replace(/\s+/g, " ").trim().slice(0, 300) || "no reason given"
775
+ return new AgentError("invalid", `The session refused it: ${said}`, { extra: { refusedBy: "session" } })
776
+ }
573
777
  /** The spoke lacks the core op: unsupported {feature, harness}, as the read routes answer. */
574
778
  const unsupported = ({ feature, harness }: Unsupported) => new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
575
779
 
576
780
  /** An error's audit decision and outcome (shared with the launch routes). */
577
781
  export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
578
782
  if (!(cause instanceof AgentError)) return ["allow", "failed"]
783
+ if (cause.options.extra?.refusedBy === "session") return ["allow", "failed"]
579
784
  switch (cause.code) {
580
785
  case "paused":
581
786
  return ["paused", "refused"]