@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
@@ -11,7 +11,7 @@
11
11
  // another one and reaches it through SpokeHub.request (pod RPC). Events, alerts and speech stay with the pod that
12
12
  // holds the connection; remote state is never re-exported.
13
13
  import path from "node:path"
14
- import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
14
+ import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type RevertCommitted, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
15
15
  import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
16
16
  import { sanitizeLabel } from "./agent/envelope"
17
17
  import { DoneWatch } from "./done"
@@ -224,6 +224,8 @@ export interface SessionView extends BrowserFields {
224
224
  readonly terminal?: TerminalInfo
225
225
  /** A staged revert (feature "revert"), as the most recent report says. */
226
226
  readonly revert?: SessionRevertInfo
227
+ /** The latest committed revert's removed window (feature "revert"), as the most recent report says. */
228
+ readonly revertCommitted?: RevertCommitted
227
229
  /** (CON-04) A locked (Concierge) session: the agent and the context revision the most recent report says. */
228
230
  readonly agent?: string
229
231
  readonly contextRevision?: number
@@ -253,6 +255,8 @@ export interface CatalogProcess {
253
255
  readonly connected: number
254
256
  readonly lastSeen: number
255
257
  readonly sessions: CatalogSession[]
258
+ /** (FX-07) Connected, but its first sessions frame has not come yet: an empty `sessions` says nothing then. */
259
+ readonly sessionsPending?: true
256
260
  }
257
261
 
258
262
  /** A machine's terminal context: its Herdr workspaces (latest terminals frame of the machine). */
@@ -279,6 +283,8 @@ interface Conn {
279
283
  sessions: Map<string, SessionInfo>
280
284
  /** The last sessions frame as JSON, to skip unchanged snapshots. */
281
285
  sessionsKey: string
286
+ /** (FX-07) No sessions frame yet. */
287
+ sessionsPending?: true
282
288
  /** sessionID → activity sequence number of the last event this process sent for it. */
283
289
  activity: Map<string, number>
284
290
  /**
@@ -304,6 +310,8 @@ export interface SliceConn {
304
310
  readonly launchID?: string
305
311
  /** The machine's terminal context, when this connection sent the latest one. */
306
312
  readonly terminals?: MachineTerminals
313
+ /** (FX-07) No sessions frame yet (absent from older pods: reported). */
314
+ readonly sessionsPending?: true
307
315
  }
308
316
 
309
317
  /** A root session's status tree (live: run starts and recommendations included) and activity feed. */
@@ -547,6 +555,12 @@ export class Registry {
547
555
  return conn && this.tenant(conn)
548
556
  }
549
557
 
558
+ /** The committed revert windows the connection reports for its own tenant's sessions (feature "revert"). */
559
+ revertCommits(connID: string): Array<{ sessionID: string; committed: RevertCommitted }> {
560
+ const conn = this.conn(connID)
561
+ return conn ? this.visible(conn).flatMap((s) => (s.revertCommitted ? [{ sessionID: s.id, committed: s.revertCommitted }] : [])) : []
562
+ }
563
+
550
564
  /** IDs of the sessions the connection reports for its own tenant (subagents included); empty when unknown. */
551
565
  reportedSessions(connID: string): string[] {
552
566
  const conn = this.conn(connID)
@@ -598,6 +612,7 @@ export class Registry {
598
612
  lastSeen: t,
599
613
  sessions: new Map(),
600
614
  sessionsKey: "[]",
615
+ sessionsPending: true,
601
616
  activity: new Map(),
602
617
  })
603
618
  this.changed(this.tenant(this.conns.get(connID)!))
@@ -640,7 +655,9 @@ export class Registry {
640
655
  conn.lastSeen = this.now()
641
656
  // Spokes re-send their snapshot periodically; only changes reach the phones.
642
657
  const key = JSON.stringify(sessions)
643
- if (key !== conn.sessionsKey) {
658
+ const first = conn.sessionsPending === true
659
+ delete conn.sessionsPending
660
+ if (key !== conn.sessionsKey || first) {
644
661
  conn.sessionsKey = key
645
662
  conn.sessions = new Map(sessions.map((s) => [s.id, s]))
646
663
  this.syncBoards()
@@ -1165,6 +1182,15 @@ export class Registry {
1165
1182
  }
1166
1183
 
1167
1184
  /** Something in the session's tree is working or waiting for the user (typed input steers then). */
1185
+ /**
1186
+ * Whether a process reports a staged revert for the session (feature "revert"): anything admitted into it would
1187
+ * commit the revert (OpenCode's next prompt does), so hub deliveries and act prompts wait or are refused.
1188
+ */
1189
+ revertStaged(sessionID: string): boolean {
1190
+ for (const conn of this.all()) if (this.reports(conn, sessionID)?.revert) return true
1191
+ return false
1192
+ }
1193
+
1168
1194
  running(sessionID: string): boolean {
1169
1195
  const state = this.browserFields(sessionID).state
1170
1196
  return state === "working" || state === "blocked"
@@ -1232,6 +1258,7 @@ export class Registry {
1232
1258
  features: [...conn.features].sort(),
1233
1259
  connected: conn.connected,
1234
1260
  lastSeen: conn.lastSeen,
1261
+ ...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
1235
1262
  sessions: this.visible(conn).map((s) => {
1236
1263
  const fields = this.browserFields(s.id)
1237
1264
  return {
@@ -1249,6 +1276,19 @@ export class Registry {
1249
1276
  return [...machines.values()]
1250
1277
  }
1251
1278
 
1279
+ /**
1280
+ * (FX-07) The tenant's sessions a spoke reports locked now (the Concierge's: `locked`, from the first frame after a
1281
+ * restart, and `agent` / `contextRevision`, sent only for locked sessions), on this pod's connections and other pods'.
1282
+ */
1283
+ lockedSessions(tenant: string): string[] {
1284
+ const out = new Set<string>()
1285
+ for (const conn of this.all()) {
1286
+ if (this.tenant(conn) !== tenant) continue
1287
+ for (const s of conn.sessions.values()) if (s.locked === true || s.agent !== undefined || s.contextRevision !== undefined) out.add(s.id)
1288
+ }
1289
+ return [...out]
1290
+ }
1291
+
1252
1292
  /** The session as reported by any process plus its live status; undefined when nobody reports it. */
1253
1293
  session(sessionID: string): SessionView | undefined {
1254
1294
  let info: SessionInfo | undefined
@@ -1273,6 +1313,7 @@ export class Registry {
1273
1313
  updated: info.updated,
1274
1314
  ...(terminal ? { open: true, terminal } : {}),
1275
1315
  ...(info.revert ? { revert: info.revert } : {}),
1316
+ ...(info.revertCommitted ? { revertCommitted: info.revertCommitted } : {}),
1276
1317
  ...(info.agent !== undefined ? { agent: info.agent } : {}),
1277
1318
  ...(info.contextRevision !== undefined ? { contextRevision: info.contextRevision } : {}),
1278
1319
  ...fields,
@@ -1722,10 +1763,12 @@ export class Registry {
1722
1763
  recommend(requestID: string, recommendation: BlockerRecommendation): boolean {
1723
1764
  const origin = this.permissions.get(requestID)
1724
1765
  if (!origin) return false
1725
- const why = recommendation.why === undefined ? "" : sanitizeLabel(recommendation.why, RECOMMENDATION_WHY_MAX)
1766
+ // (R-03) Without recommends it only links the Decision: no why either.
1767
+ const recommends = recommendation.recommends === "approve"
1768
+ const why = !recommends || recommendation.why === undefined ? "" : sanitizeLabel(recommendation.why, RECOMMENDATION_WHY_MAX)
1726
1769
  origin.recommendation = {
1727
1770
  by: sanitizeLabel(recommendation.by, RECOMMENDATION_BY_MAX) || "An agent",
1728
- recommends: "approve",
1771
+ ...(recommends ? { recommends: "approve" as const } : {}),
1729
1772
  ...(why ? { why } : {}),
1730
1773
  ...(recommendation.decisionID && DECISION_ID.test(recommendation.decisionID) ? { decisionID: recommendation.decisionID } : {}),
1731
1774
  }
@@ -1893,6 +1936,7 @@ export class Registry {
1893
1936
  activity: [...conn.activity],
1894
1937
  ...(conn.launchID !== undefined ? { launchID: conn.launchID } : {}),
1895
1938
  ...(terminals?.connID === conn.id ? { terminals: terminals.terminals } : {}),
1939
+ ...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
1896
1940
  }
1897
1941
  })
1898
1942
  const boards: SliceBoard[] = [...this.boards]
@@ -1966,6 +2010,7 @@ export class Registry {
1966
2010
  sessionsKey: "",
1967
2011
  activity: new Map(c.activity ?? []),
1968
2012
  ...(c.launchID !== undefined ? { launchID: c.launchID } : {}),
2013
+ ...(c.sessionsPending === true ? { sessionsPending: true as const } : {}),
1969
2014
  remote: { pod, tenant },
1970
2015
  })
1971
2016
  if (c.terminals) terminals.set(c.machine.id, c.terminals)
@@ -0,0 +1,66 @@
1
+ // Committed revert windows (feature "revert", docs/session-view.md → Fork and revert). A committed revert removed a
2
+ // session's history from the reverted message on; hub message rows (agent messages the session sent or got) from
3
+ // the reverted message's time until the revert was staged belonged to that history (later ones were held for after
4
+ // it, decision 3q94ez94). The spoke reports the latest window (SessionInfo.revertCommitted); the hub keeps every
5
+ // window per (user, session) in the StateStore, so GET /api/sessions/:id/messages leaves those rows out for good
6
+ // (also after the spoke restarts or another process reports the session).
7
+ import type { RevertCommitted } from "../protocol"
8
+ import type { StateStore } from "./backend/types"
9
+
10
+ const COLLECTION = "revertWindows"
11
+ /** Windows kept per session (the oldest go first). */
12
+ export const REVERT_WINDOWS_MAX = 50
13
+
14
+ export interface RevertWindow {
15
+ readonly from: number
16
+ readonly until: number
17
+ /** Commit time (identifies the commit). */
18
+ readonly at: number
19
+ }
20
+
21
+ interface Stored {
22
+ readonly windows: readonly RevertWindow[]
23
+ }
24
+
25
+ /** Whether a row at `at` falls into one of the windows. */
26
+ export function inRevertWindow(at: unknown, windows: readonly RevertWindow[]): boolean {
27
+ return typeof at === "number" && windows.some((w) => at >= w.from && at < w.until)
28
+ }
29
+
30
+ export class RevertWindows {
31
+ /** Windows known stored (by user, session and the whole window): the frames that repeat them read nothing. */
32
+ private readonly stored = new Set<string>()
33
+
34
+ constructor(private readonly state: StateStore) {}
35
+
36
+ /**
37
+ * A reported window (each sessions frame repeats it): kept unless known stored. Only a stored (or already present)
38
+ * window is remembered, so one reported first without its start (nothing to store), or whose write failed, is taken
39
+ * again from the next frame. Errors are the caller's.
40
+ */
41
+ async observe(userID: string, sessionID: string, committed: RevertCommitted): Promise<void> {
42
+ if (committed.from === undefined || committed.until <= committed.from) return
43
+ const key = JSON.stringify([userID, sessionID, committed.from, committed.until, committed.at])
44
+ if (this.stored.has(key)) return
45
+ if (await this.record(userID, sessionID, committed)) this.stored.add(key)
46
+ }
47
+
48
+ async list(userID: string, sessionID: string): Promise<readonly RevertWindow[]> {
49
+ return (await this.state.get<Stored>(userID, COLLECTION, sessionID))?.value.windows ?? []
50
+ }
51
+
52
+ /**
53
+ * Keeps a reported window (one without a start removes no rows: skipped). True when it is stored now (new, or
54
+ * already there); false when skipped or the write lost a race (version check).
55
+ */
56
+ async record(userID: string, sessionID: string, committed: RevertCommitted): Promise<boolean> {
57
+ if (committed.from === undefined || committed.until <= committed.from) return false
58
+ const window: RevertWindow = { from: committed.from, until: committed.until, at: committed.at }
59
+ const current = await this.state.get<Stored>(userID, COLLECTION, sessionID)
60
+ const windows = current?.value.windows ?? []
61
+ if (windows.some((w) => w.at === window.at && w.from === window.from && w.until === window.until)) return true
62
+ const next = [...windows, window].slice(-REVERT_WINDOWS_MAX)
63
+ const result = await this.state.put<Stored>(userID, COLLECTION, sessionID, { windows: next }, current ? { ifVersion: current.version } : {})
64
+ return result.ok
65
+ }
66
+ }
@@ -61,9 +61,12 @@ export interface Notifier {
61
61
 
62
62
  /** The spoke answered with an error, or disconnected before answering. */
63
63
  export class SpokeError extends Error {
64
- constructor(message: string) {
64
+ /** The refusal's protocol code (Res.code, e.g. "busy", "not_found"), when the spoke sent one. */
65
+ readonly code?: string
66
+ constructor(message: string, code?: string) {
65
67
  super(message)
66
68
  this.name = "SpokeError"
69
+ if (code !== undefined) this.code = code
67
70
  }
68
71
  }
69
72
 
@@ -669,7 +672,7 @@ export class SpokeHub {
669
672
  }
670
673
 
671
674
  /** Resolves or rejects the pending request a `res` frame answers. */
672
- private settle(connID: string, message: { readonly id: string; readonly ok: boolean; readonly result?: unknown; readonly error?: string }): void {
675
+ private settle(connID: string, message: { readonly id: string; readonly ok: boolean; readonly result?: unknown; readonly error?: string; readonly code?: string }): void {
673
676
  const pending = this.pending.get(message.id)
674
677
  if (!pending || pending.connID !== connID) return
675
678
  this.pending.delete(message.id)
@@ -683,7 +686,7 @@ export class SpokeHub {
683
686
  return pending.resolve(message.result)
684
687
  }
685
688
  if (message.ok) pending.resolve(message.result)
686
- else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`))
689
+ else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`, message.code))
687
690
  }
688
691
 
689
692
  private async answer(ws: Socket, frame: Req<SpokeRequest>): Promise<void> {
@@ -25,6 +25,9 @@
25
25
  // progress is reported explicitly through setStatus (POST /teams/:id/intake/:intakeID/status, MCP
26
26
  // team_intake_update, the OpenCode hub tool's intake_update verb), which the submitter reads (GET, `team.intake`
27
27
  // events). The text says so, with the exact verb and IDs (reportingLines).
28
+ // Hops (0.8.1): a session's submission continues its hop chain — the submitter's depth + 1 at submit time (hopOf)
29
+ // is kept on the row (never shown) and delivered as SendOptions.chainHop, so intake is not a way to restart a chain
30
+ // at hop 1; the user's and apps' submissions start their own.
28
31
  //
29
32
  // Status (explicit, never inferred from idleness/presence/departure; HWA-04/06): intake `queued` → `routed` →
30
33
  // `done` | `failed` | `cancelled` (queued may be cancelled). While routed, `progress` mirrors the assignment:
@@ -143,6 +146,8 @@ export interface StoredIntake extends TeamIntake {
143
146
 
144
147
  interface IntakeRow extends StoredIntake {
145
148
  readonly claim?: { readonly owner: string; readonly at: number }
149
+ /** A session submitter's depth + 1 at submit time: the delivery continues its hop chain (never shown). */
150
+ readonly hop?: number
146
151
  }
147
152
 
148
153
  interface IntakeKey {
@@ -159,8 +164,14 @@ interface IntakeDoc {
159
164
  readonly keys: Readonly<Record<string, IntakeKey>>
160
165
  }
161
166
 
162
- /** Delivers one task text to a session (production: Delivery.send as intakeActor). */
163
- export type IntakeDeliver = (userID: string, sessionID: string, text: string) => Promise<{ readonly messageID: string }>
167
+ /**
168
+ * Delivers one task text to a session (production: Delivery.send as intakeActor); `hop`: the chain it continues (a
169
+ * submitting session's depth + 1; production passes it as SendOptions.chainHop).
170
+ */
171
+ export type IntakeDeliver = (userID: string, sessionID: string, text: string, options?: { readonly hop?: number }) => Promise<{ readonly messageID: string }>
172
+
173
+ /** The hop a message from the session would carry now (production: Delivery.hops.next). */
174
+ export type IntakeHopOf = (userID: string, sessionID: string) => Promise<number>
164
175
 
165
176
  export interface TeamIntakeDeps {
166
177
  readonly state: StateStore
@@ -170,6 +181,8 @@ export interface TeamIntakeDeps {
170
181
  readonly members: Pick<MembershipStore, "members" | "assign" | "assignment" | "setStatus" | "reassign">
171
182
  readonly presence: SessionPresenceLookup
172
183
  readonly deliver: IntakeDeliver
184
+ /** A session submitter's hop chain (absent: deliveries start their own chain). */
185
+ readonly hopOf?: IntakeHopOf
173
186
  readonly events?: Pick<AgentEvents, "append">
174
187
  readonly report?: (cause: unknown) => void
175
188
  readonly now?: Clock
@@ -239,9 +252,9 @@ const sameSource = (row: StoredIntake, actor: AgentActor): boolean =>
239
252
  (actor.kind === "session" && row.source.kind === "session" && row.source.sessionID === actor.sessionID) ||
240
253
  (actor.kind === "user" && row.source.kind === "user")
241
254
 
242
- /** The stored row as reads show it (no routing claim). */
255
+ /** The stored row as reads show it (no routing claim, no hop). */
243
256
  function view(row: IntakeRow): StoredIntake {
244
- const { claim: _claim, ...rest } = row
257
+ const { claim: _claim, hop: _hop, ...rest } = row
245
258
  return rest
246
259
  }
247
260
 
@@ -308,6 +321,7 @@ export class TeamIntakeService {
308
321
  const digest = digestOf(body)
309
322
  const slot = key === undefined ? undefined : `${partyOf(actor)}|${key}`
310
323
  const id = this.newID("intake")
324
+ const hop = actor.kind === "session" && this.deps.hopOf ? await this.deps.hopOf(userID, actor.sessionID) : undefined
311
325
  if (body.brief !== undefined) await this.state.put(userID, TEAM_INTAKE_BRIEFS, id, { v: 1, teamID, brief: body.brief })
312
326
  let result: { row: IntakeRow; created: boolean } | undefined
313
327
  try {
@@ -331,7 +345,7 @@ export class TeamIntakeService {
331
345
  if (doc.items.filter((i) => !CLOSED.has(i.status)).length >= this.limits.openPerTeam)
332
346
  throw new AgentError("rate_limited", `A team holds at most ${this.limits.openPerTeam} open submissions`, { hint: "Close or cancel some first", extra: { limit: this.limits.openPerTeam } })
333
347
  const { brief: _brief, ...fields } = body
334
- const row: IntakeRow = { v: 1, id, teamID, ...fields, source: sourceOf(actor), status: "queued", revision: 1, createdAt: at, updatedAt: at }
348
+ const row: IntakeRow = { v: 1, id, teamID, ...fields, source: sourceOf(actor), status: "queued", revision: 1, createdAt: at, updatedAt: at, ...(hop === undefined ? {} : { hop }) }
335
349
  result = { row, created: true }
336
350
  const nextKeys = slot === undefined ? keys : { ...keys, [slot]: { intakeID: id, digest, at } }
337
351
  return { ...doc, items: this.retained([...doc.items, row]), keys: this.boundedKeys(nextKeys) }
@@ -540,7 +554,7 @@ export class TeamIntakeService {
540
554
  await this.mutate(userID, team.id, intakeID, (row) => ({ ...row, assignmentID: assignment.id, workspaceID: target.workspaceID }), false)
541
555
  const brief = (await this.state.get<{ brief?: unknown }>(userID, TEAM_INTAKE_BRIEFS, intakeID))?.value?.brief
542
556
  const text = this.text(team, claimed, assignment.id, target, typeof brief === "string" ? brief : undefined)
543
- const sent = await this.deps.deliver(userID, target.sessionID, text)
557
+ const sent = await this.deps.deliver(userID, target.sessionID, text, claimed.hop === undefined ? {} : { hop: claimed.hop })
544
558
  let cancelled = false
545
559
  let routed: IntakeRow | undefined
546
560
  await this.writeDoc(userID, team.id, (doc) => {
@@ -219,8 +219,15 @@ export interface TeamStaffingDeps {
219
219
  readonly report?: (cause: unknown) => void
220
220
  readonly now?: Clock
221
221
  readonly newID?: (kind: IdKind) => string
222
+ /**
223
+ * (FX-07) Whether the session is one of the user's Concierge sessions (current or past). Such a session is never
224
+ * staffed (attach, replace, a team launch), for anyone: the hub manages it. The routes guard this too.
225
+ */
226
+ readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
222
227
  }
223
228
 
229
+ const conciergeRefusal = () => new AgentError("forbidden", "This does not apply to the Concierge: the hub manages it", { extra: { policy: "concierge" } })
230
+
224
231
  interface Need {
225
232
  readonly verb: "launch" | "roles"
226
233
  readonly directory: string
@@ -540,7 +547,7 @@ export class TeamStaffingService {
540
547
  /** The launch body: the caller's, with the role's placement/harness/model/profile defaults and team labels. */
541
548
  private async launchSpec(userID: string, team: Team, role: TeamRoleSpec, base: unknown, requestID: string): Promise<Record<string, unknown>> {
542
549
  if (!isObj(base)) throw new AgentError("invalid", "launch must be a launch body (workspace, prompt, …)")
543
- if (base.key !== undefined) throw new AgentError("invalid", "launch.key is set by staffing", { hint: "Send an Idempotency-Key header instead" })
550
+ if (base.key !== undefined) throw new AgentError("invalid", "launch.key is set by staffing", { hint: "Send idempotencyKey (the Idempotency-Key header) instead" })
544
551
  const labels = isObj(base.labels) ? base.labels : base.labels === undefined ? {} : undefined
545
552
  if (!labels) throw new AgentError("invalid", "launch.labels must be an object")
546
553
  if (TEAM_LAUNCH_LABEL in labels || TEAM_REQUEST_LABEL in labels) throw new AgentError("invalid", `launch.labels ${TEAM_LAUNCH_LABEL} and ${TEAM_REQUEST_LABEL} are set by staffing`)
@@ -560,7 +567,7 @@ export class TeamStaffingService {
560
567
  ...(agentProfile !== undefined ? { agentProfile } : {}),
561
568
  labels: { ...labels, [TEAM_LAUNCH_LABEL]: team.id, [TEAM_REQUEST_LABEL]: requestID },
562
569
  }
563
- validateLaunchSpec(spec)
570
+ validateLaunchSpec(spec, { staffing: true })
564
571
  return spec
565
572
  }
566
573
 
@@ -604,6 +611,7 @@ export class TeamStaffingService {
604
611
  let staffing: TeamSeatRecord
605
612
  let created: boolean
606
613
  try {
614
+ await this.notConcierge(userID, sessionID)
607
615
  const team = await this.deps.teams.require(userID, request.teamID)
608
616
  if (request.replaces) {
609
617
  staffable(team)
@@ -800,9 +808,23 @@ export class TeamStaffingService {
800
808
  if (typeof sessionID !== "string" || sessionID.length === 0 || sessionID.length > 200) throw new AgentError("invalid", "sessionID must be a session ID")
801
809
  const presence = await this.deps.presence(userID, sessionID)
802
810
  if (presence === "unknown" || presence === "deleted") throw new AgentError("not_found", `No session ${sessionID}`)
811
+ await this.notConcierge(userID, sessionID)
803
812
  return sessionID
804
813
  }
805
814
 
815
+ /**
816
+ * (FX-07) Refuses a Concierge session. A failed identity read refuses too (fail closed): a timed-out one with its own
817
+ * `busy` (503), any other as the Concierge refusal.
818
+ */
819
+ private async notConcierge(userID: string, sessionID: string): Promise<void> {
820
+ if (!this.deps.protects) return
821
+ const concierge = await this.deps.protects(userID, sessionID).catch((cause: unknown) => {
822
+ if (cause instanceof AgentError && cause.code === "busy") throw cause
823
+ return true
824
+ })
825
+ if (concierge) throw conciergeRefusal()
826
+ }
827
+
806
828
  /** The session's project for a grant check; offline when no spoke reports it (as role writes answer). */
807
829
  private directoryOf(userID: string, sessionID: string): string {
808
830
  const directory = this.deps.sessionDirectory(userID, sessionID)
@@ -223,7 +223,7 @@ export function sideChatRoutes(deps: SideChatRoutesDeps): AgentRoute[] {
223
223
  snapshot: full ? snapshot : summary,
224
224
  status,
225
225
  ...(launch
226
- ? { launch: { id: launch.id, state: launch.state, ...(launch.outcome ? { outcome: launch.outcome } : {}), ...(context.actor.kind === "user" || launch.by === launchedBy(context.actor) ? { reason: launch.reason } : {}) } }
226
+ ? { launch: { id: launch.id, state: launch.state, ...(launch.outcome ? { outcome: launch.outcome } : {}), ...(context.actor.kind === "user" ? { reason: launch.reason } : launch.by === launchedBy(context.actor) ? { reason: (launch.state === "queued" ? launch.waiting?.shared : undefined) ?? launch.reason } : {}) } }
227
227
  : {}),
228
228
  ...(updatedAt !== undefined ? { sourceUpdatedAt: updatedAt, stale: updatedAt > snapshot.takenAt } : {}),
229
229
  }
package/src/index.ts CHANGED
@@ -14,25 +14,31 @@ import { defineVoiceAgent, orchestratorParts, STOP_TOOL, STOP_TOOL_DESCRIPTION }
14
14
  import { defineConciergeAgent } from "./spoke/concierge-agent"
15
15
 
16
16
  import { CONCIERGE_AGENT, CONCIERGE_TOOLS } from "../packages/protocol/src/concierge.ts"
17
- import { envInjector, hubBinDir, ShellCorrelator, stripInheritedHubEnv } from "./spoke/env-inject"
17
+ import { defaultHubBinDir, envInjector, hubBinDir, ShellCorrelator, stripInheritedHubEnv } from "./spoke/env-inject"
18
18
  import { herdrRunner } from "./spoke/herdr"
19
19
  import { launchSource } from "./spoke/launch-source"
20
20
  import { HubRpcError, hubMsg, hubWho } from "./spoke/hub-rpc"
21
21
  import { HUB_TOOL, HUB_TOOL_DESCRIPTION, HUB_TOOL_INPUT, runHubTool } from "./spoke/hub-tool"
22
22
  import { cliEntry, installLaunchers, linkUserBin, standaloneHubctl } from "./spoke/launchers"
23
23
  import { listServiceSessions } from "./spoke/listing"
24
- import { defaultStateDir, ensureLocalHub } from "./spoke/localhub"
24
+ import { conciergeLockFiles, ensureLocalHub } from "./spoke/localhub"
25
+ import { hintStateDirOnce, resolveHubStateDir } from "./migrate"
25
26
  import path from "node:path"
26
27
  import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
27
28
  import { PromptReferences } from "./spoke/prompt-refs"
28
29
  import { PROFILE_TOOL, PROFILE_TOOL_DESCRIPTION, PROFILE_TOOL_INPUT } from "./spoke/profile-apply"
29
- import { replyPermission, type PermissionLocation } from "./spoke/locations"
30
+ import { listedNames, namesReader, replyPermission, serverNames, vcsReader, type PermissionLocation, type VcsReader } from "./spoke/locations"
30
31
  import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
31
32
  import { joinProcess, type Membership } from "./spoke/singleton"
33
+ import { applySessionContext, sortTools } from "./spoke/session-context"
32
34
  import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
33
35
 
34
36
  /** Where the spoke keeps the IDs of Concierge sessions (always restricted to the Concierge's tools). */
35
- const conciergeLockFile = () => path.join(defaultStateDir(), "concierge-sessions.json")
37
+ /** Written in the state dir; also read from the other default places, so a lock from before an upgrade still holds. */
38
+ const conciergeLocks = () => {
39
+ const { file, read } = conciergeLockFiles()
40
+ return { conciergeLockFile: file, conciergeLockReadFiles: read }
41
+ }
36
42
 
37
43
  type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
38
44
 
@@ -67,7 +73,7 @@ function pluginVersion(): string {
67
73
  * its own server, only the sessions of the locations it loaded (`scope`), so idle sessions of a loaded project are
68
74
  * online without having run in this process, and nothing of other projects is claimed.
69
75
  */
70
- async function listRecent(kind: "service" | "standalone", scope: () => string[], options?: { days: number; max: number; include: readonly string[] }) {
76
+ async function listRecent(kind: "service" | "standalone", scope: () => string[], options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) {
71
77
  if (kind === "service") {
72
78
  const { Service } = await import("@opencode/client/service")
73
79
  const { OpenCode } = await import("@opencode/client")
@@ -165,13 +171,42 @@ function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
165
171
  /** This instance's location-scoped permission API (OpenCode keeps pending permissions per location). */
166
172
  function permissionLocation(ctx: Context): PermissionLocation {
167
173
  const permission = ctx.permission as any
174
+ const vcs = (ctx as any).vcs
175
+ const skill = (ctx as any).skill
176
+ const command = (ctx as any).command
168
177
  return {
169
178
  directory: ctx.location.directory,
170
179
  get: (input) => permission.get(input),
171
180
  reply: (input) => permission.reply(input),
181
+ // OpenCode's plugin vcs, skill and command lists answer for this instance's location only (whatever location is passed).
182
+ ...(vcs?.diff && vcs?.get ? { vcs: vcsOf(vcs, ctx.location.directory) } : {}),
183
+ ...(skill?.list && command?.list ? { names: () => localNamesOf(skill, command, ctx.location.directory) } : {}),
184
+ }
185
+ }
186
+
187
+ /** A plugin instance's own location's skill and command names (its lists answer only for that location). */
188
+ async function localNamesOf(skill: any, command: any, directory: string) {
189
+ const [skills, commands] = await Promise.all([skill.list({ location: { directory } }), command.list({ location: { directory } })])
190
+ return { skills: listedNames(skills), commands: listedNames(commands) }
191
+ }
192
+
193
+ /** A vcs API's working-copy changes and current branch of a directory (plugin API or own-server client). */
194
+ function vcsOf(vcs: any, directory: string) {
195
+ return {
196
+ diff: async () => unwrap<ReadonlyArray<unknown>>(await vcs.diff({ mode: "working", location: { directory } }), Array.isArray),
197
+ branch: async () => {
198
+ const info = unwrap<{ branch?: { current?: string } }>(await vcs.get({ location: { directory } }), (v) => "branch" in v)
199
+ return typeof info?.branch?.current === "string" ? info.branch.current : undefined
200
+ },
172
201
  }
173
202
  }
174
203
 
204
+ /** The own server's vcs API (it honours the location), when the server can be reached. */
205
+ function serverVcs(client: { vcs?: unknown } | undefined): VcsReader | undefined {
206
+ if (!client?.vcs) return undefined
207
+ return { diff: (directory) => vcsOf(client.vcs, directory).diff(), branch: (directory) => vcsOf(client.vcs, directory).branch() }
208
+ }
209
+
175
210
  /** `locations`: the permission APIs of every plugin instance in this process (one per location). */
176
211
  function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "service" | "standalone"): SpokeHost {
177
212
  const session = ctx.session as any
@@ -180,7 +215,11 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
180
215
  const vcs = (ctx as any).vcs
181
216
  const skill = (ctx as any).skill
182
217
  const command = (ctx as any).command
183
- const names = (value: unknown) => (unwrap<ReadonlyArray<{ name?: unknown }>>(value, Array.isArray) ?? []).flatMap((item) => (typeof item?.name === "string" ? [item.name] : []))
218
+ const vcsRead = vcsReader(locations, async () => (canList(kind) ? serverVcs(await ownClient(kind).catch(() => undefined)) : undefined))
219
+ const namesRead = namesReader(locations, async () => {
220
+ const client = canList(kind) ? await ownClient(kind).catch(() => undefined) : undefined
221
+ return client?.skill && client?.command ? serverNames(client) : undefined
222
+ })
184
223
  return {
185
224
  directory: ctx.location.directory,
186
225
  opencodeVersion: ctx.app?.version ?? "unknown",
@@ -212,7 +251,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
212
251
  const list = unwrap<Array<{ id: string; mode?: string; hidden?: boolean }>>(await agent.list({}), Array.isArray)
213
252
  return list?.find((a) => a.mode !== "subagent" && !a.hidden)?.id
214
253
  },
215
- ...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[] }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
254
+ ...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
216
255
  activity: (directories) => activity(kind, directories),
217
256
  // Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
218
257
  ...(canList(kind)
@@ -227,24 +266,12 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
227
266
  unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
228
267
  ...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
229
268
  // Hub profiles (src/spoke/profile-apply.ts): local skills/commands win over a profile's; its own need the tool.
230
- ...(skill?.list && command?.list
231
- ? {
232
- localNames: async (directory: string) => {
233
- const [skills, commands] = await Promise.all([skill.list({ location: { directory } }), command.list({ location: { directory } })])
234
- return { skills: names(skills), commands: names(commands) }
235
- },
236
- }
237
- : {}),
269
+ // Read in the session's own directory (src/spoke/locations.ts); unknown (undefined): the profile's skills and
270
+ // commands are not offered, never checked against another folder's names.
271
+ ...((skill?.list && command?.list) || canList(kind) ? { localNames: (directory: string) => namesRead(directory) } : {}),
238
272
  profileTool: typeof (ctx as any).tool?.transform === "function",
239
- ...(vcs?.diff ? { vcsDiff: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await vcs.diff({ mode: "working", location: { directory } }), Array.isArray) } : {}),
240
- ...(vcs?.get
241
- ? {
242
- vcsBranch: async (directory: string) => {
243
- const info = unwrap<{ branch?: { current?: string } }>(await vcs.get({ location: { directory } }), (v) => "branch" in v)
244
- return typeof info?.branch?.current === "string" ? info.branch.current : undefined
245
- },
246
- }
247
- : {}),
273
+ // A directory's branch and changes: its own plugin instance, else the own server (src/spoke/locations.ts).
274
+ ...(vcs?.diff || canList(kind) ? { vcsDiff: (directory: string) => vcsRead.diff(directory), vcsBranch: (directory: string) => vcsRead.branch(directory) } : {}),
248
275
  }
249
276
  }
250
277
 
@@ -254,6 +281,8 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
254
281
  */
255
282
  async function registerShellEnv(ctx: Context, identity: (sessionID: string) => { token: string; url?: string } | undefined) {
256
283
  const binDir = hubBinDir()
284
+ // XDG_STATE_HOME set while the existing ~/.local/state/hub is kept: one line saying so, with the mv to switch.
285
+ hintStateDirOnce(resolveHubStateDir(process.env, homedir()))
257
286
  try {
258
287
  mkdirSync(binDir, { recursive: true, mode: 0o700 })
259
288
  // The `hub`/`hubctl` launchers run the CLI with this runtime (OpenCode's binary as bun; src/spoke/launchers.ts).
@@ -262,7 +291,7 @@ async function registerShellEnv(ctx: Context, identity: (sessionID: string) => {
262
291
  if (entry) {
263
292
  installLaunchers({ binDir, execPath: process.execPath, bun, entry, standalone: standaloneHubctl(homedir()) })
264
293
  // `hub` in the user's own terminals too (~/.local/bin/hub, never over anything of theirs).
265
- const { hint } = linkUserBin({ home: homedir(), binDir, path: process.env.PATH })
294
+ const { hint } = linkUserBin({ home: homedir(), binDir, path: process.env.PATH, defaultBinDir: defaultHubBinDir() })
266
295
  if (hint) console.error(`[hub] ${hint}`)
267
296
  } else console.error("[hub] no built hub CLI (packages/cli/dist/hub-cli.js): `hub` is not installed")
268
297
  } catch (cause) {
@@ -297,13 +326,13 @@ export default definePlugin({
297
326
  const common = { machineName: hostname(), kind, processID, pid: process.pid, pluginVersion: pluginVersion() }
298
327
  if (source.kind === "launch") {
299
328
  const nodeConfig = source.nodeConfig
300
- return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig), conciergeLockFile: conciergeLockFile() })
329
+ return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig), ...conciergeLocks() })
301
330
  }
302
331
  return new Spoke(host, {
303
332
  ...common,
304
333
  configFile: prepareConfigPath(),
305
334
  // Concierge session locks survive a restart before the hub reconnects (src/spoke/session-context.ts).
306
- conciergeLockFile: conciergeLockFile(),
335
+ ...conciergeLocks(),
307
336
  // Loopback servers only, and only when autoStartLocalHub is on (see src/spoke/localhub.ts).
308
337
  localHub: (server) => ensureLocalHub(server),
309
338
  // One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts). Launch creates
@@ -343,12 +372,16 @@ export default definePlugin({
343
372
  // A hub profile: after OpenCode's own parts (local instructions win), for this session only.
344
373
  for (const text of spoke?.profiles.systemParts(event.sessionID) ?? []) event.system.push({ type: "text", text })
345
374
  if (event.tools && !spoke?.profiles.toolVisible(event.sessionID)) delete event.tools[PROFILE_TOOL]
346
- // (CON-04) The hub's session context: its parts after the profile's, its denied tools removed.
347
- for (const text of spoke?.contexts.systemParts(event.sessionID) ?? []) event.system.push({ type: "text", text })
375
+ // (CON-04) The hub's session context: its stable parts after the profile's, its board after the conversation
376
+ // (V-01: the prompt cache survives a board change), its denied tools removed, the kept tools in name order.
377
+ if (spoke) applySessionContext(event as any, spoke.contexts.systemParts(event.sessionID), spoke.contexts.tailParts(event.sessionID))
348
378
  if (event.tools) spoke?.contexts.filterTools(event.sessionID, event.tools as Record<string, unknown>)
349
379
  // The concierge agent keeps exactly CONCIERGE_TOOLS even without a hub context (a spoke that restarted before
350
380
  // the hub re-sent it): OpenCode 2.2.3 offers `skill` whatever the agent's rules say.
351
- if (event.tools && event.agent === CONCIERGE_AGENT) for (const name of Object.keys(event.tools)) if (!(CONCIERGE_TOOLS as readonly string[]).includes(name)) delete event.tools[name]
381
+ if (event.tools && event.agent === CONCIERGE_AGENT) {
382
+ for (const name of Object.keys(event.tools)) if (!(CONCIERGE_TOOLS as readonly string[]).includes(name)) delete event.tools[name]
383
+ sortTools(event.tools as Record<string, unknown>)
384
+ }
352
385
  }),
353
386
  ),
354
387
  // The voice agent stops one worker ("cancel the deploy one") without stopping itself or the others.