@huynle/hub 0.10.1 → 0.11.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 (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +36 -8
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/sessions.ts +4 -1
  6. package/packages/cli/dist/hub-cli.js +851 -466
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +12 -7
  9. package/packages/cli/src/decisions.ts +52 -4
  10. package/packages/cli/src/format.ts +9 -0
  11. package/packages/cli/src/grants-mcp.ts +65 -0
  12. package/packages/cli/src/mcp-operations.ts +49 -0
  13. package/packages/cli/src/mcp.ts +26 -4
  14. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  15. package/packages/cli/src/team-intake-mcp.ts +2 -1
  16. package/packages/cli/src/teams-mcp.ts +9 -3
  17. package/packages/codex/package.json +1 -1
  18. package/packages/codex/src/sessions.ts +14 -3
  19. package/packages/node/package.json +1 -1
  20. package/packages/preset/package.json +1 -1
  21. package/packages/protocol/package.json +1 -1
  22. package/packages/protocol/schema/api/v1/_root.json +119 -0
  23. package/packages/protocol/schema/api/v1/account.json +419 -0
  24. package/packages/protocol/schema/api/v1/decisions.json +635 -0
  25. package/packages/protocol/schema/api/v1/discovery.json +110 -0
  26. package/packages/protocol/schema/api/v1/events.json +679 -0
  27. package/packages/protocol/schema/api/v1/session-actions.json +660 -0
  28. package/packages/protocol/schema/api/v1/sessions.json +2055 -0
  29. package/packages/protocol/schema/api/v1/speech.json +492 -0
  30. package/packages/protocol/schema/api/v1/unspecified.json +282 -0
  31. package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
  32. package/packages/protocol/schema/app-front/v1.schema.json +7 -3
  33. package/packages/protocol/src/api-v1.ts +8 -0
  34. package/packages/protocol/src/index.ts +2 -0
  35. package/packages/protocol/src/teams.ts +16 -0
  36. package/packages/protocol/src/voice-recording.ts +39 -2
  37. package/packages/runtime-openshell/image/entry.sh +3 -1
  38. package/packages/runtime-openshell/image/start.sh +3 -1
  39. package/packages/runtime-openshell/package.json +1 -1
  40. package/packages/runtime-sdk/package.json +1 -1
  41. package/packages/sdk/package.json +1 -1
  42. package/packages/sdk/src/app-front.ts +2 -2
  43. package/packages/sdk/src/ask-fields.ts +76 -0
  44. package/packages/sdk/src/client.ts +24 -2
  45. package/packages/sdk/src/envelope.ts +3 -2
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/team-intake.ts +6 -0
  48. package/packages/sdk/src/teams.ts +4 -0
  49. package/packages/sdk/src/types.ts +36 -4
  50. package/packages/sdk/src/wake.ts +9 -4
  51. package/packages/spoke-sdk/package.json +1 -1
  52. package/src/central/agent/agent-limits.ts +185 -0
  53. package/src/central/agent/app-decisions.ts +6 -5
  54. package/src/central/agent/decisions.ts +136 -9
  55. package/src/central/agent/delivery.ts +164 -22
  56. package/src/central/agent/errors.ts +2 -1
  57. package/src/central/agent/events.ts +20 -2
  58. package/src/central/agent/grant-routes.ts +14 -1
  59. package/src/central/agent/grants.ts +23 -0
  60. package/src/central/agent/launch-engine.ts +9 -6
  61. package/src/central/agent/message-limits.ts +6 -0
  62. package/src/central/agent/message-routes.ts +33 -6
  63. package/src/central/agent/messages.ts +7 -0
  64. package/src/central/agent/placement.ts +4 -2
  65. package/src/central/agent/read-routes.ts +5 -0
  66. package/src/central/agent/routes.ts +13 -0
  67. package/src/central/agent/subscriptions.ts +15 -4
  68. package/src/central/api-v1.ts +115 -0
  69. package/src/central/artifacts/store.ts +4 -2
  70. package/src/central/concierge/board-results.ts +2 -1
  71. package/src/central/concierge/notifier.ts +4 -2
  72. package/src/central/config.ts +24 -3
  73. package/src/central/health/classify.ts +209 -0
  74. package/src/central/http.ts +380 -36
  75. package/src/central/recording/export.ts +57 -3
  76. package/src/central/recording/index.ts +236 -17
  77. package/src/central/recording/ingest.ts +11 -2
  78. package/src/central/recording/routes.ts +61 -7
  79. package/src/central/recording/schema.ts +68 -3
  80. package/src/central/recording/settings.ts +19 -5
  81. package/src/central/recording/store.ts +19 -0
  82. package/src/central/recording/taps.ts +45 -1
  83. package/src/central/registry.ts +34 -3
  84. package/src/central/speech/openai.ts +4 -4
  85. package/src/central/speech/openrouter.ts +4 -4
  86. package/src/central/speech/provider.ts +32 -1
  87. package/src/central/speech/service.ts +6 -5
  88. package/src/central/teams/budgets.ts +144 -0
  89. package/src/central/teams/hop-stalls.ts +80 -0
  90. package/src/central/teams/intake.ts +38 -6
  91. package/src/central/teams/involvement.ts +95 -0
  92. package/src/central/teams/item-decisions.ts +60 -0
  93. package/src/central/teams/item-eligibility.ts +35 -0
  94. package/src/central/teams/item-stalls.ts +87 -0
  95. package/src/central/teams/loops.ts +40 -0
  96. package/src/central/teams/policy.ts +10 -0
  97. package/src/central/teams/progress.ts +154 -0
  98. package/src/central/teams/routes.ts +2 -0
  99. package/src/central/teams/seats.ts +3 -3
  100. package/src/central/teams/staffing.ts +5 -0
  101. package/src/central/teams/store.ts +20 -1
  102. package/src/central/teams/validation.ts +16 -2
  103. package/src/central/workspaces/coordinator.ts +3 -1
  104. package/src/spoke/hub-tool.ts +26 -11
  105. package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-TbFJi59n.js} +1 -1
  106. package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-7D-M-Ich.js} +1 -1
  107. package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
  108. package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-PvOPetOI.js} +1 -1
  109. package/web/dist/assets/{Library-rv5DGtrm.js → Library-Cdi83pOV.js} +2 -2
  110. package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-cryC7P6j.js} +1 -1
  111. package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
  112. package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
  113. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  114. package/web/dist/assets/index-DA0gKIzv.js +33 -0
  115. package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
  116. package/web/dist/assets/live-B2IfKm37.js +5 -0
  117. package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
  118. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  119. package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-CsZnF3_o.js} +1 -1
  120. package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-BIMA0wFn.js} +2 -2
  121. package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-dtrnw9NF.js} +1 -1
  122. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  123. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  124. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  125. package/web/dist/index.html +5 -5
  126. package/web/dist/assets/TeamsPage-D_17MkvN.js +0 -1
  127. package/web/dist/assets/Watchtower-DlrNOJ7X.js +0 -2
  128. package/web/dist/assets/index-bX8B6NLo.js +0 -33
  129. package/web/dist/assets/live-BGLA-peO.js +0 -5
@@ -1,6 +1,7 @@
1
1
  // /api/agent/grants (design §5, §7): the user's grants. User actors only (allow {}): the device cookie (same-origin
2
2
  // writes, checked by the agent framework) or a device token. A session or app token never creates, edits, reads
3
- // or revokes a grant (escalation guard). Answers {grant} / {grants}, never cached; the owner is not echoed.
3
+ // or revokes a grant on the admin routes (escalation guard). /mine is holder-scoped; a user has no holder.
4
+ // Answers {grant} / {grants}, never cached; the owner is not echoed.
4
5
  //
5
6
  // POST /api/agent/grants create (201) GET /api/agent/grants list, newest first
6
7
  // GET /api/agent/grants/:id one grant PUT /api/agent/grants/:id replace (holder fixed)
@@ -37,6 +38,18 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
37
38
  } satisfies AgentRoute,
38
39
  ]
39
40
  : []),
41
+ {
42
+ method: "GET", path: `${PREFIX}/mine`, allow: { session: true, scope: "messages:read" },
43
+ handle: async ({ actor }) => Response.json({ grants: (await grants.held(actor)).map((g) => grantView(g, now())) }, { headers: NO_STORE }),
44
+ },
45
+ {
46
+ method: "GET", path: `${PREFIX}/mine/:id`, allow: { session: true, scope: "messages:read" },
47
+ handle: async ({ actor, params }) => one(await grants.heldGrant(actor, params.id!)),
48
+ },
49
+ {
50
+ method: "DELETE", path: `${PREFIX}/mine/:id`, allow: { session: true, scope: "messages:send" },
51
+ handle: async ({ actor, params }) => one(await grants.revokeHeld(actor, params.id!)),
52
+ },
40
53
  {
41
54
  method: "POST",
42
55
  path: PREFIX,
@@ -136,6 +136,29 @@ export class Grants {
136
136
  return (await this.all(owner)).sort((a, b) => b.created - a.created || (a.id < b.id ? -1 : 1)).map((g) => ({ ...g, state: grantState(g, at) }))
137
137
  }
138
138
 
139
+ /** Grants currently held by this credential; a user credential has no session/app holder. */
140
+ async held(actor: AgentActor): Promise<GrantView[]> {
141
+ if (actor.kind === "user") return []
142
+ return (await this.list(actor.userID)).filter((grant) => grant.owner === actor.userID && this.holds(grant, actor))
143
+ }
144
+
145
+ /** A holder cannot inspect another holder's grant (including an unheld role). */
146
+ async heldGrant(actor: AgentActor, id: string): Promise<Grant> {
147
+ if (actor.kind === "user") throw notFound()
148
+ const grant = await this.get(actor.userID, id)
149
+ if (grant.owner !== actor.userID || !this.holds(grant, actor)) throw notFound()
150
+ return grant
151
+ }
152
+
153
+ /** Re-check holder inside the versioned mutation, so a role lost after a read cannot revoke it. */
154
+ async revokeHeld(actor: AgentActor, id: string): Promise<Grant> {
155
+ await this.heldGrant(actor, id)
156
+ return this.change(actor.userID, id, "grant.revoke", (grant, at) => {
157
+ if (actor.kind === "user" || grant.owner !== actor.userID || !this.holds(grant, actor)) throw notFound()
158
+ return { ...grant, revokedAt: at }
159
+ })
160
+ }
161
+
139
162
  /**
140
163
  * Replaces an active grant with a new spec (its expiry restarts now); id, holder (and its pin) and created
141
164
  * stay. Changing the holder is invalid (revoke and create instead); an ended grant is gone.
@@ -120,6 +120,7 @@ export interface LaunchEngineDeps {
120
120
  /** This process (pod), as placement lease owner. */
121
121
  readonly owner: string
122
122
  readonly caps?: HubLaunches
123
+ readonly currentCaps?: () => { readonly maxQueued: number; readonly maxActive: number; readonly perHour: number; readonly lostAfterMinutes?: number | false }
123
124
  readonly nodes: EngineNodes
124
125
  readonly nodeOps: EngineNodeOps
125
126
  readonly registry: EngineRegistry
@@ -334,7 +335,10 @@ export class LaunchEngine {
334
335
  private readonly timers: Timers
335
336
  private tickTimer: unknown
336
337
  /** How long the reporting node instance may omit a running launch (NODE_UNREPORTED_MS, or caps.lostAfterMinutes; undefined: never). */
337
- private readonly nodeLostMs: number | undefined
338
+ private get nodeLostMs(): number | undefined {
339
+ const lostAfter = this.deps.currentCaps?.().lostAfterMinutes ?? this.deps.caps?.lostAfterMinutes
340
+ return lostAfter === false || lostAfter === 0 ? undefined : lostAfter !== undefined ? lostAfter * 60_000 : NODE_UNREPORTED_MS
341
+ }
338
342
  /** Machines being reconciled now. */
339
343
  private readonly reconciling = new Set<string>()
340
344
  /** Reconcile cleanup retries per launch, and the launches whose failing retry was logged. */
@@ -352,8 +356,6 @@ export class LaunchEngine {
352
356
  this.now = deps.now ?? Date.now
353
357
  this.timers = deps.timers ?? defaultTimers
354
358
  this.store = new LaunchStore({ state: deps.state, now: this.now })
355
- const lostAfter = deps.caps?.lostAfterMinutes
356
- this.nodeLostMs = lostAfter === false || lostAfter === 0 ? undefined : lostAfter !== undefined ? lostAfter * 60_000 : NODE_UNREPORTED_MS
357
359
  const { registry } = deps
358
360
  this.placement = new Placement({
359
361
  store: this.store,
@@ -369,6 +371,7 @@ export class LaunchEngine {
369
371
  locks: deps.locks,
370
372
  owner: deps.owner,
371
373
  ...(deps.caps ? { caps: deps.caps } : {}),
374
+ ...(deps.currentCaps ? { currentCaps: deps.currentCaps } : {}),
372
375
  ...(deps.launchers ? { launchers: deps.launchers } : {}),
373
376
  now: this.now,
374
377
  })
@@ -746,7 +749,7 @@ export class LaunchEngine {
746
749
  const workspace = work?.workspace ?? (launch ? leftWorkspace(launch, report) : undefined)
747
750
  if (!workspace) continue
748
751
  if (HARNESS_UP.has(report.phase)) await nodeOps.requestNode(node.connID, { op: "stop", launchID: report.launchID, workspace, mode: "kill" })
749
- await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (workspace.reused ? "keep" : "remove") })
752
+ await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (launch?.spec.end.cleanup ?? (workspace.reused ? "keep" : "remove")) })
750
753
  this.cleanupTries.delete(report.launchID)
751
754
  this.cleanupLogged.delete(report.launchID)
752
755
  } catch (cause) {
@@ -1122,6 +1125,8 @@ export class LaunchEngine {
1122
1125
  // A lost launch whose session a spoke still reports keeps its workspace (never removed under a live session).
1123
1126
  const sessionLive = outcome === "lost" && work.sessionID !== undefined && this.deps.registry.hasSession(work.sessionID)
1124
1127
  const policy: CleanupPolicy = outcome === "needs_user" || sessionLive ? "keep" : !work.prompted ? "remove" : launch.spec.end.cleanup
1128
+ // Reconciliation must replay the policy actually applied, not the launch's original preference.
1129
+ await this.updateWork(userID, id, { cleanup: policy })
1125
1130
  const node = launch.machineID !== undefined ? this.deps.nodes.forMachine(launch.machineID) : undefined
1126
1131
  if (node) {
1127
1132
  if (work.started) {
@@ -1145,11 +1150,9 @@ export class LaunchEngine {
1145
1150
  } catch (cause) {
1146
1151
  // The node may still hold the launch (and its slot): reconcile retries with this policy.
1147
1152
  notes.push(`workspace cleanup failed: ${errorText(cause)}; retried later`)
1148
- await this.updateWork(userID, id, { cleanup: policy })
1149
1153
  }
1150
1154
  } else {
1151
1155
  notes.push("workspace cleanup waits for the node")
1152
- await this.updateWork(userID, id, { cleanup: policy })
1153
1156
  }
1154
1157
  }
1155
1158
  try {
@@ -28,9 +28,12 @@ import type { Clock, Counters, StateStore } from "../backend/types"
28
28
  import { type Party, partyKey } from "./envelope"
29
29
  import { AgentError } from "./errors"
30
30
  import { updateVersioned } from "./versioned"
31
+ import { TEAM_LIMITS } from "../../protocol"
31
32
 
32
33
  /** Hops a reply chain may take without a human turn. */
33
34
  export const HOP_CAP = 8
35
+ /** (HC-1) The absolute bound of any configured hop limit or ceiling (the live ceiling is in AgentLimits). */
36
+ export const HOP_ABSOLUTE_MAX = TEAM_LIMITS.hopLimit
34
37
  /** A depth no message refreshed for this long is forgotten. */
35
38
  export const HOP_TTL_MS = 24 * 60 * 60_000
36
39
 
@@ -160,6 +163,9 @@ export class MessageLimiter {
160
163
  private readonly now: Clock
161
164
  private readonly limits: MessageLimits
162
165
 
166
+ /** The configured recipient wake cap for the current 10-minute window. */
167
+ get wakeLimit(): number { return this.limits.wake }
168
+
163
169
  constructor(deps: { readonly counters: Counters; readonly now?: Clock; readonly limits?: Partial<MessageLimits> }) {
164
170
  this.counters = deps.counters
165
171
  this.now = deps.now ?? Date.now
@@ -43,6 +43,7 @@ import type { AgentActor } from "./actor"
43
43
  import { type AskInput, DECISION_STATES, type DecisionState, type Decisions, type Tier, type Urgency } from "./decisions"
44
44
  import type { Delivery, SendOptions, Sent } from "./delivery"
45
45
  import type { Directory } from "./directory"
46
+ import { isContractID } from "../../protocol"
46
47
  import { partyFromActor, partyKey } from "./envelope"
47
48
  import { AgentError } from "./errors"
48
49
  import { idempotencyKeyOf, REPLAYED_HEADER, RouteIdempotency } from "./idempotency"
@@ -136,7 +137,7 @@ const sendOptions = (body: Body, delivery: Pick<Delivery, "checkNotAfter">): Sen
136
137
  /** The user's address (also the malformed `@me.project`, which the delivery engine refuses). */
137
138
  const isMe = (to: string) => to === "@me" || to.startsWith("@me.")
138
139
 
139
- const DECISION_FIELDS = ["options", "recommendation", "urgency", "tier", "deadline", "onTimeout", "about"] as const
140
+ const DECISION_FIELDS = ["options", "recommendation", "choices", "kind", "urgency", "tier", "deadline", "onTimeout", "about"] as const
140
141
 
141
142
  /** The state query parameter of a Decision listing, or undefined. */
142
143
  export function decisionStateQuery(raw: string | null): DecisionState | undefined {
@@ -162,7 +163,21 @@ function decisionFields(body: Body): Omit<AskInput, "question" | "about" | "expi
162
163
  if (typeof r !== "object" || r === null || Array.isArray(r)) throw new AgentError("invalid", "recommendation must be {option, why?}")
163
164
  out.recommendation = { option: r.option, ...(r.why === undefined ? {} : { why: r.why }) }
164
165
  }
165
- for (const key of ["urgency", "tier", "onTimeout"] as const) {
166
+ if (body.choices !== undefined) {
167
+ const shape = "choices must be a list of {id, label, options: [{id, label, recommended?}]}"
168
+ if (!Array.isArray(body.choices)) throw new AgentError("invalid", shape)
169
+ out.choices = body.choices.map((group: unknown) => {
170
+ if (typeof group !== "object" || group === null || Array.isArray(group)) throw new AgentError("invalid", shape)
171
+ const { id, label, options } = group as Record<string, unknown>
172
+ if (!Array.isArray(options)) throw new AgentError("invalid", shape)
173
+ return { id, label, options: options.map((option: unknown) => {
174
+ if (typeof option !== "object" || option === null || Array.isArray(option)) throw new AgentError("invalid", shape)
175
+ const o = option as Record<string, unknown>
176
+ return { id: o.id, label: o.label, ...(o.recommended === undefined ? {} : { recommended: o.recommended }) }
177
+ }) }
178
+ })
179
+ }
180
+ for (const key of ["urgency", "tier", "onTimeout", "kind"] as const) {
166
181
  if (body[key] === undefined) continue
167
182
  if (typeof body[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
168
183
  out[key] = body[key]
@@ -177,11 +192,12 @@ function decisionFields(body: Body): Omit<AskInput, "question" | "about" | "expi
177
192
  }
178
193
 
179
194
  /** The stored message, how it was delivered and, for a session recipient, whether it woke it (and why not). */
180
- const sentView = ({ message, delivery, woke, wakeSkipped }: Sent) => ({
195
+ const sentView = ({ message, delivery, woke, wakeSkipped, wakeLimit }: Sent) => ({
181
196
  message,
182
197
  ...(delivery ? { delivery } : {}),
183
198
  ...(woke === undefined ? {} : { woke }),
184
199
  ...(wakeSkipped ? { wakeSkipped } : {}),
200
+ ...(wakeLimit !== undefined ? { wakeLimit } : {}),
185
201
  })
186
202
 
187
203
  export function messageRoutes(deps: MessageDeps): AgentRoute[] {
@@ -271,6 +287,13 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
271
287
  return sent
272
288
  }
273
289
 
290
+ async function explicitItem(userID: string, actor: AgentActor, to: string, raw: unknown): Promise<string | undefined> {
291
+ if (raw === undefined) return undefined
292
+ if (!isContractID("intake", raw)) throw new AgentError("invalid", "item must be an itk_ ID")
293
+ if (isMe(to) || actor.kind !== "session") throw new AgentError("invalid", "item applies to messages between team members")
294
+ return raw
295
+ }
296
+
274
297
  return [
275
298
  {
276
299
  method: "POST",
@@ -281,7 +304,8 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
281
304
  const to = string(body, "to")
282
305
  const text = string(body, "text", TEXT_MAX)
283
306
  const all = flag(body, "all")
284
- const sent = await send(user.id, actor, to, "msg", text, sendOptions(body, delivery), all)
307
+ const item = await explicitItem(user.id, actor, to, body.item)
308
+ const sent = await send(user.id, actor, to, "msg", text, { ...sendOptions(body, delivery), ...(item ? { item } : {}) }, all)
285
309
  return ok(all ? { sent: sent.map(sentView) } : sentView(sent[0]!), 201)
286
310
  }),
287
311
  },
@@ -293,11 +317,12 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
293
317
  const body = await bodyOf(request)
294
318
  const to = string(body, "to")
295
319
  const text = string(body, "text", TEXT_MAX)
320
+ const item = await explicitItem(user.id, actor, to, body.item)
296
321
  const all = flag(body, "all")
297
322
  const expiresIn = seconds(body.expiresIn, "expiresIn")
298
323
  const wait = seconds(body.wait, "wait", WAIT_MAX_S)
299
324
  if (all && wait !== undefined) throw new AgentError("invalid", "wait cannot be combined with all")
300
- const options: SendOptions = { ...sendOptions(body, delivery), ...(expiresIn === undefined ? {} : { expiresInMs: Math.round(expiresIn * 1000) }) }
325
+ const options: SendOptions = { ...sendOptions(body, delivery), ...(item ? { item } : {}), ...(expiresIn === undefined ? {} : { expiresInMs: Math.round(expiresIn * 1000) }) }
301
326
  const decisionAsked = DECISION_FIELDS.some((key) => body[key] !== undefined)
302
327
  if (isMe(to) && deps.decisions) {
303
328
  if (all) throw new AgentError("invalid", "all cannot be combined with @me")
@@ -314,7 +339,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
314
339
  const done = await settled(user.id, message, wait, request.signal)
315
340
  return ok({ ...done, decision: (await deps.decisions.get(user.id, decision.id)) ?? decision }, 201)
316
341
  }
317
- if (decisionAsked) throw new AgentError("invalid", "options, recommendation, urgency, tier, deadline, onTimeout and about are for asks to @me")
342
+ if (decisionAsked) throw new AgentError("invalid", "options, recommendation, choices, kind, urgency, tier, deadline, onTimeout and about are for asks to @me")
318
343
  const sent = await send(user.id, actor, to, "ask", text, options, all)
319
344
  if (all) return ok({ sent: sent.map(sentView) }, 201)
320
345
  return ok({ ...sentView(sent[0]!), ...(await settled(user.id, sent[0]!.message, wait, request.signal)) }, 201)
@@ -326,6 +351,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
326
351
  allow: SEND,
327
352
  async handle({ request, user, actor }) {
328
353
  const body = await bodyOf(request)
354
+ if (body.item !== undefined) throw new AgentError("invalid", "item is not accepted on replies", { hint: "replies inherit the item of the message they answer" })
329
355
  const replyTo = string(body, "replyTo")
330
356
  const text = string(body, "text", TEXT_MAX)
331
357
  const wake = flag(body, "wake")
@@ -342,6 +368,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
342
368
  allow: SEND,
343
369
  async handle({ request, user, actor }) {
344
370
  const body = await bodyOf(request)
371
+ if (body.item !== undefined) throw new AgentError("invalid", "item is not accepted on declines", { hint: "replies inherit the item of the message they answer" })
345
372
  const replyTo = string(body, "replyTo")
346
373
  const why = string(body, "why", WHY_MAX)
347
374
  ensureOn(user.id)
@@ -59,6 +59,8 @@ export interface Message {
59
59
  /** The role holder generation it reached (role addresses). */
60
60
  readonly generation?: number
61
61
  readonly text: string
62
+ /** Explicit intake item attribution, checked against the sender's current team seat. */
63
+ readonly item?: string
62
64
  /** What a reply or decline answers. */
63
65
  readonly replyTo?: string
64
66
  readonly state: MessageState
@@ -90,6 +92,7 @@ export interface SendInput {
90
92
  readonly recipient: Party
91
93
  readonly kind: "msg" | "ask"
92
94
  readonly text: string
95
+ readonly item?: string
93
96
  readonly generation?: number
94
97
  /** From HopTracker.next (default 0). */
95
98
  readonly hop?: number
@@ -106,6 +109,8 @@ export interface AnswerInput {
106
109
  /** The replier's current role holder generation, when it holds the role the original reached. */
107
110
  readonly generation?: number
108
111
  readonly hop?: number
112
+ /** Only when both parties still hold seats and the answered item's team is active/open. */
113
+ readonly item?: string
109
114
  }
110
115
 
111
116
  export interface Answered {
@@ -228,6 +233,7 @@ export class Messages {
228
233
  recipient: input.recipient,
229
234
  ...(input.generation === undefined ? {} : { generation: input.generation }),
230
235
  text,
236
+ ...(input.item ? { item: input.item } : {}),
231
237
  state: input.kind === "ask" ? "open" : "sent",
232
238
  at,
233
239
  hop: input.hop ?? 0,
@@ -377,6 +383,7 @@ export class Messages {
377
383
  recipient: original.from,
378
384
  text,
379
385
  replyTo: original.id,
386
+ ...(input.item ? { item: input.item } : {}),
380
387
  state: "sent",
381
388
  at,
382
389
  hop: input.hop ?? 0,
@@ -203,6 +203,7 @@ export interface PlacementDeps {
203
203
  /** This process (pod) as lease owner. */
204
204
  readonly owner: string
205
205
  readonly caps?: HubLaunches
206
+ readonly currentCaps?: () => LaunchCaps
206
207
  readonly now?: Clock
207
208
  readonly leaseMs?: number
208
209
  /** Revoked launchers (flagged in the active wait); without it none is. */
@@ -244,12 +245,13 @@ export const pastNotAfter = (launch: Pick<Launch, "spec">, now: number): boolean
244
245
  export const placementLeaseKey = (userID: string) => `placement:${userID}`
245
246
 
246
247
  export class Placement {
247
- readonly caps: LaunchCaps
248
+ get caps(): LaunchCaps { return this.deps.currentCaps?.() ?? this.initialCaps }
249
+ private readonly initialCaps: LaunchCaps
248
250
  private readonly now: Clock
249
251
  private readonly leaseMs: number
250
252
 
251
253
  constructor(private readonly deps: PlacementDeps) {
252
- this.caps = { ...DEFAULT_LAUNCH_CAPS, ...deps.caps }
254
+ this.initialCaps = { ...DEFAULT_LAUNCH_CAPS, ...deps.caps }
253
255
  this.now = deps.now ?? Date.now
254
256
  this.leaseMs = deps.leaseMs ?? PLACEMENT_LEASE_MS
255
257
  }
@@ -26,6 +26,7 @@ import type { EventsRouteDeps } from "./events-routes"
26
26
  import { type Census, type TeamDiscovery, visible } from "../teams/discovery"
27
27
  import type { SessionIntent } from "../../protocol"
28
28
  import { intentField } from "./intents"
29
+ import type { SessionHealth } from "../health/classify"
29
30
 
30
31
  /** Labels per session ID. */
31
32
  export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
@@ -86,6 +87,8 @@ export interface AgentSessionView {
86
87
  readonly harness: string
87
88
  readonly parentID?: string
88
89
  readonly busySince?: number
90
+ /** (SS-1) Why it is not making progress (provider retry, quota, auth, …); absent: ok. */
91
+ readonly health?: SessionHealth
89
92
  readonly now?: string
90
93
  readonly blockers: { kind: string; id: string; summary: string; sessionID?: string }[]
91
94
  readonly children: number
@@ -139,6 +142,7 @@ function view({ session: s, harness }: Row, entry: DirectoryEntry | undefined, l
139
142
  harness,
140
143
  ...(s.parentID ? { parentID: s.parentID } : {}),
141
144
  ...(own?.since !== undefined ? { busySince: own.since } : {}),
145
+ ...(own?.health ? { health: own.health } : {}),
142
146
  ...(s.now ? { now: nowText(s.now) } : {}),
143
147
  blockers: s.blockers.map((b) => ({ kind: b.kind, id: b.id, summary: b.summary, ...(b.sessionID !== s.id ? { sessionID: b.sessionID } : {}) })),
144
148
  children: s.status.filter((e) => e.parentID === s.id).length,
@@ -290,6 +294,7 @@ async function show(deps: ReadDeps, userID: string, addr: string, census?: Censu
290
294
  directory: live.directory,
291
295
  ...(live.parentID ? { parentID: live.parentID } : {}),
292
296
  ...(own?.since !== undefined ? { busySince: own.since } : {}),
297
+ ...(own?.health ? { health: own.health } : {}),
293
298
  ...(live.now ? { now: nowText(live.now), activity: live.now } : {}),
294
299
  blockers: live.blockers.map(({ sessionTitle: _, ...b }) => b),
295
300
  children: live.status.filter((e) => e.parentID === live.id).length,
@@ -67,6 +67,7 @@ import { isConciergeLaunch } from "../concierge/supervisor"
67
67
  import type { ConciergeService } from "../concierge/service"
68
68
  import type { SessionIntent } from "../../protocol"
69
69
  import type { SessionIntents } from "./intents"
70
+ import type { NodeRegistry } from "../nodes"
70
71
  import { intentRoutes } from "./intent-routes"
71
72
  import { contextCardRoutes } from "../context-cards/routes"
72
73
  import type { ContextCardService } from "../context-cards/service"
@@ -97,7 +98,10 @@ export interface AgentRoute {
97
98
 
98
99
  /** What the agent API needs from the hub. */
99
100
  export interface AgentDeps {
101
+ readonly agentLimits?: { view(): Promise<unknown> }
100
102
  readonly store: Store
103
+ /** Owner's connected launch nodes: read-only user route; never expose connection IDs or launch internals. */
104
+ readonly nodes?: Pick<NodeRegistry, "list">
101
105
  /** (AF1) Connect an app: the connected-app token verifier and the app-clients/connections routes when given. */
102
106
  readonly connect?: AppConnect
103
107
  /** (AF2) Speech for apps: POST /api/agent/speech/{transcribe,speak} (scope speech:use) when given. */
@@ -238,6 +242,15 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
238
242
  const feed = deps.feed ?? new MessageFeed()
239
243
  const appDecisions = appDecisionParts(deps, feed)
240
244
  const routes: AgentRoute[] = [
245
+ ...(deps.nodes ? [{
246
+ method: "GET", path: `${AGENT_PREFIX}/launch-nodes`, allow: {},
247
+ handle: ({ user }) => Response.json({ nodes: deps.nodes!.list(user.id).map((node) => ({
248
+ machineID: node.machineID, machineName: node.machineName, workspaceRoots: node.workspaceRoots,
249
+ capacity: node.capacity, used: node.used, free: node.free, runtimes: node.runtimes,
250
+ labels: node.labels, draining: node.draining,
251
+ })) }, { headers: { "cache-control": "no-store" } }),
252
+ } satisfies AgentRoute] : []),
253
+ ...(deps.agentLimits ? [{ method: "GET", path: `${AGENT_PREFIX}/agent-limits`, allow: { session: true, app: true }, handle: async () => Response.json(await deps.agentLimits!.view(), { headers: { "cache-control": "no-store" } }) }] : []),
241
254
  ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
242
255
  ...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
243
256
  ...(deps.directory && deps.contextCards ? contextCardRoutes({ cards: deps.contextCards, directory: deps.directory, ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
@@ -54,7 +54,7 @@ import type { AgentActor, AppActor, SessionActor } from "./actor"
54
54
  import { sanitizeLabel } from "./envelope"
55
55
  import { AgentError } from "./errors"
56
56
  import { parsePosition } from "./event-cursor"
57
- import type { AgentEvent, AgentEvents, EventInput, EventRecord } from "./events"
57
+ import { stateChanged, type AgentEvent, type AgentEvents, type EventInput, type EventRecord } from "./events"
58
58
  import { visible as actorSees } from "./events-routes"
59
59
  import { HOP_CAP } from "./message-limits"
60
60
  import { parseAddress } from "./names"
@@ -245,6 +245,9 @@ export interface SubscriptionsDeps {
245
245
  readonly send: SubscriptionSend
246
246
  /** The session's message hop depth (HopTracker.depth). */
247
247
  readonly hopDepth: (userID: string, sessionID: string) => Promise<number>
248
+ /** Hub-wide hop ceiling (team notices are not session-to-session messages). */
249
+ readonly hopLimit?: number
250
+ readonly currentHopLimit?: () => number
248
251
  /** Directory facts of these sessions (deleted and unknown ones absent). */
249
252
  readonly entries: (userID: string, sessionIDs: readonly string[]) => ReadonlyMap<string, SubscriptionEntry>
250
253
  /** The sessions an address names for the subscriber (its visibility); throws not_found / invalid / ambiguous. */
@@ -307,7 +310,7 @@ export function typeMatches(patterns: readonly string[], event: Pick<AgentEvent,
307
310
  return patterns.some((p) => {
308
311
  if (p === "*") return true
309
312
  if (Object.hasOwn(SUBSCRIPTION_FAMILIES, p)) return SUBSCRIPTION_FAMILIES[p]!.includes(type)
310
- if (p.startsWith("state:")) return type === "session.changed" && event.data.state === p.slice(6)
313
+ if (p.startsWith("state:")) return type === "session.changed" && event.data.state === p.slice(6) && stateChanged(event.data)
311
314
  return p.endsWith(".*") ? type.startsWith(p.slice(0, -1)) : p === type
312
315
  })
313
316
  }
@@ -318,12 +321,20 @@ export function typeMatches(patterns: readonly string[], event: Pick<AgentEvent,
318
321
  const tok = (v: unknown): string => (typeof v === "string" && /^[A-Za-z0-9_.:@#-]{1,80}$/.test(v) ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : "other")
319
322
  const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
320
323
 
324
+ /** (SS-1) " (quota until 2026-11-18 10:00 UTC)", " (retrying)", " (ok)": the health a session.changed carries, else "". */
325
+ function healthNote(d: Readonly<Record<string, unknown>>): string {
326
+ if (d.healthKind === undefined) return ""
327
+ const at = typeof d.healthResetAt === "number" && Number.isFinite(d.healthResetAt) ? new Date(d.healthResetAt) : undefined
328
+ const until = at && !Number.isNaN(at.getTime()) ? ` until ${at.toISOString().slice(0, 16).replace("T", " ")} UTC` : ""
329
+ return ` (${tok(d.healthKind)}${until})`
330
+ }
331
+
321
332
  function describe(event: AgentEvent): string {
322
333
  const d = event.data
323
334
  const s = event.subject
324
335
  switch (event.type) {
325
336
  case "session.changed":
326
- return `${tok(d.state)}${d.state === "idle" && d.prev === "working" ? " (turn done)" : ""}`
337
+ return `${tok(d.state)}${d.state === "idle" && d.prev === "working" ? " (turn done)" : ""}${healthNote(d)}`
327
338
  case "session.added":
328
339
  return "online"
329
340
  case "session.removed":
@@ -858,7 +869,7 @@ export class Subscriptions {
858
869
  if (scoped && !scoped(subject)) continue
859
870
  const cause = isMessage && subject.senderID !== undefined && subject.senderID.startsWith("ses_") ? subject.senderID : (subject.sessionID ?? target)
860
871
  const depth = await this.depth(ctx, cause)
861
- if (depth + 1 > HOP_CAP) {
872
+ if (depth + 1 > (this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP)) {
862
873
  suppressed++
863
874
  continue
864
875
  }
@@ -0,0 +1,115 @@
1
+ // The /api/v1 boundary (docs/plans/2026-11-12-api-v1-contract-and-native-app-design.md §1.1, §1.2), without hub
2
+ // state. /api/v1/<rest> is an alias of /api/<rest> in the console and agent route tables: handle() in http.ts gives
3
+ // the handlers a copy of the URL on the unversioned path (never another Request), so routes, access checks and
4
+ // idempotency keys are the unversioned ones. Every error answered under /api/v1 is the agent API's envelope
5
+ // {error: {code, message, ...extra}}: console handlers keep answering {error: string, code?, ...extra} (the web
6
+ // console reads it on the unversioned paths) and apiV1Response rewrites that on the way out. Answers below 400 go
7
+ // out as they are, unread: the live stream and streamed audio are never buffered.
8
+ import { API_V1_PREFIX } from "../protocol"
9
+
10
+ const API_PREFIX = "/api"
11
+
12
+ /**
13
+ * The unversioned path a /api/v1 path is an alias of, or undefined outside /api/v1. One prefix only: /api/v1/v1/me
14
+ * is /api/v1/me, which no table has; the bare prefix is /api or /api/, which no table has either.
15
+ */
16
+ export function unversionedPath(pathname: string): string | undefined {
17
+ if (pathname !== API_V1_PREFIX && !pathname.startsWith(`${API_V1_PREFIX}/`)) return undefined
18
+ return `${API_PREFIX}${pathname.slice(API_V1_PREFIX.length)}`
19
+ }
20
+
21
+ /** A copy of a /api/v1 URL on its unversioned path (query string kept), or undefined outside /api/v1. */
22
+ export function unversionedURL(url: URL): URL | undefined {
23
+ const pathname = unversionedPath(url.pathname)
24
+ if (pathname === undefined) return undefined
25
+ const copy = new URL(url)
26
+ copy.pathname = pathname
27
+ return copy
28
+ }
29
+
30
+ /** The code of a console error that names none, by status (design §1.2). */
31
+ const STATUS_CODES: Readonly<Record<number, string>> = {
32
+ 400: "invalid",
33
+ 401: "unauthorized",
34
+ 403: "forbidden",
35
+ 404: "not_found",
36
+ 408: "timeout",
37
+ 409: "conflict",
38
+ 410: "gone",
39
+ 413: "too_large",
40
+ 422: "unsupported",
41
+ 429: "rate_limited",
42
+ 500: "internal",
43
+ 502: "upstream_failed",
44
+ 503: "unavailable",
45
+ 504: "timeout",
46
+ }
47
+
48
+ export const apiV1ErrorCode = (status: number): string => STATUS_CODES[status] ?? (status >= 500 ? "internal" : "invalid")
49
+
50
+ /** The message of an error whose answer had none to keep, by code. */
51
+ const MESSAGES: Readonly<Record<string, string>> = {
52
+ invalid: "Bad request",
53
+ unauthorized: "Unauthorized",
54
+ forbidden: "Forbidden",
55
+ not_found: "Not found",
56
+ timeout: "Timed out",
57
+ conflict: "Conflict",
58
+ gone: "Gone",
59
+ too_large: "Too large",
60
+ unsupported: "Unsupported",
61
+ rate_limited: "Too many requests",
62
+ internal: "Internal error",
63
+ upstream_failed: "Upstream failed",
64
+ unavailable: "Unavailable",
65
+ }
66
+
67
+ /** A text/* error body longer than this is not a message (a page, a trace). */
68
+ const TEXT_MESSAGE_MAX_CHARS = 300
69
+
70
+ export interface ApiV1Error {
71
+ readonly error: { readonly code: string; readonly message: string; readonly [extra: string]: unknown }
72
+ }
73
+
74
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
75
+
76
+ /**
77
+ * The v1 envelope of an error answer: `body` is its parsed JSON (if it was JSON), `text` its text/* body. An
78
+ * envelope is returned as it is (the same object); a console error keeps its message, its code (else the
79
+ * status's) and moves its other fields inside `error`; anything else gets the status's code and a generic message.
80
+ */
81
+ export function apiV1ErrorBody(status: number, body: unknown, text?: string): ApiV1Error {
82
+ if (isRecord(body)) {
83
+ const { error, code, ...extra } = body
84
+ if (isRecord(error) && typeof error.code === "string" && typeof error.message === "string") return body as unknown as ApiV1Error
85
+ if (typeof error === "string") return { error: { ...extra, code: typeof code === "string" && code ? code : apiV1ErrorCode(status), message: error } }
86
+ }
87
+ const code = apiV1ErrorCode(status)
88
+ const short = text?.trim()
89
+ return { error: { code, message: short && short.length <= TEXT_MESSAGE_MAX_CHARS ? short : MESSAGES[code] ?? "Request failed" } }
90
+ }
91
+
92
+ /** An answer on its way out of /api/v1: errors (400 and up) in the v1 envelope, same status and headers. */
93
+ export async function apiV1Response(response: Response): Promise<Response> {
94
+ if (response.status < 400) return response
95
+ const type = (response.headers.get("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase()
96
+ const json = type === "application/json" || type.endsWith("+json")
97
+ // An event stream has no end to read to.
98
+ const text = type.startsWith("text/") && type !== "text/event-stream"
99
+ // A copy is read, so an answer that is already the envelope (every agent route's) goes out as its handler made it.
100
+ const raw = json || text ? await response.clone().text() : undefined
101
+ let parsed: unknown
102
+ if (json) {
103
+ try {
104
+ parsed = JSON.parse(raw!)
105
+ } catch {}
106
+ }
107
+ const body = apiV1ErrorBody(response.status, parsed, text ? raw : undefined)
108
+ if (body === parsed) return response
109
+ void response.body?.cancel().catch(() => {})
110
+ const bytes = new TextEncoder().encode(JSON.stringify(body))
111
+ const headers = new Headers(response.headers)
112
+ headers.set("content-type", "application/json;charset=utf-8")
113
+ headers.set("content-length", String(bytes.length))
114
+ return new Response(bytes, { status: response.status, statusText: response.statusText, headers })
115
+ }
@@ -95,6 +95,8 @@ export interface ArtifactEvent {
95
95
  readonly number?: number
96
96
  /** artifact.archived: the state it moved to (archive and unarchive share the event). */
97
97
  readonly state?: RecordState
98
+ /** created/revised by a session (in-process only: never journaled; TA2-5 progress credit). */
99
+ readonly authorSessionID?: string
98
100
  }
99
101
 
100
102
  export interface ArtifactStoreOptions {
@@ -393,7 +395,7 @@ export class ArtifactStore {
393
395
  if (!written.ok) throw conflict("Artifact ID collision; retry", undefined)
394
396
  return artifact
395
397
  })
396
- this.emit(userID, { type: "artifact.created", artifactID, kind: published.artifact.kind, revision: 1, revisionID: published.revision.id, number: 1 })
398
+ this.emit(userID, { type: "artifact.created", artifactID, kind: published.artifact.kind, revision: 1, revisionID: published.revision.id, number: 1, ...(published.revision.author.kind === "session" ? { authorSessionID: published.revision.author.sessionID } : {}) })
397
399
  return published
398
400
  }
399
401
 
@@ -426,7 +428,7 @@ export class ArtifactStore {
426
428
  }
427
429
  throw conflict("The artifact kept changing; retry", undefined)
428
430
  })
429
- this.emit(userID, { type: "artifact.revised", artifactID: id, kind: published.artifact.kind, revision: published.artifact.revision, revisionID: published.revision.id, number })
431
+ this.emit(userID, { type: "artifact.revised", artifactID: id, kind: published.artifact.kind, revision: published.artifact.revision, revisionID: published.revision.id, number, ...(published.revision.author.kind === "session" ? { authorSessionID: published.revision.author.sessionID } : {}) })
430
432
  return published
431
433
  }
432
434
 
@@ -17,7 +17,7 @@
17
17
  // stalled store further results are dropped. observe() never throws or rejects (it is called with `void`).
18
18
  import type { ConciergeResultOutcome } from "../../../packages/protocol/src/concierge"
19
19
  import { isHubSessionID } from "../../../packages/protocol/src/harness-events"
20
- import type { AgentEvent } from "../agent/events"
20
+ import { stateChanged, type AgentEvent } from "../agent/events"
21
21
  import { updateVersioned } from "../agent/versioned"
22
22
  import type { Clock, StateStore } from "../backend/types"
23
23
  import type { LastResult } from "./board"
@@ -56,6 +56,7 @@ export function resultOf(event: Pick<AgentEvent, "type" | "subject" | "data"> |
56
56
  if (!isHubSessionID(sessionID)) return undefined
57
57
  if (event.type === "session.changed") {
58
58
  if (subject.rootID !== undefined && subject.rootID !== sessionID) return undefined
59
+ if (!stateChanged(data)) return undefined
59
60
  if (data.state === "error") return { sessionID, outcome: "error" }
60
61
  if (data.state === "idle" && (data.prev === "working" || data.prev === "blocked")) return { sessionID, outcome: "turn_done" }
61
62
  return undefined