@huynle/hub 0.9.0 → 0.9.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 (63) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +6 -4
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/wrapper.ts +2 -2
  6. package/packages/cli/dist/hub-cli.js +1126 -479
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
  9. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
  10. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
  11. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
  12. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
  13. package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
  14. package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
  15. package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
  16. package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
  17. package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
  18. package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
  19. package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
  20. package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
  21. package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
  22. package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
  23. package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
  24. package/packages/cli/src/bootstrap/release/assets.ts +4 -1
  25. package/packages/cli/src/node.ts +94 -12
  26. package/packages/cli/src/setup/commands.ts +27 -7
  27. package/packages/cli/src/setup/runtime.ts +31 -4
  28. package/packages/codex/package.json +1 -1
  29. package/packages/codex/src/wrapper.ts +2 -2
  30. package/packages/node/package.json +1 -1
  31. package/packages/node/src/config.ts +49 -17
  32. package/packages/node/src/git.ts +16 -2
  33. package/packages/node/src/harnesses.ts +54 -9
  34. package/packages/node/src/host.ts +61 -15
  35. package/packages/node/src/opencode-service.ts +111 -0
  36. package/packages/node/src/opencode-warm.ts +11 -5
  37. package/packages/node/src/run.ts +50 -5
  38. package/packages/preset/package.json +1 -1
  39. package/packages/protocol/package.json +1 -1
  40. package/packages/protocol/src/index.ts +3 -1
  41. package/packages/protocol/src/launch.ts +39 -4
  42. package/packages/runtime-openshell/package.json +1 -1
  43. package/packages/runtime-sdk/package.json +1 -1
  44. package/packages/sdk/package.json +1 -1
  45. package/packages/spoke-sdk/package.json +1 -1
  46. package/src/central/agent/launch-spec.ts +4 -2
  47. package/src/central/install/CONTRACT.md +4 -4
  48. package/src/central/install/install.ps1 +37 -5
  49. package/src/central/install/render.ts +13 -8
  50. package/web/dist/assets/{ArtifactPage-4GoBqSpU.js → ArtifactPage-BKISN-Qj.js} +1 -1
  51. package/web/dist/assets/{ArtifactPane-Dx_0iWwH.js → ArtifactPane-CKOiAUNH.js} +1 -1
  52. package/web/dist/assets/{ArtifactWorkbench-CuHgARMk.js → ArtifactWorkbench-d7R1o-rs.js} +1 -1
  53. package/web/dist/assets/{Diagram-CZM_R7MG.js → Diagram-azw2B_QH.js} +1 -1
  54. package/web/dist/assets/{Library-TwvrwqSk.js → Library-CcjZXsfF.js} +2 -2
  55. package/web/dist/assets/{ProfilesDestination-rKNDpLjR.js → ProfilesDestination-DdzbzmeK.js} +1 -1
  56. package/web/dist/assets/{TeamsPage-DtJylLrt.js → TeamsPage-CDyjrK-I.js} +1 -1
  57. package/web/dist/assets/{Watchtower-Bmgdyfjb.js → Watchtower-DYwoUWME.js} +2 -2
  58. package/web/dist/assets/{index-D-xG9nrc.js → index-LNMZCqgE.js} +15 -15
  59. package/web/dist/assets/{live-7q72Lyj2.js → live-ycZ_aQZO.js} +1 -1
  60. package/web/dist/assets/{renderer2d-hfZxM6Xl.js → renderer2d-BIwbgRoX.js} +1 -1
  61. package/web/dist/assets/{renderer3d-Km9vipTM.js → renderer3d-C1a7eYGQ.js} +1 -1
  62. package/web/dist/assets/{safe-area-BAbgBiFk.js → safe-area-DHhPy61Q.js} +1 -1
  63. package/web/dist/index.html +1 -1
@@ -8,6 +8,9 @@
8
8
  // - node.json `harnesses` overrides detection either way (true offers a harness, false withholds it).
9
9
  //
10
10
  // Which binary (claudeBinary, codexBinary): the explicit setting, then the one `hubctl setup` installed, then PATH.
11
+ // On Windows (Phase 3) executables carry an extension: a bare name is tried with PATHEXT's .exe/.com (the ones a
12
+ // program starts without a shell; .cmd/.bat shims are never chosen), PATH splits on `;`, and a regular file counts
13
+ // (no execute bit); hub's Codex is bin/codex.exe.
11
14
  import { accessSync, constants, readFileSync, statSync } from "node:fs"
12
15
  import { homedir } from "node:os"
13
16
  import path from "node:path"
@@ -25,21 +28,35 @@ export interface DetectHarnessesOptions {
25
28
  readonly overrides?: Readonly<Record<string, boolean>>
26
29
  /** Where hub's config and data are (default os.homedir()). */
27
30
  readonly home?: string
31
+ /** The platform (default this one). */
32
+ readonly platform?: string
28
33
  }
29
34
 
30
- function executable(file: string): boolean {
35
+ function executable(file: string, win32 = false): boolean {
31
36
  try {
32
37
  if (!statSync(file).isFile()) return false
33
- accessSync(file, constants.X_OK)
38
+ if (!win32) accessSync(file, constants.X_OK)
34
39
  return true
35
40
  } catch {
36
41
  return false
37
42
  }
38
43
  }
39
44
 
45
+ /** Extensions a Windows program can be started with without a shell. */
46
+ const WIN_RUNNABLE = [".exe", ".com"]
47
+
48
+ /** The names `name` may be on Windows: as it is when it has a runnable extension, else with each, in PATHEXT order. */
49
+ function windowsNames(name: string, env: Env): string[] {
50
+ if (WIN_RUNNABLE.includes(path.win32.extname(name).toLowerCase())) return [name]
51
+ const listed = (env.PATHEXT ?? "").split(";").map((e) => e.trim().toLowerCase()).filter((e) => WIN_RUNNABLE.includes(e))
52
+ const order = listed.length ? [...new Set(listed)] : WIN_RUNNABLE
53
+ return order.map((ext) => `${name}${ext}`)
54
+ }
55
+
40
56
  /** The executable `name` runs as: an absolute path checked as it is, else the first match on PATH. */
41
- export function findExecutable(name: string, env: Env): string | undefined {
57
+ export function findExecutable(name: string, env: Env, platform: string = process.platform): string | undefined {
42
58
  if (!name) return undefined
59
+ if (platform === "win32") return findWindowsExecutable(name, env)
43
60
  if (path.isAbsolute(name)) return executable(name) ? name : undefined
44
61
  if (name.includes("/")) return undefined
45
62
  for (const dir of (env.PATH ?? "").split(path.delimiter)) {
@@ -50,13 +67,27 @@ export function findExecutable(name: string, env: Env): string | undefined {
50
67
  return undefined
51
68
  }
52
69
 
70
+ function findWindowsExecutable(name: string, env: Env): string | undefined {
71
+ if (path.isAbsolute(name) || path.win32.isAbsolute(name)) return windowsNames(name, env).find((file) => executable(file, true))
72
+ if (/[\\/]/.test(name)) return undefined
73
+ for (const dir of (env.PATH ?? env.Path ?? "").split(";")) {
74
+ if (!dir || !(path.isAbsolute(dir) || path.win32.isAbsolute(dir))) continue
75
+ for (const file of windowsNames(name, env)) {
76
+ const full = path.join(dir, file)
77
+ if (executable(full, true)) return full
78
+ }
79
+ }
80
+ return undefined
81
+ }
82
+
53
83
  /** The harnesses this machine's host runtime offers. */
54
84
  export function detectHarnesses(options: DetectHarnessesOptions = {}): string[] {
55
85
  const env = options.env ?? process.env
56
86
  const found = new Set<string>(["opencode"])
57
87
  const home = options.home ?? homedir()
58
- if (findExecutable(claudeBinary(env, home), env)) found.add("claude-code")
59
- if (findExecutable(codexBinary(env, home), env)) found.add("codex")
88
+ const platform = options.platform ?? process.platform
89
+ if (findExecutable(claudeBinary(env, home), env, platform)) found.add("claude-code")
90
+ if (findExecutable(codexBinary(env, home, platform), env, platform)) found.add("codex")
60
91
  for (const [name, on] of Object.entries(options.overrides ?? {})) {
61
92
  if (on) found.add(name)
62
93
  else found.delete(name)
@@ -85,11 +116,25 @@ export function claudeBinary(env: Env, home: string = homedir()): string {
85
116
 
86
117
  /**
87
118
  * The Codex CLI to run: HUB_CODEX_BIN, else the Codex `hubctl setup` installed (data/hub/harnesses/codex/current/
88
- * bin/codex, under XDG_DATA_HOME or ~/.local/share) when it is there, else `codex` on PATH.
119
+ * bin/codex, bin/codex.exe on Windows, under XDG_DATA_HOME or ~/.local/share) when it is there, else `codex` on PATH.
89
120
  */
90
- export function codexBinary(env: Env, home: string = homedir()): string {
121
+ export function codexBinary(env: Env, home: string = homedir(), platform: string = process.platform): string {
91
122
  if (env.HUB_CODEX_BIN) return env.HUB_CODEX_BIN
92
123
  const data = env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path.join(home, ".local", "share")
93
- const current = path.join(data, "hub", "harnesses", "codex", "current", "bin", "codex")
94
- return executable(current) ? current : "codex"
124
+ const win32 = platform === "win32"
125
+ const current = path.join(data, "hub", "harnesses", "codex", "current", "bin", win32 ? "codex.exe" : "codex")
126
+ return executable(current, win32) ? current : "codex"
127
+ }
128
+
129
+ /**
130
+ * The OpenCode CLI (the node starts its background service with it on Windows): HUB_OPENCODE_BIN, else the OpenCode
131
+ * `hubctl setup` installed (data/hub/harnesses/opencode/current/bin/opencode, opencode.exe on Windows) when it is
132
+ * there, else `opencode` on PATH.
133
+ */
134
+ export function opencodeBinary(env: Env, home: string = homedir(), platform: string = process.platform): string {
135
+ if (env.HUB_OPENCODE_BIN) return env.HUB_OPENCODE_BIN
136
+ const data = env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path.join(home, ".local", "share")
137
+ const win32 = platform === "win32"
138
+ const current = path.join(data, "hub", "harnesses", "opencode", "current", "bin", win32 ? "opencode.exe" : "opencode")
139
+ return executable(current, win32) ? current : "opencode"
95
140
  }
@@ -14,8 +14,10 @@
14
14
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
15
15
  import path from "node:path"
16
16
  import {
17
+ canonicalNativePath,
17
18
  isAbsolutePath,
18
19
  isRefName,
20
+ isWindowsAbsolutePath,
19
21
  launchWorkspaceProblem,
20
22
  preparedWorkspaceProblem,
21
23
  type CleanupPolicy,
@@ -38,41 +40,84 @@ export interface HostRuntimeOptions {
38
40
  readonly git?: Omit<GitOptions, "cwd" | "signal">
39
41
  }
40
42
 
43
+ /**
44
+ * The file-system calls path resolution needs (tests inject a Windows one). On win32 (Phase 3) realpath reports native
45
+ * paths (`C:\\…`, `\\\\?\\C:\\…`); they are turned canonical (`C:/…`, protocol canonicalNativePath) and compared without
46
+ * case, as Windows compares them; a real path off a local drive (a mapped share's UNC path) is refused.
47
+ */
48
+ export interface PathOps {
49
+ readonly win32: boolean
50
+ /** dirname and basename for the platform's paths (path.win32 also reads `/`). */
51
+ readonly path: Pick<typeof path, "dirname" | "basename">
52
+ readonly realpath: (p: string) => string
53
+ readonly isSymlink: (p: string) => boolean
54
+ }
55
+
56
+ const nativeOps: PathOps = {
57
+ win32: process.platform === "win32",
58
+ path,
59
+ realpath: (p) => realpathSync(p),
60
+ isSymlink: (p) => {
61
+ try {
62
+ return lstatSync(p).isSymbolicLink()
63
+ } catch {
64
+ return false
65
+ }
66
+ },
67
+ }
68
+
69
+ /** realpath in the form the runtime keeps: as it is on POSIX, canonical (or refused) on Windows. */
70
+ function realOf(p: string, ops: PathOps): string {
71
+ const real = ops.realpath(p)
72
+ if (!ops.win32) return real
73
+ const canonical = canonicalNativePath(real)
74
+ if (canonical === undefined || !isWindowsAbsolutePath(canonical)) throw new RuntimeError(`${p} resolves to ${real}, which is not on a local drive`)
75
+ return canonical
76
+ }
77
+
41
78
  /** `p` with every existing part resolved through symlinks; refuses a dangling symlink on the way. */
42
- function resolvePath(p: string): string {
79
+ function resolvePath(p: string, ops: PathOps = nativeOps): string {
43
80
  const rest: string[] = []
44
81
  let current = p
45
82
  for (;;) {
83
+ let real: string
46
84
  try {
47
- return path.join(realpathSync(current), ...rest.reverse())
85
+ real = realOf(current, ops)
48
86
  } catch (cause) {
87
+ if (cause instanceof RuntimeError) throw cause
49
88
  if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw new RuntimeError(`cannot resolve ${p}: ${(cause as Error).message}`)
50
- let link = false
51
- try {
52
- link = lstatSync(current).isSymbolicLink()
53
- } catch {}
54
- if (link) throw new RuntimeError(`${p} goes through a dangling symlink`)
55
- const parent = path.dirname(current)
89
+ if (ops.isSymlink(current)) throw new RuntimeError(`${p} goes through a dangling symlink`)
90
+ const parent = ops.path.dirname(current)
56
91
  if (parent === current) throw new RuntimeError(`cannot resolve ${p}`)
57
- rest.push(path.basename(current))
92
+ rest.push(ops.path.basename(current))
58
93
  current = parent
94
+ continue
59
95
  }
96
+ return ops.win32 ? path.posix.join(real, ...rest.reverse()) : path.join(real, ...rest.reverse())
60
97
  }
61
98
  }
62
99
 
100
+ /** Whether real path `real` is real root `root` or inside it (without case on Windows). */
101
+ function within(real: string, root: string, ops: PathOps): boolean {
102
+ if (!ops.win32) return real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
103
+ const r = real.toLowerCase()
104
+ const base = root.toLowerCase()
105
+ return r === base || r.startsWith(base.endsWith("/") ? base : `${base}/`)
106
+ }
107
+
63
108
  /** The real path of `p` when it is (or would be) under one of `roots`; throws a RuntimeError otherwise. */
64
- export function resolveUnderRoots(p: string, roots: readonly string[]): string {
109
+ export function resolveUnderRoots(p: string, roots: readonly string[], ops: PathOps = nativeOps): string {
65
110
  if (roots.length === 0) throw new RuntimeError("this node has no workspaceRoots in node.json, so it prepares no workspaces")
66
111
  if (!isAbsolutePath(p)) throw new RuntimeError(`${p} is not an absolute path`)
67
- const real = resolvePath(p)
112
+ const real = resolvePath(p, ops)
68
113
  for (const root of roots) {
69
114
  let realRoot: string
70
115
  try {
71
- realRoot = realpathSync(root)
116
+ realRoot = realOf(root, ops)
72
117
  } catch {
73
118
  continue
74
119
  }
75
- if (real === realRoot || real.startsWith(realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep)) return real
120
+ if (within(real, realRoot, ops)) return real
76
121
  }
77
122
  throw new RuntimeError(`${p} is not under this node's workspace roots`)
78
123
  }
@@ -128,7 +173,7 @@ export class HostRuntime implements Runtime {
128
173
  const directory = resolveUnderRoots(existing, this.roots)
129
174
  return this.prepared(repo, directory, "worktree", branch, base, true, ctx)
130
175
  }
131
- const directory = resolveUnderRoots(path.join(repo, ".worktrees", ...branch.split("/")), this.roots)
176
+ const directory = resolveUnderRoots(path.posix.join(repo, ".worktrees", ...branch.split("/")), this.roots)
132
177
  if (existsSync(directory)) throw new RuntimeError(`${directory} exists and is not the worktree of ${branch}`)
133
178
  if (base) await this.git(repo, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`], ctx, `base ${base} is not a commit in ${repo}`)
134
179
  ctx.progress?.(`creating worktree ${branch}`)
@@ -154,7 +199,8 @@ export class HostRuntime implements Runtime {
154
199
  const { branch, base, remote } = input.workspace
155
200
  const target = resolveUnderRoots(input.workspace.repo, this.roots)
156
201
  if (existsSync(target) || isLink(target)) throw new RuntimeError(`${target} exists; a clone needs a new directory`)
157
- const parent = path.dirname(target)
202
+ // Canonical paths join and split with "/" on every platform (path.posix is path on POSIX).
203
+ const parent = path.posix.dirname(target)
158
204
  mkdirSync(parent, { recursive: true })
159
205
  // The parent was created through resolved paths, but check again before git writes there.
160
206
  if (resolveUnderRoots(parent, this.roots) !== parent) throw new RuntimeError(`${parent} moved while preparing`)
@@ -0,0 +1,111 @@
1
+ // OpenCode's background service on Windows (Phase 3, PHASE3-CONTRACT.md "The node on Windows"). On macOS and Linux a
2
+ // login item or the user keeps OpenCode's background service running; on Windows nothing does, so the node starts
3
+ // it: when no service is registered (service.json missing, or its process gone), `<opencode> service start` (no shell,
4
+ // hidden window) at most once per warm tick, backing off after a failure (START_BACKOFF_MS doubling up to
5
+ // START_BACKOFF_MAX_MS), and logged. `service start` asks OpenCode's client to ensure the service (it spawns
6
+ // `opencode serve --service` detached, waits until it is ready, prints its URL and exits), so the service outlives
7
+ // the command: only the command itself is ended on a timeout, never its tree. The warmer then warms it as elsewhere.
8
+ import { spawn } from "node:child_process"
9
+ import type { WarmEndpoint } from "./opencode-warm.ts"
10
+
11
+ /** First back-off after a failed start (the warm interval); it doubles up to START_BACKOFF_MAX_MS. */
12
+ export const START_BACKOFF_MS = 30_000
13
+ export const START_BACKOFF_MAX_MS = 10 * 60_000
14
+ /** How long `service start` may take (it waits for the service to be ready). */
15
+ export const START_TIMEOUT_MS = 90_000
16
+ const STDERR_MAX = 2000
17
+
18
+ /** Runs `file args` without a shell; resolves when it exits (or failed to start). */
19
+ export type StartRun = (file: string, args: readonly string[]) => Promise<{ code: number | null; stderr: string; spawnError?: string }>
20
+
21
+ export interface OpenCodeServiceStarterOptions {
22
+ /** The OpenCode executable (an absolute path), or undefined when it is not installed. */
23
+ readonly binary: () => string | undefined
24
+ readonly log: (line: string) => void
25
+ readonly run?: StartRun
26
+ readonly now?: () => number
27
+ /** The environment `service start` runs with (default process.env). */
28
+ readonly env?: Readonly<Record<string, string | undefined>>
29
+ }
30
+
31
+ /** The default run: hidden, no shell, stdin and stdout closed, stderr bounded; the command alone ends on a timeout. */
32
+ export function startRun(env: Readonly<Record<string, string | undefined>> = process.env, timeoutMs = START_TIMEOUT_MS): StartRun {
33
+ return (file, args) =>
34
+ new Promise((resolve) => {
35
+ const clean = Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => typeof e[1] === "string"))
36
+ let child: ReturnType<typeof spawn>
37
+ try {
38
+ child = spawn(file, [...args], { shell: false, windowsHide: true, env: clean, stdio: ["ignore", "ignore", "pipe"] })
39
+ } catch (cause) {
40
+ return resolve({ code: null, stderr: "", spawnError: (cause as Error).message })
41
+ }
42
+ let stderr = ""
43
+ let done = false
44
+ const finish = (r: { code: number | null; stderr: string; spawnError?: string }) => {
45
+ if (done) return
46
+ done = true
47
+ clearTimeout(timer)
48
+ // The service it started may hold an inherited handle; never wait for the pipe to close.
49
+ child.stderr?.destroy()
50
+ resolve(r)
51
+ }
52
+ child.stderr?.on("data", (chunk: Buffer) => {
53
+ if (stderr.length < STDERR_MAX) stderr += chunk.toString("utf8").slice(0, STDERR_MAX - stderr.length)
54
+ })
55
+ child.on("error", (cause) => finish({ code: null, stderr, spawnError: cause.message }))
56
+ child.on("exit", (code) => finish({ code, stderr }))
57
+ const timer = setTimeout(() => {
58
+ child.kill()
59
+ finish({ code: null, stderr, spawnError: `timed out after ${Math.round(timeoutMs / 1000)} s` })
60
+ }, timeoutMs)
61
+ })
62
+ }
63
+
64
+ const oneLine = (text: string) => text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]+/gu, " ").replace(/\s+/g, " ").trim().slice(0, 300)
65
+
66
+ export class OpenCodeServiceStarter {
67
+ private nextAttempt = 0
68
+ private delay = START_BACKOFF_MS
69
+ private missingLogged = false
70
+
71
+ constructor(private readonly options: OpenCodeServiceStarterOptions) {}
72
+
73
+ /** Starts the service unless backing off; true when `service start` succeeded. */
74
+ async start(): Promise<boolean> {
75
+ const now = (this.options.now ?? Date.now)()
76
+ if (now < this.nextAttempt) return false
77
+ const binary = this.options.binary()
78
+ if (!binary) {
79
+ if (!this.missingLogged) this.options.log("OpenCode is not installed (no opencode.exe found), so its background service cannot be started")
80
+ this.missingLogged = true
81
+ return false
82
+ }
83
+ this.missingLogged = false
84
+ this.options.log("OpenCode's background service is not running; starting it (opencode service start)")
85
+ const r = await (this.options.run ?? startRun(this.options.env))(binary, ["service", "start"])
86
+ if (r.code === 0) {
87
+ this.delay = START_BACKOFF_MS
88
+ this.nextAttempt = 0
89
+ this.options.log("started OpenCode's background service")
90
+ return true
91
+ }
92
+ const why = r.spawnError ?? (oneLine(r.stderr) || `exit ${r.code}`)
93
+ this.options.log(`could not start OpenCode's background service (${why}); retrying in ${Math.round(this.delay / 1000)} s`)
94
+ this.nextAttempt = (this.options.now ?? Date.now)() + this.delay
95
+ this.delay = Math.min(this.delay * 2, START_BACKOFF_MAX_MS)
96
+ return false
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The warmer's discovery of the background service: on win32 a missing registration starts the service (at most one
102
+ * attempt per call, i.e. per tick) and reads the registration again; elsewhere `read` as it is.
103
+ */
104
+ export function serviceDiscovery(read: () => Promise<WarmEndpoint | undefined>, starter: OpenCodeServiceStarter, platform: string = process.platform): () => Promise<WarmEndpoint | undefined> {
105
+ if (platform !== "win32") return read
106
+ return async () => {
107
+ const endpoint = await read()
108
+ if (endpoint) return endpoint
109
+ return (await starter.start()) ? read() : undefined
110
+ }
111
+ }
@@ -248,19 +248,25 @@ export class OpenCodeWarmer {
248
248
  * OpenCode's background service from its registration file (service.json: {url, pid, password?, id?, version?}),
249
249
  * without any request: undefined when the file is missing or malformed, is not this user's own (a symbolic link, not
250
250
  * owned by this uid, or writable by group or others), or its process is gone or another user's (EPERM). The URL is
251
- * passed on as it is; OpenCodeWarmer.tick decides whether it may be asked.
251
+ * passed on as it is; OpenCodeWarmer.tick decides whether it may be asked. On Windows (no modes, no uid; Phase 3) the
252
+ * file is this user's when its access list allows no one but this user, SYSTEM and Administrators (src/win-acl.ts, as
253
+ * for password files: it holds the service's password); Bun ignores numeric open flags there, so it is opened "r"
254
+ * after the lstat check.
252
255
  */
253
- export async function readServiceRegistration(file: string, options: { readonly uid?: number } = {}): Promise<WarmEndpoint | undefined> {
254
- const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined)
256
+ export async function readServiceRegistration(file: string, options: { readonly uid?: number } & SecretFileOptions = {}): Promise<WarmEndpoint | undefined> {
257
+ const win32 = isWin32(options)
258
+ const uid = win32 ? undefined : (options.uid ?? (typeof process.getuid === "function" ? process.getuid() : undefined))
255
259
  let info: unknown
256
260
  let fd: number | undefined
257
261
  try {
258
262
  const before = lstatSync(file)
259
263
  if (!before.isFile()) return undefined
260
- fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
264
+ fd = win32 ? openSync(file, "r") : openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
261
265
  const stat = fstatSync(fd)
262
266
  if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) return undefined
263
- if ((stat.mode & 0o022) !== 0) return undefined
267
+ if (win32) {
268
+ if (!(options.acl ?? defaultWinAcl()).check(nativeWinPath(file)).ok) return undefined
269
+ } else if ((stat.mode & 0o022) !== 0) return undefined
264
270
  if (uid !== undefined && stat.uid !== uid) return undefined
265
271
  if (stat.size > REGISTRATION_MAX) return undefined
266
272
  // Read through the descriptor that was checked (no second open by path).
@@ -7,7 +7,9 @@
7
7
  // node keeps OpenCode's background service and node.json's foreground servers warm (opencode-warm.ts) so their hub
8
8
  // plugin loads without a client opening a project. Exit codes: 0 after a clean stop, 1 when
9
9
  // the machine is not enrolled, EXIT_CONFIG (78, EX_CONFIG) when node.json gives the node nothing to do; the
10
- // service units do not restart on 78.
10
+ // service units do not restart on 78. On Windows (Phase 3) the logon task restarts the node whatever its exit code,
11
+ // so a node with nothing to do waits there, re-reading node.json, instead of exiting; and while it offers opencode the node starts OpenCode's
12
+ // background service when none is registered (opencode-service.ts), since nothing else keeps it running there.
11
13
  import { homedir } from "node:os"
12
14
  import path from "node:path"
13
15
  import type { NodeLaunchReport } from "../../protocol/src/index.ts"
@@ -15,8 +17,9 @@ import { OpenShellCli } from "../../runtime-openshell/src/cli.ts"
15
17
  import type { Runtime } from "../../runtime-sdk/src/index.ts"
16
18
  import { nodeConfigPath, readNodeConfig, readSpokeCredentials, type NodeConfig, type SpokeCredentials } from "./config.ts"
17
19
  import { NodeDaemon, NODE_VERSION } from "./daemon.ts"
18
- import { detectHarnesses } from "./harnesses.ts"
20
+ import { detectHarnesses, findExecutable, opencodeBinary } from "./harnesses.ts"
19
21
  import { HostRuntime } from "./host.ts"
22
+ import { OpenCodeServiceStarter, serviceDiscovery, type StartRun } from "./opencode-service.ts"
20
23
  import { OpenCodeWarmer, readServiceRegistration, serverEndpoint, type WarmEndpoint } from "./opencode-warm.ts"
21
24
  import { createOpenShellRuntime, openshellBackends, PROBE_TIMEOUT_MS, sweepSandboxes } from "./openshell.ts"
22
25
 
@@ -35,8 +38,30 @@ export interface RunNodeOptions {
35
38
  readonly openshellBinary?: string
36
39
  /** Finds OpenCode's background service (default: readServiceRegistration on this env's registration file). */
37
40
  readonly openCodeService?: () => Promise<WarmEndpoint | undefined>
41
+ /** The platform (default this one): win32 exits 0 for "nothing to do" and starts OpenCode's service. */
42
+ readonly platform?: string
43
+ /** win32: the OpenCode executable (default opencodeBinary found like a harness), undefined when not installed. */
44
+ readonly openCodeBinary?: () => string | undefined
45
+ /** win32: how `opencode service start` runs (default opencode-service.ts startRun). */
46
+ readonly startOpenCode?: StartRun
47
+ /** win32: how often a node with nothing to do re-reads node.json (default IDLE_RECHECK_MS). */
48
+ readonly idleRecheckMs?: number
38
49
  }
39
50
 
51
+ /** win32: how often a node with no workspace roots re-reads node.json while it waits. */
52
+ export const IDLE_RECHECK_MS = 60_000
53
+
54
+ const sleep = (ms: number, signal: AbortSignal) =>
55
+ new Promise<void>((resolve) => {
56
+ const timer = setTimeout(done, ms)
57
+ function done() {
58
+ clearTimeout(timer)
59
+ signal.removeEventListener("abort", done)
60
+ resolve()
61
+ }
62
+ signal.addEventListener("abort", done, { once: true })
63
+ })
64
+
40
65
  /** OpenCode's service registration file for `env` and `home` (@opencode/client's default location). */
41
66
  export const openCodeServiceFile = (env: Env, home: string): string =>
42
67
  path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "opencode", "service.json")
@@ -88,6 +113,7 @@ async function setupOpenShell(config: NodeConfig, credentials: SpokeCredentials,
88
113
  export async function runNode(options: RunNodeOptions): Promise<number> {
89
114
  const env = options.env ?? process.env
90
115
  const home = options.home ?? homedir()
116
+ const platform = options.platform ?? process.platform
91
117
  const log = options.log ?? ((line: string) => process.stderr.write(`[hub node] ${line}\n`))
92
118
 
93
119
  const credentials = readSpokeCredentials(env, home)
@@ -96,11 +122,21 @@ export async function runNode(options: RunNodeOptions): Promise<number> {
96
122
  return 1
97
123
  }
98
124
  const file = nodeConfigPath(env, home)
99
- const { config, problems } = readNodeConfig(file)
125
+ let { config, problems } = readNodeConfig(file, { platform, home })
100
126
  for (const problem of problems) log(`${file}: ${problem}`)
101
127
  if (config.workspaceRoots.length === 0) {
102
128
  log(`${file} lists no workspaceRoots, so this node can prepare no workspaces; add e.g. {"workspaceRoots": ["${home}/projects"]}`)
103
- return EXIT_CONFIG
129
+ if (platform !== "win32") return EXIT_CONFIG
130
+ // Task Scheduler restarts the logon task whatever the exit code (its repetition trigger), so a node with nothing to
131
+ // do waits here, re-reading node.json, instead of exiting and being started again every minute.
132
+ log(`waiting: re-reading ${file} every ${Math.round((options.idleRecheckMs ?? IDLE_RECHECK_MS) / 1000)} s until it lists workspaceRoots`)
133
+ while (config.workspaceRoots.length === 0) {
134
+ await sleep(options.idleRecheckMs ?? IDLE_RECHECK_MS, options.signal)
135
+ if (options.signal.aborted) return 0
136
+ ;({ config, problems } = readNodeConfig(file, { platform, home }))
137
+ }
138
+ for (const problem of problems) log(`${file}: ${problem}`)
139
+ log(`workspace roots now set in ${file}; starting`)
104
140
  }
105
141
 
106
142
  if (options.signal.aborted) return 0
@@ -122,7 +158,16 @@ export async function runNode(options: RunNodeOptions): Promise<number> {
122
158
  })
123
159
  log(`hub node ${NODE_VERSION}: ${config.slots} slots, roots ${config.workspaceRoots.join(", ")}; host harnesses ${harnesses.join(", ") || "none"}`)
124
160
  daemon.start()
125
- if (harnesses.includes("opencode")) startOpenCodeWarmers(config, options.openCodeService ?? (() => readServiceRegistration(openCodeServiceFile(env, home))), log, options.signal)
161
+ if (harnesses.includes("opencode")) {
162
+ const read = options.openCodeService ?? (() => readServiceRegistration(openCodeServiceFile(env, home)))
163
+ const starter = new OpenCodeServiceStarter({
164
+ binary: options.openCodeBinary ?? (() => findExecutable(opencodeBinary(env, home, platform), env, platform)),
165
+ log,
166
+ env,
167
+ ...(options.startOpenCode ? { run: options.startOpenCode } : {}),
168
+ })
169
+ startOpenCodeWarmers(config, serviceDiscovery(read, starter, platform), log, options.signal)
170
+ }
126
171
  await new Promise<void>((resolve) => {
127
172
  if (options.signal.aborted) return resolve()
128
173
  options.signal.addEventListener("abort", () => resolve(), { once: true })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-preset",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-protocol",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "description": "Hub ↔ spoke wire protocol (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { featureList, HUB_URL_MAX_CHARS, ID, isByeFrame as isBye, isHubURL, isObj, isResFrame as isRes, isWelcomeFrame, MAX_FRAME, MAX_RETRY_AFTER_MS, nonEmpty, num, parseFrame as parse, str, token, UNSAFE_CHARS } from "./wire.ts"
20
20
  import type { NodeMessage, NodeServerMessage } from "./node.ts"
21
+ import { isWindowsAbsolutePath } from "./launch.ts"
21
22
  import { PROFILE_APPLY_FEATURE, profileApplyRequestProblem, type ProfileApplyRequest, type ProfileApplyResult } from "./profiles.ts"
22
23
  import { SESSION_CONTEXT_FEATURE, sessionContextProblem, type SessionContextRequest, type SessionContextResult } from "./concierge.ts"
23
24
  import { isMediaOffset, parseMediaRef, type TimelineMediaResult } from "./media.ts"
@@ -1242,7 +1243,8 @@ function isServerRequest(v: unknown): v is ServerRequest {
1242
1243
  case "create":
1243
1244
  return (
1244
1245
  nonEmpty(v.directory) &&
1245
- v.directory.startsWith("/") &&
1246
+ // POSIX as before; a Windows machine's directory in canonical form (C:/…, Phase 3).
1247
+ (v.directory.startsWith("/") || isWindowsAbsolutePath(v.directory)) &&
1246
1248
  v.directory.length <= 4096 &&
1247
1249
  (v.title === undefined || (str(v.title) && v.title.length <= 200)) &&
1248
1250
  (v.herdrWorkspace === undefined || (nonEmpty(v.herdrWorkspace) && v.herdrWorkspace.length <= 100)) &&
@@ -149,17 +149,52 @@ export const LABELS_MAX = 32
149
149
  export const LABEL_VALUE_MAX = 256
150
150
  const LABEL_KEY = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,62}$/
151
151
 
152
- /** An absolute path of at most PATH_MAX characters without `.`/`..` segments, empty segments or unsafe characters. */
152
+ /**
153
+ * An absolute path of at most PATH_MAX characters without `.`/`..` segments, empty segments or unsafe characters:
154
+ * POSIX `/a/b` (a trailing slash allowed, as before), or a canonical Windows path `C:/a/b` (upper-case drive, `/`
155
+ * separators, the root `C:/`; no backslash). UNC (`//server`, `\\server`) and device paths (`//?/`, `\\?\`) are never
156
+ * absolute here. The Windows form is the bootstrap contracts' rule (packages/cli/src/bootstrap/contracts/validate.ts
157
+ * isAbsolutePath); native forms are canonicalized at the node's edge (canonicalNativePath).
158
+ */
153
159
  export function isAbsolutePath(v: unknown): v is string {
154
- if (typeof v !== "string" || !v.startsWith("/") || v.length > PATH_MAX || UNSAFE_CHARS.test(v)) return false
155
- if (v === "/") return true
156
- return v
160
+ if (typeof v !== "string" || v.length > PATH_MAX || UNSAFE_CHARS.test(v)) return false
161
+ if (WINDOWS_ROOT.test(v)) return !v.includes("\\") && normalSegments(v.slice(2))
162
+ if (!v.startsWith("/")) return false
163
+ return normalSegments(v)
164
+ }
165
+
166
+ /** `rest` (starting with "/") is "/" or "/"-separated segments, none empty, "." or ".." (one trailing "/" allowed). */
167
+ function normalSegments(rest: string): boolean {
168
+ if (!rest.startsWith("/")) return false
169
+ if (rest === "/") return true
170
+ return rest
157
171
  .slice(1)
158
172
  .replace(/\/$/, "")
159
173
  .split("/")
160
174
  .every((part) => part !== "" && part !== "." && part !== "..")
161
175
  }
162
176
 
177
+ const WINDOWS_ROOT = /^[A-Z]:(?=\/)/
178
+
179
+ /** Whether `v` is a canonical Windows absolute path (`C:/…`). */
180
+ export const isWindowsAbsolutePath = (v: unknown): v is string => typeof v === "string" && WINDOWS_ROOT.test(v) && isAbsolutePath(v)
181
+
182
+ /**
183
+ * The canonical form of a path a host reported in its own form (node.json, realpath, git output), or undefined when
184
+ * it has none: Windows drive paths get an upper-case drive and `/` separators (a `\\?\` or `\??\` final-path
185
+ * prefix is dropped); repeated and trailing slashes are dropped. UNC, device and relative paths have no canonical
186
+ * form. Mirrors packages/cli/src/bootstrap/discovery/util.ts normalizeAbs.
187
+ */
188
+ export function canonicalNativePath(value: unknown): string | undefined {
189
+ if (typeof value !== "string") return undefined
190
+ let p = value.replace(/^(\\\\\?\\|\\\?\?\\)(?=[A-Za-z]:\\)/, "")
191
+ if (/^[A-Za-z]:[\\/]/.test(p)) p = p[0]!.toUpperCase() + p.slice(1).replace(/\\/g, "/")
192
+ else if (!p.startsWith("/") || p.startsWith("//")) return undefined
193
+ p = p.replace(/\/{2,}/g, "/")
194
+ if (p.endsWith("/") && p !== "/" && !/^[A-Z]:\/$/.test(p)) p = p.slice(0, -1)
195
+ return isAbsolutePath(p) ? p : undefined
196
+ }
197
+
163
198
  /** A branch name `git check-ref-format --branch` accepts, from a conservative character set, never starting with `-`. */
164
199
  export function isRefName(v: unknown): v is string {
165
200
  if (typeof v !== "string" || v.length === 0 || v.length > REF_MAX) return false
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-openshell",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "description": "The Runtime interface `hub node` runtimes implement (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "description": "Typed client for the hub's /api/agent (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -231,8 +231,10 @@ function validRoles(value: unknown): string[] {
231
231
  function validWorkspace(value: unknown, wording: Wording): LaunchWorkspace {
232
232
  if (!isObject(value)) throw invalid("workspace is {repo, mode?, branch?, base?, remote?}")
233
233
  onlyFields(value, WORKSPACE_FIELDS, "workspace", wording)
234
- // canonicalProject rejects relative, `~` and root paths.
235
- const repo = typeof value.repo === "string" ? canonicalProject(value.repo) : undefined
234
+ // canonicalProject rejects relative, `~` and root paths; a Windows repo is kept canonical (upper-case drive, `/`),
235
+ // and a native UNC or device path (`\\server\…`, `\\?\…`) is never one (Phase 3).
236
+ const raw = typeof value.repo === "string" && !value.repo.startsWith("\\") ? canonicalProject(value.repo) : undefined
237
+ const repo = raw && /^[a-z]:\//.test(raw) ? raw[0]!.toUpperCase() + raw.slice(1) : raw
236
238
  if (repo === undefined) throw invalid("workspace.repo is an absolute repository path")
237
239
  const mode = oneOf<WorkspaceMode>(value.mode, ["worktree", "checkout", "clone"], "worktree", "workspace.mode")
238
240
  for (const field of ["branch", "base"] as const) {