@huynle/hub 0.8.1 → 0.8.3

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 (97) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/README.md +32 -1
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +116 -53
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  8. package/packages/cli/src/artifacts-mcp.ts +8 -7
  9. package/packages/cli/src/mcp.ts +4 -3
  10. package/packages/cli/src/profiles-mcp.ts +3 -3
  11. package/packages/cli/src/team-intake-mcp.ts +8 -7
  12. package/packages/cli/src/teams-mcp.ts +2 -1
  13. package/packages/cli/src/tool-args.ts +6 -0
  14. package/packages/cli/src/watchlist.ts +5 -4
  15. package/packages/codex/package.json +1 -1
  16. package/packages/node/package.json +1 -1
  17. package/packages/preset/package.json +1 -1
  18. package/packages/protocol/package.json +1 -1
  19. package/packages/protocol/src/concierge.ts +4 -1
  20. package/packages/protocol/src/embedded-media.ts +59 -0
  21. package/packages/protocol/src/index.ts +92 -7
  22. package/packages/protocol/src/voice-recording.ts +107 -0
  23. package/packages/protocol/src/wire.ts +1 -0
  24. package/packages/runtime-openshell/package.json +1 -1
  25. package/packages/runtime-sdk/package.json +1 -1
  26. package/packages/sdk/package.json +1 -1
  27. package/packages/sdk/src/client.ts +3 -1
  28. package/packages/spoke-sdk/package.json +1 -1
  29. package/packages/spoke-sdk/src/connection.ts +13 -1
  30. package/src/central/agent/acting-routes.ts +3 -2
  31. package/src/central/agent/acting.ts +67 -12
  32. package/src/central/agent/delivery.ts +12 -3
  33. package/src/central/agent/escalation.ts +30 -9
  34. package/src/central/agent/launch-spec.ts +38 -19
  35. package/src/central/agent/routes.ts +14 -1
  36. package/src/central/artifacts/blob-store.ts +6 -1
  37. package/src/central/artifacts/blob-support.ts +3 -1
  38. package/src/central/concierge/board-render.ts +34 -11
  39. package/src/central/concierge/context.ts +3 -1
  40. package/src/central/concierge/index.ts +1 -1
  41. package/src/central/concierge/notifier.ts +2 -1
  42. package/src/central/concierge/prompt.ts +7 -4
  43. package/src/central/config.ts +4 -0
  44. package/src/central/csp.ts +55 -0
  45. package/src/central/http.ts +134 -29
  46. package/src/central/profiles/content.ts +16 -0
  47. package/src/central/recording/clock.ts +76 -0
  48. package/src/central/recording/concierge-taps.ts +104 -0
  49. package/src/central/recording/export.ts +215 -0
  50. package/src/central/recording/index.ts +658 -0
  51. package/src/central/recording/ingest.ts +209 -0
  52. package/src/central/recording/retention.ts +71 -0
  53. package/src/central/recording/routes.ts +281 -0
  54. package/src/central/recording/schema.ts +232 -0
  55. package/src/central/recording/settings.ts +35 -0
  56. package/src/central/recording/store.ts +316 -0
  57. package/src/central/recording/taps.ts +105 -0
  58. package/src/central/recording/zip.ts +138 -0
  59. package/src/central/registry.ts +91 -6
  60. package/src/central/revert-windows.ts +66 -0
  61. package/src/central/spoke-ws.ts +8 -3
  62. package/src/central/teams/staffing.ts +2 -2
  63. package/src/index.ts +26 -14
  64. package/src/spoke/deliver.ts +6 -0
  65. package/src/spoke/fork-revert.ts +94 -9
  66. package/src/spoke/hub-tool.ts +2 -2
  67. package/src/spoke/locations.ts +80 -0
  68. package/src/spoke/profile-apply.ts +24 -14
  69. package/src/spoke/session-context.ts +96 -6
  70. package/src/spoke/spoke.ts +119 -36
  71. package/src/spoke/timeline.ts +30 -3
  72. package/src/spoke/tracker.ts +23 -2
  73. package/src/status.ts +33 -6
  74. package/web/dist/assets/{ArtifactPage-mNzjlP_U.js → ArtifactPage-CSotAWob.js} +1 -1
  75. package/web/dist/assets/{ArtifactPane-CPQcuX7c.js → ArtifactPane-DPHjiWce.js} +1 -1
  76. package/web/dist/assets/{ArtifactWorkbench-NoFC5Sb2.js → ArtifactWorkbench-CkmWnP6L.js} +1 -1
  77. package/web/dist/assets/{Library-LXHsP5Fw.js → Library-DTLEyekn.js} +2 -2
  78. package/web/dist/assets/ProfilesDestination-GriuOpiZ.js +1 -0
  79. package/web/dist/assets/{TeamsPage-DBrO5hoL.js → TeamsPage-C-5lYeeM.js} +1 -1
  80. package/web/dist/assets/Watchtower-DOlPsiIy.js +2 -0
  81. package/web/dist/assets/index-6mdQ7eUY.js +34 -0
  82. package/web/dist/assets/index-C5OmwA1y.css +1 -0
  83. package/web/dist/assets/{live-BDq0AABg.js → live-CDWcyavI.js} +1 -1
  84. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  85. package/web/dist/assets/{renderer2d-DAr4IHM8.js → renderer2d-CNFdTDTr.js} +1 -1
  86. package/web/dist/assets/{renderer3d-DWZj595u.js → renderer3d-C45jPvYX.js} +4 -56
  87. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  88. package/web/dist/assets/{safe-area-JUb6OeI3.js → safe-area-sA9a8vYJ.js} +1 -1
  89. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  90. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  91. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  92. package/web/dist/index.html +5 -16
  93. package/web/dist/theme-boot.js +12 -0
  94. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +0 -1
  95. package/web/dist/assets/Watchtower-DxaJYQp_.js +0 -2
  96. package/web/dist/assets/index-BabrNDB3.css +0 -1
  97. package/web/dist/assets/index-lIOfSYck.js +0 -34
@@ -1,6 +1,6 @@
1
1
  // The act verbs on /api/agent (design §5, §8; decision wvqeuis0): thin HTTP over the acting engine (acting.ts).
2
2
  //
3
- // POST /api/agent/sessions/:addr/approve {requestID, always?, why?}
3
+ // POST /api/agent/sessions/:addr/approve {requestID, always?, why?, recommend?} recommend: a Concierge advises it (R-03)
4
4
  // POST /api/agent/sessions/:addr/deny {requestID, why?} why → the harness, with who denied it
5
5
  // POST /api/agent/sessions/:addr/answer {formID, answer | cancel: true, why?}
6
6
  // POST /api/agent/sessions/:addr/prompt {text, images? (users), delivery? "queue"|"steer", wake?, why?}
@@ -65,7 +65,8 @@ export function actingRoutes(acting: Acting): AgentRoute[] {
65
65
  async handle({ request, params, actor, user }) {
66
66
  const body = await bodyOf(request)
67
67
  const always = flag(body, "always")
68
- return ok(await acting.approve({ actor, userID: user.id }, params.addr!, { requestID: id(body, "requestID"), ...(always ? { always } : {}), ...why(body) }))
68
+ const recommend = flag(body, "recommend")
69
+ return ok(await acting.approve({ actor, userID: user.id }, params.addr!, { requestID: id(body, "requestID"), ...(always ? { always } : {}), ...(recommend ? { recommend } : {}), ...why(body) }))
69
70
  },
70
71
  },
71
72
  {
@@ -16,6 +16,9 @@
16
16
  // {verdict, reason, tier, grants}. The `escalate` hook (P3-T8 turns it into a Decision) gets the escalation
17
17
  // first; a request without parsed details, or truncated, is always needs_user (tap).
18
18
  // · deny, answer, prompt and stop need only the grant's verb (a reject is always safe).
19
+ // · (R-03) the CURRENT Concierge session's approve without any usable grant is needs_user too (a Decision at the hub's tier;
20
+ // the hub's never rules still forbid; self-tree still applies), never a bare refusal; other agents stay forbidden.
21
+ // A Concierge escalation recommends approve only when it passed `recommend: true`.
19
22
  // - Every outcome after the address resolves is in the agent audit: decision (allow, needs_user, forbidden,
20
23
  // paused, rate_limited) and outcome (ok, gone, offline, unsupported, escalated, refused, failed).
21
24
  // - Races: the request answered elsewhere first is gone (410).
@@ -83,8 +86,12 @@ export interface Escalation {
83
86
  readonly request?: PermissionRequest
84
87
  readonly always: boolean
85
88
  readonly evaluation: Evaluation
86
- /** The agent's reason (`--why`): its recommendation to approve. */
89
+ /** The agent's reason (`--why`): its recommendation to approve (a Concierge's: only with `recommend`). */
87
90
  readonly why?: string
91
+ /** (R-03) The actor is a Concierge session: its escalation recommends approve only with `recommend`. */
92
+ readonly concierge?: true
93
+ /** (R-03) The Concierge itself advises approving (absent: the user only asked it to try). */
94
+ readonly recommend?: true
88
95
  }
89
96
 
90
97
  /** The escalation hook: answers the Decision it opened (or nothing); a throw only loses the Decision. */
@@ -111,11 +118,18 @@ export interface ActingDeps {
111
118
  * "always", even inside its grant: needs_user, a tap Decision. A failed read counts as yes (fail closed).
112
119
  */
113
120
  readonly conciergeActor?: (userID: string, sessionID: string) => Promise<boolean>
121
+ /**
122
+ * (R-03 verify) Whether the acting session is the user's CURRENT Concierge session (never a retired one). Only it
123
+ * may escalate an approve without a grant; a failed read counts as no (fail closed: refused).
124
+ */
125
+ readonly currentConcierge?: (userID: string, sessionID: string) => Promise<boolean>
114
126
  }
115
127
 
116
128
  /** (CON-13 decision) Why the Concierge escalates a covered request. */
117
129
  export const CONCIERGE_TAP_REASON = "Shell, outside folders and tools always need the user's tap"
118
130
  export const CONCIERGE_ALWAYS_REASON = "The Concierge never answers “always”: the user decides"
131
+ /** (R-03) Why the Concierge escalates an approval it holds no grant for. */
132
+ export const CONCIERGE_NO_GRANT_REASON = "No grant lets it approve here: the user decides"
119
133
 
120
134
  /** Who acts, for which user. */
121
135
  export interface Caller {
@@ -127,6 +141,8 @@ export interface ApproveInput {
127
141
  readonly requestID: string
128
142
  readonly always?: boolean
129
143
  readonly why?: string
144
+ /** (R-03) The Concierge advises approving: its escalation recommends approve (other agents' why always does). */
145
+ readonly recommend?: boolean
130
146
  }
131
147
 
132
148
  export interface Approved {
@@ -282,6 +298,17 @@ function neverForms(request: PermissionRequest): PermissionRequest[] {
282
298
 
283
299
  const patterns = (texts: readonly string[]): Pattern[] => texts.flatMap((t) => parsePattern(t) ?? [])
284
300
 
301
+ /** Forbidden when a never rule (the hub's, or a usable grant's) matches the request as asked or normalised (saves too, for always). */
302
+ function checkNever(request: PermissionRequest, rules: ReadonlyArray<{ grant: Grant; never: Pattern[] }>, hubNever: readonly Pattern[], always: boolean): void {
303
+ const sets = rules.length ? rules.map((r) => ({ id: r.grant.id, never: r.never })) : [{ id: undefined, never: [] as Pattern[] }]
304
+ for (const form of neverForms(request)) {
305
+ for (const { id, never } of sets) {
306
+ const verdict = evaluatePermission(form, { grant: { allow: [], never }, hubNever, always, allowAlways: true })
307
+ if (verdict.verdict === "never") throw forbidden(`never: ${verdict.by === "hub" ? "the hub's" : `grant ${id}'s`} rule ${formatPattern(verdict.pattern)} matches ${verdict.subject}`)
308
+ }
309
+ }
310
+ }
311
+
285
312
  const invalid = (message: string) => new AgentError("invalid", message)
286
313
  const forbidden = (message: string) => new AgentError("forbidden", message)
287
314
  const offline = () => new AgentError("offline", "No spoke reports this session now")
@@ -303,6 +330,8 @@ export class Acting {
303
330
  const concierge = await this.isConcierge(caller)
304
331
  return this.act(caller, "approve", target, { requestID: input.requestID }, input.why, {
305
332
  selfTree: true,
333
+ // (R-03) The Concierge without a grant escalates (needs_user, a Decision) instead of being refused.
334
+ ...(concierge ? { withoutGrant: () => this.isCurrentConcierge(caller) } : {}),
306
335
  choose: (usable) => this.choosePermission(usable, origin.request, always, concierge),
307
336
  escalation: (evaluation) => ({
308
337
  verb: "approve",
@@ -314,6 +343,8 @@ export class Acting {
314
343
  always,
315
344
  evaluation,
316
345
  ...(input.why ? { why: input.why } : {}),
346
+ ...(concierge ? { concierge: true as const } : {}),
347
+ ...(concierge && input.recommend === true ? { recommend: true as const } : {}),
317
348
  }),
318
349
  run: async (grant) => {
319
350
  const answered = await this.deps.actions.answerPermission({ rootID: target.rootID, requestID: input.requestID, decision })
@@ -519,9 +550,20 @@ export class Acting {
519
550
  if (target.parentID) throw invalid("Prompts go to root sessions, not subagent sessions")
520
551
  if (actor.kind === "session" && actor.sessionID === target.sessionID) throw invalid("A session never prompts itself")
521
552
  }
553
+ // A prompt commits a staged revert (OpenCode, quirk 9budy9na): only the console's Send (or Restore) ends one.
554
+ // Checked after the grant (an agent without one learns nothing about the session) and audited like any refusal.
555
+ const notStaged = () => {
556
+ if ((this.deps.registry.session(target.sessionID) as { revert?: unknown } | undefined)?.revert) {
557
+ throw new AgentError("conflict", "A revert is staged in this session: a prompt would make it permanent", { hint: "Restore it or send from the session's console first" })
558
+ }
559
+ }
522
560
  return this.act(caller, "prompt", target, {}, input.why, {
561
+ check: notStaged,
523
562
  run: async (grant) => {
524
- if (!grant) return this.userPrompt(target.sessionID, text, images, input.intent)
563
+ if (!grant) {
564
+ notStaged()
565
+ return this.userPrompt(target.sessionID, text, images, input.intent)
566
+ }
525
567
  return this.agentPrompt(caller, addr, target.sessionID, text, grant, input.delivery)
526
568
  },
527
569
  })
@@ -584,6 +626,8 @@ export class Acting {
584
626
  plan: {
585
627
  /** A session never acts in its own tree (permissions and forms). */
586
628
  readonly selfTree?: boolean
629
+ /** (R-03) No usable grant is not forbidden: `choose` gets none and throws NeedsUser (or forbidden). */
630
+ readonly withoutGrant?: () => Promise<boolean>
587
631
  /** The grant to act under (default: the newest usable); throws NeedsUser or forbidden. */
588
632
  readonly choose?: (usable: Grant[]) => Grant
589
633
  readonly escalation?: (evaluation: Evaluation) => Omit<Escalation, "userID" | "actor">
@@ -610,7 +654,7 @@ export class Acting {
610
654
  acting.ensureActing(userID)
611
655
  await this.guard(userID, target)
612
656
  const usable = await grants.usable(actor, verb, { directory: target.directory })
613
- if (!usable.length) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
657
+ if (!usable.length && !(plan.choose && plan.withoutGrant && (await plan.withoutGrant()))) throw forbidden(`No grant lets this ${actor.kind} ${verb} in this session`)
614
658
  if (plan.selfTree && actor.kind === "session" && (this.deps.registry.rootOf(actor.sessionID) ?? actor.sessionID) === target.rootID) {
615
659
  throw forbidden("A session never answers requests in its own session tree")
616
660
  }
@@ -670,24 +714,32 @@ export class Acting {
670
714
  }
671
715
  }
672
716
 
717
+ /** (R-03 verify) The actor is the user's current Concierge session; a failed read (or no reader) is no. */
718
+ private async isCurrentConcierge(caller: Caller): Promise<boolean> {
719
+ const { actor } = caller
720
+ if (actor.kind !== "session" || !this.deps.currentConcierge) return false
721
+ try {
722
+ return await this.deps.currentConcierge(caller.userID, actor.sessionID)
723
+ } catch {
724
+ return false
725
+ }
726
+ }
727
+
673
728
  /**
674
729
  * The grant that covers the request, after every never rule; NeedsUser when none does. (CON-13 decision)
675
730
  * `concierge`: a Concierge actor's "always", or a covered request of the tap risk tier, is NeedsUser at tap tier.
731
+ * (R-03 verify) Every never rule (the hub's and each usable grant's, also with no grant) is checked first, before
732
+ * any NeedsUser: a forbidden request never becomes a Decision, "always" or not.
676
733
  */
677
734
  private choosePermission(usable: Grant[], request: PermissionRequest | undefined, always: boolean, concierge = false): Grant {
678
735
  const hubNever = this.deps.acting.hubNever()
679
736
  const ids = usable.map((g) => g.id)
737
+ const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
738
+ if (request) checkNever(request, rules, hubNever, always)
680
739
  if (concierge && always) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_ALWAYS_REASON, tier: "tap", grants: ids })
681
740
  if (!request) throw new NeedsUser({ verdict: "outside", reason: "The request's details are unknown", tier: "tap", grants: ids })
682
- const rules = usable.map((g) => ({ grant: g, allow: patterns(g.permissions.allow), never: patterns(g.permissions.never) }))
683
- for (const form of neverForms(request)) {
684
- for (const { grant, allow, never } of rules) {
685
- const verdict = evaluatePermission(form, { grant: { allow, never }, hubNever, always, allowAlways: grant.allowAlways })
686
- if (verdict.verdict === "never") {
687
- throw forbidden(`never: ${verdict.by === "hub" ? "the hub's" : `grant ${grant.id}'s`} rule ${formatPattern(verdict.pattern)} matches ${verdict.subject}`)
688
- }
689
- }
690
- }
741
+ // (R-03) The current Concierge holds no grant: anything the never rules let through is the user's, at the hub's tier.
742
+ if (!usable.length) throw new NeedsUser({ verdict: "outside", reason: CONCIERGE_NO_GRANT_REASON, tier: request.truncated ? "tap" : riskTier(request, hubNever), grants: [] })
691
743
  const allNever = [...hubNever, ...rules.flatMap((r) => r.never)]
692
744
  if (request.truncated) throw new NeedsUser({ verdict: "outside", reason: "The request was too large to check in full", tier: "tap", grants: ids })
693
745
  let reason: string | undefined
@@ -790,6 +842,9 @@ export function auditOf(cause: unknown): [AuditDecision, AuditOutcome] {
790
842
  return ["forbidden", "refused"]
791
843
  case "rate_limited":
792
844
  return ["rate_limited", "refused"]
845
+ // Allowed, but the session's state refuses it now (a staged revert).
846
+ case "conflict":
847
+ return ["allow", "refused"]
793
848
  // (FX-07) The hub's state did not answer in time: refused, and Activity says the hub was busy.
794
849
  case "busy":
795
850
  return ["busy", "refused"]
@@ -68,6 +68,8 @@ export interface DeliveryRegistry {
68
68
  idle(sessionID: string): boolean
69
69
  /** Higher: more recent activity. */
70
70
  recency(sessionID: string): number
71
+ /** A revert is staged in the session (Registry.revertStaged): nothing is sent to it (absent: never). */
72
+ revertStaged?(sessionID: string): boolean
71
73
  }
72
74
 
73
75
  /** Address resolution (Directory.resolve) and the primer flag (without primed/markPrimed: no primer). */
@@ -673,8 +675,13 @@ export class Delivery {
673
675
  return { message: updated, delivery: result.delivered }
674
676
  }
675
677
 
676
- /** Sends the request to each of the user's deliver-capable connections for the session until one takes it. */
678
+ /**
679
+ * Sends the request to each of the user's deliver-capable connections for the session until one takes it. Not at
680
+ * all while a revert is staged there: admitting anything commits it (decision in docs/session-view.md), so the
681
+ * message stays pending and goes in on the sessions frame that shows the revert restored or committed.
682
+ */
677
683
  private async request(userID: string, sessionID: string, req: DeliverRequest): Promise<DeliverResult | undefined> {
684
+ if (this.deps.registry.revertStaged?.(sessionID)) return undefined
678
685
  for (const connID of this.targets(userID, sessionID)) {
679
686
  let result: unknown
680
687
  try {
@@ -748,6 +755,8 @@ export class Delivery {
748
755
  if (!current || (current.kind === "ask" && current.state !== "open" && current.state !== "expired")) return
749
756
  const message = (await this.messages.setDelivery(userID, current.id, "expired")) ?? current
750
757
  const what = `${message.kind} ${message.id} to ${message.to}`
758
+ // Why it waited: a staged revert holds every delivery (request()), else the session was offline or busy.
759
+ const why = this.deps.registry.revertStaged?.(value.sessionID) ? "a revert stayed staged in its session for 24 h" : "its session stayed offline or busy for 24 h"
751
760
  try {
752
761
  this.deps.expired?.(userID, message)
753
762
  } catch (cause) {
@@ -755,14 +764,14 @@ export class Delivery {
755
764
  }
756
765
  const { from } = message
757
766
  if (from.kind === "user") {
758
- this.deps.alert?.(userID, { kind: "expired", sessionID: value.sessionID, messageID: message.id, text: `Your ${message.kind} to ${message.to} was not delivered: its session stayed offline or busy for 24 h` })
767
+ this.deps.alert?.(userID, { kind: "expired", sessionID: value.sessionID, messageID: message.id, text: `Your ${message.kind} to ${message.to} was not delivered: ${why}` })
759
768
  } else if (from.kind === "session") {
760
769
  const id = this.newID()
761
770
  await this.request(userID, from.sessionID, {
762
771
  op: "deliver",
763
772
  sessionID: from.sessionID,
764
773
  messageID: id,
765
- text: `[hub] notice · ${what} was not delivered: its session stayed offline or busy for 24 h · ${id}`,
774
+ text: `[hub] notice · ${what} was not delivered: ${why} · ${id}`,
766
775
  wake: false,
767
776
  meta: { kind: "msg", from: "hub", to: wireAddress(from), delivery: "queue" },
768
777
  })
@@ -5,6 +5,8 @@
5
5
  // agent's recommendation, the tier the hub set on the evaluation, about the asking session); escalating the same
6
6
  // request again updates the open Decision's reason instead (no new push). Concurrent escalations race on a
7
7
  // version-checked reservation: one opens the Decision, the other is rate_limited (retry).
8
+ // - (R-03) A Concierge's escalation recommends nothing unless it passed `recommend: true`: its why is then a note
9
+ // after the question ("The Concierge's note: …"), and the blocker only links the Decision (no "recommends").
8
10
  // - The recommendation also shows on the request's blocker (registry.recommend: catalog, status, the push's
9
11
  // service worker reads it there).
10
12
  // - Answering (decisions.ts runs `act` after the answer wins) replies to the permission AS THE USER through the
@@ -150,19 +152,24 @@ export class Escalations {
150
152
  const opened = await counters.incr(`escalations:${userID}:${partyKey(holder)}`, HOUR_MS)
151
153
  if (opened > ESCALATIONS_PER_HOLDER_HOUR) throw rateLimited(`At most ${ESCALATIONS_PER_HOLDER_HOUR} escalations an hour`, 3600)
152
154
  const pushes = await counters.incr(`escalation-push:${userID}`, ESCALATION_PUSH_COALESCE_MS)
153
- const recommend = escalation.always ? "always" : "approve"
155
+ // (R-03 verify) The Concierge never gets "always": its Decision offers Approve once or Deny only (the user can
156
+ // still approve always on the session's card).
157
+ const always = offersAlways(escalation)
158
+ const recommend = always ? "always" : "approve"
154
159
  const why = boundedWhy(escalation.why)
160
+ const advises = recommends(escalation)
155
161
  const { message, decision } = await decisions.ask(
156
162
  userID,
157
163
  escalation.actor,
158
164
  {
159
- question: question(escalation, partyLabel(holder)),
165
+ // (R-03) A Concierge that only tries recommends nothing: its why is a note after the question.
166
+ question: question(escalation, partyLabel(holder)) + (!advises && why ? `\nThe Concierge's note: ${why}` : ""),
160
167
  options: [
161
- ...(escalation.always ? [{ id: "always", label: "Approve always" }] : []),
168
+ ...(always ? [{ id: "always", label: "Approve always" }] : []),
162
169
  { id: "approve", label: "Approve once" },
163
170
  { id: "deny", label: "Deny", safe: true },
164
171
  ],
165
- recommendation: { option: recommend, ...(why ? { why } : {}) },
172
+ ...(advises ? { recommendation: { option: recommend, ...(why ? { why } : {}) } } : {}),
166
173
  urgency: "high",
167
174
  tier: escalation.evaluation.tier,
168
175
  about: [escalation.sessionID],
@@ -173,7 +180,7 @@ export class Escalations {
173
180
  requestID: escalation.requestID,
174
181
  rootID: escalation.rootID,
175
182
  sessionID: escalation.sessionID,
176
- always: escalation.always,
183
+ always,
177
184
  ...(escalation.request ? { request: decisionRequest(escalation.request) } : {}),
178
185
  ...(escalation.evaluation.reason ? { reason: escalation.evaluation.reason.slice(0, WHY_MAX) } : {}),
179
186
  },
@@ -189,8 +196,8 @@ export class Escalations {
189
196
  private async update(escalation: Escalation, holder: Party, decision: Decision): Promise<{ decisionID: string }> {
190
197
  const why = boundedWhy(escalation.why)
191
198
  let current = decision
192
- if (why && why !== decision.recommendation?.why) {
193
- const option = decision.recommendation?.option ?? (escalation.always ? "always" : "approve")
199
+ if (recommends(escalation) && why && why !== decision.recommendation?.why) {
200
+ const option = decision.recommendation?.option ?? (offersAlways(escalation) ? "always" : "approve")
194
201
  current = (await this.deps.decisions.revise(escalation.userID, decision.id, { recommendation: { option, why } })) ?? decision
195
202
  }
196
203
  this.showOnBlocker(escalation.requestID, holder, current)
@@ -198,8 +205,11 @@ export class Escalations {
198
205
  }
199
206
 
200
207
  private showOnBlocker(requestID: string, holder: Party, decision: Decision): void {
208
+ // (R-03) Without a recommendation the blocker only links the Decision (its tier still gates quick answers).
201
209
  const why = decision.recommendation?.why
202
- const recommendation: BlockerRecommendation = { by: partyLabel(holder), recommends: "approve", ...(why ? { why } : {}), decisionID: decision.id }
210
+ const recommendation: BlockerRecommendation = decision.recommendation
211
+ ? { by: partyLabel(holder), recommends: "approve", ...(why ? { why } : {}), decisionID: decision.id }
212
+ : { by: partyLabel(holder), decisionID: decision.id }
203
213
  this.deps.registry.recommend(requestID, recommendation)
204
214
  }
205
215
 
@@ -211,6 +221,16 @@ export class Escalations {
211
221
  }
212
222
  }
213
223
 
224
+ /** (R-03 verify) Whether the Decision offers Approve always: never for the Concierge. */
225
+ function offersAlways(escalation: Escalation): boolean {
226
+ return escalation.always && !escalation.concierge
227
+ }
228
+
229
+ /** (R-03) Whether the escalation recommends approving: always for agents, for the Concierge only with `recommend`. */
230
+ function recommends(escalation: Escalation): boolean {
231
+ return !escalation.concierge || escalation.recommend === true
232
+ }
233
+
214
234
  /** The agent's reason, cut to what a Decision keeps. */
215
235
  function boundedWhy(why: string | undefined): string | undefined {
216
236
  const trimmed = why?.trim()
@@ -221,7 +241,8 @@ function boundedWhy(why: string | undefined): string | undefined {
221
241
  function question(escalation: Escalation, by: string): string {
222
242
  const { request, evaluation } = escalation
223
243
  const what = request ? `${request.action}${request.resources.length ? `: ${request.resources.join(", ")}` : ""}` : "a permission request (details unknown)"
224
- const always = escalation.always && request?.save?.length ? ` always (saves ${request.save.join(", ")})` : escalation.always ? " always" : ""
244
+ const asksAlways = offersAlways(escalation)
245
+ const always = asksAlways && request?.save?.length ? ` always (saves ${request.save.join(", ")})` : asksAlways ? " always" : ""
225
246
  const text = `${by} asks to approve${always} ${what}. Outside its grant: ${evaluation.reason}`
226
247
  return text.length > QUESTION_MAX ? `${text.slice(0, QUESTION_MAX - 1)}…` : text
227
248
  }
@@ -105,6 +105,24 @@ const TOP_FIELDS = FIELDS.filter((field) => !SECTIONS.some(([section]) => sectio
105
105
  /** (FX-03) The hint every unknown-field answer carries: callers often send a flat spec. */
106
106
  export const LAUNCH_SPEC_SHAPE = `A launch spec is nested: ${SECTIONS.map(([section, fields]) => `${section}{${fields.join(", ")}}`).join(", ")}; top level: ${TOP_FIELDS.join(", ")}`
107
107
 
108
+ /** How validateLaunchSpec words its hints for one caller. */
109
+ export interface LaunchSpecOptions {
110
+ /**
111
+ * (R-04) Team staffing builds the spec: it sets launch.key itself and refuses the caller's, so a misplaced `key`
112
+ * points at idempotencyKey (the Idempotency-Key header), not "key (top level)".
113
+ */
114
+ readonly staffing?: boolean
115
+ }
116
+
117
+ /** Where a misplaced `key` belongs and the shape hint, for one caller. */
118
+ interface Wording {
119
+ readonly keyPlace: string
120
+ readonly shape: string
121
+ }
122
+
123
+ const PLAIN: Wording = { keyPlace: "key (top level)", shape: LAUNCH_SPEC_SHAPE }
124
+ const STAFFING: Wording = { keyPlace: "idempotencyKey (the Idempotency-Key header)", shape: `${LAUNCH_SPEC_SHAPE}; key is set by staffing: send idempotencyKey (the Idempotency-Key header) instead` }
125
+
108
126
  const KEY = /^[!-~]{1,200}$/
109
127
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/
110
128
  const AGENT = /^[A-Za-z0-9_.-]{1,80}$/
@@ -120,21 +138,21 @@ const invalid = (message: string, hint?: string) => new AgentError("invalid", me
120
138
  const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
121
139
 
122
140
  /** Where an unknown `key` at level `what` (launch, or a section) belongs instead: `section.key`s, or the top level. */
123
- function placesOf(key: string, what: string): string[] {
141
+ function placesOf(key: string, what: string, wording: Wording): string[] {
124
142
  const places = SECTIONS.filter(([section, fields]) => section !== what && fields.includes(key)).map(([section]) => `${section}.${key}`)
125
- if (what !== "launch" && TOP_FIELDS.includes(key)) places.push(`${key} (top level)`)
143
+ if (what !== "launch" && TOP_FIELDS.includes(key)) places.push(key === "key" ? wording.keyPlace : `${key} (top level)`)
126
144
  return places
127
145
  }
128
146
 
129
147
  /** Refuses every field outside `allowed` at once, naming where a misplaced one belongs (FX-03). */
130
- function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string): void {
148
+ function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string, wording: Wording = PLAIN): void {
131
149
  const unknown = Object.keys(value).filter((key) => !allowed.includes(key))
132
150
  if (unknown.length === 0) return
133
- const suggestions = unknown.slice(0, 10).map((key) => placesOf(key, what)).filter((places) => places.length > 0).map((places) => places.join(" or "))
151
+ const suggestions = unknown.slice(0, 10).map((key) => placesOf(key, what, wording)).filter((places) => places.length > 0).map((places) => places.join(" or "))
134
152
  // Keys are the caller's: bounded in number and length.
135
153
  const shown = [...unknown.slice(0, 10).map((key) => key.slice(0, 40)), ...(unknown.length > 10 ? ["…"] : [])]
136
154
  const message = `Unknown ${what} field${unknown.length > 1 ? "s" : ""} ${shown.join(", ")}${suggestions.length ? `: did you mean ${suggestions.join(", ")}?` : ""}`
137
- throw invalid(message, LAUNCH_SPEC_SHAPE)
155
+ throw invalid(message, wording.shape)
138
156
  }
139
157
 
140
158
  function oneOf<T extends string>(value: unknown, options: readonly T[], fallback: T, what: string): T {
@@ -202,9 +220,9 @@ function validRoles(value: unknown): string[] {
202
220
  return roles
203
221
  }
204
222
 
205
- function validWorkspace(value: unknown): LaunchWorkspace {
223
+ function validWorkspace(value: unknown, wording: Wording): LaunchWorkspace {
206
224
  if (!isObject(value)) throw invalid("workspace is {repo, mode?, branch?, base?, remote?}")
207
- onlyFields(value, WORKSPACE_FIELDS, "workspace")
225
+ onlyFields(value, WORKSPACE_FIELDS, "workspace", wording)
208
226
  // canonicalProject rejects relative, `~` and root paths.
209
227
  const repo = typeof value.repo === "string" ? canonicalProject(value.repo) : undefined
210
228
  if (repo === undefined) throw invalid("workspace.repo is an absolute repository path")
@@ -224,10 +242,10 @@ function validWorkspace(value: unknown): LaunchWorkspace {
224
242
  }
225
243
  }
226
244
 
227
- function validRuntime(value: unknown, workspace: LaunchWorkspace): LaunchRuntime {
245
+ function validRuntime(value: unknown, workspace: LaunchWorkspace, wording: Wording): LaunchRuntime {
228
246
  if (value === undefined) return { kind: "host" }
229
247
  if (!isObject(value)) throw invalid("runtime is {kind, profile?}")
230
- onlyFields(value, RUNTIME_FIELDS, "runtime")
248
+ onlyFields(value, RUNTIME_FIELDS, "runtime", wording)
231
249
  const kind = oneOf<RuntimeKind>(value.kind, ["host", "openshell"], "host", "runtime.kind")
232
250
  const profile = matching(value.profile, SLUG, "runtime.profile")
233
251
  if (kind === "host" && profile !== undefined) throw invalid("runtime.profile applies to the openshell runtime only")
@@ -235,20 +253,20 @@ function validRuntime(value: unknown, workspace: LaunchWorkspace): LaunchRuntime
235
253
  return { kind, ...(profile !== undefined ? { profile } : {}) }
236
254
  }
237
255
 
238
- function validPlacement(value: unknown): LaunchPlacement {
256
+ function validPlacement(value: unknown, wording: Wording): LaunchPlacement {
239
257
  if (value === undefined) return { labels: {}, mode: "require" }
240
258
  if (!isObject(value)) throw invalid("placement is {labels?, machine?, mode?}")
241
- onlyFields(value, PLACEMENT_FIELDS, "placement")
259
+ onlyFields(value, PLACEMENT_FIELDS, "placement", wording)
242
260
  const labels = validLabels(value.labels, "placement.labels")
243
261
  const machine = matching(value.machine, MACHINE, "placement.machine")
244
262
  const mode = oneOf<PlacementMode>(value.mode, ["require", "prefer"], "require", "placement.mode")
245
263
  return { labels, ...(machine !== undefined ? { machine } : {}), mode }
246
264
  }
247
265
 
248
- function validEnd(value: unknown): LaunchEnd {
266
+ function validEnd(value: unknown, wording: Wording): LaunchEnd {
249
267
  if (value === undefined) return { when: "idle", cleanup: "keep" }
250
268
  if (!isObject(value)) throw invalid("end is {when?, timeout?, cleanup?}")
251
- onlyFields(value, END_FIELDS, "end")
269
+ onlyFields(value, END_FIELDS, "end", wording)
252
270
  const when = oneOf<EndWhen>(value.when, ["idle", "asked", "never"], "idle", "end.when")
253
271
  let timeoutMs: number | undefined
254
272
  if (value.timeout !== undefined) {
@@ -262,10 +280,11 @@ function validEnd(value: unknown): LaunchEnd {
262
280
  // ---------- spec ----------
263
281
 
264
282
  /** Validates a launch body into a LaunchSpec (a fresh copy, defaults filled in); throws AgentError invalid. */
265
- export function validateLaunchSpec(input: unknown): LaunchSpec {
283
+ export function validateLaunchSpec(input: unknown, options: LaunchSpecOptions = {}): LaunchSpec {
284
+ const wording = options.staffing ? STAFFING : PLAIN
266
285
  if (!isObject(input)) throw invalid("A launch is an object")
267
286
  if ("grant" in input) throw invalid("grant is not supported yet: a launched session has its launcher's powers only")
268
- onlyFields(input, FIELDS, "launch")
287
+ onlyFields(input, FIELDS, "launch", wording)
269
288
  const key = matching(input.key, KEY, `key (1–${KEY_MAX} visible characters)`)
270
289
  let name: string | undefined
271
290
  if (input.name !== undefined) {
@@ -276,9 +295,9 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
276
295
  const harness = matching(input.harness, SLUG, "harness") ?? "opencode"
277
296
  const agent = matching(input.agent, AGENT, "agent")
278
297
  const model = matching(input.model, MODEL, "model (provider/model)", MODEL_MAX)
279
- const workspace = validWorkspace(input.workspace)
280
- const runtime = validRuntime(input.runtime, workspace)
281
- const placement = validPlacement(input.placement)
298
+ const workspace = validWorkspace(input.workspace, wording)
299
+ const runtime = validRuntime(input.runtime, workspace, wording)
300
+ const placement = validPlacement(input.placement, wording)
282
301
  let prompt: string | undefined
283
302
  if (input.prompt !== undefined) {
284
303
  if (typeof input.prompt !== "string" || input.prompt.length > PROMPT_MAX || PROMPT_CONTROL.test(input.prompt)) throw invalid(`prompt is text of up to ${PROMPT_MAX} characters`)
@@ -287,7 +306,7 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
287
306
  // (CON-02) What the session is for: untrusted, sanitised to one line, at most INTENT_MAX characters.
288
307
  const intent = input.intent === undefined ? undefined : intentText(input.intent)
289
308
  const labels = validLabels(input.labels, "labels")
290
- const end = validEnd(input.end)
309
+ const end = validEnd(input.end, wording)
291
310
  const profileProblem = input.agentProfile === undefined ? undefined : launchAgentProfileProblem(input.agentProfile)
292
311
  if (profileProblem) throw invalid(profileProblem)
293
312
  const agentProfile = input.agentProfile === undefined ? undefined : { profileID: (input.agentProfile as LaunchAgentProfile).profileID, version: (input.agentProfile as LaunchAgentProfile).version }
@@ -6,6 +6,7 @@
6
6
  // requests skip it) → 403 forbidden; credential → 401 unauthorized / token_expired; route → 404 not_found;
7
7
  // route class (agentAllows) → 403 forbidden; then the handler, whose throws become the envelope (agentFailure).
8
8
  // Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
9
+ import { type ConciergeCallRecorder, recordConciergeCalls } from "../recording/concierge-taps"
9
10
  import type { AgentEvents } from "./events"
10
11
  import type { CursorKeys } from "./event-cursor"
11
12
  import { eventsRoutes, type EventsRouteDeps } from "./events-routes"
@@ -88,6 +89,8 @@ export interface AgentRoute {
88
89
  /** What the agent API needs from the hub. */
89
90
  export interface AgentDeps {
90
91
  readonly store: Store
92
+ /** (REC-01) Voice recordings: the Concierge's agent calls (recordConciergeCalls). */
93
+ readonly recording?: ConciergeCallRecorder
91
94
  /** Hub-minted session tokens (P1-T5). */
92
95
  readonly sessionTokens?: SessionTokens
93
96
  /** Names, roles and address resolution (P1-T8); its routes are added when given. */
@@ -272,7 +275,7 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
272
275
  const identity = deps.concierge?.identity
273
276
  const directory = deps.directory
274
277
  const launches = deps.launches
275
- return identity && directory
278
+ const guarded = identity && directory
276
279
  ? guardConciergeRoutes(all, {
277
280
  identity,
278
281
  resolve: (userID, addr) => directory.resolve(userID, addr).sessions.map((s) => s.sessionID),
@@ -286,6 +289,9 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
286
289
  : {}),
287
290
  })
288
291
  : all
292
+ // (REC-01) concierge.tool: a session actor's calls (method, route, target, status, ms; never a body or response)
293
+ // reach the recording service, which keeps them only for a recorded Concierge session.
294
+ return deps.recording ? recordConciergeCalls(guarded, deps.recording) : guarded
289
295
  }
290
296
 
291
297
  /** HWA-28/40: unfiltered events and watch read by a team-scoped session leave out what discovery hides from it. */
@@ -328,6 +334,12 @@ function conciergeCurrent(deps: AgentDeps): { conciergeCurrent?: (userID: string
328
334
  return identity ? { conciergeCurrent: async (userID: string) => (await identity.current(userID)).sessionID } : {}
329
335
  }
330
336
 
337
+ /** (R-03 verify) Whether a session is the user's CURRENT Concierge session (the no-grant escalation; never a retired one). */
338
+ function currentConciergeActor(deps: AgentDeps): { currentConcierge?: (userID: string, sessionID: string) => Promise<boolean> } {
339
+ const identity = deps.concierge?.identity
340
+ return identity ? { currentConcierge: async (userID: string, sessionID: string) => (await identity.current(userID)).sessionID === sessionID } : {}
341
+ }
342
+
331
343
  /** (CON-02) What reads show of intents: the stored ones of the listed sessions, by session. */
332
344
  function intentReads(deps: AgentDeps): { intents?: (userID: string, sessionIDs: readonly string[]) => Promise<Map<string, SessionIntent>> } {
333
345
  const { intents } = deps
@@ -350,6 +362,7 @@ function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
350
362
  ...(deps.escalate ? { escalate: deps.escalate } : {}),
351
363
  ...(deps.maxPromptChars !== undefined ? { maxPromptChars: deps.maxPromptChars } : {}),
352
364
  ...(deps.concierge?.protects ? { protect: deps.concierge.protects, conciergeActor: deps.concierge.protects } : {}),
365
+ ...currentConciergeActor(deps),
353
366
  }),
354
367
  )
355
368
  }
@@ -9,7 +9,7 @@
9
9
  // usage is per user. The same bytes stored by two users are two blobs.
10
10
  // - put streams the body: it hashes as it reads, stops reading (and cancels the source) as soon as the body passes
11
11
  // `maxBytes` (default and ceiling ARTIFACT_LIMITS.blobBytes), and stores nothing when the body is too large, its
12
- // hash is not `expectedHash`, the media type is not in ARTIFACT_MEDIA_TYPES (never sniffed), or a new blob would
12
+ // hash is not `expectedHash`, the media type is not in ARTIFACT_MEDIA_TYPES (or the put's `mediaTypes`; never sniffed), or a new blob would
13
13
  // take the user past `quotaBytes` (default ARTIFACT_LIMITS.userBlobBytes). Storing bytes that are already there
14
14
  // stores nothing new, is never refused for quota, and refreshes `touchedAt`.
15
15
  // - A blob becomes visible to get/has/stat/list only once all its bytes are durably stored and verified
@@ -30,6 +30,11 @@ export interface BlobPutOptions {
30
30
  readonly maxBytes?: number
31
31
  /** The user's total stored bytes a new blob may not exceed (default ARTIFACT_LIMITS.userBlobBytes). */
32
32
  readonly quotaBytes?: number
33
+ /**
34
+ * The media types this put accepts instead of ARTIFACT_MEDIA_TYPES (REC-01: RECORDING_MEDIA_TYPES under the
35
+ * `rec~<userID>` tenant). Artifact routes never pass it, so audio stays refused there.
36
+ */
37
+ readonly mediaTypes?: readonly string[]
33
38
  }
34
39
 
35
40
  /** A stored blob's facts (no media type: that belongs to the revision's BlobRef). */
@@ -11,7 +11,9 @@ export interface PutLimits {
11
11
 
12
12
  /** Checks put's media type and options (BlobInputError) and answers the limits in force. */
13
13
  export function putLimits(mediaType: string, options: BlobPutOptions = {}): PutLimits {
14
- if (!(ARTIFACT_MEDIA_TYPES as readonly string[]).includes(mediaType)) throw new BlobInputError("mediaType is not allowed")
14
+ // The allowlist is ARTIFACT_MEDIA_TYPES unless the caller names its own (REC-01 recordings: RECORDING_MEDIA_TYPES).
15
+ const allowed: readonly string[] = options.mediaTypes ?? ARTIFACT_MEDIA_TYPES
16
+ if (!allowed.includes(mediaType)) throw new BlobInputError("mediaType is not allowed")
15
17
  const maxBytes = options.maxBytes ?? ARTIFACT_LIMITS.blobBytes
16
18
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes > ARTIFACT_LIMITS.blobBytes) {
17
19
  throw new BlobInputError(`maxBytes must be 0–${ARTIFACT_LIMITS.blobBytes}`)