@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
@@ -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
@@ -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,30 @@ 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 { replyPermission, 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"
32
33
  import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
33
34
 
34
35
  /** 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")
36
+ /** Written in the state dir; also read from the other default places, so a lock from before an upgrade still holds. */
37
+ const conciergeLocks = () => {
38
+ const { file, read } = conciergeLockFiles()
39
+ return { conciergeLockFile: file, conciergeLockReadFiles: read }
40
+ }
36
41
 
37
42
  type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
38
43
 
@@ -67,7 +72,7 @@ function pluginVersion(): string {
67
72
  * its own server, only the sessions of the locations it loaded (`scope`), so idle sessions of a loaded project are
68
73
  * online without having run in this process, and nothing of other projects is claimed.
69
74
  */
70
- async function listRecent(kind: "service" | "standalone", scope: () => string[], options?: { days: number; max: number; include: readonly string[] }) {
75
+ async function listRecent(kind: "service" | "standalone", scope: () => string[], options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) {
71
76
  if (kind === "service") {
72
77
  const { Service } = await import("@opencode/client/service")
73
78
  const { OpenCode } = await import("@opencode/client")
@@ -165,13 +170,33 @@ function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
165
170
  /** This instance's location-scoped permission API (OpenCode keeps pending permissions per location). */
166
171
  function permissionLocation(ctx: Context): PermissionLocation {
167
172
  const permission = ctx.permission as any
173
+ const vcs = (ctx as any).vcs
168
174
  return {
169
175
  directory: ctx.location.directory,
170
176
  get: (input) => permission.get(input),
171
177
  reply: (input) => permission.reply(input),
178
+ // OpenCode's plugin vcs answers for this instance's location only (whatever location is passed).
179
+ ...(vcs?.diff && vcs?.get ? { vcs: vcsOf(vcs, ctx.location.directory) } : {}),
180
+ }
181
+ }
182
+
183
+ /** A vcs API's working-copy changes and current branch of a directory (plugin API or own-server client). */
184
+ function vcsOf(vcs: any, directory: string) {
185
+ return {
186
+ diff: async () => unwrap<ReadonlyArray<unknown>>(await vcs.diff({ mode: "working", location: { directory } }), Array.isArray),
187
+ branch: async () => {
188
+ const info = unwrap<{ branch?: { current?: string } }>(await vcs.get({ location: { directory } }), (v) => "branch" in v)
189
+ return typeof info?.branch?.current === "string" ? info.branch.current : undefined
190
+ },
172
191
  }
173
192
  }
174
193
 
194
+ /** The own server's vcs API (it honours the location), when the server can be reached. */
195
+ function serverVcs(client: { vcs?: unknown } | undefined): VcsReader | undefined {
196
+ if (!client?.vcs) return undefined
197
+ return { diff: (directory) => vcsOf(client.vcs, directory).diff(), branch: (directory) => vcsOf(client.vcs, directory).branch() }
198
+ }
199
+
175
200
  /** `locations`: the permission APIs of every plugin instance in this process (one per location). */
176
201
  function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "service" | "standalone"): SpokeHost {
177
202
  const session = ctx.session as any
@@ -180,6 +205,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
180
205
  const vcs = (ctx as any).vcs
181
206
  const skill = (ctx as any).skill
182
207
  const command = (ctx as any).command
208
+ const vcsRead = vcsReader(locations, async () => (canList(kind) ? serverVcs(await ownClient(kind).catch(() => undefined)) : undefined))
183
209
  const names = (value: unknown) => (unwrap<ReadonlyArray<{ name?: unknown }>>(value, Array.isArray) ?? []).flatMap((item) => (typeof item?.name === "string" ? [item.name] : []))
184
210
  return {
185
211
  directory: ctx.location.directory,
@@ -212,7 +238,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
212
238
  const list = unwrap<Array<{ id: string; mode?: string; hidden?: boolean }>>(await agent.list({}), Array.isArray)
213
239
  return list?.find((a) => a.mode !== "subagent" && !a.hidden)?.id
214
240
  },
215
- ...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[] }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
241
+ ...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
216
242
  activity: (directories) => activity(kind, directories),
217
243
  // Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
218
244
  ...(canList(kind)
@@ -236,15 +262,8 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
236
262
  }
237
263
  : {}),
238
264
  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
- : {}),
265
+ // A directory's branch and changes: its own plugin instance, else the own server (src/spoke/locations.ts).
266
+ ...(vcs?.diff || canList(kind) ? { vcsDiff: (directory: string) => vcsRead.diff(directory), vcsBranch: (directory: string) => vcsRead.branch(directory) } : {}),
248
267
  }
249
268
  }
250
269
 
@@ -254,6 +273,8 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
254
273
  */
255
274
  async function registerShellEnv(ctx: Context, identity: (sessionID: string) => { token: string; url?: string } | undefined) {
256
275
  const binDir = hubBinDir()
276
+ // XDG_STATE_HOME set while the existing ~/.local/state/hub is kept: one line saying so, with the mv to switch.
277
+ hintStateDirOnce(resolveHubStateDir(process.env, homedir()))
257
278
  try {
258
279
  mkdirSync(binDir, { recursive: true, mode: 0o700 })
259
280
  // The `hub`/`hubctl` launchers run the CLI with this runtime (OpenCode's binary as bun; src/spoke/launchers.ts).
@@ -262,7 +283,7 @@ async function registerShellEnv(ctx: Context, identity: (sessionID: string) => {
262
283
  if (entry) {
263
284
  installLaunchers({ binDir, execPath: process.execPath, bun, entry, standalone: standaloneHubctl(homedir()) })
264
285
  // `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 })
286
+ const { hint } = linkUserBin({ home: homedir(), binDir, path: process.env.PATH, defaultBinDir: defaultHubBinDir() })
266
287
  if (hint) console.error(`[hub] ${hint}`)
267
288
  } else console.error("[hub] no built hub CLI (packages/cli/dist/hub-cli.js): `hub` is not installed")
268
289
  } catch (cause) {
@@ -297,13 +318,13 @@ export default definePlugin({
297
318
  const common = { machineName: hostname(), kind, processID, pid: process.pid, pluginVersion: pluginVersion() }
298
319
  if (source.kind === "launch") {
299
320
  const nodeConfig = source.nodeConfig
300
- return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig), conciergeLockFile: conciergeLockFile() })
321
+ return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig), ...conciergeLocks() })
301
322
  }
302
323
  return new Spoke(host, {
303
324
  ...common,
304
325
  configFile: prepareConfigPath(),
305
326
  // Concierge session locks survive a restart before the hub reconnects (src/spoke/session-context.ts).
306
- conciergeLockFile: conciergeLockFile(),
327
+ ...conciergeLocks(),
307
328
  // Loopback servers only, and only when autoStartLocalHub is on (see src/spoke/localhub.ts).
308
329
  localHub: (server) => ensureLocalHub(server),
309
330
  // One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts). Launch creates
package/src/migrate.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  symlinkSync,
17
17
  } from "node:fs"
18
18
  import path from "node:path"
19
- import type { Env } from "./env"
19
+ import { hubEnv, type Env } from "./env"
20
20
 
21
21
  export const APP_DIR = "hub"
22
22
  /** Legacy directory names under ~/.config and ~/.local/state, most recent first. */
@@ -24,8 +24,81 @@ export const LEGACY_APP_DIRS: readonly string[] = Object.freeze(["opencode-hub",
24
24
 
25
25
  /** $XDG_CONFIG_HOME or ~/.config. */
26
26
  export const configBase = (env: Env, home: string): string => env.XDG_CONFIG_HOME || path.join(home, ".config")
27
- /** ~/.local/state. */
28
- export const stateBase = (home: string): string => path.join(home, ".local", "state")
27
+ /** $XDG_STATE_HOME when it is an absolute path (the XDG spec: a relative one is invalid and ignored), else undefined. */
28
+ export const xdgStateHome = (env: Env): string | undefined => (env.XDG_STATE_HOME && path.isAbsolute(env.XDG_STATE_HOME) ? env.XDG_STATE_HOME : undefined)
29
+
30
+ /** $XDG_STATE_HOME or ~/.local/state. The hub's own state dir is resolveHubStateDir (sticky), not this. */
31
+ export const stateBase = (env: Env, home: string): string => xdgStateHome(env) ?? path.join(home, ".local", "state")
32
+
33
+ export interface HubStateDir {
34
+ readonly dir: string
35
+ /** env: HUB_STATE_DIR; xdg: an existing $XDG_STATE_HOME/hub; legacy: an existing ~/.local/state/hub kept although
36
+ * XDG_STATE_HOME is set; fresh: a new $XDG_STATE_HOME/hub; default: ~/.local/state/hub without XDG_STATE_HOME. */
37
+ readonly source: "env" | "xdg" | "legacy" | "fresh" | "default"
38
+ /** legacy only: one line for the log at startup (XDG_STATE_HOME is set, the existing dir is used, how to switch). */
39
+ readonly hint?: string
40
+ /** `dir`, then the other default places (~/.local/state/hub, $XDG_STATE_HOME/hub) it is not: the Concierge lock is read from all. */
41
+ readonly candidates: readonly string[]
42
+ }
43
+
44
+ /**
45
+ * What the hub writes in its state dir (the hub server's state.json, token-keys.json, agent/ and blobs/; the plugin's
46
+ * launchers bin/ and Concierge lock; the local hub's hub.log / hub.pid; the Claude Code and Codex spokes' dirs). A
47
+ * dir holding none of them (empty, or only `hubctl setup`'s bootstrap/) does not count as an existing state dir.
48
+ */
49
+ export const HUB_STATE_MARKERS: readonly string[] = Object.freeze(["state.json", "token-keys.json", "agent", "blobs", "concierge-sessions.json", "bin", "hub.log", "hub.pid", "claude-code", "codex"])
50
+
51
+ /** Whether `dir` holds hub state (any of HUB_STATE_MARKERS). */
52
+ export const holdsHubState = (dir: string, exists: (p: string) => boolean = existsSync): boolean => HUB_STATE_MARKERS.some((marker) => exists(path.join(dir, marker)))
53
+
54
+ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
55
+
56
+ /**
57
+ * The hub state dir, "sticky" (FX-08): $HUB_STATE_DIR; else $XDG_STATE_HOME/hub when XDG_STATE_HOME is set and that
58
+ * dir holds hub state (HUB_STATE_MARKERS; `exists` is asked about each marker); else an existing ~/.local/state/hub (kept, never copied or moved; with a hint when XDG_STATE_HOME is
59
+ * set); else $XDG_STATE_HOME/hub when it is set (fresh installs); else ~/.local/state/hub. Every part of hub (plugin,
60
+ * local hub, hub server, the Claude Code and Codex spokes) uses this, so a shell and a service unit agree unless one
61
+ * sets HUB_STATE_DIR.
62
+ */
63
+ export function resolveHubStateDir(env: Env, home: string, exists: (p: string) => boolean = existsSync): HubStateDir {
64
+ const legacy = path.join(home, ".local", "state", APP_DIR)
65
+ const xdg = xdgStateHome(env)
66
+ const xdgHub = xdg ? path.join(xdg, APP_DIR) : undefined
67
+ const defaults = [legacy, ...(xdgHub && xdgHub !== legacy ? [xdgHub] : [])]
68
+ const result = (dir: string, source: HubStateDir["source"], hint?: string): HubStateDir => ({
69
+ dir,
70
+ source,
71
+ ...(hint ? { hint } : {}),
72
+ candidates: [dir, ...defaults.filter((d) => d !== dir)],
73
+ })
74
+ const explicit = hubEnv(env).HUB_STATE_DIR
75
+ if (explicit) return result(explicit, "env")
76
+ const holdsState = (dir: string) => holdsHubState(dir, exists)
77
+ if (xdgHub && holdsState(xdgHub)) return result(xdgHub, "xdg")
78
+ if (holdsState(legacy)) {
79
+ const hint =
80
+ xdgHub && xdgHub !== legacy
81
+ ? `XDG_STATE_HOME is set, but the existing ${legacy} is used as the hub state dir; to switch, stop the hub and OpenCode, then: mv ${shellQuote(legacy)} ${shellQuote(xdgHub)}`
82
+ : undefined
83
+ return result(legacy, "legacy", hint)
84
+ }
85
+ return xdgHub ? result(xdgHub, "fresh") : result(legacy, "default")
86
+ }
87
+
88
+ let hinted = false
89
+ /** Logs the resolver's hint once per process (at startup). */
90
+ export function hintStateDirOnce(resolved: HubStateDir, log: (line: string) => void = console.warn): void {
91
+ if (!resolved.hint || hinted) return
92
+ hinted = true
93
+ log(`[hub] ${resolved.hint}`)
94
+ }
95
+
96
+ /** The legacy (opencode-hub, opencode-voice) state dirs, most recent first: under $XDG_STATE_HOME, then ~/.local/state. */
97
+ export function stateLegacyPaths(env: Env, home: string): string[] {
98
+ const xdg = xdgStateHome(env)
99
+ const bases = [...(xdg ? [xdg] : []), path.join(home, ".local", "state")]
100
+ return [...new Set(bases.flatMap((base) => legacyPaths(base)))]
101
+ }
29
102
 
30
103
  export type MigrationResult = "migrated" | "none" | "failed"
31
104
 
@@ -103,3 +176,36 @@ export function migrateFirst(legacies: readonly string[], target: string, option
103
176
 
104
177
  /** The legacy copies of `<base>/<APP_DIR>/<...rest>`, most recent first. */
105
178
  export const legacyPaths = (base: string, ...rest: string[]): string[] => LEGACY_APP_DIRS.map((dir) => path.join(base, dir, ...rest))
179
+
180
+ /**
181
+ * migrateFirst for the hub state dir: a target that holds no hub state (missing, empty, or only `hubctl setup`'s
182
+ * bootstrap/) counts as missing. The newest existing legacy dir is copied through a temporary sibling, then each of
183
+ * its top-level entries is renamed into the target unless the target already has one by that name (bootstrap/ is
184
+ * never overwritten). Never throws; the legacy copies are never touched.
185
+ */
186
+ export function migrateStateDir(legacies: readonly string[], target: string, options: MigrateOptions = {}): MigrationResult {
187
+ if (!exists(target)) return migrateFirst(legacies, target, options)
188
+ if (holdsHubState(target, exists)) return "none"
189
+ const legacy = legacies.find(exists)
190
+ if (legacy === undefined) return "none"
191
+ const tmp = `${target}.migrating-${process.pid}-${Date.now()}`
192
+ let moved = 0
193
+ try {
194
+ const present = new Set(readdirSync(target))
195
+ copyEntry(legacy, tmp, [...(options.skip ?? []), ...present])
196
+ for (const name of readdirSync(tmp)) {
197
+ if (exists(path.join(target, name))) continue
198
+ renameSync(path.join(tmp, name), path.join(target, name))
199
+ moved++
200
+ }
201
+ rmSync(tmp, { recursive: true, force: true })
202
+ } catch (cause) {
203
+ rmSync(tmp, { recursive: true, force: true })
204
+ ;(options.log ?? ((m: string) => console.warn(`[hub] warning: ${m}`)))(`could not migrate ${legacy} to ${target}: ${cause instanceof Error ? cause.message : cause}`)
205
+ return "failed"
206
+ }
207
+ // Everything was there already (an earlier migration of a legacy dir without hub-state markers): nothing to say.
208
+ if (moved === 0) return "none"
209
+ ;(options.log ?? ((m: string) => console.log(`[hub] ${m}`)))(`migrated ${legacy} to ${target} (the project is now Hub; the old copy is kept)`)
210
+ return "migrated"
211
+ }
@@ -14,6 +14,7 @@
14
14
  // they get only the stripped env and PATH, no token.
15
15
  import { homedir } from "node:os"
16
16
  import path from "node:path"
17
+ import { hubEnv } from "../env"
17
18
  import { defaultStateDir } from "./localhub"
18
19
 
19
20
  type Env = Record<string, string | undefined>
@@ -124,11 +125,17 @@ export function applyHubEnv(env: Env, identity: Identity | undefined, binDir: st
124
125
  env.PATH = [binDir, ...rest].join(path.delimiter)
125
126
  }
126
127
 
127
- /** Where the `hub`/`hubctl` launchers live: bin under the hub state dir ($HUB_STATE_DIR or ~/.local/state/hub). */
128
+ /** Where the `hub`/`hubctl` launchers live: bin under the hub state dir ($HUB_STATE_DIR, else $XDG_STATE_HOME/hub, else ~/.local/state/hub). */
128
129
  export function hubBinDir(env: Env = process.env, home: string = homedir()): string {
129
130
  return path.join(defaultStateDir(env, home), "bin")
130
131
  }
131
132
 
133
+ /** Where ~/.local/bin/hub may point: the default (resolved) hub bin dir; none with HUB_STATE_DIR (a custom state). */
134
+ export function defaultHubBinDir(env: Env = process.env, home: string = homedir()): string | undefined {
135
+ if (hubEnv(env).HUB_STATE_DIR) return undefined
136
+ return path.join(defaultStateDir(env, home), "bin")
137
+ }
138
+
132
139
  /** The hook handlers: tool execute.before/after and shell create.before. They never throw. */
133
140
  export function envInjector(options: { correlator: ShellCorrelator; binDir: string; identity: (sessionID: string) => Identity | undefined }) {
134
141
  const { correlator, binDir, identity } = options
@@ -25,6 +25,7 @@ import {
25
25
  type SentAll,
26
26
  type TeamIntake,
27
27
  type TeamIntakeStatusInput,
28
+ wakeSkipNote,
28
29
  } from "../../packages/sdk/src/index.ts"
29
30
  import { ME_LINE, reachText, whoSections } from "./who-teams"
30
31
 
@@ -41,9 +42,11 @@ export const HUB_TOOL_SELF_VERBS = ["name", "role_claim", "role_release"] as con
41
42
 
42
43
  /**
43
44
  * (CON-04) The Concierge's verbs (the `conciergeVerbs` section; CON-06 adds concierge_arm and concierge_propose). They
44
- * use the act token: the hub admits only the user's CURRENT Concierge session through it and answers 404 to every
45
- * other session. Listed in the Concierge's instructions only, never in the tool description. (CON-11) concierge_find
46
- * looks the user's sessions up by @name or title words (GET /api/agent/sessions?q=), framed as untrusted.
45
+ * use the act token. The /api/agent/concierge/* routes admit only the user's CURRENT Concierge session and answer 404
46
+ * to every other session. Listed in the Concierge's instructions only, never in the tool description. (CON-11)
47
+ * concierge_find looks the user's sessions up by @name or title words through GET /api/agent/sessions?q=, an ordinary
48
+ * read that any session (and the user) may make: it is a Concierge verb only for its framed, untrusted output. (FX-07)
49
+ * Text without a letter or digit is answered here without a request (the hub would match nothing).
47
50
  */
48
51
  export const HUB_TOOL_CONCIERGE_VERBS = ["concierge_board", "concierge_decisions", "concierge_decision", "concierge_notes", "concierge_notes_set", "concierge_watch", "concierge_unwatch", "concierge_mute", "concierge_unmute", "concierge_arm", "concierge_propose", "concierge_find", "concierge_form"] as const
49
52
  export type HubToolConciergeVerb = (typeof HUB_TOOL_CONCIERGE_VERBS)[number]
@@ -468,8 +471,8 @@ async function ask(client: HubClient, input: Input, send: { urgent?: boolean; wa
468
471
  while (asked.message.state === "open" && left > 0 && !bound.signal.aborted) {
469
472
  const step = Math.min(left, WAIT_POLL_MAX_S)
470
473
  left -= step
471
- const delivery: Asked["delivery"] = asked.delivery
472
- asked = { ...(delivery ? { delivery } : {}), ...(await client.message(asked.message.id, { wait: step, signal: bound.signal })) }
474
+ const sentAs: Sent = asked
475
+ asked = { ...deliveryMetadata(sentAs), ...(await client.message(asked.message.id, { wait: step, signal: bound.signal })) }
473
476
  }
474
477
  return askResult(asked)
475
478
  } catch (cause) {
@@ -486,22 +489,31 @@ async function ask(client: HubClient, input: Input, send: { urgent?: boolean; wa
486
489
 
487
490
  function askResult(asked: Asked): HubToolResult {
488
491
  const { message, answer } = asked
489
- const metadata = { verb: "ask", id: message.id, state: message.state, ...(asked.delivery ? { delivery: asked.delivery } : {}) }
492
+ const metadata = { verb: "ask", id: message.id, state: message.state, ...deliveryMetadata(asked) }
490
493
  if (answer) return { content: `${message.state === "declined" ? "Declined" : "Answered"}: ${message.id}. ${NOT_FROM_USER}\n${envelope(answer)}`, metadata }
491
494
  if (message.state === "expired") return { content: `The ask ${message.id} expired without an answer.`, metadata }
492
- return { content: `Asked ${message.id} (${asked.delivery ?? "stored"}); still open. The answer arrives as a hub message, or see hub inbox.`, metadata }
495
+ return { content: `Asked ${message.id} (${deliveryNote(asked)}); still open. The answer arrives as a hub message, or see hub inbox.`, metadata }
493
496
  }
494
497
 
495
498
  function sent(verb: string, result: Sent): HubToolResult {
496
499
  const { message } = result
497
500
  return {
498
- content: `${verb === "decline" ? "Declined" : verb === "reply" ? "Replied" : "Sent"} ${message.id} to ${partyText(message)} (${result.delivery ?? "stored"}).`,
499
- metadata: { verb, id: message.id, ...(result.delivery ? { delivery: result.delivery } : {}) },
501
+ content: `${verb === "decline" ? "Declined" : verb === "reply" ? "Replied" : "Sent"} ${message.id} to ${partyText(message)} (${deliveryNote(result)}).`,
502
+ metadata: { verb, id: message.id, ...deliveryMetadata(result) },
500
503
  }
501
504
  }
502
505
 
506
+ /** `queued`, `turn`… and, when the hub did not wake the recipient, why (`queued — wake skipped: hop limit reached …`). */
507
+ const deliveryNote = (result: Sent): string => `${result.delivery ?? "stored"}${wakeSkipNote(result)}`
508
+
509
+ const deliveryMetadata = ({ delivery, woke, wakeSkipped }: Sent) => ({
510
+ ...(delivery ? { delivery } : {}),
511
+ ...(woke === undefined ? {} : { woke }),
512
+ ...(wakeSkipped ? { wakeSkipped } : {}),
513
+ })
514
+
503
515
  function sentAll(verb: string, result: SentAll): HubToolResult {
504
- const lines = result.sent.map((s) => `${s.message.id} → ${partyText(s.message)} (${s.delivery ?? "stored"})`)
516
+ const lines = result.sent.map((s) => `${s.message.id} → ${partyText(s.message)} (${deliveryNote(s)})`)
505
517
  return { content: `Sent to ${result.sent.length} session(s):\n${lines.join("\n")}`, metadata: { verb, ids: result.sent.map((s) => s.message.id) } }
506
518
  }
507
519
 
@@ -690,6 +702,8 @@ async function conciergeVerb(client: HubClient, verb: HubToolConciergeVerb, inpu
690
702
  }
691
703
  case "concierge_find": {
692
704
  const text = str(input, "text", verb)
705
+ // (FX-07) The hub matches words of letters and digits: text without any names no session (never all of them).
706
+ if (!/[\p{L}\p{N}]/u.test(text)) return { content: `No session matches ${framed(text, 200)}: give the @name or title words (letters or digits).`, metadata: { verb, sessions: 0 } }
693
707
  const { sessions } = await client.sessions({ q: text, ...signal })
694
708
  const roots = sessions.filter((x) => !x.parentID)
695
709
  const lines = roots.slice(0, FIND_MAX).map(foundLine)
@@ -131,10 +131,12 @@ function onPath(name: string, searchPath: string, skip: string): string | undefi
131
131
  * terminal will not find it (~/.local/bin not on `path`, or another `hub` comes first). Small synchronous calls,
132
132
  * nothing when converged (the plugin runs this at start).
133
133
  */
134
- export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined }): { linked: string[]; hint?: string } {
135
- // Only the default hub bin dir (~/.local/state/hub/bin): a custom HUB_STATE_DIR (tests, containers, a throwaway
136
- // state) must never leave ~/.local/bin pointing into it.
137
- if (path.resolve(options.binDir) !== path.join(options.home, ".local", "state", "hub", "bin")) return { linked: [] }
134
+ export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined; readonly defaultBinDir?: string | undefined }): { linked: string[]; hint?: string } {
135
+ // Only the default hub bin dir (env-inject.ts defaultHubBinDir: the resolved state dir's bin, default
136
+ // ~/.local/state/hub/bin): a custom HUB_STATE_DIR (tests, containers, a throwaway state) must never leave
137
+ // ~/.local/bin pointing into it.
138
+ const allowed = options.defaultBinDir ?? path.join(options.home, ".local", "state", "hub", "bin")
139
+ if (path.resolve(options.binDir) !== path.resolve(allowed)) return { linked: [] }
138
140
  const userBin = userBinDir(options.home)
139
141
  const linkPath = path.join(userBin, "hub")
140
142
  const target = path.join(options.binDir, "hub")
@@ -22,14 +22,19 @@ export interface RawProject {
22
22
  readonly name?: string
23
23
  }
24
24
 
25
+ /** Per-request options (the OpenCode client's RequestOptions): the listing's cancellation. */
26
+ export interface ListingRequest {
27
+ readonly signal?: AbortSignal
28
+ }
29
+
25
30
  /** The parts of the OpenCode client the listing uses. */
26
31
  export interface ListingClient {
27
32
  readonly session: {
28
- list(input: { limit?: number; parentID?: null; cursor?: string }): Promise<{ data: readonly RawSession[]; cursor: { next?: string | null } }>
33
+ list(input: { limit?: number; parentID?: null; cursor?: string }, request?: ListingRequest): Promise<{ data: readonly RawSession[]; cursor: { next?: string | null } }>
29
34
  /** One session by ID (for always-included sessions outside the window). */
30
- get?(input: { sessionID: string }): Promise<RawSession>
35
+ get?(input: { sessionID: string }, request?: ListingRequest): Promise<RawSession>
31
36
  }
32
- readonly project: { list(): Promise<readonly RawProject[]> }
37
+ readonly project: { list(request?: ListingRequest): Promise<readonly RawProject[]> }
33
38
  }
34
39
 
35
40
  export interface ListedSession {
@@ -57,6 +62,8 @@ export interface ListingOptions {
57
62
  * projects, or in a directory under one of them, are listed. Absent: every session (the background service).
58
63
  */
59
64
  readonly scope?: readonly string[]
65
+ /** Cancels the listing: passed to every request, and no request starts once it aborted (the listing rejects). */
66
+ readonly signal?: AbortSignal
60
67
  }
61
68
 
62
69
  /** Most always-included sessions fetched one by one per listing. */
@@ -114,9 +121,12 @@ export async function listServiceSessions(client: ListingClient, options: Listin
114
121
  if (options.scope?.length === 0) return []
115
122
  const pageSize = options.pageSize ?? 100
116
123
  const maxPages = options.maxPages ?? 20
124
+ const { signal } = options
125
+ const request: ListingRequest = signal ? { signal } : {}
126
+ signal?.throwIfAborted()
117
127
  const projects = new Map<string, RawProject>()
118
128
  try {
119
- for (const project of await client.project.list()) projects.set(project.id, project)
129
+ for (const project of await client.project.list(request)) projects.set(project.id, project)
120
130
  } catch {
121
131
  // workspaces fall back to directories
122
132
  }
@@ -129,7 +139,8 @@ export async function listServiceSessions(client: ListingClient, options: Listin
129
139
  // Sessions come newest first: the first one older than the window ends the listing.
130
140
  let ended = false
131
141
  for (let page = 0; page < maxPages && out.length < max && !ended; page++) {
132
- const result = await client.session.list({ limit: pageSize, parentID: null, ...(cursor ? { cursor } : {}) })
142
+ signal?.throwIfAborted()
143
+ const result = await client.session.list({ limit: pageSize, parentID: null, ...(cursor ? { cursor } : {}) }, request)
133
144
  for (const s of result.data) {
134
145
  if (s.parentID || s.time.archived || out.length >= max) continue
135
146
  if (s.time.updated < cutoff) {
@@ -144,7 +155,8 @@ export async function listServiceSessions(client: ListingClient, options: Listin
144
155
  const have = new Set(out.map((s) => s.id))
145
156
  const missing = [...new Set(options.include ?? [])].filter((id) => !have.has(id)).slice(0, INCLUDE_FETCH_MAX)
146
157
  if (missing.length && client.session.get) {
147
- const fetched = await Promise.all(missing.map((sessionID) => client.session.get!({ sessionID }).catch(() => undefined)))
158
+ signal?.throwIfAborted()
159
+ const fetched = await Promise.all(missing.map((sessionID) => client.session.get!({ sessionID }, request).catch(() => undefined)))
148
160
  for (const s of fetched) if (s && s.id && !s.parentID && !s.time?.archived && s.location?.directory && wanted(s)) out.push(listed(s))
149
161
  }
150
162
  return out