@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
@@ -10,7 +10,9 @@ import path from "node:path"
10
10
  import { fileURLToPath } from "node:url"
11
11
  import { hubEnv } from "../env"
12
12
  import { isLoopbackURL } from "../loopback"
13
- import { APP_DIR, legacyPaths, migrateFirst, stateBase } from "../migrate"
13
+ import { migrateStateDir, resolveHubStateDir, stateLegacyPaths } from "../migrate"
14
+
15
+ const CONCIERGE_LOCK = "concierge-sessions.json"
14
16
 
15
17
  export interface ChildLike {
16
18
  readonly pid?: number
@@ -23,7 +25,7 @@ export type SpawnLike = (command: string, args: readonly string[], options: obje
23
25
 
24
26
  export interface LocalHubOptions {
25
27
  /**
26
- * Where hub.pid and hub.log live (default $HUB_STATE_DIR or ~/.local/state/hub, migrated from
28
+ * Where hub.pid and hub.log live (default $HUB_STATE_DIR, else $XDG_STATE_HOME/hub or ~/.local/state/hub, migrated from
27
29
  * ~/.local/state/opencode-hub or opencode-voice first so the hub does not find a fresh, empty state directory).
28
30
  */
29
31
  readonly stateDir?: string
@@ -86,7 +88,15 @@ export function hubScript(root: string = repoRoot()): string {
86
88
  }
87
89
 
88
90
  export function defaultStateDir(env: Readonly<Record<string, string | undefined>> = process.env, home: string = homedir()): string {
89
- return hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR)
91
+ return resolveHubStateDir(env, home).dir
92
+ }
93
+
94
+ /** The Concierge lock file (written in the state dir) and the other default places' lock files (read too: fail closed). */
95
+ export function conciergeLockFiles(env: Readonly<Record<string, string | undefined>> = process.env, home: string = homedir()): { file: string; read: string[] } {
96
+ const { candidates, source } = resolveHubStateDir(env, home)
97
+ const [dir, ...others] = candidates
98
+ // An explicit HUB_STATE_DIR is an isolated instance (a preview or a test): it never reads the user's default dirs.
99
+ return { file: path.join(dir!, CONCIERGE_LOCK), read: source === "env" ? [] : others.map((d) => path.join(d, CONCIERGE_LOCK)) }
90
100
  }
91
101
 
92
102
  /** The runtime for this process: probes the real executable, PATH and home directory. */
@@ -152,7 +162,7 @@ export async function ensureLocalHub(server: string, options: LocalHubOptions =
152
162
  if (!(options.webBuilt ?? hasWebBuild)(root)) return { status: "failed", error: WEB_NOT_BUILT }
153
163
 
154
164
  if (options.stateDir === undefined && !hubEnv(env).HUB_STATE_DIR) {
155
- migrateFirst(legacyPaths(stateBase(home)), stateDir, { skip: ["hub.pid"], ...(options.log ? { log: options.log } : {}) })
165
+ migrateStateDir(stateLegacyPaths(env, home), stateDir, { skip: ["hub.pid"], ...(options.log ? { log: options.log } : {}) })
156
166
  }
157
167
  mkdirSync(stateDir, { recursive: true, mode: 0o700 })
158
168
 
@@ -10,6 +10,7 @@ export interface PermissionLocation {
10
10
  get(input: { sessionID: string; requestID: string }): Promise<unknown>
11
11
  /** `message`: a reject's reason (OpenCode's reply message). */
12
12
  reply(input: { sessionID: string; requestID: string; decision: PermissionDecision; message?: string }): Promise<unknown>
13
+ vcs?: { diff(): Promise<ReadonlyArray<unknown>>; branch(): Promise<string | undefined> }
13
14
  }
14
15
 
15
16
  /**
@@ -33,3 +34,42 @@ export async function replyPermission(
33
34
  }
34
35
  return "gone"
35
36
  }
37
+
38
+ /** A directory's branch and working-copy changes (undefined: nothing could read them). */
39
+ export interface VcsReader {
40
+ diff(directory: string): Promise<ReadonlyArray<unknown> | undefined>
41
+ branch(directory: string): Promise<string | undefined>
42
+ }
43
+
44
+ /**
45
+ * Reads a directory's VCS state through the plugin instance whose location is that directory, else the own
46
+ * server's API (which honours the location), else not at all. OpenCode's plugin vcs API answers for the
47
+ * instance's own location whatever location is passed, so the first instance (the background service's is
48
+ * its $HOME) would report another directory's branch and changes. A read that takes longer than `timeoutMs` (a hung
49
+ * server) answers undefined, so the session summary goes on without branch and changes.
50
+ */
51
+ /** How long a branch or changes read may take before the summary goes on without it (a hung server). */
52
+ export const VCS_READ_TIMEOUT_MS = 5_000
53
+
54
+ /** The value, or undefined once `ms` passed (the read is left to finish on its own). */
55
+ function within<T>(read: Promise<T | undefined>, ms: number): Promise<T | undefined> {
56
+ let timer: ReturnType<typeof setTimeout> | undefined
57
+ const late = new Promise<undefined>((resolve) => {
58
+ timer = setTimeout(() => resolve(undefined), ms)
59
+ })
60
+ return Promise.race([read.catch(() => undefined), late]).finally(() => clearTimeout(timer))
61
+ }
62
+
63
+ export function vcsReader(locations: () => readonly PermissionLocation[], server: () => Promise<VcsReader | undefined>, timeoutMs: number = VCS_READ_TIMEOUT_MS): VcsReader {
64
+ const local = (directory: string) => locations().find((l) => l.directory === directory && l.vcs)?.vcs
65
+ return {
66
+ diff: async (directory) => {
67
+ const vcs = local(directory)
68
+ return within(vcs ? vcs.diff() : server().then((reader) => reader?.diff(directory)), timeoutMs)
69
+ },
70
+ branch: async (directory) => {
71
+ const vcs = local(directory)
72
+ return within(vcs ? vcs.branch() : server().then((reader) => reader?.branch(directory)), timeoutMs)
73
+ },
74
+ }
75
+ }
@@ -16,7 +16,7 @@
16
16
  // the plugin's state dir (lockFile), so a restarted OpenCode locks the session before the hub reconnects. The agent
17
17
  // a locked session was last seen on is reported to the hub (SessionInfo.agent), which suspends a Concierge found on
18
18
  // another agent.
19
- import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
19
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
20
20
  import path from "node:path"
21
21
  import { CONCIERGE_TOOLS, SESSION_CONTEXT_KEYS, type SessionContextRequest, type SessionContextResult, toolDenied } from "../../packages/protocol/src/concierge.ts"
22
22
 
@@ -43,6 +43,62 @@ export interface SessionContextsOptions {
43
43
  readonly maxSessions?: number
44
44
  /** Where locked session IDs persist (the plugin's state dir); in memory only without it. */
45
45
  readonly lockFile?: string
46
+ /** (FX-07) How long a write waits for the other processes' exclusive lock before it writes anyway. */
47
+ readonly lockWaitMs?: number
48
+ /**
49
+ * Other lock files read (never written): the other default state dir places (FX-08: a lock written before an
50
+ * upgrade moved the state dir still locks the session; fail closed).
51
+ */
52
+ readonly readLockFiles?: readonly string[]
53
+ }
54
+
55
+ /** (FX-07) A `<lockFile>.lock` older than this belongs to a process that died while writing: it is broken. */
56
+ const WRITE_LOCK_STALE_MS = 10_000
57
+ const WRITE_LOCK_WAIT_MS = 2_000
58
+ const pause = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
59
+
60
+ /**
61
+ * (FX-07) Runs `write` holding `<file>.lock` (created exclusively), so the read-merge-write of every OpenCode process
62
+ * on the machine is serialized and none drops another's locks. A stale lock (its holder died) is broken; after
63
+ * `waitMs` the write runs anyway (the lock still holds in this process, and the write merges what it reads).
64
+ */
65
+ function underWriteLock(file: string, waitMs: number, write: () => void): void {
66
+ const lock = `${file}.lock`
67
+ const deadline = Date.now() + waitMs
68
+ let held = false
69
+ for (;;) {
70
+ try {
71
+ closeSync(openSync(lock, "wx", 0o600))
72
+ held = true
73
+ break
74
+ } catch (cause) {
75
+ if ((cause as NodeJS.ErrnoException).code !== "EEXIST") break
76
+ }
77
+ try {
78
+ if (Date.now() - statSync(lock).mtimeMs > WRITE_LOCK_STALE_MS) {
79
+ unlinkSync(lock)
80
+ continue
81
+ }
82
+ } catch {
83
+ continue // released meanwhile
84
+ }
85
+ if (Date.now() >= deadline) {
86
+ console.error("[hub] the Concierge session lock file stayed busy: writing without it")
87
+ break
88
+ }
89
+ pause(2 + Math.floor(Math.random() * 5))
90
+ }
91
+ try {
92
+ write()
93
+ } finally {
94
+ if (held) {
95
+ try {
96
+ unlinkSync(lock)
97
+ } catch {
98
+ // already gone
99
+ }
100
+ }
101
+ }
46
102
  }
47
103
 
48
104
  export class SessionContexts {
@@ -60,46 +116,62 @@ export class SessionContexts {
60
116
  this.locks = new Set(this.load())
61
117
  }
62
118
 
119
+ /** Every lock file read: the written one first, then the read-only ones. */
120
+ private files(): string[] {
121
+ return [...(this.options.lockFile ? [this.options.lockFile] : []), ...(this.options.readLockFiles ?? [])]
122
+ }
123
+
63
124
  /** Adds the locks another process wrote since the last read (locks are never removed). */
64
125
  private refresh(): void {
65
- const file = this.options.lockFile
66
- if (!file) return
67
- let stamp: string
68
- try {
69
- stamp = stampOf(file)
70
- } catch {
71
- return
72
- }
126
+ const files = this.files()
127
+ if (!files.length) return
128
+ const stamp = files
129
+ .map((file) => {
130
+ try {
131
+ return stampOf(file)
132
+ } catch {
133
+ return "-"
134
+ }
135
+ })
136
+ .join("|")
73
137
  if (stamp === this.lockStamp) return
74
138
  this.lockStamp = stamp
75
139
  for (const id of this.load()) this.locks.add(id)
76
140
  }
77
141
 
142
+ /** The union of the lock files' sessions (a missing or corrupt file adds none). */
78
143
  private load(): string[] {
79
- if (!this.options.lockFile) return []
80
- try {
81
- const raw = JSON.parse(readFileSync(this.options.lockFile, "utf8")) as { v?: unknown; sessions?: unknown }
82
- return raw.v === 1 && Array.isArray(raw.sessions) ? raw.sessions.filter((id): id is string => typeof id === "string" && id.length > 0 && id.length <= 200) : []
83
- } catch {
84
- return []
85
- }
144
+ return this.files().flatMap((file) => {
145
+ try {
146
+ const raw = JSON.parse(readFileSync(file, "utf8")) as { v?: unknown; sessions?: unknown }
147
+ return raw.v === 1 && Array.isArray(raw.sessions) ? raw.sessions.filter((id): id is string => typeof id === "string" && id.length > 0 && id.length <= 200) : []
148
+ } catch {
149
+ return []
150
+ }
151
+ })
86
152
  }
87
153
 
88
154
  private lock(sessionID: string): void {
89
155
  this.refresh()
90
156
  if (this.locks.has(sessionID)) return
91
- // Merge with what other processes wrote: never drop their locks.
92
- for (const id of this.load()) this.locks.add(id)
93
157
  this.locks.add(sessionID)
94
- while (this.locks.size > LOCKS_MAX) this.locks.delete(this.locks.values().next().value as string)
95
158
  const file = this.options.lockFile
96
- if (!file) return
159
+ if (!file) return this.boundLocks()
97
160
  try {
98
161
  mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
99
- const tmp = `${file}.${process.pid}.tmp`
100
- writeFileSync(tmp, `${JSON.stringify({ v: 1, sessions: [...this.locks] })}\n`, { mode: 0o600 })
101
- renameSync(tmp, file)
102
- this.lockStamp = stampOf(file)
162
+ underWriteLock(file, this.options.lockWaitMs ?? WRITE_LOCK_WAIT_MS, () => {
163
+ // Merge with what other processes wrote (read under the write lock): never drop their locks.
164
+ for (const id of this.load()) this.locks.add(id)
165
+ // The newest lock last, so the bound never drops it.
166
+ this.locks.delete(sessionID)
167
+ this.locks.add(sessionID)
168
+ this.boundLocks()
169
+ const tmp = `${file}.${process.pid}.tmp`
170
+ writeFileSync(tmp, `${JSON.stringify({ v: 1, sessions: [...this.locks] })}\n`, { mode: 0o600 })
171
+ renameSync(tmp, file)
172
+ })
173
+ this.lockStamp = ""
174
+ this.refresh()
103
175
  } catch (cause) {
104
176
  // The lock still holds in this process; the hub re-sends the context after a restart.
105
177
  console.error(`[hub] could not persist the Concierge session lock: ${(cause as Error).message}`)
@@ -126,6 +198,11 @@ export class SessionContexts {
126
198
  return { revision: req.revision }
127
199
  }
128
200
 
201
+ /** At most LOCKS_MAX locks (the oldest dropped). */
202
+ private boundLocks(): void {
203
+ while (this.locks.size > LOCKS_MAX) this.locks.delete(this.locks.values().next().value as string)
204
+ }
205
+
129
206
  private bound(map: Map<string, unknown>): void {
130
207
  while (map.size > this.maxSessions) map.delete(map.keys().next().value as string)
131
208
  }
@@ -166,11 +243,14 @@ export class SessionContexts {
166
243
  return true
167
244
  }
168
245
 
169
- /** What the sessions frame says about the session (locked ones only): its agent and context revision. */
170
- report(sessionID: string): { agent?: string; contextRevision?: number } {
246
+ /**
247
+ * What the sessions frame says about the session (locked ones only): locked (FX-07: from the very first frame, even
248
+ * after a restart, before its agent is read or the hub re-sends a context), its agent and context revision.
249
+ */
250
+ report(sessionID: string): { locked?: true; agent?: string; contextRevision?: number } {
171
251
  if (!this.locks.has(sessionID)) return {}
172
252
  const agent = this.agents.get(sessionID)
173
253
  const revision = this.contexts.get(sessionID)?.revision
174
- return { ...(agent ? { agent } : {}), ...(revision !== undefined ? { contextRevision: revision } : {}) }
254
+ return { locked: true, ...(agent ? { agent } : {}), ...(revision !== undefined ? { contextRevision: revision } : {}) }
175
255
  }
176
256
  }
@@ -147,13 +147,14 @@ export interface SpokeHost {
147
147
  * only those of the locations it loaded. Absent (a TUI's own server, which this process cannot reach): the
148
148
  * process reports only the sessions it sees active.
149
149
  */
150
- listRecent?(options?: { days: number; max: number; include: readonly string[] }): Promise<Array<{ id: string } & SessionDetails>>
150
+ /** `signal` aborts when the spoke abandons the listing because it timed out (listingTimeoutMs): stop asking the server. */
151
+ listRecent?(options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }): Promise<Array<{ id: string } & SessionDetails>>
151
152
  /** Creates a session in a directory (service processes only). */
152
153
  createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
153
154
  /** MCP servers and their status for a directory (raw OpenCode McpServer list). */
154
155
  mcpList?(directory: string): Promise<ReadonlyArray<unknown>>
155
- /** Working-tree changes of a directory (raw OpenCode FileDiffInfo list). */
156
- vcsDiff?(directory: string): Promise<ReadonlyArray<unknown>>
156
+ /** Working-tree changes of a directory (raw OpenCode FileDiffInfo list); undefined when nothing can read that directory. */
157
+ vcsDiff?(directory: string): Promise<ReadonlyArray<unknown> | undefined>
157
158
  /** Current branch of a directory. */
158
159
  vcsBranch?(directory: string): Promise<string | undefined>
159
160
  /** (HWA-16) A location's local skill and command names (they win over a hub profile's); absent: none applied. */
@@ -187,6 +188,8 @@ const ANSWER_IN_TERMINAL = "This OpenCode process cannot answer questions from t
187
188
  export interface SpokeOptions {
188
189
  /** (CON-04) Where Concierge session locks persist (the plugin's state dir); memory only without it. */
189
190
  readonly conciergeLockFile?: string
191
+ /** Other Concierge lock files read, never written (the other default state dir places; FX-08, fail closed). */
192
+ readonly conciergeLockReadFiles?: readonly string[]
190
193
  readonly configFile: string
191
194
  readonly machineName: string
192
195
  readonly kind: ProcessKind
@@ -410,7 +413,10 @@ export class Spoke {
410
413
  ) {
411
414
  this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
412
415
  this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
413
- this.contexts = new SessionContexts(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {})
416
+ this.contexts = new SessionContexts({
417
+ ...(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {}),
418
+ ...(options.conciergeLockReadFiles?.length ? { readLockFiles: options.conciergeLockReadFiles } : {}),
419
+ })
414
420
  const localNames = host.localNames?.bind(host)
415
421
  this.profiles = new OpenCodeProfiles({
416
422
  isBusy: (id) => this.running.has(id) || this.tracker.isBusy(id),
@@ -1824,13 +1830,15 @@ export class Spoke {
1824
1830
  const limit = this.options.listingTimeoutMs ?? LISTING_TIMEOUT_MS
1825
1831
  let timer: ReturnType<typeof setTimeout> | undefined
1826
1832
  const timedOut = Symbol("timeout")
1833
+ const cancel = new AbortController()
1827
1834
  try {
1828
1835
  const keep = this.keep()
1829
1836
  const { days, max } = this.config.list
1830
- const listed = Promise.resolve(this.host.listRecent?.({ days, max, include: [...keep] }))
1837
+ const listed = Promise.resolve(this.host.listRecent?.({ days, max, include: [...keep], signal: cancel.signal }))
1831
1838
  listed.catch(() => {}) // an abandoned listing that fails later is not an unhandled rejection
1832
1839
  const result = await Promise.race([listed, new Promise<typeof timedOut>((resolve) => (timer = setTimeout(() => resolve(timedOut), limit)))])
1833
1840
  if (result === timedOut) {
1841
+ cancel.abort(new Error(`listing timed out after ${limit} ms`))
1834
1842
  if (!this.listingHung) console.error(`[hub] listing recent sessions timed out after ${limit} ms; listing again later`)
1835
1843
  this.listingHung = true
1836
1844
  return
@@ -0,0 +1 @@
1
+ import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-Dh1-78Cl.js";import{L as o,R as s,z as c}from"./index-lIOfSYck.js";import{t as l}from"./ArtifactWorkbench-NoFC5Sb2.js";var u=a(`<div class="feature-page av-page">`),d=a(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(a){return(()=>{var f=u();return n(f,e(l,{get id(){return a.id},layout:`page`,get lead(){return[e(t,{get when(){return o()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(i,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>c(a.id)})}})),f})()}export{f as default};
@@ -0,0 +1 @@
1
+ import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-Dh1-78Cl.js";import{t as o}from"./ArtifactWorkbench-NoFC5Sb2.js";var s=a(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(a){return e(o,{get id(){return a.artifactID},layout:`pane`,onArtifact:e=>a.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(i,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(a.artifactID)}`)),o})()},get trailing(){return a.paneTools}})}export{c as ArtifactPane};