@indigoai-us/hq-cli 5.125.0 → 5.126.0

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 (57) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
  3. package/assets/bot-workers/setup/skills/your-business.md +270 -0
  4. package/assets/bot-workers/setup/worker.yaml +189 -58
  5. package/dist/command-catalog.generated.d.ts +407 -4
  6. package/dist/command-catalog.generated.js +522 -4
  7. package/dist/command-registration-plan.d.ts +6 -0
  8. package/dist/command-registration-plan.js +1 -0
  9. package/dist/commands/bot.d.ts +10 -0
  10. package/dist/commands/bot.js +6 -0
  11. package/dist/commands/lanes.d.ts +75 -0
  12. package/dist/commands/lanes.js +2032 -0
  13. package/dist/commands/mesh.js +37 -9
  14. package/dist/commands/onboard-create-identity.d.ts +26 -0
  15. package/dist/commands/onboard-create-identity.js +23 -0
  16. package/dist/commands/onboard.js +13 -4
  17. package/dist/lib/bot/progress.d.ts +6 -0
  18. package/dist/lib/bot/progress.js +16 -0
  19. package/dist/lib/bot/runtime/grok.d.ts +30 -7
  20. package/dist/lib/bot/runtime/grok.js +86 -8
  21. package/dist/lib/bot/runtime/index.js +15 -8
  22. package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
  23. package/dist/lib/bot/runtime/messages-stream.js +49 -3
  24. package/dist/lib/lanes/accounts.d.ts +43 -0
  25. package/dist/lib/lanes/accounts.js +128 -0
  26. package/dist/lib/lanes/config.d.ts +56 -0
  27. package/dist/lib/lanes/config.js +351 -0
  28. package/dist/lib/lanes/errors.d.ts +39 -0
  29. package/dist/lib/lanes/errors.js +72 -0
  30. package/dist/lib/lanes/index.d.ts +19 -0
  31. package/dist/lib/lanes/index.js +12 -0
  32. package/dist/lib/lanes/liveness.d.ts +89 -0
  33. package/dist/lib/lanes/liveness.js +273 -0
  34. package/dist/lib/lanes/registry.d.ts +103 -0
  35. package/dist/lib/lanes/registry.js +247 -0
  36. package/dist/lib/lanes/store.d.ts +64 -0
  37. package/dist/lib/lanes/store.js +627 -0
  38. package/dist/lib/lanes/throttle.d.ts +89 -0
  39. package/dist/lib/lanes/throttle.js +297 -0
  40. package/dist/lib/lanes/types.d.ts +193 -0
  41. package/dist/lib/lanes/types.js +217 -0
  42. package/dist/lib/mesh/live/daemon/config.d.ts +27 -0
  43. package/dist/lib/mesh/live/daemon/config.js +75 -0
  44. package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
  45. package/dist/lib/mesh/live/daemon/index.js +3 -3
  46. package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
  47. package/dist/lib/mesh/live/daemon/install.js +444 -2
  48. package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
  49. package/dist/lib/mesh/live/daemon/paths.js +2 -0
  50. package/dist/lib/scan-packages/index.js +17 -12
  51. package/dist/lib/work-context/config.d.ts +20 -0
  52. package/dist/lib/work-context/config.js +26 -5
  53. package/dist/lib/work-context/reconcile.js +28 -7
  54. package/dist/lib/work-context/windows-acl.d.ts +123 -0
  55. package/dist/lib/work-context/windows-acl.js +425 -0
  56. package/dist/utils/pack-contributions.js +5 -4
  57. package/package.json +1 -1
@@ -4,6 +4,8 @@
4
4
  * - macOS: LaunchAgent ai.getindigo.hq-mesh-daemon
5
5
  * - Linux: systemd user unit hq-mesh-daemon.service
6
6
  * (documented fallback: root-owned unit running as ec2-user)
7
+ * - Windows: per-user Scheduled Task ai.getindigo.hq-mesh-daemon registered
8
+ * from a rendered task XML via schtasks.exe (no native dependencies)
7
9
  *
8
10
  * The unit embeds the hq binary resolved at install time. Logs go to
9
11
  * ~/.hq/work-mesh/daemon/log (rotation at 5MB handled by the daemon).
@@ -14,8 +16,39 @@ import * as os from "node:os";
14
16
  import * as path from "node:path";
15
17
  import { spawnSync } from "node:child_process";
16
18
  import { resolveDaemonAssetDir } from "./log.js";
17
- import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, daemonDir, daemonLogPath, } from "./paths.js";
18
- import { registerTrustedHqRoot } from "./config.js";
19
+ import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME, daemonDir, daemonLogPath, daemonPidPath, } from "./paths.js";
20
+ import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot } from "./config.js";
21
+ import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
22
+ /**
23
+ * `schtasks /End` terminates the task's root process only. The daemon is
24
+ * launched through `conhost.exe --headless`, so ending the task kills conhost
25
+ * and leaves node running as an orphan that still holds the pid lock. Stop
26
+ * the recorded daemon pid explicitly. Returns the pid that was signalled, or
27
+ * null when no live daemon pid was recorded.
28
+ */
29
+ export function stopWindowsDaemonProcess(dir, deps = {}) {
30
+ const record = readPidLock(daemonPidPath(dir), {
31
+ readFileSync: deps.readFileSync,
32
+ existsSync: deps.existsSync,
33
+ });
34
+ if (!record || record.pid === (deps.selfPid ?? process.pid))
35
+ return null;
36
+ try {
37
+ (deps.kill ?? ((pid) => process.kill(pid)))(record.pid);
38
+ return record.pid;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ export const defaultSchtasks = (args) => {
45
+ const r = spawnSync("schtasks.exe", args, { encoding: "utf8", windowsHide: true });
46
+ return {
47
+ status: r.status,
48
+ stdout: r.stdout ?? "",
49
+ stderr: r.error ? r.error.message : (r.stderr ?? ""),
50
+ };
51
+ };
19
52
  /**
20
53
  * Restart the installed user daemon after a CLI upgrade. This is deliberately
21
54
  * best-effort: an update must remain successful if a user has no service,
@@ -53,6 +86,16 @@ export function restartDaemonServiceIfInstalled(opts = {}) {
53
86
  }
54
87
  return { attempted: false, restarted: false };
55
88
  }
89
+ if (platform === "win32") {
90
+ const dir = daemonDir(undefined, home);
91
+ const xml = path.join(dir, `${WINDOWS_TASK_NAME}.xml`);
92
+ if (!existsSync(xml))
93
+ return { attempted: false, restarted: false };
94
+ run("schtasks.exe", ["/End", "/TN", WINDOWS_TASK_NAME]);
95
+ stopWindowsDaemonProcess(dir, { existsSync, kill: opts.kill });
96
+ const result = run("schtasks.exe", ["/Run", "/TN", WINDOWS_TASK_NAME]);
97
+ return { attempted: true, restarted: result.status === 0 };
98
+ }
56
99
  }
57
100
  catch {
58
101
  // The caller has already completed the update; service restart failure is
@@ -65,6 +108,8 @@ export function detectPlatform(platform = process.platform) {
65
108
  return "darwin";
66
109
  if (platform === "linux")
67
110
  return "linux";
111
+ if (platform === "win32")
112
+ return "win32";
68
113
  return "other";
69
114
  }
70
115
  /**
@@ -128,10 +173,71 @@ export function buildInstallPaths(opts) {
128
173
  plistPath: path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`),
129
174
  systemdUserDir: path.join(home, ".config", "systemd", "user"),
130
175
  systemdUnitPath: path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME),
176
+ windowsTaskXmlPath: path.join(ddir, `${WINDOWS_TASK_NAME}.xml`),
177
+ windowsUser: opts.windowsUser ?? resolveWindowsUser(env),
178
+ windowsHeadlessLauncher: opts.windowsHeadlessLauncher === undefined
179
+ ? resolveWindowsHeadlessLauncher(env)
180
+ : (opts.windowsHeadlessLauncher ?? undefined),
131
181
  systemUser: opts.systemUser,
132
182
  hqRoot: opts.hqRoot,
183
+ hqRootSource: opts.hqRootSource,
133
184
  };
134
185
  }
186
+ /** `DOMAIN\user` for the current account, as Task Scheduler expects. */
187
+ export function resolveWindowsUser(env = process.env) {
188
+ const user = env.USERNAME?.trim() || safeUsername();
189
+ const domain = env.USERDOMAIN?.trim();
190
+ return domain ? `${domain}\\${user}` : user;
191
+ }
192
+ /**
193
+ * `conhost.exe --headless` is undocumented. It shipped with the ConPTY
194
+ * conhost in Windows 10 1809 (build 17763); older conhost builds reject the
195
+ * flag and the task would fail on every logon. Gate on the kernel build
196
+ * reported by os.release() ("10.0.<build>") plus a presence check.
197
+ */
198
+ export const WINDOWS_CONHOST_HEADLESS_MIN_BUILD = 17763;
199
+ /** Build number from an os.release() string such as "10.0.26200"; null if unparsable. */
200
+ export function parseWindowsBuild(release) {
201
+ const m = /^\d+\.\d+\.(\d+)/.exec(release.trim());
202
+ if (!m)
203
+ return null;
204
+ const build = Number(m[1]);
205
+ return Number.isFinite(build) ? build : null;
206
+ }
207
+ /**
208
+ * `conhost.exe --headless` (Windows 10 1809+) runs a console app with no
209
+ * window. Returns the launcher path only when conhost.exe exists under
210
+ * SystemRoot and the host build is new enough to honour `--headless`.
211
+ */
212
+ export function resolveWindowsHeadlessLauncher(env = process.env, existsSync = fs.existsSync, release = os.release()) {
213
+ const build = parseWindowsBuild(release);
214
+ if (build === null || build < WINDOWS_CONHOST_HEADLESS_MIN_BUILD)
215
+ return undefined;
216
+ const systemRoot = env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows";
217
+ const candidate = path.join(systemRoot, "System32", "conhost.exe");
218
+ return existsSync(candidate) ? candidate : undefined;
219
+ }
220
+ /** Which launch wrapper the rendered task uses (recorded in the install message). */
221
+ export function windowsLaunchMode(paths) {
222
+ return paths.windowsHeadlessLauncher ? "conhost-headless" : "direct";
223
+ }
224
+ /** The task uses the interactive token, so it exists only inside the user's logon session. */
225
+ export function windowsLogonNotice(paths) {
226
+ return `runs at logon of ${paths.windowsUser}; not running while logged out`;
227
+ }
228
+ function describeWindowsLaunchMode(paths) {
229
+ return windowsLaunchMode(paths) === "conhost-headless"
230
+ ? `mode: conhost --headless via ${paths.windowsHeadlessLauncher}`
231
+ : "mode: direct node (conhost --headless unavailable on this host; a console window may appear at logon)";
232
+ }
233
+ function safeUsername() {
234
+ try {
235
+ return os.userInfo().username;
236
+ }
237
+ catch {
238
+ return "";
239
+ }
240
+ }
135
241
  export function renderLaunchdPlist(paths) {
136
242
  return `<?xml version="1.0" encoding="UTF-8"?>
137
243
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -224,6 +330,224 @@ StandardError=append:/home/${user}/.hq/work-mesh/daemon/log
224
330
  WantedBy=multi-user.target
225
331
  `;
226
332
  }
333
+ /**
334
+ * Windows Task Scheduler definition (schtasks /Create /XML). Runs at logon of
335
+ * the registering user, hidden, no execution time limit, one instance, and
336
+ * restarts every minute on failure. HOME/USERPROFILE come from the user's own
337
+ * session. Task Scheduler XML has no environment block, so the HQ root the
338
+ * install was run for is passed explicitly as `mesh daemon run --hq-root
339
+ * <path>` (the launchd/systemd equivalent of Environment=HQ_ROOT) and is also
340
+ * persisted in the daemon config as a trusted root.
341
+ *
342
+ * LogonType is InteractiveToken: S4U ("run whether user is logged on or not")
343
+ * needs elevation to register, and the interactive token is what an
344
+ * unprivileged `hq mesh daemon install` can create. As a consequence the task
345
+ * runs only while the registering user is logged on. The console window that
346
+ * an interactive-token task would otherwise open is avoided by launching
347
+ * through `conhost.exe --headless` when the host supports it (see
348
+ * resolveWindowsHeadlessLauncher); otherwise node is run directly.
349
+ */
350
+ export function renderWindowsTaskXml(paths) {
351
+ const user = escapeXml(paths.windowsUser);
352
+ const nodeArg = windowsArgQuote(paths.nodeBinary);
353
+ const hqArg = windowsArgQuote(paths.hqBinary);
354
+ const command = paths.windowsHeadlessLauncher ?? paths.nodeBinary;
355
+ const rootArgs = paths.hqRoot ? ` --hq-root ${windowsArgQuote(paths.hqRoot)}` : "";
356
+ const args = paths.windowsHeadlessLauncher
357
+ ? `--headless ${nodeArg} ${hqArg} mesh daemon run${rootArgs}`
358
+ : `${hqArg} mesh daemon run${rootArgs}`;
359
+ return `<?xml version="1.0" encoding="UTF-16"?>
360
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
361
+ <RegistrationInfo>
362
+ <URI>\\${WINDOWS_TASK_NAME}</URI>
363
+ <Description>HQ Work Mesh Live daemon (spool drainer + MQTT presence)</Description>
364
+ </RegistrationInfo>
365
+ <Triggers>
366
+ <LogonTrigger>
367
+ <Enabled>true</Enabled>
368
+ <UserId>${user}</UserId>
369
+ </LogonTrigger>
370
+ </Triggers>
371
+ <Principals>
372
+ <Principal id="Author">
373
+ <UserId>${user}</UserId>
374
+ <LogonType>${WINDOWS_TASK_LOGON_TYPE}</LogonType>
375
+ <RunLevel>LeastPrivilege</RunLevel>
376
+ </Principal>
377
+ </Principals>
378
+ <Settings>
379
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
380
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
381
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
382
+ <AllowHardTerminate>true</AllowHardTerminate>
383
+ <StartWhenAvailable>true</StartWhenAvailable>
384
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
385
+ <AllowStartOnDemand>true</AllowStartOnDemand>
386
+ <Enabled>true</Enabled>
387
+ <Hidden>true</Hidden>
388
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
389
+ <DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
390
+ <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
391
+ <WakeToRun>false</WakeToRun>
392
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
393
+ <Priority>7</Priority>
394
+ <RestartOnFailure>
395
+ <Interval>PT1M</Interval>
396
+ <Count>999</Count>
397
+ </RestartOnFailure>
398
+ </Settings>
399
+ <Actions Context="Author">
400
+ <Exec>
401
+ <Command>${escapeXml(command)}</Command>
402
+ <Arguments>${escapeXml(args)}</Arguments>
403
+ <WorkingDirectory>${escapeXml(paths.daemonDir)}</WorkingDirectory>
404
+ </Exec>
405
+ </Actions>
406
+ </Task>
407
+ `;
408
+ }
409
+ const WINDOWS_TASK_LOGON_TYPE = "InteractiveToken";
410
+ /** schtasks stores task XML as UTF-16 LE with BOM; match it so /XML parses. */
411
+ export function encodeWindowsTaskXml(rendered) {
412
+ return Buffer.from(`\uFEFF${rendered}`, "utf16le");
413
+ }
414
+ function windowsArgQuote(value) {
415
+ if (/^[A-Za-z0-9_.:\\/-]+$/.test(value))
416
+ return value;
417
+ return `"${value.replace(/"/g, '\\"')}"`;
418
+ }
419
+ /** Status column of `schtasks /Query /FO CSV /NH`: "TaskName","Next Run Time","Status". */
420
+ export function parseSchtasksCsvStatus(stdout) {
421
+ for (const line of stdout.split(/\r?\n/)) {
422
+ const cells = line.match(/"([^"]*)"/g);
423
+ if (!cells || cells.length < 3)
424
+ continue;
425
+ return cells[2].slice(1, -1).trim();
426
+ }
427
+ return null;
428
+ }
429
+ /** Split one CSV line as schtasks emits it (all cells quoted, `""` escapes a quote). */
430
+ function splitCsvLine(line) {
431
+ const cells = [];
432
+ let cur = "";
433
+ let inQuotes = false;
434
+ for (let i = 0; i < line.length; i++) {
435
+ const ch = line[i];
436
+ if (inQuotes) {
437
+ if (ch === '"') {
438
+ if (line[i + 1] === '"') {
439
+ cur += '"';
440
+ i++;
441
+ }
442
+ else
443
+ inQuotes = false;
444
+ }
445
+ else
446
+ cur += ch;
447
+ }
448
+ else if (ch === '"')
449
+ inQuotes = true;
450
+ else if (ch === ",") {
451
+ cells.push(cur);
452
+ cur = "";
453
+ }
454
+ else
455
+ cur += ch;
456
+ }
457
+ cells.push(cur);
458
+ return cells;
459
+ }
460
+ /** Column index of "Last Result" in `schtasks /Query /V /FO CSV` (fixed order, header localized). */
461
+ const SCHTASKS_LAST_RESULT_COLUMN = 6;
462
+ /**
463
+ * "Last Result" of `schtasks /Query /TN <name> /V /FO CSV`, normalized to an
464
+ * unsigned 32-bit code (schtasks prints HRESULT/NTSTATUS values as signed
465
+ * decimals, e.g. -2147024894 for 0x80070002). Prefers the English header
466
+ * when present; otherwise uses the fixed column position. Null when the
467
+ * output carries no task row or the cell is not numeric.
468
+ */
469
+ export function parseSchtasksLastResult(stdout) {
470
+ const lines = stdout.split(/\r?\n/).filter((l) => l.trim().length > 0);
471
+ if (lines.length === 0)
472
+ return null;
473
+ let column = SCHTASKS_LAST_RESULT_COLUMN;
474
+ let rows = lines;
475
+ const header = splitCsvLine(lines[0]);
476
+ const headerIdx = header.findIndex((h) => /^last result$/i.test(h.trim()));
477
+ if (headerIdx >= 0 || /^hostname$/i.test(header[0]?.trim() ?? "")) {
478
+ if (headerIdx >= 0)
479
+ column = headerIdx;
480
+ rows = lines.slice(1);
481
+ }
482
+ for (const line of rows) {
483
+ const cells = splitCsvLine(line);
484
+ if (cells.length <= column)
485
+ continue;
486
+ const raw = cells[column].trim();
487
+ if (!/^-?\d+$/.test(raw))
488
+ continue;
489
+ return Number(raw) >>> 0;
490
+ }
491
+ return null;
492
+ }
493
+ /**
494
+ * Task Scheduler informational results mean "no failed start": 0 is success,
495
+ * and every SCHED_S_* code (ready, running, not yet run, terminated, queued,
496
+ * ...) is a success-severity HRESULT (bit 31 clear) in FACILITY_ITF (0x4)
497
+ * with code 0x1300-0x13FF, i.e. 0x00041300-0x000413FF. Everything else
498
+ * (0x8007xxxx, 0xC000xxxx, process exit codes such as 1) is a failed start.
499
+ */
500
+ export function schtasksLastResultFailed(code) {
501
+ if (code === null || code === 0)
502
+ return false;
503
+ const severityFailed = (code & 0x80000000) !== 0;
504
+ const facility = (code >>> 16) & 0x7ff;
505
+ const lowCode = code & 0xffff;
506
+ if (!severityFailed && facility === 0x4 && lowCode >= 0x1300 && lowCode <= 0x13ff)
507
+ return false;
508
+ return true;
509
+ }
510
+ /** `--hq-root <path>` from a rendered task's <Arguments> (XML-escaped, optionally quoted). */
511
+ export function parseWindowsTaskHqRoot(xml) {
512
+ const m = /<Arguments>([^<]*)<\/Arguments>/.exec(xml);
513
+ if (!m)
514
+ return null;
515
+ const args = unescapeXml(m[1]);
516
+ const quoted = /--hq-root "((?:[^"\\]|\\.)*)"/.exec(args);
517
+ if (quoted)
518
+ return quoted[1].replace(/\\"/g, '"');
519
+ const bare = /--hq-root (\S+)/.exec(args);
520
+ return bare ? bare[1] : null;
521
+ }
522
+ /**
523
+ * The HQ root the installed Windows task actually carries: the task XML on
524
+ * disk first, else the first persisted trusted root. Reads state only; never
525
+ * resolves from cwd, so status reports what install recorded.
526
+ */
527
+ export function readRecordedWindowsHqRoot(paths, deps = {}) {
528
+ const existsSync = deps.existsSync ?? fs.existsSync;
529
+ const readFileSync = deps.readFileSync ?? fs.readFileSync;
530
+ try {
531
+ if (existsSync(paths.windowsTaskXmlPath)) {
532
+ const raw = readFileSync(paths.windowsTaskXmlPath);
533
+ const text = Buffer.isBuffer(raw) ? raw.toString("utf16le").replace(/^\uFEFF/, "") : String(raw);
534
+ const fromXml = parseWindowsTaskHqRoot(text);
535
+ if (fromXml)
536
+ return fromXml;
537
+ }
538
+ }
539
+ catch {
540
+ // fall through to config
541
+ }
542
+ return readDaemonConfig(paths.daemonDir).trustedHqRoots[0] ?? null;
543
+ }
544
+ function unescapeXml(value) {
545
+ return value
546
+ .replace(/&quot;/g, '"')
547
+ .replace(/&lt;/g, "<")
548
+ .replace(/&gt;/g, ">")
549
+ .replace(/&amp;/g, "&");
550
+ }
227
551
  function escapeXml(value) {
228
552
  return value
229
553
  .replace(/&/g, "&amp;")
@@ -292,6 +616,41 @@ export function installDaemonService(opts) {
292
616
  message: `Installed systemd user unit ${SYSTEMD_UNIT_NAME} → ${paths.systemdUnitPath}`,
293
617
  };
294
618
  }
619
+ if (platform === "win32") {
620
+ const schtasks = opts.schtasks ?? defaultSchtasks;
621
+ const rendered = renderWindowsTaskXml(paths);
622
+ writeFileSync(paths.windowsTaskXmlPath, encodeWindowsTaskXml(rendered), { mode: 0o600 });
623
+ const created = schtasks([
624
+ "/Create", "/TN", WINDOWS_TASK_NAME, "/XML", paths.windowsTaskXmlPath, "/F",
625
+ ]);
626
+ if (created.status !== 0) {
627
+ throw new Error(`schtasks /Create failed for ${WINDOWS_TASK_NAME} (exit ${created.status ?? "signal"}): ` +
628
+ `${(created.stderr || created.stdout).trim()}`);
629
+ }
630
+ // /F replaced any previous registration without ending its instance; stop
631
+ // the recorded daemon so the fresh task instance can take the pid lock.
632
+ schtasks(["/End", "/TN", WINDOWS_TASK_NAME]);
633
+ stopWindowsDaemonProcess(paths.daemonDir, { kill: opts.kill });
634
+ const started = schtasks(["/Run", "/TN", WINDOWS_TASK_NAME]);
635
+ const running = started.status === 0;
636
+ const mode = describeWindowsLaunchMode(paths);
637
+ const rootLine = paths.hqRoot
638
+ ? `HQ root: ${paths.hqRoot}${paths.hqRootSource ? ` (from ${paths.hqRootSource})` : ""}\n`
639
+ : "";
640
+ return {
641
+ platform,
642
+ action: "install",
643
+ paths,
644
+ rendered,
645
+ dest: paths.windowsTaskXmlPath,
646
+ running,
647
+ message: running
648
+ ? `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (started; ${mode})`
649
+ : `${rootLine}Installed Scheduled Task ${WINDOWS_TASK_NAME} → ${paths.windowsTaskXmlPath} (${mode}); ` +
650
+ `start failed: ${(started.stderr || started.stdout).trim()}. It starts at next logon.`,
651
+ notice: windowsLogonNotice(paths),
652
+ };
653
+ }
295
654
  return {
296
655
  platform,
297
656
  action: "install",
@@ -304,6 +663,30 @@ export function uninstallDaemonService(opts) {
304
663
  const paths = opts.paths ?? buildInstallPaths({});
305
664
  const unlinkSync = opts.unlinkSync ?? fs.unlinkSync;
306
665
  const existsSync = opts.existsSync ?? fs.existsSync;
666
+ if (platform === "win32") {
667
+ const schtasks = opts.schtasks ?? defaultSchtasks;
668
+ const registered = schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "CSV", "/NH"]).status === 0;
669
+ if (registered) {
670
+ schtasks(["/End", "/TN", WINDOWS_TASK_NAME]);
671
+ stopWindowsDaemonProcess(paths.daemonDir, { existsSync, kill: opts.kill });
672
+ const deleted = schtasks(["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"]);
673
+ if (deleted.status !== 0) {
674
+ throw new Error(`schtasks /Delete failed for ${WINDOWS_TASK_NAME} (exit ${deleted.status ?? "signal"}): ` +
675
+ `${(deleted.stderr || deleted.stdout).trim()}`);
676
+ }
677
+ }
678
+ if (existsSync(paths.windowsTaskXmlPath))
679
+ unlinkSync(paths.windowsTaskXmlPath);
680
+ return {
681
+ platform,
682
+ action: "uninstall",
683
+ paths,
684
+ dest: paths.windowsTaskXmlPath,
685
+ message: registered
686
+ ? `Removed Scheduled Task ${WINDOWS_TASK_NAME}`
687
+ : `Scheduled Task ${WINDOWS_TASK_NAME} was not registered; removed local task XML if present`,
688
+ };
689
+ }
307
690
  if (platform === "darwin") {
308
691
  if (existsSync(paths.plistPath)) {
309
692
  opts.deactivate?.(platform, paths.plistPath);
@@ -345,6 +728,65 @@ export function daemonServiceStatus(opts) {
345
728
  const platform = opts.platform ?? detectPlatform();
346
729
  const paths = opts.paths ?? buildInstallPaths({});
347
730
  const existsSync = opts.existsSync ?? fs.existsSync;
731
+ if (platform === "win32") {
732
+ const schtasks = opts.schtasks ?? defaultSchtasks;
733
+ // schtasks only tells us whether the task exists. Its Status column is
734
+ // localized ("Running" / "Wird ausgeführt" / ...), so the daemon pid lock
735
+ // is the source of truth for "running"; the column is shown when it
736
+ // happens to carry a value, as extra context only.
737
+ const query = schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "CSV", "/NH"]);
738
+ const installed = query.status === 0;
739
+ const taskState = installed ? parseSchtasksCsvStatus(query.stdout) : null;
740
+ // The verbose query carries "Last Result": a non-zero, non-informational
741
+ // code means the last start failed (for example conhost rejecting
742
+ // --headless), which the pid lock alone would report as merely "not
743
+ // running". Surface it so a failed start never looks like a clean idle.
744
+ const verbose = installed
745
+ ? schtasks(["/Query", "/TN", WINDOWS_TASK_NAME, "/V", "/FO", "CSV"])
746
+ : null;
747
+ const lastResult = verbose && verbose.status === 0 ? parseSchtasksLastResult(verbose.stdout) : null;
748
+ const lastRunFailed = schtasksLastResultFailed(lastResult);
749
+ const lock = pidLockStatus(daemonPidPath(paths.daemonDir), {
750
+ isProcessAlive: opts.isProcessAlive ?? defaultPidLockDeps().isProcessAlive,
751
+ readFileSync: opts.readFileSync,
752
+ existsSync,
753
+ });
754
+ const running = lock === "held";
755
+ // Read the root the task actually carries and re-check it: a moved,
756
+ // renamed, or deleted HQ tree leaves the task pointing at nothing.
757
+ const recordedHqRoot = installed
758
+ ? readRecordedWindowsHqRoot(paths, { existsSync, readFileSync: opts.readFileSync })
759
+ : null;
760
+ const rootValid = recordedHqRoot ? isInstallableHqRoot(recordedHqRoot) : undefined;
761
+ const detail = (taskState ? ` [task: ${taskState}]` : "") +
762
+ (lastRunFailed ? ` [last run failed: result ${lastResult}]` : "") +
763
+ (rootValid === false ? ` [recorded root no longer qualifies: ${recordedHqRoot}]` : "");
764
+ let message;
765
+ if (!installed) {
766
+ message = running
767
+ ? `Scheduled Task ${WINDOWS_TASK_NAME}: not installed (daemon running outside the task)`
768
+ : `Scheduled Task ${WINDOWS_TASK_NAME}: not installed`;
769
+ }
770
+ else if (running) {
771
+ message = `Scheduled Task ${WINDOWS_TASK_NAME}: running${detail}`;
772
+ }
773
+ else {
774
+ message = `Scheduled Task ${WINDOWS_TASK_NAME}: installed (daemon not running${lock === "stale" ? ", stale pid lock" : ""})${detail}`;
775
+ }
776
+ return {
777
+ platform,
778
+ action: "status",
779
+ paths,
780
+ dest: paths.windowsTaskXmlPath,
781
+ running,
782
+ message,
783
+ lastResult,
784
+ lastRunFailed,
785
+ notice: installed ? windowsLogonNotice(paths) : undefined,
786
+ recordedHqRoot,
787
+ rootValid,
788
+ };
789
+ }
348
790
  if (platform === "darwin") {
349
791
  const installed = existsSync(paths.plistPath);
350
792
  const running = installed
@@ -9,6 +9,8 @@ export declare const DAEMON_LOG_NAME = "log";
9
9
  export declare const DAEMON_LOG_MAX_BYTES: number;
10
10
  export declare const LAUNCHD_LABEL = "ai.getindigo.hq-mesh-daemon";
11
11
  export declare const SYSTEMD_UNIT_NAME = "hq-mesh-daemon.service";
12
+ /** Windows per-user Scheduled Task name (same label as the LaunchAgent). */
13
+ export declare const WINDOWS_TASK_NAME = "ai.getindigo.hq-mesh-daemon";
12
14
  export declare function daemonDir(meshRoot?: string, home?: string, env?: NodeJS.ProcessEnv): string;
13
15
  export declare function daemonPidPath(dir?: string): string;
14
16
  export declare function daemonStatePath(dir?: string): string;
@@ -12,6 +12,8 @@ export const DAEMON_LOG_NAME = "log";
12
12
  export const DAEMON_LOG_MAX_BYTES = 5 * 1024 * 1024;
13
13
  export const LAUNCHD_LABEL = "ai.getindigo.hq-mesh-daemon";
14
14
  export const SYSTEMD_UNIT_NAME = "hq-mesh-daemon.service";
15
+ /** Windows per-user Scheduled Task name (same label as the LaunchAgent). */
16
+ export const WINDOWS_TASK_NAME = "ai.getindigo.hq-mesh-daemon";
15
17
  export function daemonDir(meshRoot, home = os.homedir(), env = process.env) {
16
18
  return path.join(meshRoot ?? workMeshRoot(home, env), DAEMON_DIRNAME);
17
19
  }
@@ -163,34 +163,39 @@ function ensureSymlink(link, info, warn) {
163
163
  warn(`payload missing: ${link.src} (declared but not shipped)`);
164
164
  return;
165
165
  }
166
+ const relativeSrc = path.relative(path.dirname(link.dst), link.src);
166
167
  fs.mkdirSync(path.dirname(link.dst), { recursive: true });
167
168
  try {
168
169
  const stat = fs.lstatSync(link.dst);
169
170
  if (stat.isSymbolicLink()) {
170
171
  const existing = fs.readlinkSync(link.dst);
171
- if (existing === link.src)
172
+ // Correct form is relative to the host directory so a tree can move
173
+ // between machines. An absolute match (this host, pre-relative installer)
174
+ // is ours and converges. A dangling leftover from another HQ root is
175
+ // not host content and is rewritten the same way.
176
+ if (existing === relativeSrc)
172
177
  return;
173
- // "Host content wins" protects content. A symlink that does not resolve
174
- // is not content — and it is the shape every pack link takes when a tree
175
- // moves between machines, because these links are absolute and the old
176
- // HQ root does not exist on the new host. Skipping it would make that
177
- // state permanent: stale on this run, and on every run after it.
178
- //
178
+ if (existing === link.src) {
179
+ fs.rmSync(link.dst, { force: true });
180
+ fs.symlinkSync(relativeSrc, link.dst);
181
+ info(`relinked ${link.dst} -> ${relativeSrc} (was ${existing})`);
182
+ return;
183
+ }
179
184
  // Only a lookup that specifically says "not there" earns a relink.
180
185
  // fs.existsSync also answers false for EACCES, which would read content
181
186
  // this user merely cannot traverse as absent and delete the link to it.
182
187
  const lookup = targetLookup(link.dst);
183
188
  if (lookup === 'absent') {
184
189
  fs.rmSync(link.dst, { force: true });
185
- fs.symlinkSync(link.src, link.dst);
186
- info(`relinked ${link.dst} -> ${link.src} (was ${existing}, which does not exist here)`);
190
+ fs.symlinkSync(relativeSrc, link.dst);
191
+ info(`relinked ${link.dst} -> ${relativeSrc} (was ${existing}, which does not exist here)`);
187
192
  return;
188
193
  }
189
194
  if (lookup !== 'present') {
190
195
  warn(`collision: ${link.dst} points at ${existing}, which could not be checked (${lookup}) — leaving it alone`);
191
196
  return;
192
197
  }
193
- warn(`collision: ${link.dst} already points at ${existing} (wanted ${link.src}) — skipping`);
198
+ warn(`collision: ${link.dst} already points at ${existing} (wanted ${relativeSrc}) — skipping`);
194
199
  return;
195
200
  }
196
201
  warn(`collision: ${link.dst} exists as regular file/dir — host content wins, skipping`);
@@ -200,8 +205,8 @@ function ensureSymlink(link, info, warn) {
200
205
  if (error.code !== 'ENOENT')
201
206
  throw error;
202
207
  }
203
- fs.symlinkSync(link.src, link.dst);
204
- info(`linked ${link.dst} -> ${link.src}`);
208
+ fs.symlinkSync(relativeSrc, link.dst);
209
+ info(`linked ${link.dst} -> ${relativeSrc}`);
205
210
  }
206
211
  /**
207
212
  * The link text, when `entry` is a symlink whose target is definitively not
@@ -2,6 +2,8 @@
2
2
  * Device-local default company preference (~/.hq/work-context/config.json).
3
3
  * Preference only — never authority. activeCompany is never treated as default.
4
4
  */
5
+ import * as fs from "node:fs";
6
+ import { type WindowsAclCheckOptions } from "./windows-acl.js";
5
7
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
6
8
  export declare const DEVICE_CONFIG_SCHEMA_VERSION: 1;
7
9
  export interface DeviceDefaultCompany {
@@ -81,6 +83,24 @@ export declare function acquireDeviceConfigLock(root: string, options?: {
81
83
  timeoutMs?: number;
82
84
  staleMs?: number;
83
85
  }): () => void;
86
+ /** Injection points for assertSafeConfigPath (tests only). */
87
+ export interface SafeConfigPathIo {
88
+ platform?: NodeJS.Platform;
89
+ existsSync?: (p: string) => boolean;
90
+ lstatSync?: (p: string) => Pick<fs.Stats, "mode" | "isSymbolicLink">;
91
+ windowsAcl?: WindowsAclCheckOptions;
92
+ }
93
+ /**
94
+ * Refuse a config path that is a symlink (every platform) or writable by
95
+ * anyone other than the owner.
96
+ *
97
+ * - POSIX (darwin/linux/...): `mode & 0o022` must be clear. Unchanged.
98
+ * - win32: NTFS has no POSIX mode bits; Node reports 0666 for every regular
99
+ * file, so the mode test would reject every config (US-018). We read the
100
+ * real DACL instead (see windows-acl.ts). Unreadable DACL → documented
101
+ * reduced-guarantee path: one diagnostic line, no throw.
102
+ */
103
+ export declare function assertSafeConfigPath(configPath: string, io?: SafeConfigPathIo): void;
84
104
  export declare function readDeviceConfig(deps: Pick<DeviceConfigDeps, "root">): WorkContextDeviceConfig;
85
105
  export declare function getDefaultCompany(deps: Pick<DeviceConfigDeps, "root">): DeviceDefaultCompany | null;
86
106
  /** A clear is a durable opt-out, not an invitation for automatic reactivation. */