@huynle/hub 0.8.0 → 0.8.2

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 (133) hide show
  1. package/CHANGELOG.md +326 -1
  2. package/README.md +85 -16
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/paths.ts +3 -3
  6. package/packages/cli/dist/hub-cli.js +723 -281
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/artifact-jobs-mcp.ts +10 -9
  9. package/packages/cli/src/artifacts-mcp.ts +8 -7
  10. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  11. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  12. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  13. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  14. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  15. package/packages/cli/src/cli.ts +3 -3
  16. package/packages/cli/src/http.ts +1 -0
  17. package/packages/cli/src/launches.ts +9 -3
  18. package/packages/cli/src/login.ts +2 -2
  19. package/packages/cli/src/mcp.ts +9 -4
  20. package/packages/cli/src/messaging.ts +4 -2
  21. package/packages/cli/src/profiles-mcp.ts +301 -0
  22. package/packages/cli/src/setup/commands.ts +20 -2
  23. package/packages/cli/src/spoke-services.ts +1 -1
  24. package/packages/cli/src/team-intake-mcp.ts +8 -7
  25. package/packages/cli/src/teams-mcp.ts +61 -3
  26. package/packages/cli/src/tool-args.ts +6 -0
  27. package/packages/cli/src/watchlist.ts +5 -4
  28. package/packages/codex/package.json +1 -1
  29. package/packages/codex/src/paths.ts +3 -3
  30. package/packages/node/package.json +1 -1
  31. package/packages/node/src/config.ts +10 -2
  32. package/packages/node/src/opencode-warm.ts +27 -8
  33. package/packages/node/src/run.ts +3 -2
  34. package/packages/preset/package.json +1 -1
  35. package/packages/protocol/package.json +1 -1
  36. package/packages/protocol/src/concierge.ts +4 -1
  37. package/packages/protocol/src/embedded-media.ts +59 -0
  38. package/packages/protocol/src/index.ts +90 -7
  39. package/packages/protocol/src/launch.ts +14 -0
  40. package/packages/protocol/src/wire.ts +1 -0
  41. package/packages/runtime-openshell/package.json +1 -1
  42. package/packages/runtime-sdk/package.json +1 -1
  43. package/packages/sdk/package.json +1 -1
  44. package/packages/sdk/src/client.ts +3 -1
  45. package/packages/sdk/src/errors.ts +1 -0
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/types.ts +22 -2
  48. package/packages/sdk/src/wake.ts +20 -0
  49. package/packages/spoke-sdk/package.json +1 -1
  50. package/packages/spoke-sdk/src/connection.ts +13 -1
  51. package/src/central/agent/acting-routes.ts +3 -2
  52. package/src/central/agent/acting.ts +70 -12
  53. package/src/central/agent/audit.ts +1 -1
  54. package/src/central/agent/decisions.ts +58 -2
  55. package/src/central/agent/delivery.ts +111 -28
  56. package/src/central/agent/errors.ts +2 -0
  57. package/src/central/agent/escalation.ts +39 -10
  58. package/src/central/agent/events.ts +7 -1
  59. package/src/central/agent/launch-engine.ts +173 -30
  60. package/src/central/agent/launch-routes.ts +20 -9
  61. package/src/central/agent/launch-spec.ts +60 -17
  62. package/src/central/agent/launch-store.ts +55 -2
  63. package/src/central/agent/message-limits.ts +89 -11
  64. package/src/central/agent/message-routes.ts +8 -3
  65. package/src/central/agent/messages.ts +3 -2
  66. package/src/central/agent/placement.ts +93 -18
  67. package/src/central/agent/read-routes.ts +3 -0
  68. package/src/central/agent/routes.ts +11 -2
  69. package/src/central/agent/webhooks.ts +17 -2
  70. package/src/central/artifacts/jobs-routes.ts +4 -1
  71. package/src/central/concierge/board-routes.ts +13 -2
  72. package/src/central/concierge/context.ts +3 -1
  73. package/src/central/concierge/inbound.ts +4 -7
  74. package/src/central/concierge/prompt.ts +10 -6
  75. package/src/central/concierge/proposals.ts +8 -2
  76. package/src/central/concierge/routes.ts +62 -2
  77. package/src/central/concierge/session-routes.ts +62 -6
  78. package/src/central/concierge/supervisor.ts +34 -7
  79. package/src/central/concierge/watchlist-routes.ts +4 -2
  80. package/src/central/config.ts +21 -5
  81. package/src/central/http.ts +87 -35
  82. package/src/central/main.ts +1 -1
  83. package/src/central/registry.ts +49 -4
  84. package/src/central/revert-windows.ts +66 -0
  85. package/src/central/spoke-ws.ts +6 -3
  86. package/src/central/teams/intake.ts +20 -6
  87. package/src/central/teams/staffing.ts +24 -2
  88. package/src/central/workspaces/sidechats-routes.ts +1 -1
  89. package/src/index.ts +63 -30
  90. package/src/migrate.ts +109 -3
  91. package/src/spoke/deliver.ts +6 -0
  92. package/src/spoke/env-inject.ts +8 -1
  93. package/src/spoke/fork-revert.ts +94 -9
  94. package/src/spoke/hub-tool.ts +26 -12
  95. package/src/spoke/launchers.ts +6 -4
  96. package/src/spoke/listing.ts +18 -6
  97. package/src/spoke/localhub.ts +14 -4
  98. package/src/spoke/locations.ts +120 -0
  99. package/src/spoke/profile-apply.ts +24 -14
  100. package/src/spoke/session-context.ts +183 -33
  101. package/src/spoke/spoke.ts +129 -41
  102. package/src/spoke/timeline.ts +30 -3
  103. package/src/spoke/tracker.ts +17 -1
  104. package/src/status.ts +2 -1
  105. package/web/dist/assets/ArtifactPage-R0ZTpHca.js +1 -0
  106. package/web/dist/assets/ArtifactPane-C9KrBna8.js +1 -0
  107. package/web/dist/assets/ArtifactWorkbench-sP2mZccC.js +6 -0
  108. package/web/dist/assets/Library-BXbJvTUS.js +2 -0
  109. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +1 -0
  110. package/web/dist/assets/TeamsPage-Dgxt8UI3.js +1 -0
  111. package/web/dist/assets/Watchtower-DJHx6cWw.js +2 -0
  112. package/web/dist/assets/index-BabrNDB3.css +1 -0
  113. package/web/dist/assets/index-g4V9mfSr.js +34 -0
  114. package/web/dist/assets/live-OgQWPrSY.js +5 -0
  115. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  116. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DjEipyto.js} +1 -1
  117. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-B648jKGO.js} +1 -1
  118. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-CNLyboIj.js} +1 -1
  119. package/web/dist/assets/{silero-worker-C8-8vLVS.js → silero-worker-DhlIHyD_.js} +1 -1
  120. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  121. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  122. package/web/dist/index.html +4 -4
  123. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  124. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  125. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  126. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  127. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  128. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  129. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  130. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  131. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  132. package/web/dist/assets/live-YyTjE47g.js +0 -5
  133. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -92,8 +92,36 @@ export const LAUNCH_TIMEOUT_MAX_MS = 30 * 86_400_000
92
92
  const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "intent", "labels", "end", "agentProfile"]
93
93
  const WORKSPACE_FIELDS = ["repo", "mode", "branch", "base", "remote"]
94
94
  const RUNTIME_FIELDS = ["kind", "profile"]
95
- const PLACEMENT_FIELDS = ["labels", "machine", "mode"]
95
+ const PLACEMENT_FIELDS = ["machine", "labels", "mode"]
96
96
  const END_FIELDS = ["when", "timeout", "cleanup"]
97
+ /** The nested sections, in the order suggestions name them. */
98
+ const SECTIONS: readonly (readonly [string, readonly string[]])[] = [
99
+ ["workspace", WORKSPACE_FIELDS],
100
+ ["placement", PLACEMENT_FIELDS],
101
+ ["runtime", RUNTIME_FIELDS],
102
+ ["end", END_FIELDS],
103
+ ]
104
+ const TOP_FIELDS = FIELDS.filter((field) => !SECTIONS.some(([section]) => section === field))
105
+ /** (FX-03) The hint every unknown-field answer carries: callers often send a flat spec. */
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
+
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` }
97
125
 
98
126
  const KEY = /^[!-~]{1,200}$/
99
127
  const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/
@@ -109,8 +137,22 @@ const PROMPT_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/
109
137
  const invalid = (message: string, hint?: string) => new AgentError("invalid", message, hint ? { hint } : {})
110
138
  const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
111
139
 
112
- function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string): void {
113
- for (const key of Object.keys(value)) if (!allowed.includes(key)) throw invalid(`Unknown ${what} field ${key}`)
140
+ /** Where an unknown `key` at level `what` (launch, or a section) belongs instead: `section.key`s, or the top level. */
141
+ function placesOf(key: string, what: string, wording: Wording): string[] {
142
+ const places = SECTIONS.filter(([section, fields]) => section !== what && fields.includes(key)).map(([section]) => `${section}.${key}`)
143
+ if (what !== "launch" && TOP_FIELDS.includes(key)) places.push(key === "key" ? wording.keyPlace : `${key} (top level)`)
144
+ return places
145
+ }
146
+
147
+ /** Refuses every field outside `allowed` at once, naming where a misplaced one belongs (FX-03). */
148
+ function onlyFields(value: Record<string, unknown>, allowed: readonly string[], what: string, wording: Wording = PLAIN): void {
149
+ const unknown = Object.keys(value).filter((key) => !allowed.includes(key))
150
+ if (unknown.length === 0) return
151
+ const suggestions = unknown.slice(0, 10).map((key) => placesOf(key, what, wording)).filter((places) => places.length > 0).map((places) => places.join(" or "))
152
+ // Keys are the caller's: bounded in number and length.
153
+ const shown = [...unknown.slice(0, 10).map((key) => key.slice(0, 40)), ...(unknown.length > 10 ? ["…"] : [])]
154
+ const message = `Unknown ${what} field${unknown.length > 1 ? "s" : ""} ${shown.join(", ")}${suggestions.length ? `: did you mean ${suggestions.join(", ")}?` : ""}`
155
+ throw invalid(message, wording.shape)
114
156
  }
115
157
 
116
158
  function oneOf<T extends string>(value: unknown, options: readonly T[], fallback: T, what: string): T {
@@ -178,9 +220,9 @@ function validRoles(value: unknown): string[] {
178
220
  return roles
179
221
  }
180
222
 
181
- function validWorkspace(value: unknown): LaunchWorkspace {
223
+ function validWorkspace(value: unknown, wording: Wording): LaunchWorkspace {
182
224
  if (!isObject(value)) throw invalid("workspace is {repo, mode?, branch?, base?, remote?}")
183
- onlyFields(value, WORKSPACE_FIELDS, "workspace")
225
+ onlyFields(value, WORKSPACE_FIELDS, "workspace", wording)
184
226
  // canonicalProject rejects relative, `~` and root paths.
185
227
  const repo = typeof value.repo === "string" ? canonicalProject(value.repo) : undefined
186
228
  if (repo === undefined) throw invalid("workspace.repo is an absolute repository path")
@@ -200,10 +242,10 @@ function validWorkspace(value: unknown): LaunchWorkspace {
200
242
  }
201
243
  }
202
244
 
203
- function validRuntime(value: unknown, workspace: LaunchWorkspace): LaunchRuntime {
245
+ function validRuntime(value: unknown, workspace: LaunchWorkspace, wording: Wording): LaunchRuntime {
204
246
  if (value === undefined) return { kind: "host" }
205
247
  if (!isObject(value)) throw invalid("runtime is {kind, profile?}")
206
- onlyFields(value, RUNTIME_FIELDS, "runtime")
248
+ onlyFields(value, RUNTIME_FIELDS, "runtime", wording)
207
249
  const kind = oneOf<RuntimeKind>(value.kind, ["host", "openshell"], "host", "runtime.kind")
208
250
  const profile = matching(value.profile, SLUG, "runtime.profile")
209
251
  if (kind === "host" && profile !== undefined) throw invalid("runtime.profile applies to the openshell runtime only")
@@ -211,20 +253,20 @@ function validRuntime(value: unknown, workspace: LaunchWorkspace): LaunchRuntime
211
253
  return { kind, ...(profile !== undefined ? { profile } : {}) }
212
254
  }
213
255
 
214
- function validPlacement(value: unknown): LaunchPlacement {
256
+ function validPlacement(value: unknown, wording: Wording): LaunchPlacement {
215
257
  if (value === undefined) return { labels: {}, mode: "require" }
216
258
  if (!isObject(value)) throw invalid("placement is {labels?, machine?, mode?}")
217
- onlyFields(value, PLACEMENT_FIELDS, "placement")
259
+ onlyFields(value, PLACEMENT_FIELDS, "placement", wording)
218
260
  const labels = validLabels(value.labels, "placement.labels")
219
261
  const machine = matching(value.machine, MACHINE, "placement.machine")
220
262
  const mode = oneOf<PlacementMode>(value.mode, ["require", "prefer"], "require", "placement.mode")
221
263
  return { labels, ...(machine !== undefined ? { machine } : {}), mode }
222
264
  }
223
265
 
224
- function validEnd(value: unknown): LaunchEnd {
266
+ function validEnd(value: unknown, wording: Wording): LaunchEnd {
225
267
  if (value === undefined) return { when: "idle", cleanup: "keep" }
226
268
  if (!isObject(value)) throw invalid("end is {when?, timeout?, cleanup?}")
227
- onlyFields(value, END_FIELDS, "end")
269
+ onlyFields(value, END_FIELDS, "end", wording)
228
270
  const when = oneOf<EndWhen>(value.when, ["idle", "asked", "never"], "idle", "end.when")
229
271
  let timeoutMs: number | undefined
230
272
  if (value.timeout !== undefined) {
@@ -238,10 +280,11 @@ function validEnd(value: unknown): LaunchEnd {
238
280
  // ---------- spec ----------
239
281
 
240
282
  /** Validates a launch body into a LaunchSpec (a fresh copy, defaults filled in); throws AgentError invalid. */
241
- export function validateLaunchSpec(input: unknown): LaunchSpec {
283
+ export function validateLaunchSpec(input: unknown, options: LaunchSpecOptions = {}): LaunchSpec {
284
+ const wording = options.staffing ? STAFFING : PLAIN
242
285
  if (!isObject(input)) throw invalid("A launch is an object")
243
286
  if ("grant" in input) throw invalid("grant is not supported yet: a launched session has its launcher's powers only")
244
- onlyFields(input, FIELDS, "launch")
287
+ onlyFields(input, FIELDS, "launch", wording)
245
288
  const key = matching(input.key, KEY, `key (1–${KEY_MAX} visible characters)`)
246
289
  let name: string | undefined
247
290
  if (input.name !== undefined) {
@@ -252,9 +295,9 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
252
295
  const harness = matching(input.harness, SLUG, "harness") ?? "opencode"
253
296
  const agent = matching(input.agent, AGENT, "agent")
254
297
  const model = matching(input.model, MODEL, "model (provider/model)", MODEL_MAX)
255
- const workspace = validWorkspace(input.workspace)
256
- const runtime = validRuntime(input.runtime, workspace)
257
- 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)
258
301
  let prompt: string | undefined
259
302
  if (input.prompt !== undefined) {
260
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`)
@@ -263,7 +306,7 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
263
306
  // (CON-02) What the session is for: untrusted, sanitised to one line, at most INTENT_MAX characters.
264
307
  const intent = input.intent === undefined ? undefined : intentText(input.intent)
265
308
  const labels = validLabels(input.labels, "labels")
266
- const end = validEnd(input.end)
309
+ const end = validEnd(input.end, wording)
267
310
  const profileProblem = input.agentProfile === undefined ? undefined : launchAgentProfileProblem(input.agentProfile)
268
311
  if (profileProblem) throw invalid(profileProblem)
269
312
  const agentProfile = input.agentProfile === undefined ? undefined : { profileID: (input.agentProfile as LaunchAgentProfile).profileID, version: (input.agentProfile as LaunchAgentProfile).version }
@@ -11,6 +11,9 @@
11
11
  // id) before the launch is stored. The same key with an equivalent body returns the existing launch (even an
12
12
  // ended one, until it is pruned); a different body is invalid with a hint. A claim whose launch never appeared
13
13
  // (its creator crashed) blocks the key for RESERVATION_MS, then is taken over.
14
+ // - Waiting (FX-02): a queued launch carries why it waits (`waiting`, from placement): setWaiting writes it only
15
+ // while the launch is queued and only when it changed (no history step; `since` kept while the wait kind is the
16
+ // same), and mirrors it into `reason`; every transition clears it.
14
17
  // - Pruning: ended launches older than endedKeepMs, and all but the newest endedMax ended ones, are removed and
15
18
  // their keys released. Live launches are never pruned.
16
19
  import { randomBytes } from "node:crypto"
@@ -26,6 +29,8 @@ export const ENDED_KEEP_MS = 7 * 86_400_000
26
29
  export const ENDED_MAX = 500
27
30
  /** How long a key claim without its launch (a creator that may still be writing) blocks the key. */
28
31
  export const RESERVATION_MS = 60_000
32
+ /** The longest launcher label kept on a launch. */
33
+ const LABEL_MAX = 100
29
34
  /** Reads of a key holder's launch: its creator may be between the claim and the write, so a miss is re-read. */
30
35
  const READS = 5
31
36
  /** Claim attempts (a stale claim is taken over between them). */
@@ -45,6 +50,21 @@ const NEXT: Record<LaunchState, readonly LaunchState[]> = {
45
50
  ended: [],
46
51
  }
47
52
 
53
+ /** Why a queued launch waits (placement's Waiting without the launch ID). */
54
+ export interface LaunchWaitReason {
55
+ /** capacity: a fitting node is full; no_match: no node fits; active: the user's maxActive is reached. */
56
+ readonly wait: "capacity" | "no_match" | "active"
57
+ /** The full text, for the user (names other launchers and machines). */
58
+ readonly reason: string
59
+ /** The text for the launch's own launcher (an app or session) when it differs: counts only, no other launchers or machine names. */
60
+ readonly shared?: string
61
+ }
62
+
63
+ /** A queued launch's wait, since it first waited for this kind of reason. */
64
+ export interface LaunchWaiting extends LaunchWaitReason {
65
+ readonly since: number
66
+ }
67
+
48
68
  export interface LaunchStep {
49
69
  readonly state: LaunchState
50
70
  readonly reason: string
@@ -59,6 +79,8 @@ export interface Launch {
59
79
  readonly spec: LaunchSpec
60
80
  /** Who launched it, as the caller describes them (e.g. `app:<token id>`, `ses_…`, `user`). */
61
81
  readonly by?: string
82
+ /** The launcher's label when it launched (an app token's label, a session's @name); never a secret. */
83
+ readonly byLabel?: string
62
84
  readonly state: LaunchState
63
85
  readonly reason: string
64
86
  /** When it entered its state. */
@@ -68,6 +90,8 @@ export interface Launch {
68
90
  readonly sessionID?: string
69
91
  readonly name?: string
70
92
  readonly artifacts?: Readonly<Record<string, unknown>>
93
+ /** Only while queued: why it waits (placement). */
94
+ readonly waiting?: LaunchWaiting
71
95
  readonly created: number
72
96
  readonly history: readonly LaunchStep[]
73
97
  }
@@ -138,7 +162,7 @@ export class LaunchStore {
138
162
  * Stores a new queued launch, or (same key, equivalent body) returns the existing one with created false.
139
163
  * Throws invalid (key reused with another body) or rate_limited (the key's launch is still being created).
140
164
  */
141
- async create(userID: string, spec: LaunchSpec, options: { readonly by?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
165
+ async create(userID: string, spec: LaunchSpec, options: { readonly by?: string; readonly byLabel?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
142
166
  const id = this.newID()
143
167
  if (spec.key !== undefined) {
144
168
  const existing = await this.claimKey(userID, spec.key, id)
@@ -157,6 +181,7 @@ export class LaunchStore {
157
181
  owner: userID,
158
182
  spec,
159
183
  ...(options.by !== undefined ? { by: options.by } : {}),
184
+ ...(options.byLabel ? { byLabel: cleanReason(options.byLabel).slice(0, LABEL_MAX) } : {}),
160
185
  state: "queued",
161
186
  reason: "Queued",
162
187
  at,
@@ -190,8 +215,9 @@ export class LaunchStore {
190
215
  if (!NEXT[current.state].includes(to) || (input.from && !input.from.includes(current.state))) throw new LaunchTransitionError(current.state, to)
191
216
  checkDetails(to, input)
192
217
  const step: LaunchStep = { state: to, reason, at, ...(input.outcome !== undefined ? { outcome: input.outcome } : {}) }
218
+ const { waiting: _cleared, ...rest } = current
193
219
  return {
194
- ...current,
220
+ ...rest,
195
221
  state: to,
196
222
  reason,
197
223
  at,
@@ -206,6 +232,33 @@ export class LaunchStore {
206
232
  return next!
207
233
  }
208
234
 
235
+ /**
236
+ * Records why a queued launch waits (undefined: it no longer waits). Writes nothing (resolves undefined) when the
237
+ * launch is not queued or the wait and reason are unchanged; else resolves with the launch as written. The
238
+ * reason shows as the launch's reason; `since` stays while the wait kind stays the same.
239
+ */
240
+ async setWaiting(userID: string, id: string, waiting: LaunchWaitReason | undefined): Promise<Launch | undefined> {
241
+ const reason = waiting ? cleanReason(waiting.reason) : undefined
242
+ const shared = waiting?.shared !== undefined ? cleanReason(waiting.shared) : undefined
243
+ let written = false
244
+ const next = await updateVersioned<Launch>(this.deps.state, userID, LAUNCHES, id, (current) => {
245
+ written = false
246
+ if (!current || current.state !== "queued") return undefined
247
+ const was = current.waiting
248
+ if (!waiting) {
249
+ if (!was) return undefined
250
+ const { waiting: _cleared, ...rest } = current
251
+ written = true
252
+ return { ...rest, reason: "Queued" }
253
+ }
254
+ if (was && was.wait === waiting.wait && was.reason === reason && was.shared === shared) return undefined
255
+ written = true
256
+ const since = was?.wait === waiting.wait ? was.since : this.now()
257
+ return { ...current, reason: reason!, waiting: { wait: waiting.wait, reason: reason!, ...(shared !== undefined ? { shared } : {}), since } }
258
+ })
259
+ return written ? next : undefined
260
+ }
261
+
209
262
  /** Removes ended launches past endedKeepMs or beyond the newest endedMax; resolves with the removed ids. */
210
263
  async prune(userID: string): Promise<string[]> {
211
264
  const { state } = this.deps
@@ -1,9 +1,27 @@
1
1
  // Loop protection for agent messages (design §3.4), over the backend interfaces with an injected clock.
2
2
  //
3
- // - HopTracker: every message carries a hop. A session's (or app's) depth is the deepest hop that reached it
4
- // since its last human turn; what it sends carries depth + 1. The user sends hop 0, and a hop-0 message (or a
5
- // human turn in the session) resets the depth. A hop over HOP_CAP is delivered without waking and the user
6
- // is alerted (the delivery engine does both).
3
+ // - HopTracker: every message carries a hop. A session's depth is the deepest hop that reached it since its last
4
+ // human turn; what it sends (msg, ask, reply, decline) carries depth + 1. The user sends hop 0, and a hop-0
5
+ // message (or a human turn in the session) resets the depth. A hop over HOP_CAP is delivered without waking and
6
+ // the user is alerted (the delivery engine does both).
7
+ // - Apps (app:<tokenID>, including the hub's own app:concierge, the workspace coordinator and team intake) keep NO
8
+ // depth (S1, bug wge0vjdz; decision in Brain, tag fixup-0.8.1): one token serves many unrelated conversations and
9
+ // has no human turn to reset it, so a shared depth grew by 2 per ask/reply round trip and silenced the app for
10
+ // 24 h after 4 of them. Instead the hop is per conversation: a NEW msg or ask from an app is hop 1, a reply or
11
+ // decline from an app is the hop of the message it answers + 1, and messages to an app raise nothing. Reply
12
+ // ping-pong through an app still counts up (the session side keeps its depth) and hits HOP_CAP. Session-to-session
13
+ // behaviour is unchanged.
14
+ // - Relay depth (0.8.1 verify finding 1): so an app cannot launder a chain by relaying between sessions as NEW
15
+ // messages (A → app → B → app → A, each hop 1), an app remembers, per (user, app) and per sending session, the
16
+ // deepest hop it RECEIVED from a session in the last RELAY_WINDOW_MS (5 min; refreshed by each message, then it
17
+ // fades). Its new msg/ask carries that relay depth + 1 (else 1), counting only what OTHER sessions sent it: what
18
+ // the recipient itself sent is the same conversation, not a relay, so a one-to-one app conversation stays at hop 1
19
+ // at any pace (the S1 case; an app answering one session with new messages forever is held by the pair limit and
20
+ // the recipient's wake budget instead). A relay loop through an app thus reaches HOP_CAP after ~8 hops. Only
21
+ // session senders count (the user's hop 0 and other apps do not), and the hub's own parties (HUB_APP_TOKENS:
22
+ // app:concierge, the workspace coordinator, team intake) keep none: they send on events and submissions, not on
23
+ // the messages they receive (team intake carries the submitter's depth + 1 itself, SendOptions.chainHop). It is
24
+ // not a persistent depth: messages to an app still raise nothing beyond this time-boxed memory.
7
25
  // - MessageLimiter: fixed windows aligned to the clock (so retryAfter is exact): messages per sender→recipient
8
26
  // pair and per user (refused with rate_limited), and wakes per recipient (exhausted: deliver without waking).
9
27
  import type { Clock, Counters, StateStore } from "../backend/types"
@@ -18,26 +36,67 @@ export const HOP_TTL_MS = 24 * 60 * 60_000
18
36
 
19
37
  export const overHopCap = (hop: number): boolean => hop > HOP_CAP
20
38
 
39
+ /** How long what an app received from a session raises the app's new messages (relay depth). */
40
+ export const RELAY_WINDOW_MS = 5 * 60_000
41
+ /** Sending sessions remembered per (user, app); the oldest go first. */
42
+ export const RELAY_SOURCES_MAX = 32
43
+ /** The hub's own app parties (app:<tokenID>): no relay depth (see the header). */
44
+ export const HUB_APP_TOKENS: ReadonlySet<string> = new Set(["concierge", "workspace-coordinator", "team-intake"])
45
+
21
46
  /** StateStore collection: partyKey → HopDepth. */
22
47
  const HOPS = "message-hops"
48
+ /** StateStore collection: partyKey of an app → RelayDepth. */
49
+ const RELAYS = "message-relays"
23
50
 
24
51
  interface HopDepth {
25
52
  readonly depth: number
26
53
  readonly at: number
27
54
  }
28
55
 
56
+ interface RelayDepth {
57
+ /** Sending session → the deepest hop it sent the app, and when it last sent one. */
58
+ readonly sources: Readonly<Record<string, HopDepth>>
59
+ }
60
+
61
+ /** What a message continues: the message a reply or decline answers, and its recipient. */
62
+ export interface HopContext {
63
+ readonly answering?: { readonly hop: number }
64
+ readonly to?: Party
65
+ }
66
+
67
+ const relays = (party: Party): party is Extract<Party, { kind: "app" }> => party.kind === "app" && !HUB_APP_TOKENS.has(party.tokenID)
68
+
29
69
  export class HopTracker {
30
70
  private readonly state: StateStore
31
71
  private readonly now: Clock
72
+ private readonly relayWindowMs: number
32
73
 
33
- constructor(deps: { readonly state: StateStore; readonly now?: Clock }) {
74
+ constructor(deps: { readonly state: StateStore; readonly now?: Clock; readonly relayWindowMs?: number }) {
34
75
  this.state = deps.state
35
76
  this.now = deps.now ?? Date.now
77
+ this.relayWindowMs = deps.relayWindowMs ?? RELAY_WINDOW_MS
78
+ }
79
+
80
+ /**
81
+ * The hop a message from `from` carries. The user: 0. An app: a reply or decline (`answering`) is the answered
82
+ * message's hop + 1; a new msg or ask is its relay depth toward `to` + 1 (see the header). A session: its depth + 1.
83
+ */
84
+ async next(userID: string, from: Party, context: HopContext = {}): Promise<number> {
85
+ if (from.kind === "user") return 0
86
+ if (from.kind === "app") return (context.answering ? context.answering.hop : await this.relayDepth(userID, from, context.to)) + 1
87
+ return (await this.depth(userID, from)) + 1
36
88
  }
37
89
 
38
- /** The hop a new message from `from` carries. */
39
- async next(userID: string, from: Party): Promise<number> {
40
- return from.kind === "user" ? 0 : (await this.depth(userID, from)) + 1
90
+ /** The deepest hop sessions other than `to` sent the app within the relay window (0: none; hub parties: 0). */
91
+ async relayDepth(userID: string, app: Party, to?: Party): Promise<number> {
92
+ if (!relays(app)) return 0
93
+ const entry = (await this.state.get<RelayDepth>(userID, RELAYS, partyKey(app)))?.value
94
+ const skip = to?.kind === "session" ? to.sessionID : undefined
95
+ let deepest = 0
96
+ for (const [sessionID, source] of Object.entries(entry?.sources ?? {})) {
97
+ if (sessionID !== skip && this.relayLive(source)) deepest = Math.max(deepest, source.depth)
98
+ }
99
+ return deepest
41
100
  }
42
101
 
43
102
  /** The deepest hop that reached the party since its last human turn (0: none, or forgotten). */
@@ -45,9 +104,13 @@ export class HopTracker {
45
104
  return this.live((await this.state.get<HopDepth>(userID, HOPS, partyKey(party)))?.value)
46
105
  }
47
106
 
48
- /** A message with `hop` reached the party. */
49
- async received(userID: string, to: Party, hop: number): Promise<void> {
50
- if (to.kind === "user") return
107
+ /**
108
+ * A message with `hop` from `from` reached the party. Sessions keep a depth; an app (not a hub party) only
109
+ * remembers, for RELAY_WINDOW_MS, what each session sent it (relay depth); the user keeps nothing.
110
+ */
111
+ async received(userID: string, to: Party, hop: number, from?: Party): Promise<void> {
112
+ if (relays(to) && from?.kind === "session") return this.relayed(userID, to, from.sessionID, hop)
113
+ if (to.kind !== "session") return
51
114
  const at = this.now()
52
115
  await updateVersioned<HopDepth>(this.state, userID, HOPS, partyKey(to), (current) => ({ depth: hop === 0 ? 0 : Math.max(this.live(current), hop), at }))
53
116
  }
@@ -57,6 +120,21 @@ export class HopTracker {
57
120
  await this.state.delete(userID, HOPS, partyKey({ kind: "session", sessionID }))
58
121
  }
59
122
 
123
+ private async relayed(userID: string, app: Party, sessionID: string, hop: number): Promise<void> {
124
+ const at = this.now()
125
+ await updateVersioned<RelayDepth>(this.state, userID, RELAYS, partyKey(app), (current) => {
126
+ const live = Object.entries(current?.sources ?? {}).filter(([id, source]) => id !== sessionID && this.relayLive(source))
127
+ const previous = current?.sources[sessionID]
128
+ const depth = Math.max(previous && this.relayLive(previous) ? previous.depth : 0, hop)
129
+ const kept = live.sort((a, b) => b[1].at - a[1].at).slice(0, RELAY_SOURCES_MAX - 1)
130
+ return { sources: Object.fromEntries([[sessionID, { depth, at }], ...kept]) }
131
+ })
132
+ }
133
+
134
+ private relayLive(source: HopDepth): boolean {
135
+ return this.now() - source.at < this.relayWindowMs
136
+ }
137
+
60
138
  private live(entry: HopDepth | undefined): number {
61
139
  return entry && this.now() - entry.at < HOP_TTL_MS ? entry.depth : 0
62
140
  }
@@ -154,7 +154,13 @@ function decisionFields(body: Body): Omit<AskInput, "question" | "about" | "expi
154
154
  return out as Omit<AskInput, "question" | "about" | "expiresInMs"> & { urgency?: Urgency; tier?: Tier; about?: string[] }
155
155
  }
156
156
 
157
- const sentView = ({ message, delivery }: Sent) => ({ message, ...(delivery ? { delivery } : {}) })
157
+ /** The stored message, how it was delivered and, for a session recipient, whether it woke it (and why not). */
158
+ const sentView = ({ message, delivery, woke, wakeSkipped }: Sent) => ({
159
+ message,
160
+ ...(delivery ? { delivery } : {}),
161
+ ...(woke === undefined ? {} : { woke }),
162
+ ...(wakeSkipped ? { wakeSkipped } : {}),
163
+ })
158
164
 
159
165
  export function messageRoutes(deps: MessageDeps): AgentRoute[] {
160
166
  const { delivery, feed } = deps
@@ -270,8 +276,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
270
276
  if (decisionAsked) throw new AgentError("invalid", "options, recommendation, urgency, tier, deadline, onTimeout and about are for asks to @me")
271
277
  const sent = await send(user.id, actor, to, "ask", text, options, all)
272
278
  if (all) return ok({ sent: sent.map(sentView) }, 201)
273
- const { message, delivery: how } = sent[0]!
274
- return ok({ ...sentView({ message, ...(how ? { delivery: how } : {}) }), ...(await settled(user.id, message, wait, request.signal)) }, 201)
279
+ return ok({ ...sentView(sent[0]!), ...(await settled(user.id, sent[0]!.message, wait, request.signal)) }, 201)
275
280
  },
276
281
  },
277
282
  {
@@ -42,9 +42,10 @@ export type MessageKind = "msg" | "ask" | "reply" | "decline"
42
42
  export type MessageState = "sent" | "open" | "answered" | "declined" | "expired"
43
43
  /**
44
44
  * How a message reached its recipient (design §3.3), recorded by the delivery engine. (CON-10) "dropped": it was
45
- * pending for a session that ended for good (a retired Concierge session) and will never be delivered.
45
+ * pending for a session that ended for good (a retired Concierge session) and will never be delivered. "expired": it
46
+ * was pending for PENDING_MS (its recipient stayed offline or busy) and will never be delivered; its sender was told.
46
47
  */
47
- export type Delivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped"
48
+ export type Delivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped" | "expired"
48
49
 
49
50
  export interface Message {
50
51
  readonly id: string