@huynle/hub 0.6.0 → 0.7.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 (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,599 @@
1
+ // The acting engine (design §5, §7, §11.4; decision wvqeuis0; exploration hcw63slg): approve, deny, answer, prompt
2
+ // and stop a session for its user. acting-routes.ts maps HTTP onto it; actions.ts runs the spoke side.
3
+ //
4
+ // Who may act:
5
+ // - Users (cookie, device token) always may, on their own sessions: tenancy only, no grant, no kill switch.
6
+ // - Sessions (through their act token only, never a shell token) and app tokens (scope sessions:act) act under a
7
+ // grant (grants.ts) of their user, in this order:
8
+ // tenancy → kill switch → grant → self-tree → hub never → grant never → allow (else needs_user) → hourly cap →
9
+ // kill switch again → spoke.
10
+ // · tenancy: the address resolves among the user's sessions; a request or form must be pending in that tree
11
+ // (otherwise not_found, like another user's).
12
+ // · self-tree: a session never answers a permission request or form in its own session tree (forbidden).
13
+ // · never (hub-wide agents.never, then every usable grant's never): beats any allow; tried on the request as
14
+ // asked and on its normalised shell form (normaliseShell), a hit on either is forbidden.
15
+ // · allow: the first usable grant whose policy covers the request; none → needs_user (403) with the evaluation
16
+ // {verdict, reason, tier, grants}. The `escalate` hook (P3-T8 turns it into a Decision) gets the escalation
17
+ // first; a request without parsed details, or truncated, is always needs_user (tap).
18
+ // · deny, answer, prompt and stop need only the grant's verb (a reject is always safe).
19
+ // - Every outcome after the address resolves is in the agent audit: decision (allow, needs_user, forbidden,
20
+ // paused, rate_limited) and outcome (ok, gone, offline, unsupported, escalated, refused, failed).
21
+ // - Races: the request answered elsewhere first is gone (410).
22
+ import {
23
+ DELIVER_TEXT_MAX,
24
+ type Delivered,
25
+ type DeliveryMode,
26
+ type FormAnswer,
27
+ isDeliverResult,
28
+ PERMISSION_MESSAGE_MAX,
29
+ type PermissionDecision,
30
+ type PromptImage,
31
+ promptImagesProblem,
32
+ type ResultFor,
33
+ type ServerRequest,
34
+ } from "../../protocol"
35
+ import { type Actions, type Unsupported, unsupportedMessage } from "../actions"
36
+ import { BusyError, type Registry } from "../registry"
37
+ import { SpokeError } from "../spoke-ws"
38
+ import type { PermissionRequest } from "../../status"
39
+ import type { ActingSwitch } from "./acting-switch"
40
+ import type { AgentActor } from "./actor"
41
+ import type { AgentAudit, AuditDecision, AuditOutcome, AuditTarget } from "./audit"
42
+ import type { Tier } from "./decisions"
43
+ import type { Directory } from "./directory"
44
+ import { type Party, partyFromActor, partyLabel, sanitizeText } from "./envelope"
45
+ import { AgentError } from "./errors"
46
+ import type { Grant, Grants } from "./grants"
47
+ import { newMessageID } from "./messages"
48
+ import { evaluatePermission, formatPattern, type GrantVerb, parsePattern, type Pattern, riskTier } from "./policy"
49
+
50
+ /** What the engine reads from the Registry. */
51
+ export type ActingRegistry = Pick<
52
+ Registry,
53
+ "sessionTenant" | "session" | "rootOf" | "permissionRequest" | "permissionTargets" | "formTargets" | "owner" | "supports" | "harnessOf"
54
+ >
55
+
56
+ /** Why a request needs the user, as the policy saw it. */
57
+ export interface Evaluation {
58
+ readonly verdict: "outside"
59
+ readonly reason: string
60
+ /** Set by the hub (decision wvqeuis0 §7), never by the agent. */
61
+ readonly tier: Tier
62
+ /** The usable grants it was evaluated against. */
63
+ readonly grants: readonly string[]
64
+ }
65
+
66
+ /** An agent's approval outside its grant: what the user is asked to decide (P3-T8 opens a Decision). */
67
+ export interface Escalation {
68
+ readonly userID: string
69
+ readonly actor: AgentActor
70
+ readonly verb: "approve"
71
+ /** The addressed session. */
72
+ readonly target: string
73
+ /** The session that asked, and its tree. */
74
+ readonly sessionID: string
75
+ readonly rootID: string
76
+ readonly requestID: string
77
+ readonly request?: PermissionRequest
78
+ readonly always: boolean
79
+ readonly evaluation: Evaluation
80
+ /** The agent's reason (`--why`): its recommendation to approve. */
81
+ readonly why?: string
82
+ }
83
+
84
+ /** The escalation hook: answers the Decision it opened (or nothing); a throw only loses the Decision. */
85
+ export type Escalate = (escalation: Escalation) => Promise<{ readonly decisionID?: string } | void>
86
+
87
+ export interface ActingDeps {
88
+ readonly registry: ActingRegistry
89
+ readonly directory: Pick<Directory, "resolveOne">
90
+ readonly actions: Actions
91
+ readonly spokes: { request(connID: string, req: ServerRequest): Promise<unknown> }
92
+ readonly grants: Pick<Grants, "usable" | "consume">
93
+ readonly acting: Pick<ActingSwitch, "ensureActing" | "hubNever">
94
+ readonly audit: Pick<AgentAudit, "record">
95
+ /** needs_user escalations (P3-T8); without it needs_user is answered and audited as refused. */
96
+ readonly escalate?: Escalate
97
+ readonly newMessageID?: () => string
98
+ /** Longest prompt a user sends (the console's limit); agents' prompts are at most DELIVER_TEXT_MAX. */
99
+ readonly maxPromptChars?: number
100
+ }
101
+
102
+ /** Who acts, for which user. */
103
+ export interface Caller {
104
+ readonly actor: AgentActor
105
+ readonly userID: string
106
+ }
107
+
108
+ export interface ApproveInput {
109
+ readonly requestID: string
110
+ readonly always?: boolean
111
+ readonly why?: string
112
+ }
113
+
114
+ export interface Approved {
115
+ readonly sessionID: string
116
+ readonly requestID: string
117
+ readonly decision: PermissionDecision
118
+ readonly grant?: string
119
+ }
120
+
121
+ export interface DenyInput {
122
+ readonly requestID: string
123
+ /** Why: passed to the harness (prefixed with who denied it) by spokes announcing "permissionMessage". */
124
+ readonly why?: string
125
+ }
126
+
127
+ export interface Denied {
128
+ readonly sessionID: string
129
+ readonly requestID: string
130
+ readonly grant?: string
131
+ /** With a reason: whether the harness got it (older spokes take the reject without it). */
132
+ readonly reasonSent?: boolean
133
+ }
134
+
135
+ export type AnswerInput =
136
+ | { readonly formID: string; readonly answer: FormAnswer; readonly cancel?: undefined; readonly why?: string }
137
+ | { readonly formID: string; readonly cancel: true; readonly answer?: undefined; readonly why?: string }
138
+
139
+ export interface Answered {
140
+ readonly sessionID: string
141
+ readonly formID: string
142
+ readonly grant?: string
143
+ }
144
+
145
+ export interface StopInput {
146
+ readonly why?: string
147
+ }
148
+
149
+ export interface StoppedSession {
150
+ readonly sessionID: string
151
+ readonly grant?: string
152
+ /** The spoke's answer. */
153
+ readonly result: ResultFor["stop"]
154
+ }
155
+
156
+ export interface PromptInput {
157
+ readonly text: string
158
+ /** Users only. */
159
+ readonly images?: readonly PromptImage[]
160
+ /** How an agent's prompt enters a busy session (the spoke picks when absent). */
161
+ readonly delivery?: DeliveryMode
162
+ /** Agents' prompts always wake the session: false is refused. */
163
+ readonly wake?: boolean
164
+ readonly why?: string
165
+ }
166
+
167
+ /** An agent's prompt, admitted by the spoke under its provenance header. */
168
+ export interface AgentPrompted {
169
+ readonly sessionID: string
170
+ readonly messageID: string
171
+ readonly delivered: Delivered
172
+ readonly inboxID?: string
173
+ readonly grant: string
174
+ }
175
+
176
+ /** The user's prompt: steered into the running tree, or a new turn (its reply streams to the console). */
177
+ export type UserPrompted = { readonly sessionID: string; readonly mode: "steer"; readonly inboxID: string } | { readonly sessionID: string; readonly mode: "turn" }
178
+
179
+ /** The console's prompt limit (http.ts MAX_PROMPT_CHARS; Central passes it). */
180
+ const DEFAULT_MAX_PROMPT_CHARS = 100_000
181
+
182
+ /** The addressed session, live in the user's catalog. */
183
+ interface Located {
184
+ readonly sessionID: string
185
+ readonly directory: string
186
+ readonly rootID: string
187
+ readonly parentID?: string
188
+ }
189
+
190
+ // ---------- shell normalisation (never rules) ----------
191
+
192
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
193
+ /** Wrappers that run the rest of the line, and their options that take a value. */
194
+ const WRAPPERS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
195
+ ["sudo", new Set(["-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U", "-T"])],
196
+ ["env", new Set(["-u", "-C", "-S"])],
197
+ ["time", new Set(["-f", "-o"])],
198
+ ["command", new Set<string>()],
199
+ ["builtin", new Set<string>()],
200
+ ["exec", new Set(["-a"])],
201
+ ["nohup", new Set<string>()],
202
+ ])
203
+
204
+ /**
205
+ * A shell command as a never rule also sees it: whitespace collapsed, leading variable assignments and wrappers
206
+ * (env, command, builtin, exec, nohup, sudo, time, with their options) stripped.
207
+ */
208
+ export function normaliseShell(command: string): string {
209
+ const words = command.trim().split(/\s+/).filter(Boolean)
210
+ for (;;) {
211
+ const word = words[0]
212
+ if (word === undefined) break
213
+ if (ASSIGNMENT.test(word)) {
214
+ words.shift()
215
+ continue
216
+ }
217
+ const valued = WRAPPERS.get(word)
218
+ if (!valued) break
219
+ words.shift()
220
+ while (words[0]?.startsWith("-")) {
221
+ const option = words.shift()!
222
+ if (valued.has(option)) words.shift()
223
+ }
224
+ }
225
+ return words.join(" ")
226
+ }
227
+
228
+ const isShell = (action: string) => action === "shell" || action === "bash"
229
+
230
+ /** The request as asked and (for shell) normalised, for never matching. */
231
+ function neverForms(request: PermissionRequest): PermissionRequest[] {
232
+ if (!isShell(request.action)) return [request]
233
+ const normalised = {
234
+ action: request.action,
235
+ resources: request.resources.map(normaliseShell),
236
+ ...(request.save ? { save: request.save.map(normaliseShell) } : {}),
237
+ }
238
+ return [request, normalised]
239
+ }
240
+
241
+ const patterns = (texts: readonly string[]): Pattern[] => texts.flatMap((t) => parsePattern(t) ?? [])
242
+
243
+ const invalid = (message: string) => new AgentError("invalid", message)
244
+ const forbidden = (message: string) => new AgentError("forbidden", message)
245
+ const offline = () => new AgentError("offline", "No spoke reports this session now")
246
+
247
+ /** Thrown by a policy: the request needs the user. */
248
+ class NeedsUser {
249
+ constructor(readonly evaluation: Evaluation) {}
250
+ }
251
+
252
+ export class Acting {
253
+ constructor(private readonly deps: ActingDeps) {}
254
+
255
+ /** Approves a pending permission request in the addressed session's tree (once, or always). */
256
+ async approve(caller: Caller, addr: string, input: ApproveInput): Promise<Approved> {
257
+ const target = this.locate(caller.userID, addr)
258
+ const origin = this.pendingPermission(target, input.requestID)
259
+ const always = input.always === true
260
+ const decision: PermissionDecision = always ? "always" : "once"
261
+ return this.act(caller, "approve", target, { requestID: input.requestID }, input.why, {
262
+ selfTree: true,
263
+ choose: (usable) => this.choosePermission(usable, origin.request, always),
264
+ escalation: (evaluation) => ({
265
+ verb: "approve",
266
+ target: target.sessionID,
267
+ sessionID: origin.sessionID,
268
+ rootID: target.rootID,
269
+ requestID: input.requestID,
270
+ ...(origin.request ? { request: origin.request } : {}),
271
+ always,
272
+ evaluation,
273
+ ...(input.why ? { why: input.why } : {}),
274
+ }),
275
+ run: async (grant) => {
276
+ const answered = await this.deps.actions.answerPermission({ rootID: target.rootID, requestID: input.requestID, decision })
277
+ if (answered.outcome === "unsupported") throw unsupported(answered)
278
+ if (answered.outcome === "gone") throw gone()
279
+ return { sessionID: target.sessionID, requestID: input.requestID, decision, ...(grant ? { grant: grant.id } : {}) }
280
+ },
281
+ })
282
+ }
283
+
284
+ /** Rejects a pending permission request (any request: a reject is always safe), with an optional reason. */
285
+ async deny(caller: Caller, addr: string, input: DenyInput): Promise<Denied> {
286
+ const target = this.locate(caller.userID, addr)
287
+ this.pendingPermission(target, input.requestID)
288
+ return this.act(caller, "deny", target, { requestID: input.requestID }, input.why, {
289
+ selfTree: true,
290
+ run: async (grant) => {
291
+ const { registry } = this.deps
292
+ const why = input.why === undefined ? "" : sanitizeText(input.why).trim()
293
+ const reasonSent = why !== "" && registry.permissionTargets(target.rootID, input.requestID).every((t) => registry.supports(t.connID, "permissionMessage"))
294
+ const message = reasonSent ? bounded(`Denied by ${partyLabel(this.party(caller))}${grant ? ` (grant ${grant.id})` : ""}: ${why}`, PERMISSION_MESSAGE_MAX) : undefined
295
+ const answered = await this.deps.actions.answerPermission({ rootID: target.rootID, requestID: input.requestID, decision: "reject", ...(message ? { message } : {}) })
296
+ if (answered.outcome === "unsupported") throw unsupported(answered)
297
+ if (answered.outcome === "gone") throw gone()
298
+ return { sessionID: target.sessionID, requestID: input.requestID, ...(grant ? { grant: grant.id } : {}), ...(why ? { reasonSent } : {}) }
299
+ },
300
+ })
301
+ }
302
+
303
+ /** Answers (or cancels) a form pending in the addressed session's tree. */
304
+ async answer(caller: Caller, addr: string, input: AnswerInput): Promise<Answered> {
305
+ const target = this.locate(caller.userID, addr)
306
+ 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
+ return this.act(caller, "answer", target, { formID: input.formID }, input.why, {
308
+ selfTree: true,
309
+ run: async (grant) => {
310
+ const form = input.cancel
311
+ ? { rootID: target.rootID, formID: input.formID, cancel: true as const }
312
+ : { rootID: target.rootID, formID: input.formID, answer: input.answer }
313
+ const answered = await this.deps.actions.answerForm(form)
314
+ if (answered.outcome === "unsupported") throw unsupported(answered)
315
+ if (answered.outcome === "gone") throw gone()
316
+ return { sessionID: target.sessionID, formID: input.formID, ...(grant ? { grant: grant.id } : {}) }
317
+ },
318
+ })
319
+ }
320
+
321
+ /** Stops the addressed session's tree. */
322
+ async stop(caller: Caller, addr: string, input: StopInput): Promise<StoppedSession> {
323
+ const target = this.locate(caller.userID, addr)
324
+ return this.act(caller, "stop", target, {}, input.why, {
325
+ run: async (grant) => {
326
+ const stopped = await this.deps.actions.stop(target.sessionID)
327
+ if (stopped.outcome === "offline") throw offline()
328
+ if (stopped.outcome === "unsupported") throw unsupported(stopped)
329
+ return { sessionID: target.sessionID, ...(grant ? { grant: grant.id } : {}), result: stopped.result }
330
+ },
331
+ })
332
+ }
333
+
334
+ /**
335
+ * Prompts the addressed session. An agent's prompt is a deliver of kind "prompt" naming the sender and grant
336
+ * (feature hubPrompt; the spoke renders the provenance header), never a human turn: hop chains are kept.
337
+ * Refused (invalid): subagent sessions, the session itself, wake:false, images. The user's prompt is a human turn.
338
+ */
339
+ async prompt(caller: Caller, addr: string, input: PromptInput): Promise<AgentPrompted | UserPrompted> {
340
+ const { actor, userID } = caller
341
+ const text = input.text.trim()
342
+ const images = input.images?.length ? input.images : undefined
343
+ if (actor.kind === "user") {
344
+ if (images) {
345
+ const problem = promptImagesProblem(images)
346
+ if (problem) throw invalid(problem)
347
+ }
348
+ if (!text && !images) throw invalid("Empty prompt")
349
+ const max = this.deps.maxPromptChars ?? DEFAULT_MAX_PROMPT_CHARS
350
+ if (text.length > max) throw invalid(`Prompt longer than ${max} characters`)
351
+ } else {
352
+ if (images) throw invalid("An agent's prompt is text only")
353
+ if (input.wake === false) throw invalid("An agent's prompt always wakes the session (send a message for wake: false)")
354
+ if (!text) throw invalid("Empty prompt")
355
+ if (text.length > DELIVER_TEXT_MAX) throw invalid(`Prompt longer than ${DELIVER_TEXT_MAX} characters`)
356
+ }
357
+ const target = this.locate(userID, addr)
358
+ if (actor.kind !== "user") {
359
+ if (target.parentID) throw invalid("Prompts go to root sessions, not subagent sessions")
360
+ if (actor.kind === "session" && actor.sessionID === target.sessionID) throw invalid("A session never prompts itself")
361
+ }
362
+ return this.act(caller, "prompt", target, {}, input.why, {
363
+ run: async (grant) => {
364
+ if (!grant) return this.userPrompt(target.sessionID, text, images)
365
+ return this.agentPrompt(caller, addr, target.sessionID, text, grant, input.delivery)
366
+ },
367
+ })
368
+ }
369
+
370
+ private async userPrompt(sessionID: string, text: string, images: readonly PromptImage[] | undefined): Promise<UserPrompted> {
371
+ let started
372
+ try {
373
+ started = await this.deps.actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
374
+ } catch (cause) {
375
+ if (cause instanceof BusyError) throw new AgentError("rate_limited", "Another turn is starting in this session", { hint: "Try again", extra: { retryAfter: 1 } })
376
+ throw cause
377
+ }
378
+ if (started.outcome === "offline") throw offline()
379
+ if (started.outcome === "unsupported") throw unsupported(started)
380
+ if (started.mode === "steer") return { sessionID, mode: "steer", inboxID: started.inboxID }
381
+ // Nobody waits for the turn here: its reply streams to the console.
382
+ void started.result.catch(() => {})
383
+ return { sessionID, mode: "turn" }
384
+ }
385
+
386
+ private async agentPrompt(caller: Caller, addr: string, sessionID: string, text: string, grant: Grant, delivery: DeliveryMode | undefined): Promise<AgentPrompted> {
387
+ const { registry, spokes } = this.deps
388
+ const connID = registry.owner(sessionID)
389
+ if (!connID) throw offline()
390
+ for (const feature of ["deliver", "hubPrompt"] as const) {
391
+ if (!registry.supports(connID, feature)) {
392
+ const harness = registry.harnessOf(connID) ?? "unknown"
393
+ throw new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
394
+ }
395
+ }
396
+ const messageID = (this.deps.newMessageID ?? newMessageID)()
397
+ const from = this.address(caller)
398
+ let result: unknown
399
+ try {
400
+ result = await spokes.request(connID, {
401
+ op: "deliver",
402
+ sessionID,
403
+ messageID,
404
+ text,
405
+ wake: true,
406
+ meta: { kind: "prompt", from, to: addr, grant: grant.id, ...(delivery ? { delivery } : {}) },
407
+ })
408
+ } catch (cause) {
409
+ if (cause instanceof SpokeError && /disconnected/i.test(cause.message)) throw new AgentError("offline", "The session's spoke disconnected")
410
+ throw cause
411
+ }
412
+ if (!isDeliverResult(result)) throw new Error("The spoke answered a deliver malformed")
413
+ return { sessionID, messageID, delivered: result.delivered, ...(result.inboxID ? { inboxID: result.inboxID } : {}), grant: grant.id }
414
+ }
415
+
416
+ // ---------- the check order ----------
417
+
418
+ private async act<T>(
419
+ caller: Caller,
420
+ verb: GrantVerb,
421
+ target: Located,
422
+ ids: Omit<AuditTarget, "sessionID">,
423
+ why: string | undefined,
424
+ plan: {
425
+ /** A session never acts in its own tree (permissions and forms). */
426
+ readonly selfTree?: boolean
427
+ /** The grant to act under (default: the newest usable); throws NeedsUser or forbidden. */
428
+ readonly choose?: (usable: Grant[]) => Grant
429
+ readonly escalation?: (evaluation: Evaluation) => Omit<Escalation, "userID" | "actor">
430
+ readonly run: (grant: Grant | undefined) => Promise<T>
431
+ },
432
+ ): Promise<T> {
433
+ const { actor, userID } = caller
434
+ const { acting, grants } = this.deps
435
+ let grant: Grant | undefined
436
+ const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
437
+ this.deps.audit.record(userID, {
438
+ actor: this.party(caller),
439
+ verb,
440
+ target: { sessionID: target.sessionID, ...ids },
441
+ ...(grant ? { grant: grant.id } : {}),
442
+ decision,
443
+ ...(reason ? { reason } : {}),
444
+ outcome,
445
+ })
446
+ try {
447
+ if (actor.kind !== "user") {
448
+ acting.ensureActing(userID)
449
+ const usable = await grants.usable(actor, verb, { directory: target.directory })
450
+ if (!usable.length) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
451
+ if (plan.selfTree && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
452
+ throw forbidden("A session never answers requests in its own session tree")
453
+ }
454
+ grant = plan.choose ? plan.choose(usable) : usable[0]!
455
+ await grants.consume(grant)
456
+ acting.ensureActing(userID)
457
+ }
458
+ const result = await plan.run(grant)
459
+ await record("allow", "ok", why)
460
+ return result
461
+ } catch (cause) {
462
+ if (cause instanceof NeedsUser) {
463
+ const escalated = await this.escalate(caller, plan.escalation?.(cause.evaluation))
464
+ const decisionID = escalated?.decisionID
465
+ await record("needs_user", escalated ? "escalated" : "refused", cause.evaluation.reason)
466
+ throw new AgentError("needs_user", `Needs the user: ${cause.evaluation.reason}`, {
467
+ hint: "The user decides in the console",
468
+ extra: { evaluation: cause.evaluation, ...(decisionID !== undefined ? { decisionID } : {}) },
469
+ })
470
+ }
471
+ const [decision, outcome] = auditOf(cause)
472
+ await record(decision, outcome, cause instanceof Error ? cause.message : undefined)
473
+ throw cause
474
+ }
475
+ }
476
+
477
+ /** Hands the escalation to the hook: undefined when there is none or it failed. */
478
+ private async escalate(caller: Caller, escalation: Omit<Escalation, "userID" | "actor"> | undefined): Promise<{ decisionID?: string } | undefined> {
479
+ if (!this.deps.escalate || !escalation) return undefined
480
+ try {
481
+ return (await this.deps.escalate({ userID: caller.userID, actor: caller.actor, ...escalation })) ?? {}
482
+ } catch (cause) {
483
+ console.error(`[hub] escalation failed: ${cause instanceof Error ? cause.message : String(cause)}`)
484
+ return undefined
485
+ }
486
+ }
487
+
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 {
490
+ const hubNever = this.deps.acting.hubNever()
491
+ const ids = usable.map((g) => g.id)
492
+ if (!request) throw new NeedsUser({ verdict: "outside", reason: "The request's details are unknown", tier: "tap", grants: ids })
493
+ const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
494
+ for (const form of neverForms(request)) {
495
+ for (const { grant, allow, never } of rules) {
496
+ const verdict = evaluatePermission(form, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
497
+ if (verdict.verdict === "never") {
498
+ throw forbidden(`never: ${verdict.by === "hub" ? "the hub's" : `grant ${grant.id}'s`} rule ${formatPattern(verdict.pattern)} matches ${verdict.subject}`)
499
+ }
500
+ }
501
+ }
502
+ const allNever = [...hubNever, ...rules.flatMap((r) => r.never)]
503
+ if (request.truncated) throw new NeedsUser({ verdict: "outside", reason: "The request was too large to check in full", tier: "tap", grants: ids })
504
+ let reason: string | undefined
505
+ for (const { grant, allow, never } of rules) {
506
+ const verdict = evaluatePermission(request, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
507
+ if (verdict.verdict === "allow") return grant
508
+ if (verdict.verdict === "outside") reason ??= verdict.reason
509
+ }
510
+ throw new NeedsUser({ verdict: "outside", reason: reason ?? "Outside the grant", tier: riskTier(request, allNever), grants: ids })
511
+ }
512
+
513
+ // ---------- targets ----------
514
+
515
+ /** The addressed session among the user's (not_found otherwise), live now (else offline). */
516
+ private locate(userID: string, addr: string): Located {
517
+ const entry = this.deps.directory.resolveOne(userID, addr)
518
+ const { registry } = this.deps
519
+ if (registry.sessionTenant(entry.sessionID) !== userID) throw offline()
520
+ const live = registry.session(entry.sessionID)
521
+ if (!live) throw offline()
522
+ const parentID = live.parentID ?? entry.parentID
523
+ return { sessionID: entry.sessionID, directory: live.directory, rootID: registry.rootOf(entry.sessionID) ?? entry.sessionID, ...(parentID ? { parentID } : {}) }
524
+ }
525
+
526
+ /** A permission request pending in the target's tree; not_found otherwise (unknown, elsewhere, another user's). */
527
+ private pendingPermission(target: Located, requestID: string): { sessionID: string; request?: PermissionRequest } {
528
+ const { registry } = this.deps
529
+ const origin = registry.permissionRequest(requestID)
530
+ if (!origin || (registry.rootOf(origin.sessionID) ?? origin.sessionID) !== target.rootID || !registry.permissionTargets(target.rootID, requestID).length) {
531
+ throw new AgentError("not_found", `No pending request ${requestID} in this session's tree`)
532
+ }
533
+ return origin
534
+ }
535
+
536
+ /** The caller's address as a deliver's meta.from: `@name` or the session ID, `app:<token id>`. */
537
+ private address(caller: Caller): string {
538
+ const party = this.party(caller)
539
+ switch (party.kind) {
540
+ case "session":
541
+ return party.name ? `@${party.name}` : party.sessionID
542
+ case "app":
543
+ return `app:${party.tokenID}`
544
+ case "user":
545
+ return "@me"
546
+ }
547
+ }
548
+
549
+ /** The caller as the audit and envelopes name it (a session by its directory name). */
550
+ private party(caller: Caller): Party {
551
+ const { actor } = caller
552
+ if (actor.kind !== "session") return partyFromActor(actor)
553
+ let name: string | undefined
554
+ try {
555
+ name = this.deps.directory.resolveOne(caller.userID, actor.sessionID).name
556
+ } catch {}
557
+ return partyFromActor(actor, name ? { name } : {})
558
+ }
559
+ }
560
+
561
+ /** At most `max` UTF-16 units, cut on a character boundary. */
562
+ function bounded(text: string, max: number): string {
563
+ if (text.length <= max) return text
564
+ let out = ""
565
+ for (const char of text) {
566
+ if (out.length + char.length > max) break
567
+ out += char
568
+ }
569
+ return out
570
+ }
571
+
572
+ const gone = () => new AgentError("gone", "Already answered, expired or ended")
573
+ /** The spoke lacks the core op: unsupported {feature, harness}, as the read routes answer. */
574
+ const unsupported = ({ feature, harness }: Unsupported) => new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
575
+
576
+ /** An error's audit decision and outcome (shared with the launch routes). */
577
+ export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
578
+ if (!(cause instanceof AgentError)) return ["allow", "failed"]
579
+ switch (cause.code) {
580
+ case "paused":
581
+ return ["paused", "refused"]
582
+ case "forbidden":
583
+ case "invalid":
584
+ case "not_found":
585
+ return ["forbidden", "refused"]
586
+ case "rate_limited":
587
+ return ["rate_limited", "refused"]
588
+ case "needs_user":
589
+ return ["needs_user", "refused"]
590
+ case "gone":
591
+ return ["allow", "gone"]
592
+ case "offline":
593
+ return ["allow", "offline"]
594
+ case "unsupported":
595
+ return ["allow", "unsupported"]
596
+ default:
597
+ return ["allow", "failed"]
598
+ }
599
+ }