@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
@@ -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
@@ -25,7 +27,7 @@ import type { BlockerRecommendation } from "../../status"
25
27
  import type { Escalation } from "./acting"
26
28
  import type { AgentActor, UserActor } from "./actor"
27
29
  import type { AgentAudit } from "./audit"
28
- import { type ActOutcome, type Decision, type DecisionActor, type Decisions, WHY_MAX } from "./decisions"
30
+ import { type ActOutcome, type Decision, type DecisionActor, type Decisions, decisionRequest, WHY_MAX } from "./decisions"
29
31
  import { type Party, partyFromActor, partyKey, partyLabel } from "./envelope"
30
32
  import { AgentError } from "./errors"
31
33
  import type { Message } from "./messages"
@@ -150,25 +152,38 @@ 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],
169
176
  },
170
177
  {
171
- action: { kind: "permission", requestID: escalation.requestID, rootID: escalation.rootID, sessionID: escalation.sessionID, always: escalation.always },
178
+ action: {
179
+ kind: "permission",
180
+ requestID: escalation.requestID,
181
+ rootID: escalation.rootID,
182
+ sessionID: escalation.sessionID,
183
+ always,
184
+ ...(escalation.request ? { request: decisionRequest(escalation.request) } : {}),
185
+ ...(escalation.evaluation.reason ? { reason: escalation.evaluation.reason.slice(0, WHY_MAX) } : {}),
186
+ },
172
187
  quiet: pushes > 1,
173
188
  },
174
189
  )
@@ -181,8 +196,8 @@ export class Escalations {
181
196
  private async update(escalation: Escalation, holder: Party, decision: Decision): Promise<{ decisionID: string }> {
182
197
  const why = boundedWhy(escalation.why)
183
198
  let current = decision
184
- if (why && why !== decision.recommendation?.why) {
185
- 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")
186
201
  current = (await this.deps.decisions.revise(escalation.userID, decision.id, { recommendation: { option, why } })) ?? decision
187
202
  }
188
203
  this.showOnBlocker(escalation.requestID, holder, current)
@@ -190,8 +205,11 @@ export class Escalations {
190
205
  }
191
206
 
192
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).
193
209
  const why = decision.recommendation?.why
194
- 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 }
195
213
  this.deps.registry.recommend(requestID, recommendation)
196
214
  }
197
215
 
@@ -203,6 +221,16 @@ export class Escalations {
203
221
  }
204
222
  }
205
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
+
206
234
  /** The agent's reason, cut to what a Decision keeps. */
207
235
  function boundedWhy(why: string | undefined): string | undefined {
208
236
  const trimmed = why?.trim()
@@ -213,7 +241,8 @@ function boundedWhy(why: string | undefined): string | undefined {
213
241
  function question(escalation: Escalation, by: string): string {
214
242
  const { request, evaluation } = escalation
215
243
  const what = request ? `${request.action}${request.resources.length ? `: ${request.resources.join(", ")}` : ""}` : "a permission request (details unknown)"
216
- 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" : ""
217
246
  const text = `${by} asks to approve${always} ${what}. Outside its grant: ${evaluation.reason}`
218
247
  return text.length > QUESTION_MAX ? `${text.slice(0, QUESTION_MAX - 1)}…` : text
219
248
  }
@@ -193,7 +193,13 @@ export function launchEvent(launch: Launch): EventInput {
193
193
  type: "launch.changed",
194
194
  family: "launch",
195
195
  subject: { launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) },
196
- data: { state: launch.state, ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}), ...(launch.name !== undefined ? { name: launch.name } : {}) },
196
+ data: {
197
+ state: launch.state,
198
+ ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
199
+ ...(launch.name !== undefined ? { name: launch.name } : {}),
200
+ // FX-02: a queued launch's wait kind (never its text); the engine journals a change of kind only.
201
+ ...(launch.state === "queued" && launch.waiting ? { wait: launch.waiting.wait } : {}),
202
+ },
197
203
  ...(launch.by !== undefined ? { audience: { by: launch.by } } : {}),
198
204
  }
199
205
  }
@@ -23,12 +23,25 @@
23
23
  // the session's own asks open and none of its Decisions open, unchanged for END_SETTLE_MS. end.when asked also
24
24
  // ends needs_user when a question stays open that long (blocked, or idle with an ask or Decision open); never
25
25
  // ends only by stop or timeout. end.timeout counts from running and stops the session (outcome timeout). A
26
- // session unreported for SESSION_LOST_MS is lost. Registry status events, a tick (END_TICK_MS) and settle/lost
27
- // timers drive it.
26
+ // session unreported for SESSION_LOST_MS is lost. A running launch is lost too when the SAME node instance
27
+ // (instanceID) that reported it leaves it out of its reports for NODE_UNREPORTED_MS (hub.json
28
+ // agents.launches.lostAfterMinutes; 0 or false: never) while its session is not reported by any spoke. A new
29
+ // node instance (a restarted daemon keeps host launches in memory only) never starts that clock for launches it
30
+ // never reported; a disconnected node, the session reported again or the launch reported again starts it over.
31
+ // Registry status events, node events, a tick (END_TICK_MS) and settle/lost timers drive it.
28
32
  // - Ending: stop the session when asked (stop, timeout), collect artifacts (once prompted) and clean the workspace
29
33
  // up on the node (policy: needs_user keeps; never prompted removes; else spec.end.cleanup; the node never removes
30
- // reused, dirty or unpushed work), then the ended step with artifacts. Node away: its cleanup waits in the work
31
- // record and runs when the node reconnects (reconcile).
34
+ // reused, dirty or unpushed work), then the ended step with artifacts. Node away, or the cleanup request failed:
35
+ // its cleanup waits in the work record and runs on reconcile: when the node (re)connects, reports capacity, and
36
+ // on every tick (a failing retry stops after RECONCILE_RETRIES per process and is logged once), so a node never
37
+ // keeps a slot for a launch the hub ended.
38
+ // - Waiting (FX-02): each placement pass's waiting launches get their reason stored (LaunchStore.setWaiting: only
39
+ // on change) and logged once per change: `[hub] launch <id> waits (<wait>): <reason>`. A new wait kind is a launch
40
+ // change (onLaunchChanged: journal, webhooks, SSE); a new text of the same kind (e.g. slot counts) goes only to
41
+ // onLaunchWaiting (the console's live stream).
42
+ // - A lost launch whose session a spoke still reports keeps its workspace (cleanup keep, never remove).
43
+ // - Launchers: view and list flag a launch whose app token was revoked (launcherRevoked; deps.launchers); its
44
+ // launches are not stopped, they keep counting toward maxActive until the user stops them.
32
45
  // - Work records (StateStore LAUNCH_WORK, per launch): fence, workspace, sessionID, prompted, seenWorking, pending
33
46
  // cleanup, so a restarted hub carries on: queued/placing → placement; provisioning → recover (see recover);
34
47
  // running → watched again (seen working and the timeout survive).
@@ -52,7 +65,7 @@ import { AgentError } from "./errors"
52
65
  import { validateLaunchSpec } from "./launch-spec"
53
66
  import type { CleanupPolicy, LaunchSpec } from "./launch-spec"
54
67
  import { type Launch, type LaunchFilter, type LaunchOutcome, type LaunchState, LaunchStore, LaunchTransitionError } from "./launch-store"
55
- import { Placement, PlacementLoop, type Placed, type PlacementNode } from "./placement"
68
+ import { Placement, PlacementLoop, type Placed, type PlacementLaunchers, type PlacementNode, type Waiting } from "./placement"
56
69
 
57
70
  /** The NodeRegistry calls the engine needs. */
58
71
  export type EngineNodes = {
@@ -124,6 +137,10 @@ export interface LaunchEngineDeps {
124
137
  readonly profiles?: { applyAtLaunch(userID: string, sessionID: string, ref: { readonly profileID: string; readonly version: number }): Promise<string[]> }
125
138
  /** (CON-02) Stores the spec's intent as the session's intent (source launch) at identify. */
126
139
  readonly intents?: { storeLaunch(userID: string, sessionID: string, text: string, by?: string): Promise<void> }
140
+ /** (FX-02) A queued launch's waiting text changed, same kind: live only (SSE), no journal or webhook. */
141
+ readonly onLaunchWaiting?: (launch: Launch) => void
142
+ /** (FX-02) Revoked launchers: their launches are flagged (view, list, the active wait); without it none is. */
143
+ readonly launchers?: PlacementLaunchers
127
144
  }
128
145
 
129
146
  /** Node events of every pod (cluster mode; the same as local ones on a single hub). */
@@ -156,6 +173,13 @@ export const END_SETTLE_MS = 30_000
156
173
  export const END_TICK_MS = 15_000
157
174
  /** How long a running launch's session may go unreported before the launch is lost. */
158
175
  export const SESSION_LOST_MS = 10 * 60_000
176
+ /** How long a connected node may leave a running launch out of its reports before it is lost (default). */
177
+ export const NODE_UNREPORTED_MS = 5 * 60_000
178
+ /** setTimeout's longest delay (2^31 − 1 ms); longer ones fire at once. Every engine timer is clamped to it. */
179
+ export const MAX_TIMER_MS = 2 ** 31 - 1
180
+ const clampDelay = (ms: number) => Math.min(Math.max(0, ms), MAX_TIMER_MS)
181
+ /** Reconcile retries of a node cleanup that keeps failing (per launch and process; a node reconnecting tries again). */
182
+ export const RECONCILE_RETRIES = 5
159
183
 
160
184
  /** A provisioning run in this process; stop: a stop was asked for (its reason). */
161
185
  interface Pipeline {
@@ -220,10 +244,16 @@ interface Watch {
220
244
  readonly launchID: string
221
245
  readonly sessionID: string
222
246
  readonly when: LaunchSpec["end"]["when"]
247
+ readonly machineID?: string
223
248
  seenWorking: boolean
224
249
  /** The end it would take and since when that has held. */
225
250
  candidate?: { readonly outcome: LaunchOutcome; readonly since: number }
226
251
  unreportedSince?: number
252
+ /** The node instance that last reported it (only its omission counts). */
253
+ listedBy?: string
254
+ /** Since when that node instance has left it out of its reports (its session unreported too). */
255
+ unlistedSince?: number
256
+ unlistedTimer?: unknown
227
257
  settleTimer?: unknown
228
258
  lostTimer?: unknown
229
259
  timeoutTimer?: unknown
@@ -234,7 +264,7 @@ interface Watch {
234
264
 
235
265
  const defaultTimers: Timers = {
236
266
  set: (fn, ms) => {
237
- const handle = setTimeout(fn, ms)
267
+ const handle = setTimeout(fn, clampDelay(ms))
238
268
  ;(handle as { unref?: () => void }).unref?.()
239
269
  return handle
240
270
  },
@@ -242,7 +272,7 @@ const defaultTimers: Timers = {
242
272
  }
243
273
 
244
274
  /** A launch as the API shows it: the stored launch plus its session's directory. */
245
- export type LaunchView = Launch & { readonly directory?: string }
275
+ export type LaunchView = Launch & { readonly directory?: string; readonly launcherRevoked?: boolean }
246
276
 
247
277
  /** `provider/model#variant` as a ModelSelection. */
248
278
  export function modelSelection(model: string): ModelSelection {
@@ -292,6 +322,13 @@ export class LaunchEngine {
292
322
  private readonly seenEarly = new Set<string>()
293
323
  private readonly timers: Timers
294
324
  private tickTimer: unknown
325
+ /** How long the reporting node instance may omit a running launch (NODE_UNREPORTED_MS, or caps.lostAfterMinutes; undefined: never). */
326
+ private readonly nodeLostMs: number | undefined
327
+ /** Machines being reconciled now. */
328
+ private readonly reconciling = new Set<string>()
329
+ /** Reconcile cleanup retries per launch, and the launches whose failing retry was logged. */
330
+ private readonly cleanupTries = new Map<string, number>()
331
+ private readonly cleanupLogged = new Set<string>()
295
332
  /** start() ran and shutdown() did not: this engine runs placement, pipelines and end watchers. */
296
333
  private supervising = false
297
334
 
@@ -299,6 +336,8 @@ export class LaunchEngine {
299
336
  this.now = deps.now ?? Date.now
300
337
  this.timers = deps.timers ?? defaultTimers
301
338
  this.store = new LaunchStore({ state: deps.state, now: this.now })
339
+ const lostAfter = deps.caps?.lostAfterMinutes
340
+ this.nodeLostMs = lostAfter === false || lostAfter === 0 ? undefined : lostAfter !== undefined ? lostAfter * 60_000 : NODE_UNREPORTED_MS
302
341
  const { registry } = deps
303
342
  this.placement = new Placement({
304
343
  store: this.store,
@@ -314,12 +353,14 @@ export class LaunchEngine {
314
353
  locks: deps.locks,
315
354
  owner: deps.owner,
316
355
  ...(deps.caps ? { caps: deps.caps } : {}),
356
+ ...(deps.launchers ? { launchers: deps.launchers } : {}),
317
357
  now: this.now,
318
358
  })
319
359
  this.loop = new PlacementLoop({
320
360
  placement: this.placement,
321
361
  users: async () => [...(await deps.users())],
322
362
  onPlaced: (placed) => void this.provision(placed),
363
+ onWaiting: (userID, waiting) => this.waited(userID, waiting),
323
364
  ...(deps.timers ? { timers: deps.timers } : {}),
324
365
  })
325
366
  }
@@ -328,7 +369,7 @@ export class LaunchEngine {
328
369
  * Validates and queues a launch (or, same key and body, returns the existing one with created false). Throws
329
370
  * invalid, name_taken (the name is held by a session or another live launch) or rate_limited (caps).
330
371
  */
331
- async accept(userID: string, body: unknown, options: { readonly by?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
372
+ async accept(userID: string, body: unknown, options: { readonly by?: string; readonly byLabel?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
332
373
  const spec = validateLaunchSpec(body)
333
374
  const launches = await this.store.list(userID)
334
375
  const retry = spec.key !== undefined && launches.some((l) => l.spec.key === spec.key)
@@ -336,7 +377,7 @@ export class LaunchEngine {
336
377
  if (spec.name !== undefined) this.checkName(userID, spec.name, launches)
337
378
  await this.placement.admit(userID)
338
379
  }
339
- const result = await this.store.create(userID, spec, options.by !== undefined ? { by: options.by } : {})
380
+ const result = await this.store.create(userID, spec, { ...(options.by !== undefined ? { by: options.by } : {}), ...(options.byLabel ? { byLabel: options.byLabel } : {}) })
340
381
  if (result.created) {
341
382
  await this.placement.recordLaunch(userID)
342
383
  this.changed(result.launch)
@@ -349,19 +390,49 @@ export class LaunchEngine {
349
390
  async view(userID: string, id: string): Promise<LaunchView | undefined> {
350
391
  const launch = await this.store.get(userID, id)
351
392
  if (!launch) return undefined
352
- const directory = (await this.work(userID, id))?.workspace?.directory
353
- return directory !== undefined ? { ...launch, directory } : launch
393
+ return this.viewOf(userID, launch)
394
+ }
395
+
396
+ /** The launch as the user's live stream shows it: flagged when its launcher (app token) was revoked. */
397
+ live(launch: Launch): LaunchView {
398
+ return this.revoked(launch) ? { ...launch, launcherRevoked: true } : launch
399
+ }
400
+
401
+ private revoked(launch: Launch): boolean {
402
+ return launch.by !== undefined && launch.by.startsWith("app:") && this.deps.launchers?.revoked(launch.owner, launch.by) === true
403
+ }
404
+
405
+ /** The stored launch with its directory, flagged when its launcher was revoked. */
406
+ private async viewOf(userID: string, launch: Launch): Promise<LaunchView> {
407
+ const directory = (await this.work(userID, launch.id))?.workspace?.directory
408
+ const revoked = this.revoked(launch)
409
+ return { ...launch, ...(directory !== undefined ? { directory } : {}), ...(revoked ? { launcherRevoked: true } : {}) }
410
+ }
411
+
412
+ /** A placement pass's waiting launches: the reason stored (only when it changed), announced and logged once. */
413
+ private async waited(userID: string, waiting: readonly Waiting[]): Promise<void> {
414
+ for (const { launchID, wait, reason, shared } of waiting) {
415
+ const before = (await this.store.get(userID, launchID))?.waiting?.wait
416
+ const launch = await this.store.setWaiting(userID, launchID, { wait, reason, ...(shared !== undefined ? { shared } : {}) })
417
+ if (!launch) continue
418
+ console.log(`[hub] launch ${launchID} waits (${wait}): ${launch.reason}`)
419
+ if (before !== wait) this.changed(launch)
420
+ else this.waitingText(launch)
421
+ }
422
+ }
423
+
424
+ private waitingText(launch: Launch): void {
425
+ try {
426
+ this.deps.onLaunchWaiting?.(launch)
427
+ } catch (cause) {
428
+ console.error("[hub] launch listener failed:", cause instanceof Error ? cause.message : cause)
429
+ }
354
430
  }
355
431
 
356
432
  /** The user's launches by creation (filtered), with their directories. */
357
433
  async list(userID: string, filter: LaunchFilter = {}): Promise<LaunchView[]> {
358
434
  const launches = await this.store.list(userID, filter)
359
- return Promise.all(
360
- launches.map(async (launch) => {
361
- const directory = (await this.work(userID, launch.id))?.workspace?.directory
362
- return directory !== undefined ? { ...launch, directory } : launch
363
- }),
364
- )
435
+ return Promise.all(launches.map((launch) => this.viewOf(userID, launch)))
365
436
  }
366
437
 
367
438
  /** The labels of the (latest) launch that started the session, or undefined when no launch did. */
@@ -380,8 +451,10 @@ export class LaunchEngine {
380
451
  const { nodes, registry } = this.deps
381
452
  this.unsubscribe.push(
382
453
  nodes.subscribe((event) => {
454
+ // Its running launches: is the node (still) reporting them?
455
+ for (const watch of this.watches.values()) if (watch.machineID === event.node.machineID) this.evaluate(watch)
383
456
  if (event.type === "disconnected" || event.node.ownerID === undefined) return
384
- if (event.type === "connected") void this.reconcile(event.node)
457
+ if (event.type === "connected" || event.type === "capacity") void this.reconcile(event.node, event.type === "connected")
385
458
  void this.loop.kick(event.node.ownerID)
386
459
  }, REMOTE),
387
460
  registry.subscribe((message) => {
@@ -619,15 +692,30 @@ export class LaunchEngine {
619
692
  })
620
693
  }
621
694
 
622
- /** A node (re)connected: it cleans up what it still holds for launches that ended (or that this hub forgot). */
623
- private async reconcile(node: NodeView): Promise<void> {
695
+ /**
696
+ * A node (re)connected, reported capacity, or the tick: it cleans up what it still holds for launches that ended
697
+ * (or that this hub forgot). Outside a connect, a cleanup that keeps failing is retried RECONCILE_RETRIES times.
698
+ */
699
+ private async reconcile(node: NodeView, connected = false): Promise<void> {
624
700
  const userID = node.ownerID
625
- if (userID === undefined) return
701
+ if (userID === undefined || this.reconciling.has(node.machineID)) return
702
+ this.reconciling.add(node.machineID)
703
+ try {
704
+ await this.reconcileNow(node, userID, connected)
705
+ } finally {
706
+ this.reconciling.delete(node.machineID)
707
+ }
708
+ }
709
+
710
+ private async reconcileNow(node: NodeView, userID: string, connected: boolean): Promise<void> {
626
711
  const { nodeOps } = this.deps
627
712
  for (const report of node.launches) {
628
- if (this.pipelines.has(report.launchID)) continue
713
+ if (this.pipelines.has(report.launchID) || this.ending.has(report.launchID)) continue
629
714
  const launch = await this.store.get(userID, report.launchID)
630
715
  if (launch && launch.state !== "ended") continue
716
+ const tries = this.cleanupTries.get(report.launchID) ?? 0
717
+ if (!connected && tries >= RECONCILE_RETRIES) continue
718
+ if (!connected) this.cleanupTries.set(report.launchID, tries + 1)
631
719
  try {
632
720
  if (report.phase === "preparing") {
633
721
  await nodeOps.requestNode(node.connID, { op: "cancel", launchID: report.launchID })
@@ -638,7 +726,11 @@ export class LaunchEngine {
638
726
  if (!workspace) continue
639
727
  if (HARNESS_UP.has(report.phase)) await nodeOps.requestNode(node.connID, { op: "stop", launchID: report.launchID, workspace, mode: "kill" })
640
728
  await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (workspace.reused ? "keep" : "remove") })
729
+ this.cleanupTries.delete(report.launchID)
730
+ this.cleanupLogged.delete(report.launchID)
641
731
  } catch (cause) {
732
+ if (this.cleanupLogged.has(report.launchID)) continue
733
+ this.cleanupLogged.add(report.launchID)
642
734
  console.error(`[hub] launch ${report.launchID}: cleanup on the node failed: ${errorText(cause)}`)
643
735
  }
644
736
  }
@@ -818,14 +910,24 @@ export class LaunchEngine {
818
910
  /** Watches a running launch for its end (timeout, idle or asked, a lost session). */
819
911
  private arm(userID: string, launch: Launch, seenWorking: boolean): void {
820
912
  if (this.watches.has(launch.id) || launch.sessionID === undefined) return
821
- const watch: Watch = { userID, launchID: launch.id, sessionID: launch.sessionID, when: launch.spec.end.when, seenWorking: seenWorking || this.seenEarly.delete(launch.id) }
913
+ const watch: Watch = {
914
+ userID,
915
+ launchID: launch.id,
916
+ sessionID: launch.sessionID,
917
+ when: launch.spec.end.when,
918
+ ...(launch.machineID !== undefined ? { machineID: launch.machineID } : {}),
919
+ seenWorking: seenWorking || this.seenEarly.delete(launch.id),
920
+ }
822
921
  this.watches.set(launch.id, watch)
823
922
  const { timeoutMs } = launch.spec.end
824
923
  if (timeoutMs !== undefined) {
825
- watch.timeoutTimer = this.timers.set(
826
- () => void this.end(userID, launch.id, "timeout", "Timed out", { stopSession: true }),
827
- Math.max(0, launch.at + timeoutMs - this.now()),
828
- )
924
+ // Up to 30 days: past setTimeout's limit the clamped timer fires early and is armed again for the rest.
925
+ const fire = () => {
926
+ const left = launch.at + timeoutMs - this.now()
927
+ if (left > 0) watch.timeoutTimer = this.timers.set(fire, clampDelay(left))
928
+ else void this.end(userID, launch.id, "timeout", "Timed out", { stopSession: true })
929
+ }
930
+ watch.timeoutTimer = this.timers.set(fire, clampDelay(launch.at + timeoutMs - this.now()))
829
931
  }
830
932
  this.evaluate(watch)
831
933
  }
@@ -834,7 +936,7 @@ export class LaunchEngine {
834
936
  const watch = this.watches.get(id)
835
937
  if (!watch) return
836
938
  this.watches.delete(id)
837
- for (const timer of [watch.settleTimer, watch.lostTimer, watch.timeoutTimer]) if (timer !== undefined) this.timers.clear(timer)
939
+ for (const timer of [watch.settleTimer, watch.lostTimer, watch.timeoutTimer, watch.unlistedTimer]) if (timer !== undefined) this.timers.clear(timer)
838
940
  }
839
941
 
840
942
  /** The launch's prompt was seen working: an idle end may start from now on. */
@@ -854,6 +956,7 @@ export class LaunchEngine {
854
956
  private scheduleTick(): void {
855
957
  this.tickTimer = this.timers.set(() => {
856
958
  for (const watch of this.watches.values()) this.evaluate(watch)
959
+ for (const node of this.deps.nodes.list()) void this.reconcile(node)
857
960
  this.scheduleTick()
858
961
  }, END_TICK_MS)
859
962
  }
@@ -884,6 +987,11 @@ export class LaunchEngine {
884
987
  const { registry, delivery, decisions } = this.deps
885
988
  const { userID, sessionID } = watch
886
989
  const now = this.now()
990
+ if (this.unlisted(watch, now)) {
991
+ const minutes = Math.round(this.nodeLostMs! / 60_000)
992
+ await this.end(userID, watch.launchID, "lost", `The node stopped reporting the launch for ${minutes} minute${minutes === 1 ? "" : "s"}`)
993
+ return
994
+ }
887
995
  if (!registry.hasSession(sessionID)) {
888
996
  this.clearCandidate(watch)
889
997
  watch.unreportedSince ??= now
@@ -928,6 +1036,37 @@ export class LaunchEngine {
928
1036
  await this.end(userID, watch.launchID, outcome, outcome === "idle" ? "Idle: the session finished" : "Needs the user: the session asked a question")
929
1037
  }
930
1038
 
1039
+ /**
1040
+ * Whether its connected node has left the launch out of its reports for nodeLostMs (it is lost). A disconnected
1041
+ * node, or the launch in its reports again, starts the time over.
1042
+ */
1043
+ private unlisted(watch: Watch, now: number): boolean {
1044
+ const lostMs = this.nodeLostMs
1045
+ const node = watch.machineID !== undefined ? this.deps.nodes.forMachine(watch.machineID) : undefined
1046
+ const reset = () => {
1047
+ watch.unlistedSince = undefined
1048
+ if (watch.unlistedTimer !== undefined) this.timers.clear(watch.unlistedTimer)
1049
+ watch.unlistedTimer = undefined
1050
+ return false
1051
+ }
1052
+ if (!node) return reset()
1053
+ if (node.launches.some((l) => l.launchID === watch.launchID)) {
1054
+ watch.listedBy = node.instanceID
1055
+ return reset()
1056
+ }
1057
+ // A new instance (a restarted daemon) never reported it; a live session means the launch still runs.
1058
+ if (lostMs === undefined || watch.listedBy !== node.instanceID || this.deps.registry.hasSession(watch.sessionID)) return reset()
1059
+ watch.unlistedSince ??= now
1060
+ if (now - watch.unlistedSince >= lostMs) return true
1061
+ if (watch.unlistedTimer === undefined) {
1062
+ watch.unlistedTimer = this.timers.set(() => {
1063
+ watch.unlistedTimer = undefined
1064
+ this.evaluate(watch)
1065
+ }, clampDelay(watch.unlistedSince + lostMs - now))
1066
+ }
1067
+ return false
1068
+ }
1069
+
931
1070
  private clearCandidate(watch: Watch): void {
932
1071
  watch.candidate = undefined
933
1072
  if (watch.settleTimer !== undefined) this.timers.clear(watch.settleTimer)
@@ -958,7 +1097,9 @@ export class LaunchEngine {
958
1097
  const notes: string[] = []
959
1098
  let artifacts: Record<string, unknown> | undefined
960
1099
  if (work?.workspace) {
961
- const policy: CleanupPolicy = outcome === "needs_user" ? "keep" : !work.prompted ? "remove" : launch.spec.end.cleanup
1100
+ // A lost launch whose session a spoke still reports keeps its workspace (never removed under a live session).
1101
+ const sessionLive = outcome === "lost" && work.sessionID !== undefined && this.deps.registry.hasSession(work.sessionID)
1102
+ const policy: CleanupPolicy = outcome === "needs_user" || sessionLive ? "keep" : !work.prompted ? "remove" : launch.spec.end.cleanup
962
1103
  const node = launch.machineID !== undefined ? this.deps.nodes.forMachine(launch.machineID) : undefined
963
1104
  if (node) {
964
1105
  if (work.started) {
@@ -980,7 +1121,9 @@ export class LaunchEngine {
980
1121
  const cleaned = await this.deps.nodeOps.requestNode(node.connID, { op: "cleanup", launchID: id, workspace: work.workspace, policy })
981
1122
  notes.push(cleaned.removed ? "workspace removed" : `workspace kept${cleaned.reason ? ` (${cleaned.reason})` : ""}`)
982
1123
  } catch (cause) {
983
- notes.push(`workspace cleanup failed: ${errorText(cause)}`)
1124
+ // The node may still hold the launch (and its slot): reconcile retries with this policy.
1125
+ notes.push(`workspace cleanup failed: ${errorText(cause)}; retried later`)
1126
+ await this.updateWork(userID, id, { cleanup: policy })
984
1127
  }
985
1128
  } else {
986
1129
  notes.push("workspace cleanup waits for the node")
@@ -4,7 +4,8 @@
4
4
  // POST /api/agent/launches {launch spec} → 201 {launch}; an idempotent repeat (same key and
5
5
  // body) → 200 {launch}; same key, other body → 400
6
6
  // GET /api/agent/launches?state=a,b&label=k=v → {launches} (oldest first; every label must match)
7
- // GET /api/agent/launches/:id → {launch}
7
+ // GET /api/agent/launches/:id → {launch} (queued: `waiting` {wait, reason, since}, and `reason` is
8
+ // the waiting reason; launcherRevoked: its app token was revoked)
8
9
  // DELETE /api/agent/launches/:id → 200 {launch} (ended stopped); already ended → 410 gone
9
10
  //
10
11
  // Who: users always (their own launches; tenancy only). App tokens with scope sessions:launch and sessions through
@@ -85,16 +86,25 @@ function wireSpec(spec: LaunchSpec): WireSpec {
85
86
  return { ...spec, end: { ...end, ...(timeoutMs !== undefined ? { timeout: duration(timeoutMs) } : {}) } }
86
87
  }
87
88
 
88
- /** The API view of a launch: never its owner; `updated` is when it entered its state. */
89
- export function launchInfo(launch: LaunchView): LaunchApiView {
89
+ /**
90
+ * The API view of a launch: never its owner; `updated` is when it entered its state. forLauncher (an app or session
91
+ * reading its own launch): a waiting reason in its launcher-safe form (counts only, no other launchers or machine
92
+ * names); the user gets the full text.
93
+ */
94
+ export function launchInfo(launch: LaunchView, options: { readonly forLauncher?: boolean } = {}): LaunchApiView {
95
+ const waiting = launch.state === "queued" ? launch.waiting : undefined
96
+ const waitText = waiting ? (options.forLauncher ? (waiting.shared ?? waiting.reason) : waiting.reason) : undefined
90
97
  return {
91
98
  id: launch.id,
92
99
  state: launch.state,
93
- reason: launch.reason,
100
+ reason: waitText ?? launch.reason,
94
101
  spec: wireSpec(launch.spec),
95
102
  created: launch.created,
96
103
  updated: launch.at,
97
104
  ...(launch.by !== undefined ? { by: launch.by } : {}),
105
+ ...(launch.byLabel !== undefined ? { byLabel: launch.byLabel } : {}),
106
+ ...(launch.launcherRevoked ? { launcherRevoked: true } : {}),
107
+ ...(waiting ? { waiting: { wait: waiting.wait, reason: waitText!, since: waiting.since } } : {}),
98
108
  ...(launch.machineID !== undefined ? { machineID: launch.machineID } : {}),
99
109
  ...(launch.directory !== undefined ? { directory: launch.directory } : {}),
100
110
  ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}),
@@ -203,6 +213,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
203
213
  throw new AgentError("forbidden", "A launch's agentProfile needs scope profiles:assign; sessions cannot pin profiles")
204
214
  }
205
215
  const by = launchedBy(actor)
216
+ const byLabel = actor.kind === "app" ? actor.label : undefined
206
217
  const held = spec.key === undefined ? undefined : (await launches.list(user.id)).find((l) => l.spec.key === spec.key)
207
218
  let launchID: string | undefined
208
219
  const result = await act(user.id, actor, "launch", spec.workspace.repo, {
@@ -212,12 +223,12 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
212
223
  run: async () => {
213
224
  // An agent never gets another launcher's launch through its key.
214
225
  if (held && actor.kind !== "user" && held.by !== by) throw new AgentError("invalid", "This key was used by another launcher", { hint: "Use a key of your own" })
215
- const accepted = await launches.accept(user.id, raw, { by })
226
+ const accepted = await launches.accept(user.id, raw, { by, ...(byLabel ? { byLabel } : {}) })
216
227
  launchID = accepted.launch.id
217
228
  return accepted
218
229
  },
219
230
  })
220
- return Response.json({ launch: launchInfo(result.launch) }, { status: result.created ? 201 : 200, headers: NO_STORE })
231
+ return Response.json({ launch: launchInfo(result.launch, { forLauncher: actor.kind !== "user" }) }, { status: result.created ? 201 : 200, headers: NO_STORE })
221
232
  },
222
233
  },
223
234
  {
@@ -228,7 +239,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
228
239
  const filter = filterOf(url.searchParams)
229
240
  const by = actor.kind === "user" ? undefined : launchedBy(actor)
230
241
  const list = (await launches.list(user.id, filter)).filter((l) => by === undefined || l.by === by)
231
- return Response.json({ launches: list.map(launchInfo) }, { headers: NO_STORE })
242
+ return Response.json({ launches: list.map((l) => launchInfo(l, { forLauncher: actor.kind !== "user" })) }, { headers: NO_STORE })
232
243
  },
233
244
  },
234
245
  {
@@ -236,7 +247,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
236
247
  path: `${PATH}/:id`,
237
248
  allow: LAUNCH,
238
249
  async handle({ params, actor, user }) {
239
- return Response.json({ launch: launchInfo(await own(user.id, actor, params.id!)) }, { headers: NO_STORE })
250
+ return Response.json({ launch: launchInfo(await own(user.id, actor, params.id!), { forLauncher: actor.kind !== "user" }) }, { headers: NO_STORE })
240
251
  },
241
252
  },
242
253
  {
@@ -250,7 +261,7 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
250
261
  spend: async () => true,
251
262
  run: () => launches.stop(user.id, launch.id),
252
263
  })
253
- return Response.json({ launch: launchInfo(stopped) }, { headers: NO_STORE })
264
+ return Response.json({ launch: launchInfo(stopped, { forLauncher: actor.kind !== "user" }) }, { headers: NO_STORE })
254
265
  },
255
266
  },
256
267
  ]