@indigoai-us/hq-cli 5.345.135 → 5.345.136

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 +41 -0
  2. package/assets/scaffold/core/scripts/worktree-gc.sh +98 -8
  3. package/dist/command-catalog.generated.d.ts +17 -6
  4. package/dist/command-catalog.generated.js +23 -7
  5. package/dist/commands/core-checkpoint-native.d.ts +10 -0
  6. package/dist/commands/core-checkpoint-native.js +60 -15
  7. package/dist/commands/daemon.js +3 -1
  8. package/dist/commands/integrations-core.d.ts +22 -0
  9. package/dist/commands/integrations-core.js +25 -0
  10. package/dist/commands/integrations-manage.d.ts +24 -1
  11. package/dist/commands/integrations-manage.js +92 -17
  12. package/dist/commands/integrations.d.ts +11 -0
  13. package/dist/commands/integrations.js +100 -19
  14. package/dist/commands/lanes-loop.d.ts +9 -0
  15. package/dist/commands/lanes-loop.js +9 -1
  16. package/dist/commands/lanes.js +69 -54
  17. package/dist/commands/meetings.d.ts +2 -0
  18. package/dist/commands/meetings.js +57 -14
  19. package/dist/commands/mesh.d.ts +19 -1
  20. package/dist/commands/mesh.js +158 -70
  21. package/dist/commands/reindex.js +80 -5
  22. package/dist/commands/storage-offload.d.ts +93 -28
  23. package/dist/commands/storage-offload.js +290 -34
  24. package/dist/commands/storage.js +60 -7
  25. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2550 -1748
  26. package/dist/hq-cloud-sync-runner/package.json +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/lib/capacity/route.js +24 -0
  29. package/dist/lib/core-utils/session-company.d.ts +11 -0
  30. package/dist/lib/core-utils/session-company.js +34 -0
  31. package/dist/lib/core-utils/worktree-resume.js +44 -4
  32. package/dist/lib/core-utils/worktree-session-owner.d.ts +27 -0
  33. package/dist/lib/core-utils/worktree-session-owner.js +157 -0
  34. package/dist/lib/daemon/catalog.d.ts +2 -0
  35. package/dist/lib/daemon/catalog.js +3 -3
  36. package/dist/lib/daemon/hq-child-bootstrap-stage.d.ts +5 -0
  37. package/dist/lib/daemon/hq-child-bootstrap-stage.js +3 -11
  38. package/dist/lib/daemon/server.js +10 -1
  39. package/dist/lib/daemon/supervisor.js +6 -0
  40. package/dist/lib/daemon/sync-runner-bundle.d.ts +5 -0
  41. package/dist/lib/daemon/sync-runner-bundle.js +4 -12
  42. package/dist/lib/doctor/checks/claude-wiring.js +2 -1
  43. package/dist/lib/doctor/fix/apply.d.ts +20 -0
  44. package/dist/lib/doctor/fix/apply.js +63 -3
  45. package/dist/lib/doctor/fix/remediation.d.ts +1 -1
  46. package/dist/lib/doctor/fix/remediation.js +16 -0
  47. package/dist/lib/doctor/shared.d.ts +11 -1
  48. package/dist/lib/doctor/shared.js +16 -2
  49. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  50. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  51. package/dist/lib/lanes/monitor.js +14 -2
  52. package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
  53. package/dist/lib/mesh/live/daemon/install.js +22 -0
  54. package/dist/lib/mesh/live/flush.js +106 -3
  55. package/dist/lib/mesh/live/session-events-client.d.ts +9 -0
  56. package/dist/lib/mesh/live/session-events-client.js +15 -0
  57. package/dist/lib/work-context/state.d.ts +10 -0
  58. package/dist/lib/work-context/state.js +5 -0
  59. package/dist/utils/fsync-directory.d.ts +17 -0
  60. package/dist/utils/fsync-directory.js +27 -0
  61. package/dist/utils/windows-hide-default.d.ts +42 -0
  62. package/dist/utils/windows-hide-default.js +98 -0
  63. package/package.json +2 -2
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.71","bundleSha256":"809fe7fd214f197c14df0586b2bf4e4536002cd122eb61a6817af4f7ba478637","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.76","bundleSha256":"77b46d74eee514c5c0231766a9b361481d6ada90fdbddba84d37fff827547f7c","formatVersion":1}
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { CLI_COMMAND_HELP_CATALOG_FLAG } from "./lib/cli-flag-keys.js";
12
12
  // heavy import (main.js) is lazy and reached only when a rejection fires.
13
13
  import { installProcessRejectionBoundary } from "./unhandled-rejection-boundary.js";
14
14
  import { installFastPathStreamErrorHandlers } from "./utils/fast-stream-errors.js";
15
+ import { installWindowsHideDefault } from "./utils/windows-hide-default.js";
15
16
  import { fileURLToPath } from "node:url";
16
17
  import { parseMonitorCheckFastRequest } from "./lanes-monitor-check-argv.js";
17
18
  import { createRequire } from "node:module";
@@ -155,6 +156,9 @@ async function runMonitorCheckFastWithRecovery(request) {
155
156
  await runMainWithInstallTreeRecovery();
156
157
  }
157
158
  function dispatchCliEntry() {
159
+ // Before anything spawns: a background hq on Windows (no terminal) hides the
160
+ // console window of every child it starts (feedback_351a40d5).
161
+ installWindowsHideDefault();
158
162
  if (process.platform === "win32" && coreCommandHasPathOperands(process.argv)) {
159
163
  // Keep cygpath off non-Windows and non-path command routes.
160
164
  process.argv = normalizeCorePathOperands(process.argv, "win32", cygpathMixedPath);
@@ -258,6 +258,30 @@ export function pickAccount(input) {
258
258
  continue;
259
259
  }
260
260
  const authState = account.authState ?? "unknown";
261
+ // authState "unknown" means the ledger has no usage reading for the
262
+ // account, not that its sign-in failed. Do not send the user to log in
263
+ // for it: rank it as unknown when readings are unreliable, otherwise
264
+ // skip it as a missing reading.
265
+ if (!account.signedOut && authState === "unknown") {
266
+ if (!laneMatch.enabled) {
267
+ skipped.push({
268
+ ...base,
269
+ reason: "usage-only, lanes cannot run on it",
270
+ fix: `hq lanes accounts enable ${providerId} ${laneMatch.id}`,
271
+ });
272
+ continue;
273
+ }
274
+ if (readingsUnreliable) {
275
+ rankUnknown(base, laneMatch.id, false);
276
+ continue;
277
+ }
278
+ skipped.push({
279
+ ...base,
280
+ reason: "no usage reading for this account yet",
281
+ fix: "hq capacity doctor",
282
+ });
283
+ continue;
284
+ }
261
285
  if (account.signedOut || !RUNNABLE_AUTH.has(authState)) {
262
286
  skipped.push({
263
287
  ...base,
@@ -1,3 +1,14 @@
1
1
  /** Reads the active session's company lock without importing the full hq-session command. */
2
2
  export declare function sessionCompanySlugs(root: string, env?: NodeJS.ProcessEnv, multiCompanyLockEnabled?: boolean): string[];
3
+ /**
4
+ * The company a session bound itself to, from
5
+ * workspace/sessions/<sessionId>/meta.yaml `company_slug`. This is the bind a
6
+ * company bot's runner and `hq-session.sh add company` write, and that the
7
+ * HQ hooks read.
8
+ *
9
+ * Returns undefined unless the slug names a real (non-symlink) directory
10
+ * under companies/. hqd's own synthetic `hqd-*` check sessions are ignored,
11
+ * so a caller cannot borrow the binding hqd wrote for a different check.
12
+ */
13
+ export declare function sessionMetaCompany(root: string, sessionId: string): string | undefined;
3
14
  //# sourceMappingURL=session-company.d.ts.map
@@ -75,4 +75,38 @@ export function sessionCompanySlugs(root, env = process.env, multiCompanyLockEna
75
75
  }
76
76
  return listedCompanySlugs(contents).filter((slug) => slug === "personal" || isExactCompanyDirectory(root, slug));
77
77
  }
78
+ /** A caller session id that is safe to use as one path segment. */
79
+ const SESSION_DIR_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
80
+ /**
81
+ * The company a session bound itself to, from
82
+ * workspace/sessions/<sessionId>/meta.yaml `company_slug`. This is the bind a
83
+ * company bot's runner and `hq-session.sh add company` write, and that the
84
+ * HQ hooks read.
85
+ *
86
+ * Returns undefined unless the slug names a real (non-symlink) directory
87
+ * under companies/. hqd's own synthetic `hqd-*` check sessions are ignored,
88
+ * so a caller cannot borrow the binding hqd wrote for a different check.
89
+ */
90
+ export function sessionMetaCompany(root, sessionId) {
91
+ if (!SESSION_DIR_RE.test(sessionId) || sessionId.includes("..") || sessionId.startsWith("hqd-"))
92
+ return undefined;
93
+ const metaPath = path.join(root, "workspace", "sessions", sessionId, "meta.yaml");
94
+ let contents;
95
+ try {
96
+ const stat = fs.lstatSync(metaPath);
97
+ if (!stat.isFile())
98
+ return undefined;
99
+ contents = fs.readFileSync(metaPath, "utf8");
100
+ }
101
+ catch (error) {
102
+ const code = error.code;
103
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES" || code === "ELOOP")
104
+ return undefined;
105
+ throw error;
106
+ }
107
+ const slug = metaValue(contents, "company_slug").replace(/\r$/, "");
108
+ if (!slug || slug === "personal" || !/^[A-Za-z0-9_-]+$/.test(slug))
109
+ return undefined;
110
+ return isExactCompanyDirectory(root, slug) ? slug : undefined;
111
+ }
78
112
  //# sourceMappingURL=session-company.js.map
@@ -6,6 +6,7 @@ import { randomBytes } from "node:crypto";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { serializeJqJson } from "./jq-json-serializer.js";
8
8
  import { shellDisplayPath } from "./shell-display-path.js";
9
+ import { liveProcessCwds, recentTranscriptCwds, sessionOwnerReason } from "./worktree-session-owner.js";
9
10
  export const WORKTREE_GC_ASSET = "core/scripts/worktree-gc.sh";
10
11
  export const CLEAN_HANDOFF_ASSET = "core/scripts/clean-worktree-reconcile-handoff.sh";
11
12
  export const RESUME_LOCK_ASSET = "core/scripts/resume-thread-lock.sh";
@@ -255,8 +256,14 @@ DATA SAFETY IS PARAMOUNT. A worktree is removed only when EVERY guard holds:
255
256
  is already merged into the HQ root's
256
257
  local \`main\`. Fetch is best-effort; a
257
258
  failed fetch makes the worktree UNSAFE.
258
- 4. Not in use — no active session references its path,
259
- and it was not touched in the last 2h.
259
+ 4. Not in use — no live process has its cwd inside it,
260
+ no Claude Code / Codex session transcript
261
+ written in the last 7 days (env
262
+ HQ_WORKTREE_SESSION_WINDOW_HOURS) recorded
263
+ a cwd inside it, no session / lock file
264
+ references its path, and neither the
265
+ directory nor its HEAD / HEAD reflog
266
+ changed in the last 2h.
260
267
 
261
268
  Removal mechanics are the gentle git verbs only — never \`rm -rf\`, never
262
269
  \`--force\`, never \`branch -D\`:
@@ -363,6 +370,17 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
363
370
  }
364
371
  }).filter((value, index, all) => all.indexOf(value) === index));
365
372
  const selfDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
373
+ // Session ownership: live process cwds and recent transcript cwds, gathered
374
+ // once per run (same sources as worktree-gc.sh).
375
+ const processCwds = candidates.length > 0 ? liveProcessCwds() ?? [] : [];
376
+ const transcriptCwds = candidates.length > 0
377
+ ? recentTranscriptCwds({ env, nowMs: nowOf(io).getTime() }) ?? []
378
+ : [];
379
+ let hqRootPhys = hqRoot;
380
+ try {
381
+ hqRootPhys = fs.realpathSync(hqRoot);
382
+ }
383
+ catch { /* compare the given spelling */ }
366
384
  const records = [];
367
385
  let examined = 0, removed = 0, would = 0, reclaimedKb = 0, skipped = 0;
368
386
  const record = (values) => { records.push(values); };
@@ -408,7 +426,17 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
408
426
  const branch = branchResult.status === 0 ? commandOutput(branchResult) : "";
409
427
  const headResult = gitText(["-C", wt, "rev-parse", "HEAD"]);
410
428
  const head = headResult.status === 0 ? commandOutput(headResult) : "";
411
- const statusResult = gitText(["-C", wt, "status", "--porcelain"]);
429
+ // Session ownership is checked before anything else inspects the tree.
430
+ // wt is the physical path; the spelling under the given HQ root counts too.
431
+ const wtAlt = wt.startsWith(`${hqRootPhys}${path.sep}`) ? path.join(hqRoot, wt.slice(hqRootPhys.length + 1)) : wt;
432
+ const owner = sessionOwnerReason(processCwds, transcriptCwds, wt, wtAlt);
433
+ if (owner) {
434
+ skip(wt, mainRepo, branch, isHqRoot, null, null, owner);
435
+ continue;
436
+ }
437
+ // --no-optional-locks keeps the scan from rewriting the index. Untracked
438
+ // files count as dirty even when status.showUntrackedFiles=no.
439
+ const statusResult = gitText(["--no-optional-locks", "-C", wt, "status", "--porcelain", "--untracked-files=all"]);
412
440
  const porcelain = statusResult.status === 0 ? stripTrailingNewlines(statusResult.text) : "__ERR__";
413
441
  if (porcelain === "__ERR__") {
414
442
  skip(wt, mainRepo, branch, isHqRoot, null, null, "git-error");
@@ -419,7 +447,19 @@ export function runWorktreeGc(args, hqRoot, io = {}) {
419
447
  continue;
420
448
  }
421
449
  const wtMtime = statMtime(wt);
422
- if (wtMtime > recentEpoch) {
450
+ // Newest of the directory and its per-worktree HEAD and HEAD reflog
451
+ // (commits, checkouts, resets). The index is left out because
452
+ // `git status` may rewrite it on its own.
453
+ let activityEpoch = wtMtime;
454
+ const gitDirResult = gitText(["-C", wt, "rev-parse", "--path-format=absolute", "--git-dir"]);
455
+ const gitDir = gitDirResult.status === 0 ? commandOutput(gitDirResult) : "";
456
+ if (gitDir) {
457
+ for (const file of [path.join(gitDir, "HEAD"), path.join(gitDir, "logs", "HEAD")]) {
458
+ if (fileExists(file))
459
+ activityEpoch = Math.max(activityEpoch, statMtime(file));
460
+ }
461
+ }
462
+ if (activityEpoch > recentEpoch) {
423
463
  skip(wt, mainRepo, branch, isHqRoot, null, null, "recent-activity");
424
464
  continue;
425
465
  }
@@ -0,0 +1,27 @@
1
+ export declare const SESSION_WINDOW_DEFAULT_HOURS = 168;
2
+ export type SessionOwnerReason = "live-session" | "recent-session";
3
+ /** Session window in hours from HQ_WORKTREE_SESSION_WINDOW_HOURS (default 168). */
4
+ export declare function sessionWindowHours(env?: NodeJS.ProcessEnv): number;
5
+ /** Transcript roots from HQ_SESSION_TRANSCRIPT_DIRS, else ~/.claude/projects and ~/.codex/sessions. */
6
+ export declare function transcriptRoots(env?: NodeJS.ProcessEnv): string[];
7
+ /**
8
+ * The cwd of every process this user can see. Linux reads /proc; macOS asks
9
+ * lsof. Returns [] when neither probe exists (the other guards still apply) and
10
+ * undefined when a probe exists but did not finish, so callers fail closed.
11
+ */
12
+ export declare function liveProcessCwds(timeoutMs?: number): string[] | undefined;
13
+ /**
14
+ * Every cwd recorded by a *.jsonl transcript under the transcript roots whose
15
+ * mtime falls inside the session window. Returns undefined when the deadline
16
+ * passes first, so callers fail closed.
17
+ */
18
+ export declare function recentTranscriptCwds(options?: {
19
+ env?: NodeJS.ProcessEnv;
20
+ nowMs?: number;
21
+ deadline?: number;
22
+ }): string[] | undefined;
23
+ /** True when a listed cwd is one of the spellings or lies inside one. */
24
+ export declare function pathInList(list: readonly string[], ...spellings: string[]): boolean;
25
+ /** live-session, recent-session, or undefined when no session owns the worktree. */
26
+ export declare function sessionOwnerReason(processCwds: readonly string[], transcriptCwds: readonly string[], ...spellings: string[]): SessionOwnerReason | undefined;
27
+ //# sourceMappingURL=worktree-session-owner.d.ts.map
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Session ownership for HQ worktree garbage collection.
3
+ *
4
+ * A worktree is owned by a session when a running process has its cwd inside
5
+ * it, or when a Claude Code / Codex session transcript written within the
6
+ * session window recorded a `cwd` inside it. Agent shells do not keep a process
7
+ * parked in the worktree between tool calls, so the transcript cwd is what
8
+ * protects an open but idle session. Every GC path (`hq reindex`, native
9
+ * `hq core worktree-gc`, core/scripts/worktree-gc.sh, free-disk-space.sh) skips
10
+ * owned worktrees. The shell scripts implement the same rule.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ export const SESSION_WINDOW_DEFAULT_HOURS = 168;
17
+ /** Session window in hours from HQ_WORKTREE_SESSION_WINDOW_HOURS (default 168). */
18
+ export function sessionWindowHours(env = process.env) {
19
+ const raw = env.HQ_WORKTREE_SESSION_WINDOW_HOURS ?? "";
20
+ return /^\d+$/u.test(raw) ? Number(raw) : SESSION_WINDOW_DEFAULT_HOURS;
21
+ }
22
+ /** Transcript roots from HQ_SESSION_TRANSCRIPT_DIRS, else ~/.claude/projects and ~/.codex/sessions. */
23
+ export function transcriptRoots(env = process.env) {
24
+ const raw = env.HQ_SESSION_TRANSCRIPT_DIRS;
25
+ if (raw !== undefined) {
26
+ const separator = process.platform === "win32" ? ";" : ":";
27
+ return raw.split(separator).filter(Boolean);
28
+ }
29
+ const home = env.HOME || os.homedir();
30
+ return [path.join(home, ".claude", "projects"), path.join(home, ".codex", "sessions")];
31
+ }
32
+ /**
33
+ * The cwd of every process this user can see. Linux reads /proc; macOS asks
34
+ * lsof. Returns [] when neither probe exists (the other guards still apply) and
35
+ * undefined when a probe exists but did not finish, so callers fail closed.
36
+ */
37
+ export function liveProcessCwds(timeoutMs = 30_000) {
38
+ if (process.platform === "win32")
39
+ return [];
40
+ if (fs.existsSync("/proc/self/cwd")) {
41
+ const cwds = [];
42
+ let entries;
43
+ try {
44
+ entries = fs.readdirSync("/proc");
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ for (const entry of entries) {
50
+ if (!/^\d+$/u.test(entry))
51
+ continue;
52
+ try {
53
+ cwds.push(fs.readlinkSync(`/proc/${entry}/cwd`));
54
+ }
55
+ catch { /* exited or not ours */ }
56
+ }
57
+ return cwds;
58
+ }
59
+ const result = spawnSync("lsof", ["-w", "-n", "-P", "-d", "cwd", "-Fn"], {
60
+ encoding: "utf8",
61
+ stdio: ["ignore", "pipe", "ignore"],
62
+ timeout: Math.max(1, timeoutMs),
63
+ maxBuffer: 64 * 1024 * 1024,
64
+ });
65
+ if (result.error) {
66
+ return result.error.code === "ENOENT" ? [] : undefined;
67
+ }
68
+ if (result.signal)
69
+ return undefined;
70
+ return (result.stdout ?? "").split("\n").filter((line) => line.startsWith("n")).map((line) => line.slice(1));
71
+ }
72
+ const CWD_KEY = Buffer.from('"cwd"');
73
+ /** Adds every `"cwd": *"<value>"` value in buf (same match as grep -o '"cwd": *"[^"]*"'). */
74
+ function collectCwdValues(buf, out) {
75
+ let index = buf.indexOf(CWD_KEY);
76
+ while (index !== -1) {
77
+ let cursor = index + CWD_KEY.length;
78
+ if (buf[cursor] === 0x3a) {
79
+ cursor++;
80
+ while (buf[cursor] === 0x20)
81
+ cursor++;
82
+ if (buf[cursor] === 0x22) {
83
+ const end = buf.indexOf(0x22, cursor + 1);
84
+ const newline = buf.indexOf(0x0a, cursor + 1);
85
+ if (end !== -1 && (newline === -1 || end < newline))
86
+ out.add(buf.toString("utf8", cursor + 1, end));
87
+ }
88
+ }
89
+ index = buf.indexOf(CWD_KEY, index + CWD_KEY.length);
90
+ }
91
+ }
92
+ /**
93
+ * Every cwd recorded by a *.jsonl transcript under the transcript roots whose
94
+ * mtime falls inside the session window. Returns undefined when the deadline
95
+ * passes first, so callers fail closed.
96
+ */
97
+ export function recentTranscriptCwds(options = {}) {
98
+ const env = options.env ?? process.env;
99
+ const nowMs = options.nowMs ?? Date.now();
100
+ const deadline = options.deadline ?? Number.POSITIVE_INFINITY;
101
+ const cutoffMs = nowMs - sessionWindowHours(env) * 60 * 60 * 1000;
102
+ const cwds = new Set();
103
+ let expired = false;
104
+ const visit = (target) => {
105
+ if (expired)
106
+ return;
107
+ if (Date.now() >= deadline) {
108
+ expired = true;
109
+ return;
110
+ }
111
+ let info;
112
+ try {
113
+ info = fs.statSync(target);
114
+ }
115
+ catch {
116
+ return;
117
+ }
118
+ if (info.isDirectory()) {
119
+ let names;
120
+ try {
121
+ names = fs.readdirSync(target);
122
+ }
123
+ catch {
124
+ return;
125
+ }
126
+ for (const name of names)
127
+ visit(path.join(target, name));
128
+ return;
129
+ }
130
+ // find -mmin -N keeps files modified strictly inside the window.
131
+ if (!info.isFile() || !target.endsWith(".jsonl") || info.mtimeMs <= cutoffMs)
132
+ return;
133
+ try {
134
+ collectCwdValues(fs.readFileSync(target), cwds);
135
+ }
136
+ catch { /* unreadable transcript */ }
137
+ };
138
+ for (const root of transcriptRoots(env))
139
+ visit(root);
140
+ if (expired)
141
+ return undefined;
142
+ return [...cwds];
143
+ }
144
+ /** True when a listed cwd is one of the spellings or lies inside one. */
145
+ export function pathInList(list, ...spellings) {
146
+ const targets = spellings.filter(Boolean);
147
+ return list.some((entry) => targets.some((target) => entry === target || entry.startsWith(`${target}/`)));
148
+ }
149
+ /** live-session, recent-session, or undefined when no session owns the worktree. */
150
+ export function sessionOwnerReason(processCwds, transcriptCwds, ...spellings) {
151
+ if (pathInList(processCwds, ...spellings))
152
+ return "live-session";
153
+ if (pathInList(transcriptCwds, ...spellings))
154
+ return "recent-session";
155
+ return undefined;
156
+ }
157
+ //# sourceMappingURL=worktree-session-owner.js.map
@@ -43,6 +43,8 @@ interface UnitBlock {
43
43
  /** `waiting` clears on its own once the requirement appears; `unavailable` needs a newer CLI. */
44
44
  state: "waiting" | "unavailable";
45
45
  reason: string;
46
+ /** A failed availability probe must not stop an already running service. */
47
+ keepRunning?: true;
46
48
  }
47
49
  export interface UnitPlan {
48
50
  /** Service name, or `<service>:<instance>` for per-instance services (bots). */
@@ -95,7 +95,7 @@ function unavailable(service, reason, over = {}) {
95
95
  }
96
96
  function firstBlock(checks) {
97
97
  const hit = checks.find(([blocked]) => blocked);
98
- return hit ? { state: "waiting", reason: hit[1] } : undefined;
98
+ return hit ? { state: "waiting", reason: hit[1], ...hit[2] } : undefined;
99
99
  }
100
100
  /**
101
101
  * The units that run hq-cloud's code on this host, restarted after the updater
@@ -377,7 +377,7 @@ export function planUnits(services, ctx) {
377
377
  [ctx.platform !== "linux", "Remote Control account pools require Linux mount namespaces"],
378
378
  [!ctx.hqRoot, NO_HQ_ROOT],
379
379
  [ctx.invalidPoolServiceSessions?.has(pool.accountId) === true, "configured serviceSessionId must reference an existing personal-only scope capability"],
380
- [ctx.unverifiablePoolSecrets?.has(pool.secretName) === true, "could not verify the configured HQ Secret"],
380
+ [ctx.unverifiablePoolSecrets?.has(pool.secretName) === true, "could not verify the configured HQ Secret", { keepRunning: true }],
381
381
  [ctx.availablePoolSecrets?.has(pool.secretName) !== true, "the configured HQ Secret is missing"],
382
382
  ]),
383
383
  }));
@@ -397,7 +397,7 @@ export function planUnits(services, ctx) {
397
397
  [!ctx.tmuxPath, "tmux is not installed (remote control runs Claude inside tmux)"],
398
398
  [!ctx.hqRoot, NO_HQ_ROOT],
399
399
  [ctx.invalidPoolServiceSessions?.has(pool.accountId) === true, "configured serviceSessionId must reference an existing personal-only scope capability"],
400
- [ctx.unverifiablePoolSecrets?.has(pool.secretName) === true, "could not verify the configured HQ Secret"],
400
+ [ctx.unverifiablePoolSecrets?.has(pool.secretName) === true, "could not verify the configured HQ Secret", { keepRunning: true }],
401
401
  [ctx.availablePoolSecrets?.has(pool.secretName) !== true, "the configured HQ Secret is missing"],
402
402
  [ctx.unverifiablePoolFrontends?.has(pool.accountId) === true, "could not verify the configured nginx frontend"],
403
403
  [ctx.availablePoolFrontends?.has(pool.accountId) !== true, "the configured nginx frontend is not responding on HTTPS port 443"],
@@ -1,5 +1,6 @@
1
1
  /** Atomic, content-addressed staging for the daemon's replace-independent hq launcher. */
2
2
  import * as fs from "node:fs";
3
+ import { type DirectorySyncIo } from "../../utils/fsync-directory.js";
3
4
  export declare const HQ_CHILD_BOOTSTRAP_FILE = "hq-child-bootstrap.mjs";
4
5
  export type HqChildBootstrapStageErrorCode = "SOURCE_MISSING" | "STAGE_INVALID" | "COPY_FAILED" | "HASH_MISMATCH" | "FSYNC_FAILED" | "RENAME_FAILED";
5
6
  export declare class HqChildBootstrapStageError extends Error {
@@ -16,6 +17,10 @@ export interface StageHqChildBootstrapOptions {
16
17
  daemonRoot: string;
17
18
  copyFile?: typeof fs.copyFileSync;
18
19
  renameDirectory?: (from: string, to: string) => void;
20
+ /** Platform for the directory-fsync decision (tests); defaults to process.platform. */
21
+ platform?: NodeJS.Platform;
22
+ /** Directory fsync seam (tests). */
23
+ directorySyncIo?: DirectorySyncIo;
19
24
  }
20
25
  /** Stage only a complete, content-verified bootstrap; temporary siblings are never selected. */
21
26
  export declare function stageHqChildBootstrap(options: StageHqChildBootstrapOptions): StagedHqChildBootstrap;
@@ -2,6 +2,7 @@
2
2
  import { createHash } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
+ import { fsyncDirectory } from "../../utils/fsync-directory.js";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { ensurePrivateDir } from "./paths.js";
7
8
  export const HQ_CHILD_BOOTSTRAP_FILE = "hq-child-bootstrap.mjs";
@@ -30,15 +31,6 @@ function syncFile(file) {
30
31
  fs.closeSync(fd);
31
32
  }
32
33
  }
33
- function syncDirectory(directory) {
34
- const fd = fs.openSync(directory, fs.constants.O_RDONLY);
35
- try {
36
- fs.fsyncSync(fd);
37
- }
38
- finally {
39
- fs.closeSync(fd);
40
- }
41
- }
42
34
  function readStage(directory, expectedHash) {
43
35
  if (!fs.existsSync(directory))
44
36
  return null;
@@ -87,7 +79,7 @@ export function stageHqChildBootstrap(options) {
87
79
  fail("HASH_MISMATCH", "The staged daemon hq bootstrap hash does not match");
88
80
  failureCode = "FSYNC_FAILED";
89
81
  syncFile(stagedPath);
90
- syncDirectory(tempDirectory);
82
+ fsyncDirectory(tempDirectory, options.platform, options.directorySyncIo);
91
83
  failureCode = "RENAME_FAILED";
92
84
  try {
93
85
  (options.renameDirectory ?? fs.renameSync)(tempDirectory, selectedDirectory);
@@ -104,7 +96,7 @@ export function stageHqChildBootstrap(options) {
104
96
  }
105
97
  tempDirectory = undefined;
106
98
  failureCode = "FSYNC_FAILED";
107
- syncDirectory(stageRoot);
99
+ fsyncDirectory(stageRoot, options.platform, options.directorySyncIo);
108
100
  }
109
101
  catch (error) {
110
102
  if (tempDirectory) {
@@ -15,6 +15,7 @@ import { canonicalDir, emptyRegistry, readRegistry, registryFile, resolveCompany
15
15
  import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
16
16
  import { createLineSplitter, encodeMessage, HQD_DEFAULT_REQUEST_TIMEOUT_MS, HQD_MAX_LINE_BYTES, parseRequestLine } from "./protocol.js";
17
17
  import { SessionRegistry } from "./session-registry.js";
18
+ import { sessionMetaCompany } from "../core-utils/session-company.js";
18
19
  import { journalAppend, JournalOpError } from "./ops/journal.js";
19
20
  import { memoryGet, memoryPut, MemoryOpError } from "./ops/memory.js";
20
21
  import { meshPresence, MeshOpError } from "./ops/mesh.js";
@@ -420,7 +421,15 @@ export async function startHqdServer(opts) {
420
421
  }
421
422
  const boundCompany = session?.company ?? cwdCompany;
422
423
  assertCompanyMatchesBinding(explicit, boundCompany);
423
- return explicit ?? boundCompany;
424
+ if (explicit ?? boundCompany)
425
+ return explicit ?? boundCompany;
426
+ // Neither the call, the in-memory session nor the cwd names a company:
427
+ // fall back to the session's own persisted bind (meta.yaml company_slug),
428
+ // which a company bot's runner writes for every turn and which survives a
429
+ // daemon restart. A bot at the unlinked HQ root then resolves its own
430
+ // company, and the policy check still scopes it to that one company
431
+ // (feedback_578dd8ff).
432
+ return opts.hqRoot && sessionId ? sessionMetaCompany(opts.hqRoot, sessionId) : undefined;
424
433
  };
425
434
  let hqMcp = opts.mcpBridge;
426
435
  if (!isPipe) {
@@ -751,6 +751,12 @@ export function createSupervisor(deps) {
751
751
  continue;
752
752
  }
753
753
  next.set(plan.id, existing);
754
+ // A secret availability probe can fail while the new daemon is signing
755
+ // in. Keep an already authorized pool running until the probe succeeds
756
+ // or positively reports that its secret is missing.
757
+ if (plan.blocked?.keepRunning && existing.child && existing.plan.blocked === undefined) {
758
+ continue;
759
+ }
754
760
  // A remote-control host is what people's sessions run in. Its tool can
755
761
  // look missing for one plan read while it is being reinstalled, so it is
756
762
  // only paused once the tool is missing on two reads in a row.
@@ -1,5 +1,6 @@
1
1
  /** Atomic staging for the sync-runner bundle shipped with hq-cli. */
2
2
  import * as fs from "node:fs";
3
+ import { type DirectorySyncIo } from "../../utils/fsync-directory.js";
3
4
  export declare const SYNC_RUNNER_BUNDLE_RUNNER_FILE = "bin/sync-runner.js";
4
5
  export declare const SYNC_RUNNER_BUNDLE_METADATA_FILE = "package.json";
5
6
  export declare const SYNC_RUNNER_BUNDLE_DIRECTORY = "hq-cloud-sync-runner";
@@ -33,6 +34,10 @@ export interface StageSyncRunnerBundleOptions {
33
34
  /** Test seams for the file copy and atomic directory rename. */
34
35
  copyFile?: typeof fs.copyFileSync;
35
36
  renameDirectory?: (from: string, to: string) => void;
37
+ /** Platform for the directory-fsync decision (tests); defaults to process.platform. */
38
+ platform?: NodeJS.Platform;
39
+ /** Directory fsync seam (tests). */
40
+ directorySyncIo?: DirectorySyncIo;
36
41
  }
37
42
  /** Read the validated metadata version for a runner inside its staged bundle. */
38
43
  export declare function stagedSyncRunnerVersion(runnerPath: string): string;
@@ -2,6 +2,7 @@
2
2
  import { createHash } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
+ import { fsyncDirectory } from "../../utils/fsync-directory.js";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import semver from "semver";
7
8
  import { ensurePrivateDir } from "./paths.js";
@@ -89,15 +90,6 @@ function syncFile(file) {
89
90
  fs.closeSync(fd);
90
91
  }
91
92
  }
92
- function syncDirectory(directory) {
93
- const fd = fs.openSync(directory, fs.constants.O_RDONLY);
94
- try {
95
- fs.fsyncSync(fd);
96
- }
97
- finally {
98
- fs.closeSync(fd);
99
- }
100
- }
101
93
  function readStaged(directory, expected, range) {
102
94
  if (!fs.existsSync(directory))
103
95
  return null;
@@ -167,8 +159,8 @@ export function stageSyncRunnerBundle(options) {
167
159
  failureCode = "FSYNC_FAILED";
168
160
  syncFile(path.join(tempDirectory, SYNC_RUNNER_BUNDLE_RUNNER_FILE));
169
161
  syncFile(path.join(tempDirectory, SYNC_RUNNER_BUNDLE_METADATA_FILE));
170
- syncDirectory(path.join(tempDirectory, "bin"));
171
- syncDirectory(tempDirectory);
162
+ fsyncDirectory(path.join(tempDirectory, "bin"), options.platform, options.directorySyncIo);
163
+ fsyncDirectory(tempDirectory, options.platform, options.directorySyncIo);
172
164
  failureCode = "RENAME_FAILED";
173
165
  try {
174
166
  (options.renameDirectory ?? fs.renameSync)(tempDirectory, selectedDirectory);
@@ -185,7 +177,7 @@ export function stageSyncRunnerBundle(options) {
185
177
  }
186
178
  tempDirectory = undefined;
187
179
  failureCode = "FSYNC_FAILED";
188
- syncDirectory(stageRoot);
180
+ fsyncDirectory(stageRoot, options.platform, options.directorySyncIo);
189
181
  }
190
182
  catch (error) {
191
183
  if (tempDirectory) {
@@ -24,6 +24,7 @@
24
24
  import * as fs from "node:fs";
25
25
  import * as path from "node:path";
26
26
  import { GATE_PROFILES, gateMembership, parseHookGateProfiles, } from "../hook-gate-profiles.js";
27
+ import { modeIsExecutable } from "../shared.js";
27
28
  import { countStrayGateEntries } from "../stray-gate-entries.js";
28
29
  /** Common id prefix for every result this check family emits. */
29
30
  export const CLAUDE_WIRING_PREFIX = "hooks.claude";
@@ -550,7 +551,7 @@ function statSafe(file) {
550
551
  }
551
552
  }
552
553
  function isExecutable(mode) {
553
- return (mode & 0o111) !== 0;
554
+ return modeIsExecutable(mode);
554
555
  }
555
556
  /** Top-level `*.sh` filenames in `dir`, sorted; empty when `dir` is absent. */
556
557
  function listShellScripts(dir) {
@@ -87,6 +87,15 @@ export interface ApplyFixesOptions {
87
87
  from: string;
88
88
  to: string;
89
89
  } | null;
90
+ /**
91
+ * Reinstall used by the `bot-hq-binary` class: rewrite the bot's
92
+ * LaunchAgent against the installed hq and reload it, as
93
+ * `hq bot daemon install <name>` does. Tests inject a stub.
94
+ */
95
+ repairBotHqBinary?: (name: string) => {
96
+ from: string | null;
97
+ to: string;
98
+ } | null;
90
99
  }
91
100
  /** One company or personal vault the `refresh-sync` class will pull. */
92
101
  export interface RefreshSyncTarget {
@@ -183,6 +192,17 @@ export declare function defaultRewriteBotNodePath(name: string): {
183
192
  from: string;
184
193
  to: string;
185
194
  } | null;
195
+ /**
196
+ * An hq path a bot may be pointed at unattended: an installed package entry
197
+ * or its stable shim, never a worktree or one-off build that happens to be
198
+ * running `hq doctor --fix`.
199
+ */
200
+ export declare function isInstalledHqBinary(hqBinary: string): boolean;
201
+ /** Default `bot-hq-binary` repair against the real bot state and LaunchAgents. */
202
+ export declare function defaultRepairBotHqBinary(name: string): {
203
+ from: string | null;
204
+ to: string;
205
+ } | null;
186
206
  /**
187
207
  * Insert a new `case` arm allowlisting `hookId` (leading to `return 0`) at the
188
208
  * top of each named profile function's `case "$1" in` block. Additive: it never