@huynle/hub 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +7 -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/agent/webhooks.ts +5 -2
  48. package/src/central/config.ts +24 -2
  49. package/src/central/http.ts +1 -0
  50. package/src/central/install/CONTRACT.md +4 -4
  51. package/src/central/install/install.ps1 +37 -5
  52. package/src/central/install/render.ts +13 -8
  53. package/src/central/push-target.ts +11 -2
  54. package/web/dist/assets/{ArtifactPage-4GoBqSpU.js → ArtifactPage-BKISN-Qj.js} +1 -1
  55. package/web/dist/assets/{ArtifactPane-Dx_0iWwH.js → ArtifactPane-CKOiAUNH.js} +1 -1
  56. package/web/dist/assets/{ArtifactWorkbench-CuHgARMk.js → ArtifactWorkbench-d7R1o-rs.js} +1 -1
  57. package/web/dist/assets/{Diagram-CZM_R7MG.js → Diagram-azw2B_QH.js} +1 -1
  58. package/web/dist/assets/{Library-TwvrwqSk.js → Library-CcjZXsfF.js} +2 -2
  59. package/web/dist/assets/{ProfilesDestination-rKNDpLjR.js → ProfilesDestination-DdzbzmeK.js} +1 -1
  60. package/web/dist/assets/{TeamsPage-DtJylLrt.js → TeamsPage-CDyjrK-I.js} +1 -1
  61. package/web/dist/assets/{Watchtower-Bmgdyfjb.js → Watchtower-DYwoUWME.js} +2 -2
  62. package/web/dist/assets/{index-D-xG9nrc.js → index-LNMZCqgE.js} +15 -15
  63. package/web/dist/assets/{live-7q72Lyj2.js → live-ycZ_aQZO.js} +1 -1
  64. package/web/dist/assets/{renderer2d-hfZxM6Xl.js → renderer2d-BIwbgRoX.js} +1 -1
  65. package/web/dist/assets/{renderer3d-Km9vipTM.js → renderer3d-C1a7eYGQ.js} +1 -1
  66. package/web/dist/assets/{safe-area-BAbgBiFk.js → safe-area-DHhPy61Q.js} +1 -1
  67. package/web/dist/index.html +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "private": true,
5
5
  "description": "The hub CLI (`hub` / `hubctl`) for agents and scripts (internal; bundled into @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -0,0 +1,112 @@
1
+ # Phase 3: user services on Windows (launch-node), shared contract
2
+
3
+ Design: `docs/plans/2026-11-05-machine-setup-installer-design.md` §3 (Phase 3); Phase 2 contract
4
+ `PHASE2-CONTRACT.md` and `docs/bootstrap-contracts.md` §11 (v1.3) still hold. Branch `feat/ms3` from v0.8.7
5
+ (`ebe8f6a1`). Three workstreams build against this file in parallel; change it only through the integrator.
6
+
7
+ Goal: `launch-node` works on `windows-x64`. `hub node` runs as a per-user logon task (no admin, no password, no
8
+ visible window), the node keeps OpenCode's background service running so the machine stays online without OpenCode
9
+ open, and launches prepare worktrees under `C:/…` workspace roots. `full-harness` (Claude Code / Codex spokes) stays
10
+ refused on Windows (Phase 4).
11
+
12
+ ## Paths in the protocol and the node (workstream A)
13
+
14
+ - The launch/node protocol (`packages/protocol/src/launch.ts` `isAbsolutePath`, `node.ts`) accepts canonical absolute
15
+ paths exactly as the bootstrap contracts do: POSIX `/a/b` or Windows `C:/a/b` (upper-case drive, `/`, no UNC or
16
+ device paths). Reuse or mirror `packages/cli/src/bootstrap/contracts/validate.ts` `isAbsolutePath` (one rule).
17
+ - Everything the node sends (hello `workspaceRoots`, prepared `directory`) is canonical; the node canonicalizes
18
+ native paths it reads (node.json, `realpathSync`, git output) at the edge (`normalizeAbs`-style). The hub compares
19
+ canonical strings; drive letters are upper-case, so case-insensitive Windows paths compare equal.
20
+ - node.json `workspaceRoots` on Windows may be written as `C:\Users\x\projects`, `C:/Users/x/projects` or
21
+ `~/projects`; the node canonicalizes them.
22
+
23
+ ## The `task-scheduler` service manager (workstream B)
24
+
25
+ - `ServiceManager` gains `"task-scheduler"`; `windows-x64` has `serviceManager: "task-scheduler"` (was `"none"`).
26
+ Discovery reports `task-scheduler` on Windows when `%SystemRoot%\System32\schtasks.exe` exists, else `none`.
27
+ - New root `task-defs` = `state/hub/tasks` (canonical `C:/Users/x/.local/state/hub/tasks`): where hubctl writes the
28
+ rendered Task XML it registers. The XML is journaled like a unit file; Task Scheduler keeps its own copy.
29
+ - Embedded asset `service/task-scheduler/node` = `scripts/hub-node.task.xml`, kind `task-xml`. The rendered file is
30
+ written as **UTF-16LE with BOM** if `schtasks /XML` needs it (verify on Windows; UTF-8 if it accepts that).
31
+ - Task: name `hub-node-<user>` (user = `%USERNAME%`, `[A-Za-z0-9._-]` only, others `_`) in the root folder;
32
+ `LogonTrigger` for this user; principal = the user's SID, `InteractiveToken`, `LeastPrivilege` (no admin, no stored
33
+ password); `MultipleInstancesPolicy` `IgnoreNew`; no battery limits; `ExecutionTimeLimit` `PT0S`;
34
+ `RestartOnFailure` interval `PT1M`, count `999`. No visible window: the action must not open a console window at
35
+ logon (try `conhost.exe --headless <hubctl.exe> node run`; if that is not reliable, find another way, verify it on
36
+ Windows, and say what you chose). The command is `user-bin:hubctl.exe node run` (native path in the XML), never a
37
+ `.cmd`.
38
+ - No per-task environment exists: settings a systemd unit carries in `Environment=` are passed another way (flags, or
39
+ nothing when they are defaults). The script layout (`__RUNTIME__ __ENTRY__`) is refused on Windows: only the
40
+ standalone `hubctl.exe` layout.
41
+ - Control: absolute `%SystemRoot%\System32\schtasks.exe`, `shell:false`, `windowsHide:true`, a fixed allowlist:
42
+ `/Create /TN <name> /XML <abs task-defs file> /F`, `/Run /TN <name>`, `/End /TN <name>`, `/Delete /TN <name> /F`,
43
+ `/Query /TN <name> /XML` (state: registered / enabled) and a locale-independent running check (e.g. the task's
44
+ process: `hubctl.exe` with `node run` whose parent is the Task Scheduler engine, or `schtasks /Query /XML` plus a
45
+ CIM process query) with the same `state` / `activate` / `deactivate` / `restore` / `health` semantics and the
46
+ existing health window. `/End` is TerminateProcess: no graceful stop on Windows (accepted; the node is crash-safe).
47
+ - Exit 78 ("nothing to do") cannot be excluded from restarts in Task XML: on win32 the node exits **0** in that case
48
+ (no restart) and logs why; the task is started again at the next logon or by `hubctl setup apply`.
49
+ - The hand path `hub node install/uninstall` on Windows writes the XML and prints the `schtasks` commands (never runs
50
+ them), like launchd/systemd today.
51
+ - Profiles: `launch-node` plans on `windows-x64`; `full-harness` stays refused there with
52
+ "<service> needs a user service hubctl does not install on Windows yet" for the claude-code / codex spokes.
53
+ `--workspace-root` takes native or canonical Windows paths and `~/…`.
54
+
55
+ ## The node on Windows (workstream A)
56
+
57
+ - **OpenCode background service.** On win32, when no background service is registered or its process is gone, the node
58
+ starts it (`<opencode> service start`, found like the warmer finds OpenCode, `windowsHide`, no shell), at most once
59
+ per tick with back-off, and logs it. Then the warmer warms it as on other OSes. Verify on Windows where OpenCode
60
+ 2.x writes `service.json` and how `opencode service start` behaves; fix `run.ts`'s path if it differs.
61
+ - `opencode-warm.ts` `readServiceRegistration`: the POSIX mode check (`mode & 0o022`) does not apply on win32; use the
62
+ ACL check from `src/win-acl.ts` (owner, SYSTEM, Administrators only) or none for a non-secret file — say which.
63
+ - Process trees: `git.ts` (and `runtime-openshell/src/cli.ts` if it can run on Windows) stop with the shared
64
+ `packages/cli/src/bootstrap/adapters/process-tree.ts` tree kill on win32 instead of `process.kill(-pid)`.
65
+ - Harness lookup (`harnesses.ts`): PATHEXT and `.exe` on win32 (`opencode.exe`, `claude.exe`, `bin/codex.exe`).
66
+ - Every spawn that can run on Windows sets `windowsHide: true` (also `control.ts` `defaultSpawn`,
67
+ `claude-code/src/wrapper.ts`, `codex/src/wrapper.ts`).
68
+
69
+ ## Installer and console (workstream C)
70
+
71
+ - `/install.ps1` accepts `profile=launch-node`, `root` and `activate=1` (still refuses `full-harness`). `root` on
72
+ Windows: `~/…` or a drive path `C:/…` (forward slashes in the URL; same character allowlist plus `:` after a single
73
+ drive letter); the server validates and the script passes it to `--workspace-root`; `activate=1` adds
74
+ `--confirm-activation`.
75
+ - The console dialog offers **+ launch node** on Windows (workspace folder default `~/projects`, Start services now);
76
+ **All harnesses** stays "Coming soon on Windows".
77
+
78
+ ## Verification on winpixel (all workstreams)
79
+
80
+ `ssh winpixel` (user `dacra`, PowerShell 5.1 remote shell; send scripts as `powershell -NoProfile -EncodedCommand
81
+ <base64 UTF-16LE>`). Work only under `C:\Users\dacra\AppData\Local\Temp\<your-workstream>\`, with `USERPROFILE`,
82
+ `HOME`, `TEMP`, `TMP`, `XDG_*` pointed inside it. Scheduled tasks you create must be named `hub-test-<something>` and
83
+ be deleted before you finish (`schtasks /Delete /F`). Never touch the real profile, the real `hub-node-*` task,
84
+ the registry or PATH. Processes started over ssh die when the session ends; a logon task needs a logon to fire, so test
85
+ it with `/Run`. If winpixel is unreachable (it may be off), finish everything else with tests and list exactly what
86
+ still needs a Windows run.
87
+
88
+ ## As built (integrator, 2026-11-06)
89
+
90
+ Verified on winpixel during the workstreams; these replace the contract text where they differ.
91
+
92
+ - **Encoding:** `schtasks /XML` refuses UTF-8 ("unable to switch the encoding"); the Task XML is written UTF-16LE
93
+ with a BOM.
94
+ - **No window:** the action is `conhost.exe --headless <hubctl.exe> node run` (checked with Comet KVM screenshots
95
+ and a window-process query; without `--headless` Windows Terminal opens).
96
+ - **Restart:** `conhost --headless` always exits 0, so `RestartOnFailure` never fires. The logon trigger repeats every
97
+ minute with `IgnoreNew`: an exited node is started again within a minute whatever its exit code.
98
+ - **Idle node:** because of that, a node with no workspace roots on win32 does not exit: it logs why and re-reads
99
+ node.json every 60 s (`IDLE_RECHECK_MS`) until it lists roots (replaces "exit 0").
100
+ - **Logs:** stderr is lost under the headless console, so on win32 `hub node run` also appends to
101
+ `<hub state dir>/node.log` (rotated to `node.log.1` past 1 MiB).
102
+ - **Stop:** `schtasks /End` ends only the console host; deactivate first runs `taskkill /T /F /PID <pid>` on the
103
+ task's process (allowlisted as exactly that), then `/End` and `/Delete`.
104
+ - **Running check:** a fixed PowerShell script reads the task through Task Scheduler's COM API (numeric state, so no
105
+ localized text, and the action's process ID).
106
+ - **Carried settings:** variables a systemd unit would carry (`XDG_CONFIG_HOME` …) are dropped on Windows and named in
107
+ a warning at preview and `hub node install`.
108
+ - **Spokes:** `full-harness` is refused per spoke ("claude-code needs a user service hubctl does not install on Windows
109
+ yet", likewise codex-spoke).
110
+ - **Node paths:** service.json is `%USERPROFILE%\.local\state\opencode\service.json` (as on POSIX); it is checked by ACL.
111
+ Launch placement compares canonical paths exactly (drive upper-case); a repo path differing only in case from a
112
+ node's root is not placed there.
@@ -7,6 +7,10 @@
7
7
  // systemctl --user show --property=ActiveState,SubState,NRestarts <hub unit>
8
8
  // launchd launchctl bootstrap gui/<uid> <the hub agent's plist> | bootout gui/<uid>/<hub label>
9
9
  // launchctl print gui/<uid>/<hub label>
10
+ // Task Scheduler schtasks /Create /TN <hub task> /XML <its task-defs file> /F | /Run | /End /TN <hub task>
11
+ // (Windows) schtasks /Delete /TN <hub task> /F | /Query /TN <hub task> /XML
12
+ // powershell -NoProfile -NonInteractive -Command <one fixed Schedule.Service query of the task>
13
+ // taskkill /T /F /PID <the task's running process>
10
14
  // Every call is checked against that set (checkManagerCall) before it reaches the injected runner, so a bug cannot
11
15
  // turn it into anything else; the real runner (spawnManagerRunner) checks again, spawns the absolute program
12
16
  // without a shell and with an empty environment (only the user bus address is passed), and reports an exit code;
@@ -23,15 +27,23 @@
23
27
  // Activation rolls back to the state the service had before: a service that was not loaded is unloaded (and
24
28
  // disabled) again; one that was already loaded stays loaded (the engine then restores its previous unit file).
25
29
  // After a crash the prior state is unknown, so recovery unloads it: no persistence remains without consent.
30
+ //
31
+ // Task Scheduler (PHASE3-CONTRACT.md): activation registers the task from the Task XML hubctl wrote (/Create /XML /F,
32
+ // replacing an older definition) and starts it (/Run); "loaded" means registered. The running check reads the task
33
+ // through the Schedule.Service COM object (a fixed PowerShell script: its State and LastTaskResult are numbers, so the
34
+ // answer does not depend on the display language, and each running instance's EnginePID is the process of its
35
+ // action). `schtasks /End` ends only that process, the hidden console host, and would leave `hubctl node run`
36
+ // running without it, so deactivation (and a re-activation) first ends the action's whole process tree with
37
+ // taskkill /T /F. There is no graceful stop on Windows; the node is crash-safe.
26
38
  import { spawn as nodeSpawn } from "node:child_process"
27
39
  import path from "node:path"
28
- import { EMBEDDED_ASSETS, isAbsolutePath, isRecord, resolvePathRef, SERVICE_IDS, type ExecutionContext, type RootMapping, type ServiceID, type ServiceManagerID } from "../../contracts/index.ts"
40
+ import { EMBEDDED_ASSETS, isAbsolutePath, isRecord, resolvePathRef, SERVICE_IDS, toNativePath, type ExecutionContext, type RootMapping, type ServiceID, type ServiceManagerID } from "../../contracts/index.ts"
29
41
  import type { ServiceControl } from "../../transaction/builtin.ts"
30
42
  import type { ActionExecutor } from "../../transaction/executor.ts"
31
43
  import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
32
- import { assetIDFor, serviceName, UNIT_ROOT } from "./render.ts"
44
+ import { assetIDFor, serviceName, TASK_NAME, UNIT_ROOT } from "./render.ts"
33
45
 
34
- export type ManagerProgram = "systemctl" | "launchctl"
46
+ export type ManagerProgram = "systemctl" | "launchctl" | "schtasks" | "powershell" | "taskkill"
35
47
 
36
48
  /** One call to the user's service manager: the program and its words (never a shell string). */
37
49
  export interface ManagerCall {
@@ -48,7 +60,7 @@ export const HEALTH_WINDOW = Object.freeze({ timeoutMs: 10_000, stableMs: 3_000,
48
60
  /** What a state query says about a service. `detail` is the manager's own state words (never other output). */
49
61
  export interface ServiceHealth {
50
62
  readonly state: "running" | "starting" | "crash-looping" | "failed" | "stopped"
51
- /** Restarts the manager counted (systemd NRestarts; launchd runs - 1). */
63
+ /** Restarts the manager counted (systemd NRestarts; launchd runs - 1; Task Scheduler counts none). */
52
64
  readonly restarts: number
53
65
  readonly detail: string
54
66
  }
@@ -61,9 +73,9 @@ export interface Clock {
61
73
  const realClock: Clock = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }
62
74
 
63
75
  export interface ServiceRunState {
64
- /** systemd: active; launchd: loaded in the user's GUI domain. */
76
+ /** systemd: active; launchd: loaded in the user's GUI domain; Task Scheduler: the task is registered. */
65
77
  readonly loaded: boolean
66
- /** systemd only: enabled (started at login). */
78
+ /** systemd: enabled (started at login); Task Scheduler: the registered task is enabled. */
67
79
  readonly enabled?: boolean
68
80
  }
69
81
 
@@ -89,12 +101,50 @@ const labelOfTarget = (word: string | undefined) => {
89
101
  return at > 0 && DOMAIN.test(word!.slice(0, at)) ? word!.slice(at + 1) : undefined
90
102
  }
91
103
 
104
+ /** The Task XML files hubctl registers (task-defs file names). */
105
+ const TASK_FILES = new Set(Object.values(EMBEDDED_ASSETS).flatMap((a) => (a.manager === "task-scheduler" ? [a.installAs] : [])))
106
+ /** A native, drive-rooted Windows path to one of them. */
107
+ const TASK_FILE = /^[A-Z]:\\(?:[^\\/:*?"<>|]+\\)*([^\\/:*?"<>|]+)$/
108
+
109
+ /** The fixed PowerShell words of the state query of `name` (Schedule.Service: numeric state, the action's pids). */
110
+ export function taskQueryWords(name: string): readonly string[] {
111
+ if (!TASK_NAME.test(name)) throw new Error(`not a hub task name: ${JSON.stringify(name)}`)
112
+ const script =
113
+ "$ErrorActionPreference='Stop';$s=New-Object -ComObject Schedule.Service;$s.Connect();" +
114
+ `$t=$s.GetFolder('\\').GetTask('${name}');` +
115
+ "'state='+[int]$t.State;'enabled='+$t.Enabled;'result='+$t.LastTaskResult;" +
116
+ "foreach($r in $t.GetInstances(0)){'pid='+$r.EnginePID}"
117
+ return ["-NoProfile", "-NonInteractive", "-Command", script]
118
+ }
119
+
120
+ function checkWindowsCall(program: ManagerProgram, words: readonly string[]): boolean {
121
+ if (program === "taskkill") return words.length === 4 && words[0] === "/T" && words[1] === "/F" && words[2] === "/PID" && /^[1-9][0-9]{0,9}$/.test(words[3]!)
122
+ if (program === "powershell") {
123
+ const name = /GetTask\('([^']*)'\)/.exec(words[words.length - 1] ?? "")?.[1]
124
+ if (!name || !TASK_NAME.test(name)) return false
125
+ const expected = taskQueryWords(name)
126
+ return words.length === expected.length && words.every((w, i) => w === expected[i])
127
+ }
128
+ const [verb, tn, name] = words
129
+ if (tn !== "/TN" || !TASK_NAME.test(name ?? "")) return false
130
+ if (verb === "/Run" || verb === "/End") return words.length === 3
131
+ if (verb === "/Delete") return words.length === 4 && words[3] === "/F"
132
+ if (verb === "/Query") return words.length === 4 && words[3] === "/XML"
133
+ if (verb === "/Create") return words.length === 6 && words[3] === "/XML" && words[5] === "/F" && TASK_FILES.has(TASK_FILE.exec(words[4]!)?.[1] ?? "")
134
+ return false
135
+ }
136
+
137
+ const WINDOWS_PROGRAMS = new Set<string>(["schtasks", "powershell", "taskkill"])
138
+
92
139
  /** Whether `call` is one of the fixed calls this client makes. */
93
140
  export function checkManagerCall(call: unknown): call is ManagerCall {
94
- if (!isRecord(call) || !Array.isArray(call.words) || call.words.length > 4) return false
141
+ if (!isRecord(call) || !Array.isArray(call.words)) return false
142
+ const windows = typeof call.program === "string" && WINDOWS_PROGRAMS.has(call.program)
143
+ if (call.words.length > (windows ? 6 : 4)) return false
95
144
  const w = call.words as unknown[]
96
145
  if (!w.every((x) => typeof x === "string" && x.length > 0 && x.length <= 4096 && !CONTROL.test(x))) return false
97
146
  const words = w as string[]
147
+ if (windows) return checkWindowsCall(call.program as ManagerProgram, words)
98
148
  if (call.program === "systemctl") {
99
149
  if (words[0] !== "--user") return false
100
150
  const verb = words[1]!
@@ -124,11 +174,36 @@ export function unitPathResolver(roots: readonly RootMapping[], context: Executi
124
174
  }
125
175
  }
126
176
 
177
+ /** The fields of the task state query (key=value lines). */
178
+ const taskFields = (output: string) => showFields(output.replaceAll("\r", ""))
179
+
180
+ /** The process ids of a task's running instances (the state query's pid= lines). */
181
+ const taskPids = (output: string): number[] =>
182
+ output
183
+ .replaceAll("\r", "")
184
+ .split("\n")
185
+ .flatMap((line) => (/^pid=([1-9][0-9]{0,9})$/.exec(line.trim()) ? [Number(line.trim().slice(4))] : []))
186
+
187
+ /** What the task state query says: TASK_STATE numbers (0 unknown, 1 disabled, 2 queued, 3 ready, 4 running). */
188
+ export function taskSchedulerHealth(output: string): ServiceHealth {
189
+ const fields = taskFields(output)
190
+ const state = /^[0-4]$/.test(fields.state ?? "") ? Number(fields.state) : undefined
191
+ const result = /^-?[0-9]{1,10}$/.test(fields.result ?? "") ? Number(fields.result) >>> 0 : undefined
192
+ const pid = taskPids(output)[0]
193
+ if (state === 4) return { state: "running", restarts: 0, detail: pid ? `running, pid ${pid}` : "running" }
194
+ if (state === 2) return { state: "starting", restarts: 0, detail: "queued" }
195
+ if (state === 1) return { state: "stopped", restarts: 0, detail: "disabled" }
196
+ if (state === 3) return { state: result === 0x41303 ? "starting" : "stopped", restarts: 0, detail: `ready, last result 0x${(result ?? 0).toString(16)}` }
197
+ return { state: "stopped", restarts: 0, detail: "unknown" }
198
+ }
199
+
127
200
  export interface ServiceControlOptions {
128
201
  readonly run: ManagerRunner
129
202
  /** The user whose launchd GUI domain the agents load in (process.getuid()). */
130
203
  readonly uid: number
131
204
  readonly unitPath: (service: ServiceID, manager: ServiceManagerID) => string | undefined
205
+ /** Task Scheduler: the task name of each service for this user (taskNameFor); without it, tasks are refused. */
206
+ readonly taskName?: (service: ServiceID) => string | undefined
132
207
  /** Time for the health window (injected in tests). */
133
208
  readonly clock?: Clock
134
209
  }
@@ -286,15 +361,73 @@ export function createServiceControl(options: ServiceControlOptions): NodeServic
286
361
  },
287
362
  }
288
363
 
364
+ const taskOf = (service: ServiceID): string | undefined => {
365
+ const name = options.taskName?.(service)
366
+ return name && TASK_NAME.test(name) ? name : undefined
367
+ }
368
+ const noTask = (service: ServiceID) => txFail("invalid", `this hubctl has no Task Scheduler task name for the ${service} service`)
369
+ const tasks = {
370
+ /** The state query: undefined when the task is not registered. */
371
+ async query(name: string): Promise<string | undefined> {
372
+ const r = await options.run(checked("powershell", ...taskQueryWords(name)))
373
+ return r.exitCode === 0 ? (r.output ?? "") : undefined
374
+ },
375
+ /** Ends each running instance's whole process tree (schtasks /End would end only the console host). */
376
+ async endTrees(name: string): Promise<void> {
377
+ for (const pid of taskPids((await tasks.query(name)) ?? "")) await call("taskkill", "/T", "/F", "/PID", String(pid))
378
+ },
379
+ async state(service: ServiceID): Promise<TxResult<ServiceRunState>> {
380
+ const name = taskOf(service)
381
+ if (!name) return noTask(service)
382
+ const r = await options.run(checked("schtasks", "/Query", "/TN", name, "/XML"))
383
+ if (r.exitCode !== 0) return txOk({ loaded: false, enabled: false })
384
+ const enabled = /<Settings>[\s\S]*?<Enabled>(true|false)<\/Enabled>/.exec(r.output ?? "")?.[1] !== "false"
385
+ return txOk({ loaded: true, enabled })
386
+ },
387
+ async activate(service: ServiceID): Promise<TxResult<void>> {
388
+ const name = taskOf(service)
389
+ if (!name) return noTask(service)
390
+ const file = options.unitPath(service, "task-scheduler")
391
+ if (!file) return txFail("invalid", `this host has no task-defs directory for ${name}`)
392
+ await tasks.endTrees(name) // a running node keeps the old definition and binary otherwise
393
+ const created = await call("schtasks", "/Create", "/TN", name, "/XML", toNativePath(file, "win32"), "/F")
394
+ if (created !== 0) return txFail("internal", `schtasks /Create of ${name} failed (exit ${created})`)
395
+ const run = await call("schtasks", "/Run", "/TN", name)
396
+ if (run !== 0) return txFail("internal", `schtasks /Run of ${name} failed (exit ${run})`)
397
+ return settle(() => tasks.health(service), name, `schtasks /Query /TN ${name} /V /FO LIST and the Task Scheduler history`)
398
+ },
399
+ async health(service: ServiceID): Promise<TxResult<ServiceHealth>> {
400
+ const name = taskOf(service)
401
+ if (!name) return noTask(service)
402
+ const output = await tasks.query(name)
403
+ return txOk(output === undefined ? { state: "stopped", restarts: 0, detail: "not registered" } : taskSchedulerHealth(output))
404
+ },
405
+ async deactivate(service: ServiceID): Promise<TxResult<void>> {
406
+ const name = taskOf(service)
407
+ if (!name) return noTask(service)
408
+ await tasks.endTrees(name)
409
+ await call("schtasks", "/End", "/TN", name) // not running is fine
410
+ await call("schtasks", "/Delete", "/TN", name, "/F") // not registered is fine
411
+ return (await call("schtasks", "/Query", "/TN", name, "/XML")) === 0 ? txFail("internal", `${name} is still registered after schtasks /Delete`) : ok
412
+ },
413
+ }
414
+
415
+ /** Explicit dispatch: an unknown manager is an error, never another manager's calls. */
416
+ const by = <T>(manager: ServiceManagerID, calls: Readonly<Record<ServiceManagerID, () => Promise<TxResult<T>>>>): Promise<TxResult<T>> =>
417
+ guarded(() => {
418
+ const f = Object.hasOwn(calls, manager) ? calls[manager] : undefined
419
+ if (!f) throw new Error(`unknown service manager ${String(manager)}`)
420
+ return f()
421
+ })
289
422
  const deactivate = (service: ServiceID, manager: ServiceManagerID) =>
290
- guarded(() => (manager === "launchd" ? launchd.deactivate(service) : systemd.restore(service, { loaded: false, enabled: false })))
423
+ by(manager, { launchd: () => launchd.deactivate(service), "systemd-user": () => systemd.restore(service, { loaded: false, enabled: false }), "task-scheduler": () => tasks.deactivate(service) })
291
424
  return {
292
- state: (service, manager) => guarded(() => (manager === "launchd" ? launchd.state(service) : systemd.state(service))),
293
- activate: (service, manager) => guarded(() => (manager === "launchd" ? launchd.activate(service) : systemd.activate(service))),
425
+ state: (service, manager) => by(manager, { launchd: () => launchd.state(service), "systemd-user": () => systemd.state(service), "task-scheduler": () => tasks.state(service) }),
426
+ activate: (service, manager) => by(manager, { launchd: () => launchd.activate(service), "systemd-user": () => systemd.activate(service), "task-scheduler": () => tasks.activate(service) }),
294
427
  deactivate,
295
428
  restore: (service, manager, prior) =>
296
- prior.loaded ? guarded(async () => (manager === "launchd" ? ok : systemd.restore(service, prior))) : deactivate(service, manager),
297
- health: (service, manager) => guarded(() => (manager === "launchd" ? launchd.health(service) : systemd.health(service))),
429
+ prior.loaded ? by(manager, { launchd: async () => ok, "systemd-user": () => systemd.restore(service, prior), "task-scheduler": async () => ok }) : deactivate(service, manager),
430
+ health: (service, manager) => by(manager, { launchd: () => launchd.health(service), "systemd-user": () => systemd.health(service), "task-scheduler": () => tasks.health(service) }),
298
431
  }
299
432
  }
300
433
 
@@ -308,12 +441,15 @@ export type Spawner = (
308
441
  /** At most this much of a state query's output is kept. */
309
442
  const MAX_OUTPUT = 64 * 1024
310
443
  /** The calls whose output is read: the state queries. */
311
- const capturesOutput = (call: ManagerCall) => (call.program === "systemctl" ? call.words[1] === "show" : call.words[0] === "print")
444
+ const capturesOutput = (call: ManagerCall) =>
445
+ call.program === "systemctl" ? call.words[1] === "show" : call.program === "launchctl" ? call.words[0] === "print" : call.program === "powershell" || (call.program === "schtasks" && call.words[0] === "/Query")
312
446
 
313
447
  export interface SpawnRunnerOptions {
314
- /** Absolute paths of the two programs (discovery's view of this context). */
315
- readonly programs: Readonly<Record<ManagerProgram, string>>
316
- /** The caller's environment; only the user bus address is passed on (systemctl --user needs it). */
448
+ /** Absolute paths of this host's programs (discovery's view of this context): systemctl and launchctl, or the
449
+ * Windows ones (schtasks.exe, powershell.exe, taskkill.exe under %SystemRoot%\System32). */
450
+ readonly programs: Readonly<Partial<Record<ManagerProgram, string>>>
451
+ /** The caller's environment; only the user bus address (systemctl --user) and, on Windows, the few variables the
452
+ * system programs need are passed on. */
317
453
  readonly env?: Readonly<Record<string, string | undefined>>
318
454
  readonly timeoutMs?: number
319
455
  /** Injected in tests; the default spawns with node:child_process (never with a shell). */
@@ -321,10 +457,12 @@ export interface SpawnRunnerOptions {
321
457
  }
322
458
 
323
459
  const BUS_ENV = ["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] as const
460
+ /** What schtasks and PowerShell need to start (no PATH: every program is absolute). */
461
+ const WINDOWS_ENV = ["SystemRoot", "windir", "SystemDrive", "TEMP", "TMP", "USERPROFILE", "LOCALAPPDATA", "APPDATA", "USERNAME", "USERDOMAIN"] as const
324
462
 
325
463
  const defaultSpawn: Spawner = (file, words, options) =>
326
464
  new Promise((resolve) => {
327
- const child = nodeSpawn(file, [...words], { shell: false, env: { ...options.env }, stdio: ["ignore", options.capture ? "pipe" : "ignore", "ignore"], timeout: options.timeoutMs, killSignal: "SIGKILL" })
465
+ const child = nodeSpawn(file, [...words], { shell: false, windowsHide: true, env: { ...options.env }, stdio: ["ignore", options.capture ? "pipe" : "ignore", "ignore"], timeout: options.timeoutMs, killSignal: "SIGKILL" })
328
466
  let output = ""
329
467
  child.stdout?.setEncoding("utf8")
330
468
  child.stdout?.on("data", (chunk: string) => {
@@ -337,18 +475,23 @@ const defaultSpawn: Spawner = (file, words, options) =>
337
475
 
338
476
  /** The real runner: checked calls only, absolute programs, no shell, no inherited environment. */
339
477
  export function spawnManagerRunner(options: SpawnRunnerOptions): ManagerRunner {
340
- for (const program of ["systemctl", "launchctl"] as const) {
478
+ const given = Object.keys(options.programs) as ManagerProgram[]
479
+ const windows = given.some((p) => WINDOWS_PROGRAMS.has(p))
480
+ for (const program of windows ? given : (["systemctl", "launchctl"] as const)) {
341
481
  const file = options.programs[program]
342
- if (!isAbsolutePath(file) || path.basename(file) !== program) throw new Error(`${program} must be given as an absolute path to ${program}`)
482
+ const base = file === undefined ? "" : path.basename(file)
483
+ if (!isAbsolutePath(file) || (base !== program && base.toLowerCase() !== `${program}.exe`)) throw new Error(`${program} must be given as an absolute path to ${program}`)
343
484
  }
344
485
  const env: Record<string, string> = {}
345
- for (const key of BUS_ENV) if (options.env?.[key]) env[key] = options.env[key]!
486
+ for (const key of windows ? WINDOWS_ENV : BUS_ENV) if (options.env?.[key]) env[key] = options.env[key]!
346
487
  const spawn = options.spawn ?? defaultSpawn
347
488
  const timeoutMs = options.timeoutMs ?? 30_000
348
489
  return async (call: ManagerCall) => {
349
490
  if (!checkManagerCall(call)) throw new Error(`refused a service manager call outside the fixed set (${String((call as { program?: unknown }).program)})`)
350
491
  const capture = capturesOutput(call)
351
- const r = await spawn(options.programs[call.program], [...call.words], { shell: false, env, timeoutMs, ...(capture ? { capture: true as const } : {}) })
492
+ const file = options.programs[call.program]
493
+ if (!file) throw new Error(`this host's service manager does not use ${call.program}`)
494
+ const r = await spawn(file, [...call.words], { shell: false, env, timeoutMs, ...(capture ? { capture: true as const } : {}) })
352
495
  if (typeof r === "number") return { exitCode: r }
353
496
  return capture ? { exitCode: r.exitCode, output: r.output.slice(0, MAX_OUTPUT) } : { exitCode: r.exitCode }
354
497
  }
@@ -21,9 +21,10 @@ import {
21
21
  type ProgramLayout,
22
22
  type Result,
23
23
  type ServiceID,
24
+ type ServiceManagerID,
24
25
  } from "../../contracts/index.ts"
25
26
  import type { PlanContributor } from "../../preview/plan.ts"
26
- import { renderServiceUnit, SERVICE_ENVIRONMENT, type RenderedUnit } from "./render.ts"
27
+ import { renderServiceUnit, SERVICE_ENVIRONMENT, type RenderedUnit, type TaskIdentity } from "./render.ts"
27
28
  import { checkNodeSettings, inside, serviceContextOf, type NodeSettings, type ServiceContext } from "./validate.ts"
28
29
 
29
30
  export interface NodeServicesOptions {
@@ -39,6 +40,8 @@ export interface NodeServicesOptions {
39
40
  readonly environment?: Readonly<Record<string, string | undefined>>
40
41
  /** One bounded verification launch, inside a workspace root (needs `activate` and a launch-enabled profile). */
41
42
  readonly launch?: { readonly harness: HarnessID; readonly workspace: PathRef }
43
+ /** Task Scheduler (Windows): the node's task name and the user's SID (the runtime finds them; never a default). */
44
+ readonly task?: TaskIdentity
42
45
  }
43
46
 
44
47
  export interface NodeServicesPlan {
@@ -54,6 +57,7 @@ export interface NodeServicesPlan {
54
57
  export const NODE_CONFIG_REF: PathRef = { root: "config", rel: "hub/node.json" }
55
58
  const CONFIG_DIR: PathRef = { root: "config", rel: "hub" }
56
59
 
60
+ const UNIT_KIND: Readonly<Record<ServiceManagerID, string>> = { launchd: "launchd agent", "systemd-user": "systemd --user unit", "task-scheduler": "logon task" }
57
61
  const LABEL: Readonly<Record<ServiceID, string>> = { node: "hub node", "claude-code": "Claude Code spoke", "codex-spoke": "Codex spoke" }
58
62
  const SERVICE_HARNESS: Readonly<Partial<Record<ServiceID, HarnessID>>> = Object.fromEntries(Object.entries(HARNESS_SERVICE).map(([h, s]) => [s, h]))
59
63
  const BIN_SETTING: Readonly<Partial<Record<HarnessID, string>>> = { "claude-code": "HUB_CLAUDE_BIN", codex: "HUB_CODEX_BIN" }
@@ -132,9 +136,19 @@ export function planNodeServices(input: PlanInput, options: NodeServicesOptions)
132
136
  }
133
137
 
134
138
  const units: RenderedUnit[] = []
139
+ const uncarried = new Set<string>()
135
140
  for (const service of profile.services) {
136
- const environment = environmentFor(service, servedBy(service, profile.harnesses), ctx, options.environment)
137
- const unit = renderServiceUnit({ service, manager: ctx.manager, layout: options.layout, home: ctx.home, environment })
141
+ let environment = environmentFor(service, servedBy(service, profile.harnesses), ctx, options.environment)
142
+ if (ctx.manager === "task-scheduler") {
143
+ // A task has no environment of its own: say what this shell sets that the node will not see.
144
+ for (const key of Object.keys(environment)) uncarried.add(key)
145
+ environment = {}
146
+ if (!options.task) {
147
+ issues.push({ path: `/services/${service}/task`, code: "missing_field", message: "a Windows logon task needs its name and your SID; hubctl could not find them (USERNAME, whoami)" })
148
+ continue
149
+ }
150
+ }
151
+ const unit = renderServiceUnit({ service, manager: ctx.manager, layout: options.layout, home: ctx.home, environment, ...(ctx.manager === "task-scheduler" ? { task: options.task! } : {}) })
138
152
  if (!unit.ok) {
139
153
  issues.push(...prefixed(`/services/${service}`, unit))
140
154
  continue
@@ -144,7 +158,7 @@ export function planNodeServices(input: PlanInput, options: NodeServicesOptions)
144
158
  id: `install-${service}-service`,
145
159
  kind: "install-service",
146
160
  escalation: "user",
147
- summary: `Write the ${LABEL[service]} ${ctx.manager === "launchd" ? "launchd agent" : "systemd --user unit"} (${unit.value.name})`,
161
+ summary: `Write the ${LABEL[service]} ${UNIT_KIND[ctx.manager]} (${unit.value.name})`,
148
162
  service,
149
163
  manager: ctx.manager,
150
164
  path: unit.value.path,
@@ -175,7 +189,7 @@ export function planNodeServices(input: PlanInput, options: NodeServicesOptions)
175
189
  if (!valid.ok) issues.push(...prefixed(`/actions/${action.id}`, valid))
176
190
  }
177
191
  if (issues.length > 0) return failed(issues)
178
- return { ok: true, value: { actions, units, ...(settings ? { settings } : {}), context: ctx, notes: notesFor(options, ctx) } }
192
+ return { ok: true, value: { actions, units, ...(settings ? { settings } : {}), context: ctx, notes: notesFor(options, ctx, [...uncarried].sort()) } }
179
193
  }
180
194
 
181
195
  function checkLaunch(input: PlanInput, options: NodeServicesOptions, ctx: ServiceContext, settings: NodeSettings | undefined): Issue[] {
@@ -191,10 +205,13 @@ function checkLaunch(input: PlanInput, options: NodeServicesOptions, ctx: Servic
191
205
  return []
192
206
  }
193
207
 
194
- function notesFor(options: NodeServicesOptions, ctx: ServiceContext): string[] {
208
+ function notesFor(options: NodeServicesOptions, ctx: ServiceContext, uncarried: readonly string[]): string[] {
195
209
  const notes: string[] = []
196
210
  if (!options.activate) notes.push("The services are installed but not loaded: pass --confirm-activation to load them (hubctl never loads a service without it).")
197
- if (ctx.manager === "systemd-user")
211
+ if (ctx.manager === "task-scheduler") {
212
+ notes.push("The hub node runs as a logon task for you (no window, no administrator rights, no stored password): Windows starts it at each logon and again within a minute after it exits; it runs while you are logged in.")
213
+ if (uncarried.length > 0) notes.push(`The logon task does not see ${uncarried.join(", ")} from this shell (a task has no environment of its own); set them as user environment variables if the node needs them.`)
214
+ } else if (ctx.manager === "systemd-user")
198
215
  notes.push(
199
216
  options.linger
200
217
  ? "To keep the services running while you are logged out, run `loginctl enable-linger` yourself; hubctl does not change login settings."