@huynle/hub 0.8.0 → 0.8.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 (112) hide show
  1. package/CHANGELOG.md +213 -1
  2. package/README.md +84 -15
  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 +649 -242
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  9. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  10. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  11. package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
  12. package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
  13. package/packages/cli/src/cli.ts +3 -3
  14. package/packages/cli/src/http.ts +1 -0
  15. package/packages/cli/src/launches.ts +9 -3
  16. package/packages/cli/src/login.ts +2 -2
  17. package/packages/cli/src/mcp.ts +7 -3
  18. package/packages/cli/src/messaging.ts +4 -2
  19. package/packages/cli/src/profiles-mcp.ts +301 -0
  20. package/packages/cli/src/setup/commands.ts +20 -2
  21. package/packages/cli/src/spoke-services.ts +1 -1
  22. package/packages/cli/src/teams-mcp.ts +59 -2
  23. package/packages/codex/package.json +1 -1
  24. package/packages/codex/src/paths.ts +3 -3
  25. package/packages/node/package.json +1 -1
  26. package/packages/node/src/config.ts +10 -2
  27. package/packages/node/src/opencode-warm.ts +27 -8
  28. package/packages/node/src/run.ts +3 -2
  29. package/packages/preset/package.json +1 -1
  30. package/packages/protocol/package.json +1 -1
  31. package/packages/protocol/src/index.ts +7 -1
  32. package/packages/protocol/src/launch.ts +14 -0
  33. package/packages/runtime-openshell/package.json +1 -1
  34. package/packages/runtime-sdk/package.json +1 -1
  35. package/packages/sdk/package.json +1 -1
  36. package/packages/sdk/src/errors.ts +1 -0
  37. package/packages/sdk/src/index.ts +1 -0
  38. package/packages/sdk/src/types.ts +22 -2
  39. package/packages/sdk/src/wake.ts +20 -0
  40. package/packages/spoke-sdk/package.json +1 -1
  41. package/src/central/agent/acting.ts +3 -0
  42. package/src/central/agent/audit.ts +1 -1
  43. package/src/central/agent/decisions.ts +58 -2
  44. package/src/central/agent/delivery.ts +101 -27
  45. package/src/central/agent/errors.ts +2 -0
  46. package/src/central/agent/escalation.ts +10 -2
  47. package/src/central/agent/events.ts +7 -1
  48. package/src/central/agent/launch-engine.ts +173 -30
  49. package/src/central/agent/launch-routes.ts +20 -9
  50. package/src/central/agent/launch-spec.ts +26 -2
  51. package/src/central/agent/launch-store.ts +55 -2
  52. package/src/central/agent/message-limits.ts +89 -11
  53. package/src/central/agent/message-routes.ts +8 -3
  54. package/src/central/agent/messages.ts +3 -2
  55. package/src/central/agent/placement.ts +93 -18
  56. package/src/central/agent/read-routes.ts +3 -0
  57. package/src/central/agent/routes.ts +4 -2
  58. package/src/central/agent/webhooks.ts +17 -2
  59. package/src/central/artifacts/jobs-routes.ts +4 -1
  60. package/src/central/concierge/board-routes.ts +13 -2
  61. package/src/central/concierge/inbound.ts +4 -7
  62. package/src/central/concierge/prompt.ts +4 -3
  63. package/src/central/concierge/proposals.ts +8 -2
  64. package/src/central/concierge/routes.ts +62 -2
  65. package/src/central/concierge/session-routes.ts +62 -6
  66. package/src/central/concierge/supervisor.ts +34 -7
  67. package/src/central/concierge/watchlist-routes.ts +4 -2
  68. package/src/central/config.ts +21 -5
  69. package/src/central/http.ts +38 -15
  70. package/src/central/main.ts +1 -1
  71. package/src/central/registry.ts +26 -1
  72. package/src/central/teams/intake.ts +20 -6
  73. package/src/central/teams/staffing.ts +22 -0
  74. package/src/central/workspaces/sidechats-routes.ts +1 -1
  75. package/src/index.ts +39 -18
  76. package/src/migrate.ts +109 -3
  77. package/src/spoke/env-inject.ts +8 -1
  78. package/src/spoke/hub-tool.ts +24 -10
  79. package/src/spoke/launchers.ts +6 -4
  80. package/src/spoke/listing.ts +18 -6
  81. package/src/spoke/localhub.ts +14 -4
  82. package/src/spoke/locations.ts +40 -0
  83. package/src/spoke/session-context.ts +107 -27
  84. package/src/spoke/spoke.ts +13 -5
  85. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  86. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  87. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  88. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  89. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  90. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  91. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  92. package/web/dist/assets/index-BabrNDB3.css +1 -0
  93. package/web/dist/assets/index-lIOfSYck.js +34 -0
  94. package/web/dist/assets/live-BDq0AABg.js +5 -0
  95. package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
  96. package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DAr4IHM8.js} +1 -1
  97. package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-DWZj595u.js} +1 -1
  98. package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-JUb6OeI3.js} +1 -1
  99. package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
  100. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  101. package/web/dist/index.html +4 -4
  102. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
  103. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
  104. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
  105. package/web/dist/assets/Library-CtxRqTjo.js +0 -2
  106. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
  107. package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
  108. package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
  109. package/web/dist/assets/index-DaH5GUdV.js +0 -31
  110. package/web/dist/assets/index-WWuKfWxL.css +0 -1
  111. package/web/dist/assets/live-YyTjE47g.js +0 -5
  112. package/web/dist/assets/ui-C9Igxzjm.js +0 -1
@@ -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
@@ -5,7 +5,7 @@
5
5
  // announcing "launch" (each harness has its own service spoke: a Claude Code one never serves an OpenCode launch),
6
6
  // and for openshell launches the node feature "sandbox" (it starts and stops the harness itself; the sandbox's
7
7
  // launch-bound spoke creates the session, so no service spoke is needed)), then the placement's machine and labels (mode require: hard; prefer: ranking only), then
8
- // free slots (capacity minus used minus reservations). The best node has the preference, then most free slots,
8
+ // free slots (capacity minus used minus reservations; a capacity wait gives each full node's counts). The best node has the preference, then most free slots,
9
9
  // then the lowest machine ID. A launch that cannot be placed waits for capacity (a fitting node is full) or for a
10
10
  // match (no node fits; the reason names each node's first problem).
11
11
  // - Admission (per user, hub.json agents.launches): admit() refuses a new launch with rate_limited while maxQueued
@@ -17,13 +17,17 @@
17
17
  // the end. A pass first returns launches left placing by a dead holder to the queue, then walks queued launches
18
18
  // oldest first: while fewer than maxActive are placing/provisioning/running, each is matched against the
19
19
  // user's nodes and moved queued → placing → provisioning on the machine. A launch that cannot be placed now is
20
- // reported as waiting and does not hold back later ones; earlier launches always pick first. Reservations are
20
+ // reported as waiting and does not hold back later ones; earlier launches always pick first. The active wait names
21
+ // who holds the cap (counts by launcher label; a revoked app token's launches are flagged). The Concierge's own
22
+ // launch (by app:concierge with label hub/system=concierge) neither counts toward maxActive nor waits for it; it
23
+ // still takes a node slot. Reservations are
21
24
  // launches provisioning or running on a machine that its node does not report (yet); slots taken in the pass
22
25
  // count at once. Reasons go on the launch only when it moves; waiting reasons are returned for the engine.
23
26
  // - PlacementLoop runs passes: kick(user) when something changed (a launch queued or ended, a node connected or
24
27
  // reported capacity, a service spoke came), coalescing kicks during a pass into one more, plus a sweep over
25
28
  // users() every intervalMs for whatever a kick missed (e.g. another process held the lease). Each placed launch
26
- // goes to onPlaced (the launch engine starts provisioning); failures go to onError and never stop the loop.
29
+ // goes to onPlaced (the launch engine starts provisioning), then the pass's waiting launches to onWaiting (the
30
+ // engine stores the reason on the launch); failures go to onError and never stop the loop.
27
31
  // - Node and spoke info come through PlacementNodes / PlacementSpokes, which the launch engine adapts from the
28
32
  // NodeRegistry and the Registry; memory is not matched (a spec has no memory need yet).
29
33
  import type { NodeCapacity, NodeRuntimeInfo } from "../../protocol"
@@ -62,7 +66,7 @@ export interface Candidate extends PlacementNode {
62
66
 
63
67
  export type Match =
64
68
  | { readonly ok: true; readonly node: PlacementNode; readonly preferred: boolean }
65
- | { readonly ok: false; readonly wait: "capacity" | "no_match"; readonly reason: string }
69
+ | { readonly ok: false; readonly wait: "capacity" | "no_match"; readonly reason: string; readonly shared?: string }
66
70
 
67
71
  const label = (node: PlacementNode) => node.name ?? node.machineID
68
72
 
@@ -102,13 +106,13 @@ export function matchNode(userID: string, spec: LaunchSpec, candidates: readonly
102
106
  const own = candidates.filter((node) => node.owner === userID)
103
107
  if (own.length === 0) return { ok: false, wait: "no_match", reason: "No node is connected" }
104
108
  const require = spec.placement.mode === "require"
105
- const problems: string[] = []
109
+ const problems: { node: string; problem: string }[] = []
106
110
  const full: Candidate[] = []
107
111
  const free: { node: Candidate; preferred: boolean }[] = []
108
112
  for (const node of own) {
109
113
  const placement = placementProblem(spec, node)
110
114
  const problem = hardProblem(spec, node) ?? (require ? placement : undefined)
111
- if (problem) problems.push(`${label(node)} ${problem}`)
115
+ if (problem) problems.push({ node: label(node), problem })
112
116
  else if (freeSlots(node) <= 0) full.push(node)
113
117
  else free.push({ node, preferred: placement === undefined })
114
118
  }
@@ -122,8 +126,17 @@ export function matchNode(userID: string, spec: LaunchSpec, candidates: readonly
122
126
  const best = free[0]!
123
127
  return { ok: true, node: best.node, preferred: best.preferred }
124
128
  }
125
- if (full.length > 0) return { ok: false, wait: "capacity", reason: `Waiting for a free slot on ${full.map(label).join(", ")}` }
126
- return { ok: false, wait: "no_match", reason: `No node fits: ${problems.join("; ")}` }
129
+ if (full.length > 0) {
130
+ const used = (node: Candidate) => `${node.used.slots}/${node.capacity.slots} used, ${node.reserved} reserved`
131
+ return {
132
+ ok: false,
133
+ wait: "capacity",
134
+ reason: `Waiting for a free slot on ${full.map((node) => `${label(node)} (${used(node)})`).join(", ")}`,
135
+ // An app or session may not see the user's machines: no names.
136
+ shared: full.length === 1 ? `Waiting for a free slot on the fitting machine (${used(full[0]!)})` : `Waiting for a free slot on ${full.length} fitting machines (all full)`,
137
+ }
138
+ }
139
+ return { ok: false, wait: "no_match", reason: `No node fits: ${problems.map((p) => `${p.node} ${p.problem}`).join("; ")}`, shared: `No machine fits: ${[...new Set(problems.map((p) => p.problem))].join("; ")}` }
127
140
  }
128
141
 
129
142
 
@@ -157,6 +170,27 @@ export interface PlacementSpokes {
157
170
  launchHarnesses(userID: string, machineID: string): readonly string[] | Promise<readonly string[]>
158
171
  }
159
172
 
173
+ /** Who launched what (the launch engine adapts the user's app tokens). */
174
+ export interface PlacementLaunchers {
175
+ /** Whether the launcher (`app:<token id>`) was revoked: its launches still count, flagged. */
176
+ revoked(userID: string, by: string): boolean
177
+ }
178
+
179
+ /** The hub's own Concierge party (packages/protocol concierge.ts CONCIERGE_PARTY) and its launch label. */
180
+ const CONCIERGE_BY = "app:concierge"
181
+ /** The Concierge's own launch: neither counts toward nor waits for maxActive (it still takes a node slot). */
182
+ export const isSystemLaunch = (launch: Pick<Launch, "by" | "spec">): boolean => launch.by === CONCIERGE_BY && launch.spec.labels["hub/system"] === "concierge"
183
+
184
+ /** A launcher as a waiting reason names it: you, app "label" (or its token ID), a session's label or ID. */
185
+ function launcherText(launch: Pick<Launch, "by" | "byLabel">, revoked: boolean): string {
186
+ const { by, byLabel } = launch
187
+ let text: string
188
+ if (by === undefined || by === "user") text = "you"
189
+ else if (by.startsWith("app:")) text = byLabel ? `app ${JSON.stringify(byLabel)}` : `app ${by.slice(4)}`
190
+ else text = byLabel ?? by
191
+ return revoked ? `${text} (launcher revoked)` : text
192
+ }
193
+
160
194
  export interface PlacementDeps {
161
195
  readonly store: LaunchStore
162
196
  readonly nodes: PlacementNodes
@@ -168,6 +202,8 @@ export interface PlacementDeps {
168
202
  readonly caps?: HubLaunches
169
203
  readonly now?: Clock
170
204
  readonly leaseMs?: number
205
+ /** Revoked launchers (flagged in the active wait); without it none is. */
206
+ readonly launchers?: PlacementLaunchers
171
207
  }
172
208
 
173
209
  /** A launch the pass placed: it is now provisioning on the node's machine. */
@@ -184,7 +220,10 @@ export interface Waiting {
184
220
  readonly launchID: string
185
221
  /** capacity: a fitting node is full; no_match: no node fits; active: the user's maxActive is reached. */
186
222
  readonly wait: "capacity" | "no_match" | "active"
223
+ /** For the user: names other launchers and machines. */
187
224
  readonly reason: string
225
+ /** For the launch's own launcher (app or session), when it differs: counts only, no names. */
226
+ readonly shared?: string
188
227
  }
189
228
 
190
229
  /** One pass. leased false: another holder has the user's lease, or it was lost mid-pass (placed: before that). */
@@ -251,16 +290,18 @@ export class Placement {
251
290
  launches = await store.list(userID)
252
291
  const queued = launches.filter((l) => l.state === "queued")
253
292
  if (queued.length === 0) return { leased: true, placed, waiting }
254
- let active = launches.filter((l) => ACTIVE.includes(l.state)).length
293
+ const counted = launches.filter((l) => ACTIVE.includes(l.state) && !isSystemLaunch(l))
294
+ let active = counted.length
255
295
  const candidates = await this.candidates(userID, launches)
256
296
  for (const launch of queued) {
257
- if (active >= this.caps.maxActive) {
258
- waiting.push({ launchID: launch.id, wait: "active", reason: `Waiting: ${active} launches are active (the most per user)` })
297
+ const system = isSystemLaunch(launch)
298
+ if (!system && active >= this.caps.maxActive) {
299
+ waiting.push({ launchID: launch.id, wait: "active", ...this.activeReason(userID, counted, launch) })
259
300
  continue
260
301
  }
261
302
  const match = matchNode(userID, launch.spec, candidates)
262
303
  if (!match.ok) {
263
- waiting.push({ launchID: launch.id, wait: match.wait, reason: match.reason })
304
+ waiting.push({ launchID: launch.id, wait: match.wait, reason: match.reason, ...(match.shared !== undefined ? { shared: match.shared } : {}) })
264
305
  continue
265
306
  }
266
307
  const name = match.node.name ?? match.node.machineID
@@ -268,14 +309,21 @@ export class Placement {
268
309
  const placing = await skipGone(store.transition(userID, launch.id, "placing", { reason: `Placing on ${name}`, from: ["queued"] }))
269
310
  if (!placing) continue
270
311
  if (!(await held())) return lost()
271
- const provisioning = await store.transition(userID, launch.id, "provisioning", {
272
- reason: `Placed on ${name}${match.preferred ? "" : " (preferred placement not available)"}`,
273
- machineID: match.node.machineID,
274
- from: ["placing"],
275
- })
312
+ // A stop between the two steps (e.g. the Concierge reaping an orphan) wins: skipped, not an error.
313
+ const provisioning = await skipGone(
314
+ store.transition(userID, launch.id, "provisioning", {
315
+ reason: `Placed on ${name}${match.preferred ? "" : " (preferred placement not available)"}`,
316
+ machineID: match.node.machineID,
317
+ from: ["placing"],
318
+ }),
319
+ )
320
+ if (!provisioning) continue
276
321
  const index = candidates.findIndex((c) => c.machineID === match.node.machineID)
277
322
  candidates[index] = { ...candidates[index]!, reserved: candidates[index]!.reserved + 1 }
278
- active++
323
+ if (!system) {
324
+ active++
325
+ counted.push(provisioning)
326
+ }
279
327
  placed.push({ userID, launch: provisioning, node: match.node, preferred: match.preferred, fence: lease.fence })
280
328
  }
281
329
  return { leased: true, placed, waiting }
@@ -284,6 +332,25 @@ export class Placement {
284
332
  }
285
333
  }
286
334
 
335
+ /**
336
+ * The user's text names who holds the cap, most first ("Waiting: 4 of 4 active launches: 3 by app "ci", 1 by you;
337
+ * …"); the launcher's own text gives counts only: its own launches and the others'.
338
+ */
339
+ private activeReason(userID: string, active: readonly Launch[], waiting: Launch): { reason: string; shared: string } {
340
+ const own = active.filter((l) => (l.by ?? "user") === (waiting.by ?? "user")).length
341
+ const others = active.length - own
342
+ const split = [own ? `${own} yours` : undefined, others ? `${others} by others` : undefined].filter(Boolean).join(", ")
343
+ const shared = `Waiting: ${active.length} of ${this.caps.maxActive} active launches (${split}); ask the user to stop one or raise agents.launches.maxActive`
344
+ const counts = new Map<string, number>()
345
+ for (const launch of active) {
346
+ const revoked = launch.by !== undefined && launch.by.startsWith("app:") && this.deps.launchers?.revoked(userID, launch.by) === true
347
+ const text = launcherText(launch, revoked)
348
+ counts.set(text, (counts.get(text) ?? 0) + 1)
349
+ }
350
+ const holders = [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)).map(([text, n]) => `${n} by ${text}`)
351
+ return { reason: `Waiting: ${active.length} of ${this.caps.maxActive} active launches: ${holders.join(", ")}; stop one or raise agents.launches.maxActive`, shared }
352
+ }
353
+
287
354
  /** The user's nodes with their service spoke and reservations (placed launches the node does not report yet). */
288
355
  private async candidates(userID: string, launches: readonly Launch[]): Promise<Candidate[]> {
289
356
  const nodes = (await this.deps.nodes.nodes(userID)).filter((node) => node.owner === userID)
@@ -323,6 +390,8 @@ export interface PlacementLoopDeps {
323
390
  readonly users: () => Promise<readonly string[]>
324
391
  /** The engine takes a placed (provisioning) launch from here. */
325
392
  readonly onPlaced: (placed: Placed) => void | Promise<void>
393
+ /** The launches the pass could not place, with why (only for a pass that held the user's lease). */
394
+ readonly onWaiting?: (userID: string, waiting: readonly Waiting[]) => void | Promise<void>
326
395
  readonly onError?: (cause: unknown, userID?: string) => void
327
396
  readonly timers?: Timers
328
397
  readonly intervalMs?: number
@@ -415,5 +484,11 @@ export class PlacementLoop {
415
484
  this.onError(cause, userID)
416
485
  }
417
486
  }
487
+ if (!result.leased || !this.deps.onWaiting) return
488
+ try {
489
+ await this.deps.onWaiting(userID, result.waiting)
490
+ } catch (cause) {
491
+ this.onError(cause, userID)
492
+ }
418
493
  }
419
494
  }
@@ -352,6 +352,8 @@ function sessionFilter(deps: ReadDeps, userID: string, params: URLSearchParams):
352
352
  const q = params.get("q") ?? ""
353
353
  if (q.length > QUERY_MAX) throw new AgentError("invalid", `q is at most ${QUERY_MAX} characters`)
354
354
  const wanted = words(q)
355
+ // (FX-07) A q with no letters or digits ("@", "«»", "?") names no word: it matches nothing (never every session).
356
+ const nothing = q.trim() !== "" && wanted.length === 0
355
357
  const states = params.get("state")?.split(",").filter(Boolean)
356
358
  if (states?.some((state) => !STATES.has(state))) throw new AgentError("invalid", "state must be idle, working, blocked or error (comma-separated)")
357
359
  const machine = params.get("machine") || undefined
@@ -362,6 +364,7 @@ function sessionFilter(deps: ReadDeps, userID: string, params: URLSearchParams):
362
364
  return [label.slice(0, at), label.slice(at + 1)] as const
363
365
  })
364
366
  return (row, view) => {
367
+ if (nothing) return false
365
368
  const { root, name } = projectOf(row.session)
366
369
  if (project !== undefined && root !== project && name.toLowerCase() !== project.toLowerCase()) return false
367
370
  if (states?.length && !states.includes(view.state)) return false
@@ -53,7 +53,7 @@ import type { ActivityTracker } from "../concierge/activity"
53
53
  import { boardRoutes as conciergeBoardRoutes } from "../concierge/board-routes"
54
54
  import type { ResultTracker } from "../concierge/board-results"
55
55
  import { type ConciergeIdentity, watchlistRoutes } from "../concierge/watchlist-routes"
56
- import { conciergeRoutes } from "../concierge/routes"
56
+ import { conciergeRoutes, type MachineModel } from "../concierge/routes"
57
57
  import { guardConciergeRoutes } from "../concierge/guard"
58
58
  import { isConciergeLaunch } from "../concierge/supervisor"
59
59
  import type { ConciergeService } from "../concierge/service"
@@ -166,6 +166,8 @@ export interface AgentDeps {
166
166
  readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
167
167
  /** (CON-04) The Concierge service: /api/agent/concierge settings, enable/disable/restart and notes. */
168
168
  readonly service?: ConciergeService
169
+ /** (FX-04) A machine's OpenCode model list (routes.ts machineModels): GET …/concierge/machines/:id/models. */
170
+ readonly models?: (machineID: string) => Promise<MachineModel[]>
169
171
  /** (CON-06 and later) More Concierge routes (the tiers slot). */
170
172
  readonly routes?: () => AgentRoute[]
171
173
  /** (CON-06) The Decision armed now by the current Concierge (the board's `armed` flag). */
@@ -256,7 +258,7 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
256
258
  ? conciergeBoardRoutes({ ...deps.concierge, directory: deps.directory, store: deps.store, registry: deps.registry, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.intents ? { intents: deps.intents } : {}), ...launchReads(deps), ...(deps.now ? { now: deps.now } : {}) })
257
259
  : []),
258
260
  // CON-04: settings, enable/disable/restart and notes; CON-06's routes through the tiers slot.
259
- ...(deps.concierge?.service && deps.concierge.identity ? conciergeRoutes({ service: deps.concierge.service, identity: deps.concierge.identity }) : []),
261
+ ...(deps.concierge?.service && deps.concierge.identity ? conciergeRoutes({ service: deps.concierge.service, identity: deps.concierge.identity, ...(deps.concierge.models ? { models: deps.concierge.models } : {}) }) : []),
260
262
  ...(deps.concierge?.routes ? deps.concierge.routes() : []),
261
263
  ]
262
264
  // HWA-13: side chats read, launch and message through the routes above, as the caller.
@@ -39,6 +39,18 @@ export const CANONICAL_WEBHOOK_EVENT_TYPES: readonly string[] = [
39
39
  "blocker.opened", "blocker.closed", "machine.changed", "role.claimed", "role.released",
40
40
  "message.changed", "launch.changed",
41
41
  ]
42
+ /** A launch.changed event for a queued launch's new wait kind (not a state step). */
43
+ export const isWaitChange = (event: Pick<AgentEvent, "type" | "data">): boolean => event.type === "launch.changed" && event.data.wait !== undefined
44
+
45
+ /**
46
+ * The legacy `launch.<state>` type a canonical launch.changed event also matches; none for a queued launch's
47
+ * change of wait kind (FX-02), so launch.queued still fires once per launch, as before.
48
+ */
49
+ export function legacyLaunchType(event: Pick<AgentEvent, "type" | "data">): string | undefined {
50
+ if (event.type !== "launch.changed" || typeof event.data.state !== "string" || isWaitChange(event)) return undefined
51
+ return `launch.${event.data.state}`
52
+ }
53
+
42
54
  /** Includes persisted pre-canonical launch registrations for rolling rollback compatibility. */
43
55
  export const WEBHOOK_EVENT_TYPES: readonly string[] = [...CANONICAL_WEBHOOK_EVENT_TYPES, ...LAUNCH_STATES.map((state) => `launch.${state}`)]
44
56
  export const DEFAULT_WEBHOOK_EVENTS: readonly string[] = ["launch.*"]
@@ -336,11 +348,14 @@ export class Webhooks {
336
348
  }
337
349
 
338
350
  publishCanonical(userID: string, event: AgentEvent, _family: EventFamily, audience?: EventAudience): Promise<number> {
339
- const legacyLaunchType = event.type === "launch.changed" && typeof event.data.state === "string" ? `launch.${event.data.state}` : undefined
351
+ // (FX-02) A queued launch's new wait kind is journaled (agent events, watch) but sent to no webhook: webhooks keep
352
+ // one delivery per launch state, as before.
353
+ if (isWaitChange(event)) return Promise.resolve(0)
354
+ const legacy = legacyLaunchType(event)
340
355
  return this.publish(userID, canonicalWebhookEvent(this.deps.source, userID, event), {
341
356
  ...(audience?.by ? { by: audience.by } : {}),
342
357
  ...(audience?.parties ? { owners: audience.parties.filter((party) => party.startsWith("app:")) } : {}),
343
- ...(legacyLaunchType ? { matchTypes: [event.type, legacyLaunchType] } : {}),
358
+ ...(legacy ? { matchTypes: [event.type, legacy] } : {}),
344
359
  })
345
360
  }
346
361
 
@@ -300,13 +300,16 @@ export function artifactJobRoutes(deps: ArtifactJobRoutesDeps): AgentRoute[] {
300
300
  launchID: launch.id,
301
301
  status: ended ? "ended" : sessionID ? "ready" : "waiting",
302
302
  launchState: launch.state,
303
- reason: launch.reason,
303
+ // A job may be read by apps and sessions: a waiting reason in its launcher-safe form (FX-02).
304
+ reason: (launch.state === "queued" ? launch.waiting?.shared : undefined) ?? launch.reason,
304
305
  ...(launch.outcome ? { outcome: launch.outcome } : {}),
305
306
  ...(sessionID ? { sessionID } : {}),
306
307
  }
307
308
  if (expired) return fail(userID, job.id, "The job timed out", undefined, next, sessionID)
308
309
  if (ended) return fail(userID, job.id, `The handler launch ended (${launch.outcome ?? "unknown"}) without a result`, undefined, next, sessionID)
309
310
  if (digestOf(next) === digestOf(handler) && job.launchID === launch.id) return job
311
+ // (FX-02) A queued launch's waiting text changes with node slot counts: shown live, without a new job revision.
312
+ if (digestOf({ ...next, reason: "" }) === digestOf({ ...handler, reason: "" }) && job.launchID === launch.id) return { ...job, handler: next }
310
313
  return jobs.update(userID, job.id, (current) =>
311
314
  isTerminal(current)
312
315
  ? undefined
@@ -319,7 +319,7 @@ export function boardRoutes(deps: BoardRouteDeps): AgentRoute[] {
319
319
  const after = query.after ? all.filter((d) => isAfter(d, query.after!)) : all
320
320
  const page = after.slice(0, query.limit)
321
321
  const more = after.length > page.length
322
- return Response.json({ decisions: page, ...(more ? { cursor: encodeCursor(page.at(-1)!) } : {}) }, { headers: NO_STORE })
322
+ return Response.json({ decisions: page.map(conciergeDecisionView), ...(more ? { cursor: encodeCursor(page.at(-1)!) } : {}) }, { headers: NO_STORE })
323
323
  },
324
324
  },
325
325
  {
@@ -330,10 +330,21 @@ export function boardRoutes(deps: BoardRouteDeps): AgentRoute[] {
330
330
  await admit(actor, user.id)
331
331
  const decision = await decisions.get(user.id, params.id!)
332
332
  if (!decision) throw new AgentError("not_found", "No such decision")
333
- return Response.json({ decision }, { headers: NO_STORE })
333
+ return Response.json({ decision: conciergeDecisionView(decision) }, { headers: NO_STORE })
334
334
  },
335
335
  },
336
336
  )
337
337
  }
338
338
  return routes
339
339
  }
340
+
341
+ /**
342
+ * (FX-05, coordinator decision: Concierge prompt-injection surface) A Decision as these routes return it: exactly
343
+ * the record they returned before FX-05, without the structured request and reason the hub now stores on its action
344
+ * for the user's console (GET /api/decisions) only.
345
+ */
346
+ export function conciergeDecisionView(decision: Decision): Decision {
347
+ if (!decision.action || (!("request" in decision.action) && !("reason" in decision.action))) return decision
348
+ const { request: _request, reason: _reason, ...action } = decision.action as typeof decision.action & { request?: unknown; reason?: unknown }
349
+ return { ...decision, action: action as Decision["action"] }
350
+ }
@@ -49,6 +49,8 @@ export function conciergeInbound(deps: ConciergeInboundDeps): InboundPolicy {
49
49
  try {
50
50
  return await deps.identity.current(userID)
51
51
  } catch (cause) {
52
+ // (FX-07) A timed-out read is `busy` (503, refused); any other failure an internal error (refused too).
53
+ if (cause instanceof AgentError) throw cause
52
54
  throw new AgentError("internal", `Could not check the Concierge inbound rule: ${(cause as Error).message}`.slice(0, 200))
53
55
  }
54
56
  }
@@ -63,13 +65,8 @@ export function conciergeInbound(deps: ConciergeInboundDeps): InboundPolicy {
63
65
  },
64
66
  async admit(userID, from, sessionID, kind, original) {
65
67
  if (from.kind === "user") return
66
- let current: Awaited<ReturnType<ConciergeIdentity["current"]>>
67
- try {
68
- current = await deps.identity.current(userID)
69
- } catch (cause) {
70
- throw new AgentError("internal", `Could not check the Concierge inbound rule: ${(cause as Error).message}`.slice(0, 200))
71
- }
72
- if (!isConcierge(current, sessionID) || admitted(from, sessionID, kind, original)) return
68
+ const now = await current(userID)
69
+ if (!isConcierge(now, sessionID) || admitted(from, sessionID, kind, original)) return
73
70
  await deps.audit
74
71
  ?.record(userID, { actor: from, verb: "msg", target: { sessionID }, decision: "forbidden", reason: "concierge inbound rule", outcome: "refused" })
75
72
  .catch((cause) => console.error(`[hub] concierge inbound audit failed: ${(cause as Error).message}`))
@@ -3,7 +3,7 @@
3
3
  // text lives here (src/central/concierge/**) and nowhere else (contract open question 1).
4
4
  import { zonedStamp } from "./board-render"
5
5
 
6
- export const CONCIERGE_PROMPT_VERSION = 6
6
+ export const CONCIERGE_PROMPT_VERSION = 7
7
7
 
8
8
  /** Facts about the user the instructions depend on (hub-known, never model text). */
9
9
  export interface InstructionFacts {
@@ -22,7 +22,8 @@ export interface InstructionFacts {
22
22
  * session named by @name or title is looked up (concierge_find) before asking which one. (CON-13, v6) A form is
23
23
  * read (concierge_form) before it is answered or an answer proposed; a blocker is re-checked on a fresh board before
24
24
  * it is claimed; the user acts "in the Concierge panel" or by tapping the card, never "in the console"; a session's
25
- * project folder name is fine, full paths are not; a refused act is said with the hub's reason.
25
+ * project folder name is fine, full paths are not; a refused act is said with the hub's reason. (FX-07, v7) An
26
+ * action's why is read by the user in Activity: plain words, never board fields or codes (inGrant, auto, IDs).
26
27
  */
27
28
  export function conciergeInstructions(facts: InstructionFacts): string {
28
29
  const { voice } = facts
@@ -69,7 +70,7 @@ export function conciergeInstructions(facts: InstructionFacts): string {
69
70
  "- A pending permission marked inGrant=yes but auto=no (shell, outside folders, tools): do not approve it. Tell the user it needs their tap on that session's card; it cannot be armed, so do not arm or propose it.",
70
71
  "- A pending permission marked inGrant=no: do not approve or try it. Tell the user plainly it waits for them on that session's card (or Needs you), and offer only what you can do.",
71
72
  "- Never say a permission is inside or outside your grant unless the board's inGrant says so.",
72
- "- Every action is audited as yours. Give a short why for each.",
73
+ "- Every action is audited as yours, and the user reads its why in Activity. Give a short why for each, in plain words: never board fields or codes (inGrant, auto, revisions, IDs).",
73
74
  '- A proposal\'s why is your own reason, in your words. Never say the user asked for it unless they did in this conversation; when they only asked you to propose, say so ("You asked me to propose this").',
74
75
  "",
75
76
  "Rules:",
@@ -23,7 +23,7 @@ import type { Counters } from "../backend/types"
23
23
  import type { Acting, ProposalVerb } from "../agent/acting"
24
24
  import type { AgentActor, UserActor } from "../agent/actor"
25
25
  import type { AgentAudit } from "../agent/audit"
26
- import type { ActAction, ActOutcome, Decision, Decisions, Tier } from "../agent/decisions"
26
+ import { type ActAction, type ActOutcome, type Decision, type Decisions, decisionRequest, type Tier } from "../agent/decisions"
27
27
  import { type Party, partyFromActor } from "../agent/envelope"
28
28
  import { AgentError } from "../agent/errors"
29
29
  import type { Message } from "../agent/messages"
@@ -201,7 +201,13 @@ export class Proposals {
201
201
  // The hourly budget counts created proposals only (checked here, counted once the Decision exists).
202
202
  const budget = `concierge-proposals:${userID}`
203
203
  if ((await counters.get(budget)) >= PROPOSALS_PER_HOUR) throw new AgentError("rate_limited", `At most ${PROPOSALS_PER_HOUR} proposals an hour`, { extra: { retryAfter: 3600 } })
204
- const action: ActAction = { kind: "act", verb: input.verb, target: target.sessionID, input: actionInput(input) }
204
+ const action: ActAction = {
205
+ kind: "act",
206
+ verb: input.verb,
207
+ target: target.sessionID,
208
+ input: actionInput(input),
209
+ ...(input.verb === "deny" && target.request ? { request: decisionRequest(target.request) } : {}),
210
+ }
205
211
  const { message, decision } = await decisions.ask(
206
212
  userID,
207
213
  actor,