@huynle/hub 0.8.3 → 0.8.5

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 (99) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +40 -3
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +5288 -4415
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
  8. package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
  9. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
  10. package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
  11. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
  12. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
  13. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
  14. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
  15. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
  16. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
  17. package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
  18. package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
  19. package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
  20. package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
  21. package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
  22. package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
  23. package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
  24. package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
  25. package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
  26. package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
  27. package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
  28. package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
  29. package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
  30. package/packages/cli/src/bootstrap/release/README.md +63 -9
  31. package/packages/cli/src/bootstrap/release/cli.ts +94 -16
  32. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
  33. package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
  34. package/packages/cli/src/bootstrap/release/pins.ts +60 -9
  35. package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
  36. package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
  37. package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
  38. package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
  39. package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
  40. package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
  41. package/packages/cli/src/credentials.ts +5 -2
  42. package/packages/cli/src/main.ts +3 -2
  43. package/packages/cli/src/setup/commands.ts +96 -18
  44. package/packages/cli/src/setup/contributors.ts +45 -10
  45. package/packages/cli/src/setup/runtime.ts +25 -6
  46. package/packages/cli/src/setup/self-aside.ts +130 -0
  47. package/packages/codex/package.json +1 -1
  48. package/packages/node/package.json +1 -1
  49. package/packages/node/src/opencode-warm.ts +12 -4
  50. package/packages/preset/package.json +1 -1
  51. package/packages/protocol/package.json +1 -1
  52. package/packages/protocol/src/index.ts +7 -2
  53. package/packages/runtime-openshell/package.json +1 -1
  54. package/packages/runtime-sdk/package.json +1 -1
  55. package/packages/sdk/package.json +1 -1
  56. package/packages/spoke-sdk/package.json +1 -1
  57. package/src/central/config.ts +12 -0
  58. package/src/central/http.ts +10 -0
  59. package/src/central/install/CONTRACT.md +66 -0
  60. package/src/central/install/install.ps1 +349 -0
  61. package/src/central/install/install.sh +266 -0
  62. package/src/central/install/render.ts +146 -0
  63. package/src/central/install/templates.ts +6 -0
  64. package/src/central/install/text-assets.d.ts +9 -0
  65. package/src/central/registry.ts +93 -22
  66. package/src/config.ts +12 -7
  67. package/src/index.ts +28 -0
  68. package/src/spoke/env-inject.ts +17 -2
  69. package/src/spoke/launchers.ts +145 -9
  70. package/src/spoke/spoke.ts +150 -10
  71. package/src/status.ts +70 -14
  72. package/src/win-acl.ts +154 -0
  73. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
  74. package/web/dist/assets/{ArtifactPane-DPHjiWce.js → ArtifactPane-DKwbMTX9.js} +1 -1
  75. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
  76. package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
  77. package/web/dist/assets/{ProfilesDestination-GriuOpiZ.js → ProfilesDestination-BOML2Dgf.js} +1 -1
  78. package/web/dist/assets/{TeamsPage-C-5lYeeM.js → TeamsPage-CNBTE8gB.js} +1 -1
  79. package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
  80. package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
  81. package/web/dist/assets/hub-CrbItiwP.js +13 -0
  82. package/web/dist/assets/index-82Ce0IAf.css +1 -0
  83. package/web/dist/assets/index-D9SP3_0z.js +22 -0
  84. package/web/dist/assets/{live-CDWcyavI.js → live-BNJgy0vg.js} +3 -3
  85. package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
  86. package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
  87. package/web/dist/assets/{renderer2d-CNFdTDTr.js → renderer2d-CYag1CnR.js} +1 -1
  88. package/web/dist/assets/{renderer3d-C45jPvYX.js → renderer3d-BRB6rgDJ.js} +1 -1
  89. package/web/dist/assets/{safe-area-sA9a8vYJ.js → safe-area-fOy0AOuG.js} +1 -1
  90. package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
  91. package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
  92. package/web/dist/index.html +6 -4
  93. package/web/dist/assets/ArtifactPage-CSotAWob.js +0 -1
  94. package/web/dist/assets/ArtifactWorkbench-CkmWnP6L.js +0 -6
  95. package/web/dist/assets/Library-DTLEyekn.js +0 -2
  96. package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
  97. package/web/dist/assets/Watchtower-DOlPsiIy.js +0 -2
  98. package/web/dist/assets/index-6mdQ7eUY.js +0 -34
  99. package/web/dist/assets/index-C5OmwA1y.css +0 -1
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Per-machine spoke configuration, shared by every OpenCode process on the machine and edited
2
- // from the /hub dialog. Holds the machine token, so it is written with mode 0600.
2
+ // from the /hub dialog. Holds the machine token, so it is written with mode 0600 (on Windows: an owner-only ACL).
3
3
  import { randomBytes } from "node:crypto"
4
4
  import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs"
5
5
  import { homedir } from "node:os"
@@ -7,6 +7,7 @@ import path from "node:path"
7
7
  import { hubEnv, type Env } from "./env"
8
8
  import { APP_DIR, configBase, legacyPaths, migrateFirst } from "./migrate"
9
9
  import { isHubURL } from "./protocol"
10
+ import { defaultWinAcl, isWin32, writeOwnerOnlyFile, type SecretFileOptions } from "./win-acl"
10
11
 
11
12
  export type Mode = "off" | "always" | "session"
12
13
 
@@ -169,8 +170,12 @@ export function readRawConfig(file: string): RawConfig | undefined {
169
170
  return parsed
170
171
  }
171
172
 
172
- /** Writes `content` to `file` atomically (a private temp file in the same directory, fsync, rename), mode 0600. */
173
- function writePrivate(file: string, content: string): void {
173
+ /**
174
+ * Writes `content` to `file` atomically (a private temp file in the same directory, fsync, rename), mode 0600. On
175
+ * Windows the temporary file is restricted to the current user (an ACL) before the content is written (win-acl.ts).
176
+ */
177
+ function writePrivate(file: string, content: string, options?: SecretFileOptions): void {
178
+ if (isWin32(options)) return writeOwnerOnlyFile(file, content, options?.acl ?? defaultWinAcl())
174
179
  mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
175
180
  const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`
176
181
  let fd: number | undefined
@@ -196,22 +201,22 @@ const serialize = (raw: RawConfig) => `${JSON.stringify(raw, null, 2)}\n`
196
201
  * returns the next one, written atomically with 0600. Refuses (throws, file untouched) when the file is not a JSON
197
202
  * object, so a hand-edited config is never replaced.
198
203
  */
199
- export function updateRawConfig(file: string, update: (raw: RawConfig) => RawConfig): void {
200
- writePrivate(file, serialize(update(readRawConfig(file) ?? {})))
204
+ export function updateRawConfig(file: string, update: (raw: RawConfig) => RawConfig, options?: SecretFileOptions): void {
205
+ writePrivate(file, serialize(update(readRawConfig(file) ?? {})), options)
201
206
  }
202
207
 
203
208
  /**
204
209
  * Atomic write with 0600 permissions. Lossless: keys config.json holds that VoiceConfig does not know are kept; the
205
210
  * known keys come from `config` (a known key it lacks is removed). A file that is not a JSON object is replaced.
206
211
  */
207
- export function writeConfig(file: string, config: VoiceConfig): void {
212
+ export function writeConfig(file: string, config: VoiceConfig, options?: SecretFileOptions): void {
208
213
  let previous: RawConfig = {}
209
214
  try {
210
215
  previous = readRawConfig(file) ?? {}
211
216
  } catch {}
212
217
  const next: RawConfig = { ...previous }
213
218
  for (const key of CONFIG_KEYS) delete next[key]
214
- writePrivate(file, serialize({ ...next, ...config }))
219
+ writePrivate(file, serialize({ ...next, ...config }), options)
215
220
  }
216
221
 
217
222
  /** Changing the server URL drops the machine credentials, which belong to the old server. */
package/src/index.ts CHANGED
@@ -162,6 +162,33 @@ async function activity(kind: "service" | "standalone", directories: readonly st
162
162
  return { active: Object.keys(active ?? {}), jobs }
163
163
  }
164
164
 
165
+ /**
166
+ * What OpenCode waits for in each session on this process's own server: its pending forms (session.form.list) and
167
+ * permission requests (session.permission.list). A session that cannot be read is left out; nothing when the
168
+ * server cannot be reached.
169
+ */
170
+ async function pending(kind: "service" | "standalone", sessionIDs: readonly string[]) {
171
+ const client = await ownClient(kind).catch(() => undefined)
172
+ if (!client) return []
173
+ const out: Array<{ sessionID: string; forms: unknown[]; permissions: unknown[] }> = []
174
+ // One budget for the whole listing (the spoke gives up after 5 s too): a hung server stops being asked.
175
+ const signal = AbortSignal.timeout(PENDING_LIST_TIMEOUT_MS)
176
+ for (const sessionID of sessionIDs) {
177
+ if (signal.aborted) break
178
+ try {
179
+ const [forms, permissions] = await Promise.all([client.session.form.list({ sessionID }, { signal }), client.permission.list({ sessionID }, { signal })])
180
+ const list = (v: unknown) => unwrap<unknown[]>(v, Array.isArray)
181
+ out.push({ sessionID, forms: Array.isArray(list(forms)) ? list(forms) : [], permissions: Array.isArray(list(permissions)) ? list(permissions) : [] })
182
+ } catch {
183
+ // a session this server does not have (any more)
184
+ }
185
+ }
186
+ return out
187
+ }
188
+
189
+ /** How long one listing of pending forms and permissions may take on the own server. */
190
+ const PENDING_LIST_TIMEOUT_MS = 5000
191
+
165
192
  /** Plugin API results may arrive bare or wrapped as { location, data }. */
166
193
  function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
167
194
  const v = value as any
@@ -253,6 +280,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
253
280
  },
254
281
  ...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
255
282
  activity: (directories) => activity(kind, directories),
283
+ pending: (sessionIDs) => pending(kind, sessionIDs),
256
284
  // Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
257
285
  ...(canList(kind)
258
286
  ? {
@@ -114,13 +114,28 @@ export function stripInheritedHubEnv(env: Env): void {
114
114
  for (const name of HUB_VARS) delete env[name]
115
115
  }
116
116
 
117
- /** Strips inherited HUB_*, sets the session's identity when it has one, and puts `binDir` first on PATH once. */
118
- export function applyHubEnv(env: Env, identity: Identity | undefined, binDir: string): void {
117
+ /**
118
+ * Strips inherited HUB_*, sets the session's identity when it has one, and puts `binDir` first on PATH once. On
119
+ * win32 the variable is case-insensitive (usually `Path`) and ';'-separated: the one key keeps its name, entries
120
+ * compare without case, and no second `PATH` key is added (which one a child sees would be undefined).
121
+ */
122
+ export function applyHubEnv(env: Env, identity: Identity | undefined, binDir: string, platform: string = process.platform): void {
119
123
  stripInheritedHubEnv(env)
120
124
  if (identity) {
121
125
  if (identity.url) env.HUB_URL = identity.url
122
126
  env.HUB_SESSION_TOKEN = identity.token
123
127
  }
128
+ if (platform === "win32") {
129
+ const keys = Object.keys(env).filter((k) => k.toLowerCase() === "path")
130
+ const key = keys.find((k) => env[k] !== undefined) ?? keys[0] ?? "Path"
131
+ const value = env[key]
132
+ for (const k of keys) delete env[k]
133
+ const same = (dir: string) => dir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === binDir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
134
+ const root = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows"
135
+ const rest = value === undefined ? [`${root}\\System32`, root] : value.split(";").filter((dir) => dir && !same(dir))
136
+ env[key] = [binDir, ...rest].join(";")
137
+ return
138
+ }
124
139
  const rest = env.PATH === undefined ? DEFAULT_PATH : env.PATH.split(path.delimiter).filter((dir) => dir !== binDir)
125
140
  env.PATH = [binDir, ...rest].join(path.delimiter)
126
141
  }
@@ -13,8 +13,16 @@
13
13
  // when nothing is there or it is hub's own link, and never when another `hub` is on PATH. ~/.local/bin/hubctl is left
14
14
  // to `hubctl setup`: it is the standalone hubctl's install path (a link there would make that install refuse to write
15
15
  // through it, and the launchers would exec themselves).
16
+ //
17
+ // On Windows (Phase 2) there are no sh scripts and no symlinks (they need Developer Mode or admin): the hub bin dir
18
+ // gets hub.cmd and hubctl.cmd, which run ~\.local\bin\hubctl.exe when it is installed (decided at plugin start)
19
+ // and the bundle otherwise, with BUN_BE_BUN set only inside the launcher (setlocal). ~\.local\bin\hub.cmd is a cmd
20
+ // shim (packages/cli/src/bootstrap/transaction/shim.ts) to the bin dir's hub.cmd, under the same user-bin rule;
21
+ // hubctl's own hub.cmd (a shim to hubctl.exe, link-hub) is the authority and is never touched. Older plugin versions
22
+ // left POSIX launchers and a ~/.local/bin/hub symlink there; being hub's own, they are removed.
16
23
  import { accessSync, chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"
17
24
  import path from "node:path"
25
+ import { cmdShim, cmdWord, parseCmdShim } from "../../packages/cli/src/bootstrap/transaction/shim.ts"
18
26
  import { userBinLinkDecision, type UserBinEntry } from "../../packages/cli/src/setup/user-bin.ts"
19
27
 
20
28
  export const LAUNCHERS = ["hub", "hubctl"] as const
@@ -31,19 +39,35 @@ export interface LauncherOptions {
31
39
  readonly bun: boolean
32
40
  /** The CLI entry the runtime runs. */
33
41
  readonly entry: string
34
- /** The standalone hubctl to prefer when it exists (standaloneHubctl(home)). */
42
+ /**
43
+ * The standalone hubctl to prefer when it exists (standaloneHubctl(home)). POSIX: checked when the launcher runs;
44
+ * win32: installLaunchers passes it only when it exists, and launcherScript then runs it unconditionally.
45
+ */
35
46
  readonly standalone?: string
47
+ /** The platform (default process.platform): "win32" writes cmd launchers. */
48
+ readonly platform?: string
36
49
  }
37
50
 
38
- /** Where `hubctl setup` installs the standalone hubctl (contract user-bin:hubctl). */
39
- export const standaloneHubctl = (home: string): string => path.join(home, ".local", "bin", "hubctl")
51
+ const isWin = (platform: string | undefined) => (platform ?? process.platform) === "win32"
52
+
53
+ /** Where `hubctl setup` installs the standalone hubctl (contract user-bin:hubctl, user-bin:hubctl.exe on Windows). */
54
+ export const standaloneHubctl = (home: string, platform: string = process.platform): string => path.join(home, ".local", "bin", platform === "win32" ? "hubctl.exe" : "hubctl")
40
55
 
41
56
  /** A word quoted for sh. */
42
57
  const quote = (word: string) => `'${word.replaceAll("'", `'\\''`)}'`
43
58
 
44
59
  const LAUNCHER_HEADER = "#!/bin/sh\n# hub CLI launcher"
60
+ const CMD_HEADER = "@rem hub CLI launcher"
61
+
62
+ /** win32: a cmd file (CRLF, ASCII; every path one quoted word, see cmdWord). */
63
+ function cmdLauncher({ execPath, bun, entry, standalone }: LauncherOptions): string {
64
+ const lines = standalone ? [`@${cmdWord(standalone)} %*`] : bun ? ["@setlocal", "@set BUN_BE_BUN=1", `@${cmdWord(execPath)} ${cmdWord(entry)} %*`] : [`@${cmdWord(execPath)} ${cmdWord(entry)} %*`]
65
+ return [`${CMD_HEADER}, written by the hub OpenCode plugin at start (edits are overwritten).`, ...lines, ""].join("\r\n")
66
+ }
45
67
 
46
- export function launcherScript({ execPath, bun, entry, standalone }: LauncherOptions): string {
68
+ export function launcherScript(options: LauncherOptions): string {
69
+ if (isWin(options.platform)) return cmdLauncher(options)
70
+ const { execPath, bun, entry, standalone } = options
47
71
  const command = bun ? `exec env BUN_BE_BUN=1 ${quote(execPath)} ${quote(entry)} "$@"` : `exec node ${quote(entry)} "$@"`
48
72
  const prefer = standalone ? `if [ -x ${quote(standalone)} ]; then exec ${quote(standalone)} "$@"; fi\n` : ""
49
73
  return `${LAUNCHER_HEADER}, written by the hub OpenCode plugin at start (edits are overwritten).\n${prefer}${command}\n`
@@ -62,6 +86,7 @@ export function cliEntry(root: string, bun: boolean): string | undefined {
62
86
  * reads half a file). Returns the names written.
63
87
  */
64
88
  export function installLaunchers(options: LauncherOptions & { readonly binDir: string }): { written: string[] } {
89
+ if (isWin(options.platform)) return installCmdLaunchers(options)
65
90
  const script = launcherScript(options)
66
91
  mkdirSync(options.binDir, { recursive: true, mode: 0o700 })
67
92
  const written: string[] = []
@@ -86,6 +111,34 @@ export function installLaunchers(options: LauncherOptions & { readonly binDir: s
86
111
  return { written }
87
112
  }
88
113
 
114
+ /** win32: hub.cmd and hubctl.cmd (no modes); an older plugin's POSIX launchers there are removed. */
115
+ function installCmdLaunchers(options: LauncherOptions & { readonly binDir: string }): { written: string[] } {
116
+ const { standalone, ...rest } = options
117
+ const script = launcherScript({ ...rest, ...(standalone && existsSync(standalone) ? { standalone } : {}) })
118
+ mkdirSync(options.binDir, { recursive: true, mode: 0o700 })
119
+ const written: string[] = []
120
+ for (const base of LAUNCHERS) {
121
+ const stale = path.join(options.binDir, base)
122
+ if (entryOf(stale) === "file" && isLauncher(stale)) rmSync(stale, { force: true })
123
+ const name = `${base}.cmd`
124
+ const file = path.join(options.binDir, name)
125
+ if (readText(file) === script) continue
126
+ const tmp = `${file}.${process.pid}.tmp`
127
+ writeFileSync(tmp, script)
128
+ renameSync(tmp, file)
129
+ written.push(name)
130
+ }
131
+ return { written }
132
+ }
133
+
134
+ const readText = (file: string): string | undefined => {
135
+ try {
136
+ return readFileSync(file, "latin1")
137
+ } catch {
138
+ return undefined
139
+ }
140
+ }
141
+
89
142
  /** The user's own bin dir (contract root user-bin). */
90
143
  export const userBinDir = (home: string): string => path.join(home, ".local", "bin")
91
144
 
@@ -98,13 +151,13 @@ function entryOf(file: string): UserBinEntry {
98
151
  }
99
152
  }
100
153
 
101
- /** A file that starts like the launchers hub writes. */
102
- function isLauncher(file: string): boolean {
154
+ /** A file that starts like the launchers hub writes (the cmd ones on win32). */
155
+ function isLauncher(file: string, header: string = LAUNCHER_HEADER): boolean {
103
156
  let fd: number | undefined
104
157
  try {
105
158
  fd = openSync(file, "r")
106
- const buffer = Buffer.alloc(LAUNCHER_HEADER.length)
107
- return readSync(fd, buffer, 0, buffer.length, 0) === buffer.length && buffer.toString("utf8") === LAUNCHER_HEADER
159
+ const buffer = Buffer.alloc(header.length)
160
+ return readSync(fd, buffer, 0, buffer.length, 0) === buffer.length && buffer.toString("utf8") === header
108
161
  } catch {
109
162
  return false
110
163
  } finally {
@@ -126,17 +179,100 @@ function onPath(name: string, searchPath: string, skip: string): string | undefi
126
179
  return undefined
127
180
  }
128
181
 
182
+ /** win32: PATH entries compare without case, separator style or a trailing separator. */
183
+ const winKey = (dir: string) => dir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
184
+
185
+ /** win32: the first `name` with a PATHEXT extension on the ';'-separated `searchPath`, outside `skip`. */
186
+ function onWinPath(name: string, searchPath: string, skip: string): string | undefined {
187
+ const exts = [".com", ".exe", ".bat", ".cmd"]
188
+ for (const dir of searchPath.split(";")) {
189
+ if (!dir || winKey(dir) === winKey(skip)) continue
190
+ for (const ext of exts) {
191
+ const file = path.join(dir, `${name}${ext}`)
192
+ try {
193
+ if (statSync(file).isFile()) return file
194
+ } catch {}
195
+ }
196
+ }
197
+ return undefined
198
+ }
199
+
200
+ /** win32: what a cmd file is, read as the user-bin rule sees links: a hub cmd shim is a link to its target. */
201
+ function cmdEntryOf(file: string): { entry: UserBinEntry; pointsTo?: string } {
202
+ const entry = entryOf(file)
203
+ if (entry !== "file") return { entry }
204
+ let bytes: Uint8Array
205
+ try {
206
+ bytes = readFileSync(file)
207
+ } catch {
208
+ return { entry }
209
+ }
210
+ const target = parseCmdShim(bytes)
211
+ return target === undefined ? { entry } : { entry: "symlink", pointsTo: target }
212
+ }
213
+
214
+ /** win32: linkUserBin with cmd shims (see the header). */
215
+ function linkUserBinCmd(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined }): { linked: string[]; hint?: string } {
216
+ const userBin = userBinDir(options.home)
217
+ // An older plugin's symlink ~/.local/bin/hub → its POSIX launcher: hub's own, and unusable here.
218
+ const old = path.join(userBin, "hub")
219
+ if (entryOf(old) === "symlink") {
220
+ let to: string | undefined
221
+ try {
222
+ to = path.resolve(userBin, readlinkSync(old))
223
+ } catch {}
224
+ if (to !== undefined && (winKey(to) === winKey(path.join(options.binDir, "hub")) || isLauncher(to))) rmSync(old, { force: true })
225
+ }
226
+ const linkPath = path.join(userBin, "hub.cmd")
227
+ const target = path.join(options.binDir, "hub.cmd")
228
+ const { entry, pointsTo } = cmdEntryOf(linkPath)
229
+ const searchPath = options.path ?? ""
230
+ const onUserPath = searchPath.split(";").some((dir) => dir && winKey(dir) === winKey(userBin))
231
+ const pathHint = onUserPath ? undefined : `${userBin} is not on PATH: add it to your user PATH to use \`hub\` in your terminal`
232
+ // hubctl's link-hub shim to the standalone hubctl.exe: the authority, keep it.
233
+ if (pointsTo !== undefined && winKey(pointsTo) === winKey(standaloneHubctl(options.home, "win32"))) return { linked: [], ...(pathHint ? { hint: pathHint } : {}) }
234
+ const foundOnPath = onWinPath("hub", searchPath, options.binDir)
235
+ const decision = userBinLinkDecision({
236
+ entry,
237
+ ...(pointsTo !== undefined ? { pointsTo: winKey(pointsTo) === winKey(target) ? target : pointsTo } : {}),
238
+ target,
239
+ linkPath,
240
+ hubOwned: (p) => isLauncher(p, CMD_HEADER),
241
+ ...(foundOnPath !== undefined && winKey(foundOnPath) !== winKey(linkPath) ? { foundOnPath } : {}),
242
+ })
243
+ if (decision === "foreign") return { linked: [] }
244
+ if (decision === "shadowed") return { linked: [], hint: `\`hub\` on your PATH is ${foundOnPath}, not hub's CLI; hub's is ${target}` }
245
+ if (decision === "in-place") return { linked: [], ...(pathHint ? { hint: pathHint } : {}) }
246
+ mkdirSync(userBin, { recursive: true })
247
+ const shim = cmdShim(target)
248
+ // A new shim never replaces what may have appeared meanwhile ("wx"); hub's own is replaced atomically.
249
+ if (decision === "create") writeFileSync(linkPath, shim, { flag: "wx" })
250
+ else {
251
+ const tmp = `${linkPath}.${process.pid}.tmp`
252
+ rmSync(tmp, { force: true })
253
+ writeFileSync(tmp, shim)
254
+ try {
255
+ renameSync(tmp, linkPath)
256
+ } catch (cause) {
257
+ rmSync(tmp, { force: true })
258
+ throw cause
259
+ }
260
+ }
261
+ return { linked: ["hub.cmd"], ...(pathHint ? { hint: pathHint } : {}) }
262
+ }
263
+
129
264
  /**
130
265
  * Links ~/.local/bin/hub to `binDir`/hub (see the header). Returns what it linked, and one hint when the user's
131
266
  * terminal will not find it (~/.local/bin not on `path`, or another `hub` comes first). Small synchronous calls,
132
267
  * nothing when converged (the plugin runs this at start).
133
268
  */
134
- export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined; readonly defaultBinDir?: string | undefined }): { linked: string[]; hint?: string } {
269
+ export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined; readonly defaultBinDir?: string | undefined; readonly platform?: string }): { linked: string[]; hint?: string } {
135
270
  // Only the default hub bin dir (env-inject.ts defaultHubBinDir: the resolved state dir's bin, default
136
271
  // ~/.local/state/hub/bin): a custom HUB_STATE_DIR (tests, containers, a throwaway state) must never leave
137
272
  // ~/.local/bin pointing into it.
138
273
  const allowed = options.defaultBinDir ?? path.join(options.home, ".local", "state", "hub", "bin")
139
274
  if (path.resolve(options.binDir) !== path.resolve(allowed)) return { linked: [] }
275
+ if (isWin(options.platform)) return linkUserBinCmd(options)
140
276
  const userBin = userBinDir(options.home)
141
277
  const linkPath = path.join(userBin, "hub")
142
278
  const target = path.join(options.binDir, "hub")
@@ -141,6 +141,12 @@ export interface SpokeHost {
141
141
  * were already running when this process started, or while its event stream was down.
142
142
  */
143
143
  activity?(directories: readonly string[]): Promise<{ readonly active: readonly string[]; readonly jobs: ReadonlyArray<JobInfo & { readonly sessionID: string }> }>
144
+ /**
145
+ * What OpenCode waits for in each of the sessions, from this process's own server: pending forms (FormInfo, as in
146
+ * form.created) and permission requests (as in permission.asked). A session it could not read is left out.
147
+ * Absent, or empty when the server cannot be reached: only what this process saw asked is re-announced.
148
+ */
149
+ pending?(sessionIDs: readonly string[]): Promise<ReadonlyArray<PendingAsks>>
144
150
  /**
145
151
  * Recent root sessions with their workspace: those in the history window (`days`, at most `max`), plus `include`
146
152
  * (always listed: open, running, ...). The background service lists every session; a foreground `opencode serve`
@@ -177,6 +183,13 @@ export interface SpokeHost {
177
183
  revertCommit?(sessionID: string): Promise<unknown>
178
184
  }
179
185
 
186
+ /** A session's pending forms and permission requests as OpenCode lists them (raw, see SpokeHost.pending). */
187
+ export interface PendingAsks {
188
+ readonly sessionID: string
189
+ readonly forms: readonly unknown[]
190
+ readonly permissions: readonly unknown[]
191
+ }
192
+
180
193
  /** A form reply for OpenCode: an answer, or a cancel. */
181
194
  export type FormAction =
182
195
  | { readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer }
@@ -205,6 +218,8 @@ export interface SpokeOptions {
205
218
  readonly forkNoteTimeoutMs?: number
206
219
  readonly snapshotIntervalMs?: number
207
220
  readonly seedIntervalMs?: number
221
+ /** Longest the own server may take to list a seed's pending forms and permissions (default 5 s). */
222
+ readonly pendingTimeoutMs?: number
208
223
  /** Delay before re-listing the service's sessions after a session event (default 2 s). */
209
224
  readonly seedDebounceMs?: number
210
225
  /** Longest one session listing may take before it is abandoned and later listings run (default 60 s). */
@@ -389,6 +404,15 @@ export class Spoke {
389
404
  private started = false
390
405
  /** A seedActivity call is in flight. */
391
406
  private seeding = false
407
+ /**
408
+ * Pending permissions and forms (`per <id>` / `frm <id>`) with the event that asked them, as forwarded: the hub
409
+ * keeps them only as long as this connection, so they are announced again after a reconnect (see reannounce).
410
+ * `seq` orders them against a pending listing (one asked while OpenCode was being asked is never closed by it).
411
+ */
412
+ private readonly asks = new Map<string, { sessionID: string; event: { type: string; data: Record<string, unknown> }; seq: number }>()
413
+ private askSeq = 0
414
+ /** Asks seen answered or cancelled (an ID never comes back): a listing taken before the reply cannot revive them. */
415
+ private readonly closedAsks = new Set<string>()
392
416
  /** Sessions shown by OpenCode TUIs (voice.presence). */
393
417
  private readonly presenceBook = new PresenceBook()
394
418
  /** Form replies waiting for a TUI to carry them out (when the plugin cannot reach its own server). */
@@ -907,6 +931,9 @@ export class Spoke {
907
931
  onWelcome: () => {
908
932
  this.sendSnapshot()
909
933
  this.sendTerminals(true)
934
+ // A new connection: the hub dropped what was pending with the old one (or restarted). What this process
935
+ // remembers goes out again at once; the seed then asks OpenCode what it still waits for.
936
+ this.reannounce()
910
937
  void this.seedActivity()
911
938
  },
912
939
  onState: (state, error) => {
@@ -1784,6 +1811,7 @@ export class Spoke {
1784
1811
  const title = (event.data as Record<string, unknown>).title
1785
1812
  if (typeof title === "string") for (const waiter of this.renameWaiters.get(sessionID) ?? []) waiter(title)
1786
1813
  }
1814
+ this.trackAsk(event)
1787
1815
  if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
1788
1816
  if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
1789
1817
  if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
@@ -1791,21 +1819,123 @@ export class Spoke {
1791
1819
  this.scheduleSeed()
1792
1820
  }
1793
1821
  void this.fetchInfo()
1794
- if (this.connection?.state === "connected" && (FORWARDED_EVENTS.has(event.type) || COMPACTED_EVENTS.has(event.type))) {
1795
- // Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
1796
- // get their session from the job.
1797
- const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
1798
- if (compact && shouldForward(this.config, this.tracker, compact)) {
1799
- this.announce(compact)
1800
- this.connection.send({ t: "event", event: compact })
1801
- if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
1802
- }
1803
- }
1822
+ this.forward(event)
1804
1823
  } catch (cause) {
1805
1824
  console.error("[hub] event handling failed", cause)
1806
1825
  }
1807
1826
  }
1808
1827
 
1828
+ /** Sends an OpenCode event to the hub (compact), when connected and the hub may see its session. */
1829
+ private forward(event: EventLike): void {
1830
+ if (this.connection?.state !== "connected" || (!FORWARDED_EVENTS.has(event.type) && !COMPACTED_EVENTS.has(event.type))) return
1831
+ // Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
1832
+ // get their session from the job.
1833
+ const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
1834
+ if (!compact || !shouldForward(this.config, this.tracker, compact)) return
1835
+ this.announce(compact)
1836
+ this.connection.send({ t: "event", event: compact })
1837
+ const sessionID = (event.data as Record<string, unknown> | undefined)?.sessionID
1838
+ if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
1839
+ }
1840
+
1841
+ /** Keeps `asks` current: a permission or form asked, replied, cancelled, or its session deleted. */
1842
+ private trackAsk(event: EventLike): void {
1843
+ const data = (event.data ?? {}) as Record<string, any>
1844
+ if (event.type === "permission.asked" && typeof data.id === "string" && typeof data.sessionID === "string") {
1845
+ this.rememberAsk(`per ${data.id}`, data.sessionID, event)
1846
+ } else if (event.type === "form.created" && typeof data.form?.id === "string" && typeof data.form?.sessionID === "string") {
1847
+ this.rememberAsk(`frm ${data.form.id}`, data.form.sessionID, event)
1848
+ } else if (event.type === "permission.replied" && typeof data.requestID === "string") this.closeAsk(`per ${data.requestID}`)
1849
+ else if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string") this.closeAsk(`frm ${data.id}`)
1850
+ else if (event.type === "session.deleted" && typeof data.sessionID === "string") {
1851
+ for (const [key, ask] of this.asks) if (ask.sessionID === data.sessionID) this.asks.delete(key)
1852
+ }
1853
+ }
1854
+
1855
+ private closeAsk(key: string): void {
1856
+ this.asks.delete(key)
1857
+ this.closedAsks.add(key)
1858
+ if (this.closedAsks.size > ASKS_CLOSED_KEPT) this.closedAsks.delete(this.closedAsks.values().next().value!)
1859
+ }
1860
+
1861
+ private rememberAsk(key: string, sessionID: string, event: EventLike): void {
1862
+ if (this.closedAsks.has(key)) return
1863
+ this.asks.delete(key)
1864
+ this.asks.set(key, { sessionID, event: { type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, seq: ++this.askSeq })
1865
+ if (this.asks.size > ASKS_KEPT) this.asks.delete(this.asks.keys().next().value!)
1866
+ }
1867
+
1868
+ /**
1869
+ * Announces every pending permission and form again (after a reconnect: the hub dropped them with the old
1870
+ * connection). A hub without "pendingReplay" would notify a re-sent permission again: it gets the forms only.
1871
+ */
1872
+ private reannounce(): void {
1873
+ const permissions = this.hubSupports("pendingReplay")
1874
+ for (const ask of [...this.asks.values()]) if (permissions || ask.event.type !== "permission.asked") this.forward(ask.event)
1875
+ }
1876
+
1877
+ /**
1878
+ * Asks OpenCode what the sessions wait for (the active ones, and those with something pending here): forms and
1879
+ * permissions this process never saw asked (it started afterwards, or its stream was down) are announced; those
1880
+ * it remembers that OpenCode no longer lists (answered while the stream was down) are closed on the hub.
1881
+ */
1882
+ private async reconcileAsks(active: readonly string[]): Promise<void> {
1883
+ const pending = this.host.pending
1884
+ if (!pending) return
1885
+ // Sessions with something remembered first: a reply missed while the stream was down is only seen by asking.
1886
+ const ids = [...new Set([...[...this.asks.values()].map((a) => a.sessionID).reverse(), ...active])].slice(0, PENDING_SESSIONS_MAX)
1887
+ if (!ids.length) return
1888
+ const asked = this.askSeq
1889
+ const limit = this.options.pendingTimeoutMs ?? PENDING_TIMEOUT_MS
1890
+ let timer: ReturnType<typeof setTimeout> | undefined
1891
+ let lists: ReadonlyArray<PendingAsks>
1892
+ try {
1893
+ const listed = Promise.resolve(pending.call(this.host, ids))
1894
+ listed.catch(() => {}) // a listing that fails after the timeout is not an unhandled rejection
1895
+ const timeout = new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new Error(`no answer within ${limit} ms`)), limit)))
1896
+ lists = await Promise.race([listed, timeout])
1897
+ } catch (cause) {
1898
+ console.error("[hub] could not read pending questions and permissions", errorText(cause))
1899
+ return
1900
+ } finally {
1901
+ clearTimeout(timer)
1902
+ }
1903
+ for (const list of lists) {
1904
+ if (!ids.includes(list.sessionID)) continue
1905
+ const listed = new Set<string>()
1906
+ const found: EventLike[] = []
1907
+ for (const form of Array.isArray(list.forms) ? list.forms : []) {
1908
+ const f = form as Record<string, unknown> | null
1909
+ if (typeof f?.id !== "string" || f.sessionID !== list.sessionID) continue
1910
+ listed.add(`frm ${f.id}`)
1911
+ if (!this.asks.has(`frm ${f.id}`) && !this.closedAsks.has(`frm ${f.id}`)) found.push({ type: "form.created", data: { form: f } })
1912
+ }
1913
+ for (const request of Array.isArray(list.permissions) ? list.permissions : []) {
1914
+ const r = request as Record<string, unknown> | null
1915
+ if (typeof r?.id !== "string" || r.sessionID !== list.sessionID) continue
1916
+ listed.add(`per ${r.id}`)
1917
+ if (!this.asks.has(`per ${r.id}`) && !this.closedAsks.has(`per ${r.id}`)) found.push({ type: "permission.asked", data: r })
1918
+ }
1919
+ const gone: EventLike[] = []
1920
+ for (const [key, ask] of this.asks) {
1921
+ if (ask.sessionID !== list.sessionID || ask.seq > asked || listed.has(key)) continue
1922
+ const id = key.slice(4)
1923
+ // unlisted: the hub drops only this process's hold on it (another process may still hold it).
1924
+ gone.push(
1925
+ key.startsWith("per ")
1926
+ ? { type: "permission.replied", data: { sessionID: ask.sessionID, requestID: id, unlisted: true } }
1927
+ : { type: "form.cancelled", data: { sessionID: ask.sessionID, id, unlisted: true } },
1928
+ )
1929
+ }
1930
+ for (const event of [...found, ...gone]) {
1931
+ this.trackAsk(event)
1932
+ this.tasks.observe(event, Date.now())
1933
+ if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
1934
+ this.forward(event)
1935
+ }
1936
+ }
1937
+ }
1938
+
1809
1939
  /**
1810
1940
  * Adds who put the item in the inbox (user or hub) to session.inbox.delivered, for hubs that route messages
1811
1941
  * (they reset reply-chain hops on a user's turn); when unknown, the event goes out unchanged.
@@ -1878,6 +2008,7 @@ export class Spoke {
1878
2008
  const event = { type: "session.seed", data: { sessionID: id, status, jobs, ...(error ? { error } : {}) } }
1879
2009
  if (this.connection?.state === "connected" && shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
1880
2010
  }
2011
+ await this.reconcileAsks([...active])
1881
2012
  void this.fetchInfo()
1882
2013
  this.scheduleSnapshot()
1883
2014
  } catch (cause) {
@@ -2030,6 +2161,15 @@ const LISTING_TIMEOUT_MS = 60_000
2030
2161
  /** Directories whose shells are listed when seeding. */
2031
2162
  const SEED_DIRECTORIES = 20
2032
2163
 
2164
+ /** Pending permissions and forms remembered to announce again (oldest dropped). */
2165
+ const ASKS_KEPT = 200
2166
+ /** Answered or cancelled asks remembered (oldest dropped). */
2167
+ const ASKS_CLOSED_KEPT = 1000
2168
+ /** Sessions whose pending forms and permissions are read from OpenCode in one seed. */
2169
+ const PENDING_SESSIONS_MAX = 50
2170
+ /** Longest the own server may take to list them (SpokeOptions.pendingTimeoutMs). */
2171
+ const PENDING_TIMEOUT_MS = 5000
2172
+
2033
2173
  /** Keeps the terminals frame within the protocol's limits. */
2034
2174
  function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[] {
2035
2175
  return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))