@indigoai-us/hq-cli 5.345.135 → 5.345.137
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.
- package/CHANGELOG.md +44 -0
- package/assets/scaffold/core/scripts/worktree-gc.sh +98 -8
- package/dist/command-catalog.generated.d.ts +17 -6
- package/dist/command-catalog.generated.js +23 -7
- package/dist/commands/core-checkpoint-native.d.ts +10 -0
- package/dist/commands/core-checkpoint-native.js +60 -15
- package/dist/commands/daemon.js +3 -1
- package/dist/commands/integrations-core.d.ts +22 -0
- package/dist/commands/integrations-core.js +25 -0
- package/dist/commands/integrations-manage.d.ts +24 -1
- package/dist/commands/integrations-manage.js +92 -17
- package/dist/commands/integrations.d.ts +11 -0
- package/dist/commands/integrations.js +100 -19
- package/dist/commands/lanes-loop.d.ts +9 -0
- package/dist/commands/lanes-loop.js +9 -1
- package/dist/commands/lanes.js +69 -54
- package/dist/commands/meetings.d.ts +2 -0
- package/dist/commands/meetings.js +57 -14
- package/dist/commands/mesh.d.ts +19 -1
- package/dist/commands/mesh.js +158 -70
- package/dist/commands/reindex.js +80 -5
- package/dist/commands/storage-offload.d.ts +93 -28
- package/dist/commands/storage-offload.js +290 -34
- package/dist/commands/storage.js +60 -7
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2550 -1748
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.js +4 -0
- package/dist/lib/capacity/route.js +24 -0
- package/dist/lib/core-utils/session-company.d.ts +11 -0
- package/dist/lib/core-utils/session-company.js +34 -0
- package/dist/lib/core-utils/worktree-resume.js +44 -4
- package/dist/lib/core-utils/worktree-session-owner.d.ts +27 -0
- package/dist/lib/core-utils/worktree-session-owner.js +157 -0
- package/dist/lib/daemon/catalog.d.ts +2 -0
- package/dist/lib/daemon/catalog.js +3 -3
- package/dist/lib/daemon/hq-child-bootstrap-stage.d.ts +5 -0
- package/dist/lib/daemon/hq-child-bootstrap-stage.js +3 -11
- package/dist/lib/daemon/server.js +10 -1
- package/dist/lib/daemon/supervisor.js +6 -0
- package/dist/lib/daemon/sync-runner-bundle.d.ts +5 -0
- package/dist/lib/daemon/sync-runner-bundle.js +4 -12
- package/dist/lib/doctor/checks/claude-wiring.js +2 -1
- package/dist/lib/doctor/fix/apply.d.ts +20 -0
- package/dist/lib/doctor/fix/apply.js +63 -3
- package/dist/lib/doctor/fix/remediation.d.ts +1 -1
- package/dist/lib/doctor/fix/remediation.js +16 -0
- package/dist/lib/doctor/shared.d.ts +11 -1
- package/dist/lib/doctor/shared.js +16 -2
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/monitor.js +14 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
- package/dist/lib/mesh/live/daemon/install.js +22 -0
- package/dist/lib/mesh/live/flush.js +106 -3
- package/dist/lib/mesh/live/session-events-client.d.ts +9 -0
- package/dist/lib/mesh/live/session-events-client.js +15 -0
- package/dist/lib/work-context/state.d.ts +10 -0
- package/dist/lib/work-context/state.js +5 -0
- package/dist/utils/fsync-directory.d.ts +17 -0
- package/dist/utils/fsync-directory.js +27 -0
- package/dist/utils/windows-hide-default.d.ts +42 -0
- package/dist/utils/windows-hide-default.js +98 -0
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.
|
|
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
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
|
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
|