@indigoai-us/hq-cli 5.345.6 → 5.345.8
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 +14 -0
- package/dist/commands/__fixtures__/make-tar.d.ts +2 -10
- package/dist/commands/__fixtures__/make-tar.js +2 -2
- package/dist/commands/agent-enroll.d.ts +2 -3
- package/dist/commands/agent-enroll.js +2 -2
- package/dist/commands/mcp-status.d.ts +4 -3
- package/dist/commands/reindex.d.ts +0 -6
- package/dist/commands/reindex.js +1 -1
- package/dist/commands/safe-extract.d.ts +2 -17
- package/dist/commands/safe-extract.js +2 -2
- package/dist/commands/search.d.ts +2 -1
- package/dist/lazy-commands.d.ts +0 -6
- package/dist/lazy-commands.js +1 -1
- package/dist/lib/agent-kit/run/inbox.js +29 -3
- package/dist/lib/daemon/supervisor.d.ts +1 -0
- package/dist/lib/daemon/supervisor.js +97 -12
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +8 -9
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +1 -1
- package/dist/lib/doctor/checks/runtime-probe.d.ts +0 -21
- package/dist/lib/doctor/checks/runtime-probe.js +4 -4
- package/dist/lib/doctor/checks/sync-health.d.ts +2 -3
- package/dist/lib/doctor/checks/sync-health.js +1 -1
- package/dist/lib/doctor/compat.d.ts +2 -1
- package/dist/lib/doctor/deep/classify.d.ts +2 -1
- package/dist/lib/doctor/fix/backup.d.ts +0 -8
- package/dist/lib/doctor/fix/backup.js +2 -2
- package/dist/lib/doctor/fixtures/discover.d.ts +2 -10
- package/dist/lib/doctor/fixtures/discover.js +1 -1
- package/dist/lib/lanes/autopilot.d.ts +2 -17
- package/dist/lib/lanes/autopilot.js +8 -8
- package/dist/lib/lanes/flag-keys.d.ts +2 -0
- package/dist/lib/lanes/flag-keys.js +2 -0
- package/dist/lib/lanes/keep-alive.d.ts +2 -0
- package/dist/lib/lanes/keep-alive.js +9 -0
- package/dist/lib/lanes/reconcile.js +44 -27
- package/dist/lib/lanes/spawn.js +2 -0
- package/dist/lib/lanes/tracker-report.d.ts +16 -15
- package/dist/lib/lanes/wait.d.ts +2 -5
- package/dist/lib/lanes/wait.js +3 -3
- package/dist/lib/local-tree-diff.d.ts +3 -2
- package/dist/lib/search-index/background.d.ts +0 -2
- package/dist/lib/search-index/background.js +1 -1
- package/dist/lib/search-index/index.d.ts +0 -19
- package/dist/lib/search-index/index.js +2 -2
- package/dist/main.d.ts +3 -0
- package/dist/main.js +126 -12
- package/dist/types.d.ts +3 -3
- package/dist/utils/client-health-contract.d.ts +14 -17
- package/dist/utils/client-health-contract.js +11 -11
- package/dist/utils/self-update.d.ts +2 -0
- package/dist/utils/self-update.js +37 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.8] — 2026-10-05
|
|
6
|
+
- Prioritize due lead keep-alive retries and honor due next-check times ahead of later backoff deadlines behind an hq-flags gate.
|
|
7
|
+
- Drain bot turns before replacing a running bot after a daemon plan change.
|
|
8
|
+
|
|
9
|
+
## [5.345.7] — 2026-10-05
|
|
10
|
+
- Retry a just-published hq-cli version when npm briefly reports `notarget`.
|
|
11
|
+
- Wait for an active sync pass to finish before restarting sync after a CLI update.
|
|
12
|
+
- Add bounded context to captured missing-module errors targeting hq-cli's own dist output.
|
|
13
|
+
- Recover interrupted agent inbox polls without duplicating mirrored journal entries.
|
|
14
|
+
- Record the actual launch time, trigger, and caller for resumed lane workers even when legacy launch history is missing.
|
|
15
|
+
|
|
5
16
|
## [5.345.6] — 2026-10-05
|
|
6
17
|
- `hq mesh context default set <slug> --count-usage` now works for people signed in with a normal HQ account. It previously refused with "the signed-in person could not be determined". An opt-in saved by 5.344.0 is ignored; run the command again to opt in.
|
|
7
18
|
- `hq daemon sync status` now identifies the executing runner and derives its hq-cloud version from the owner status or runner package when needed.
|
|
8
19
|
|
|
20
|
+
## [5.345.5] — 2026-10-05
|
|
21
|
+
- `hq daemon sync status` now identifies the executing runner and derives its hq-cloud version from the owner status or runner package when needed.
|
|
22
|
+
|
|
9
23
|
## [5.345.4] — 2026-10-05
|
|
10
24
|
- Run a pinned or newer managed hq-cloud sync runner ahead of the staged sync bundle, so daemon sync no longer stays on the hq-cloud version bundled with the CLI after an update or pin (hq-cli#1547).
|
|
11
25
|
- Avoid dispatching the global hook when the working directory is inside an HQ root.
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* lists whatever bytes we wrote, so the guard sees the real attack everywhere.
|
|
23
23
|
*/
|
|
24
24
|
/** ustar typeflag values we use. */
|
|
25
|
-
|
|
25
|
+
type TarType = 'file' | 'symlink' | 'hardlink';
|
|
26
26
|
export interface TarEntrySpec {
|
|
27
27
|
/** Recorded entry name — written VERBATIM (may contain `..`, be absolute…). */
|
|
28
28
|
name: string;
|
|
@@ -32,15 +32,7 @@ export interface TarEntrySpec {
|
|
|
32
32
|
/** File content (regular files only). */
|
|
33
33
|
content?: string | Buffer;
|
|
34
34
|
}
|
|
35
|
-
/**
|
|
36
|
-
* Build one 512-byte ustar header (plus content blocks for regular files) for a
|
|
37
|
-
* single entry. The header checksum is computed exactly per spec: sum every
|
|
38
|
-
* header byte treating the 8 checksum bytes themselves as ASCII spaces, then
|
|
39
|
-
* write that sum as 6 octal digits + NUL + space.
|
|
40
|
-
*/
|
|
41
|
-
export declare function makeTarEntry(spec: TarEntrySpec): Buffer;
|
|
42
|
-
/** Concatenate entries and append the two zero blocks that end every tar. */
|
|
43
|
-
export declare function makeTar(entries: TarEntrySpec[]): Buffer;
|
|
44
35
|
/** Build the tar, gzip it, and write it to `outPath`. Returns `outPath`. */
|
|
45
36
|
export declare function writeMaliciousTarGz(outPath: string, entries: TarEntrySpec[]): string;
|
|
37
|
+
export {};
|
|
46
38
|
//# sourceMappingURL=make-tar.d.ts.map
|
|
@@ -50,7 +50,7 @@ function writeOctal(buf, offset, width, value) {
|
|
|
50
50
|
* header byte treating the 8 checksum bytes themselves as ASCII spaces, then
|
|
51
51
|
* write that sum as 6 octal digits + NUL + space.
|
|
52
52
|
*/
|
|
53
|
-
|
|
53
|
+
function makeTarEntry(spec) {
|
|
54
54
|
const type = spec.type ?? 'file';
|
|
55
55
|
const content = type === 'file'
|
|
56
56
|
? Buffer.isBuffer(spec.content)
|
|
@@ -89,7 +89,7 @@ export function makeTarEntry(spec) {
|
|
|
89
89
|
return Buffer.concat([header, content, Buffer.alloc(pad)]);
|
|
90
90
|
}
|
|
91
91
|
/** Concatenate entries and append the two zero blocks that end every tar. */
|
|
92
|
-
|
|
92
|
+
function makeTar(entries) {
|
|
93
93
|
const blocks = entries.map(makeTarEntry);
|
|
94
94
|
blocks.push(Buffer.alloc(BLOCK * 2)); // end-of-archive marker
|
|
95
95
|
return Buffer.concat(blocks);
|
|
@@ -17,18 +17,16 @@
|
|
|
17
17
|
import { Command } from "commander";
|
|
18
18
|
import { generateHostKeyPair } from "../lib/agent-kit/creds.js";
|
|
19
19
|
import { type AgentKitPaths } from "../lib/agent-kit/paths.js";
|
|
20
|
-
export declare const ENROLL_PATH = "/v1/agents/enroll";
|
|
21
20
|
/** Canonical code: un-grouped, uppercase (server hashes exactly this). */
|
|
22
21
|
export declare function normalizeEnrollmentCode(raw: string): string;
|
|
23
22
|
/** 24 chars of Crockford base32 (no I, L, O, U). */
|
|
24
23
|
export declare function isWellFormedEnrollmentCode(code: string): boolean;
|
|
25
|
-
|
|
24
|
+
interface HostInfo {
|
|
26
25
|
os: string;
|
|
27
26
|
arch: string;
|
|
28
27
|
hostname: string;
|
|
29
28
|
cliVersion: string;
|
|
30
29
|
}
|
|
31
|
-
export declare function collectHostInfo(): HostInfo;
|
|
32
30
|
export type ExistingIdentity = {
|
|
33
31
|
kind: "none";
|
|
34
32
|
} | {
|
|
@@ -143,4 +141,5 @@ export interface EnrollResult {
|
|
|
143
141
|
/** Pure-ish orchestration so tests can drive it without a TTY or network. */
|
|
144
142
|
export declare function enrollHost(opts: EnrollOptions, deps?: EnrollDeps): Promise<EnrollResult>;
|
|
145
143
|
export declare function registerAgentEnrollCommand(agent: Command): void;
|
|
144
|
+
export {};
|
|
146
145
|
//# sourceMappingURL=agent-enroll.d.ts.map
|
|
@@ -25,7 +25,7 @@ import { networkTransportErrorCode } from "../utils/network-transport-error.js";
|
|
|
25
25
|
import { canOfferTeamUpgrade, formatUpgradeRefusal, isPlanGateError, offerUpgradeLink, planGateErrorFromPayload, } from "../utils/plan-gate-error.js";
|
|
26
26
|
import { generateHostKeyPair, machineCredsFileExists, readExternalMachineCreds, writeHostKeyPair, writeMachineCreds, } from "../lib/agent-kit/creds.js";
|
|
27
27
|
import { DEFAULT_LOCAL_AGENT_NAME, agentKitPaths, localAgentDir, } from "../lib/agent-kit/paths.js";
|
|
28
|
-
|
|
28
|
+
const ENROLL_PATH = "/v1/agents/enroll";
|
|
29
29
|
/** Canonical code: un-grouped, uppercase (server hashes exactly this). */
|
|
30
30
|
export function normalizeEnrollmentCode(raw) {
|
|
31
31
|
return raw.replace(/[\s-]+/g, "").toUpperCase();
|
|
@@ -34,7 +34,7 @@ export function normalizeEnrollmentCode(raw) {
|
|
|
34
34
|
export function isWellFormedEnrollmentCode(code) {
|
|
35
35
|
return /^[0-9A-HJKMNP-TV-Z]{24}$/.test(code);
|
|
36
36
|
}
|
|
37
|
-
|
|
37
|
+
function collectHostInfo() {
|
|
38
38
|
return {
|
|
39
39
|
os: `${os.platform()} ${os.release()}`,
|
|
40
40
|
arch: os.arch(),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { type SafeWriteEnv } from './mcp-registration.js';
|
|
3
3
|
/** A server's presence on ONE runtime surface. */
|
|
4
|
-
|
|
4
|
+
type PresenceState =
|
|
5
5
|
/** The server def is present (and parsed) on this runtime. */
|
|
6
6
|
'present'
|
|
7
7
|
/** The runtime parsed fine but this server is not registered there. */
|
|
@@ -30,7 +30,7 @@ export interface RuntimeServerStatus {
|
|
|
30
30
|
healthy?: boolean;
|
|
31
31
|
}
|
|
32
32
|
/** A single HQ-owned MCP server's cross-runtime status. */
|
|
33
|
-
|
|
33
|
+
interface McpServerStatus {
|
|
34
34
|
/** The bare server name (the `mcpServers` / `mcp_servers` key). */
|
|
35
35
|
server: string;
|
|
36
36
|
/** The pack that owns it (`_hqPack` provenance). */
|
|
@@ -59,7 +59,7 @@ export interface McpServerStatus {
|
|
|
59
59
|
drifted: boolean;
|
|
60
60
|
}
|
|
61
61
|
/** A runtime's top-level status. */
|
|
62
|
-
|
|
62
|
+
interface RuntimeStatus {
|
|
63
63
|
/** Whether the runtime is installed (Claude is always; Codex iff `~/.codex` exists). */
|
|
64
64
|
installed: boolean;
|
|
65
65
|
/** Whether the runtime's config parsed (false => unparseable). */
|
|
@@ -133,4 +133,5 @@ export declare function renderMcpCapabilities(caps: McpCapabilities): string;
|
|
|
133
133
|
* {@link registerReindexCommand} and the subcommand-group pattern in index.ts.
|
|
134
134
|
*/
|
|
135
135
|
export declare function registerMcpCommand(program: Command): void;
|
|
136
|
+
export {};
|
|
136
137
|
//# sourceMappingURL=mcp-status.d.ts.map
|
|
@@ -5,12 +5,6 @@ import { Command } from 'commander';
|
|
|
5
5
|
* layout scan and Git's status output rather than recursively walking trees.
|
|
6
6
|
*/
|
|
7
7
|
export declare function removeStaleHqWorktrees(hqRoot: string, now?: number, deadline?: number): void;
|
|
8
|
-
/**
|
|
9
|
-
* Check hook health without relying on lifecycle hooks. A fully disabled
|
|
10
|
-
* configuration is repaired only after a successful reindex; partial and
|
|
11
|
-
* malformed configurations remain untouched and receive recovery guidance.
|
|
12
|
-
*/
|
|
13
|
-
export declare function repairExtremeHookDrift(hqRoot: string, allowRepair?: boolean): void;
|
|
14
8
|
/**
|
|
15
9
|
* Stop the generated project settings from silently downgrading an operator who
|
|
16
10
|
* chose Bypass mode globally.
|
package/dist/commands/reindex.js
CHANGED
|
@@ -491,7 +491,7 @@ function runSilently(operation) {
|
|
|
491
491
|
* configuration is repaired only after a successful reindex; partial and
|
|
492
492
|
* malformed configurations remain untouched and receive recovery guidance.
|
|
493
493
|
*/
|
|
494
|
-
|
|
494
|
+
function repairExtremeHookDrift(hqRoot, allowRepair = true) {
|
|
495
495
|
// `hq reindex` can still be invoked from an arbitrary directory. Never turn
|
|
496
496
|
// an absent settings file there into a rescue attempt.
|
|
497
497
|
if (!isHqRoot(hqRoot))
|
|
@@ -45,7 +45,7 @@ export declare const DEFAULT_CAPS: ExtractCaps;
|
|
|
45
45
|
export declare class UnsafeArchiveError extends Error {
|
|
46
46
|
constructor(message: string);
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
type EntryType = 'file' | 'dir' | 'symlink' | 'hardlink' | 'other';
|
|
49
49
|
export interface TarEntry {
|
|
50
50
|
type: EntryType;
|
|
51
51
|
/** Entry path as recorded in the archive (may contain `..`, be absolute…). */
|
|
@@ -108,14 +108,6 @@ export declare function resolveContainedPath(targetDir: string, entryName: strin
|
|
|
108
108
|
* way the final location must be contained in the pack dir.
|
|
109
109
|
*/
|
|
110
110
|
export declare function assertLinkTargetContained(packDir: string, entryAbs: string, linkTarget: string): void;
|
|
111
|
-
/**
|
|
112
|
-
* List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
|
|
113
|
-
* argv form, never a shell string, keeps hostile filenames out of shell syntax.
|
|
114
|
-
* Windows drive paths always use a basename relative to the archive directory.
|
|
115
|
-
* GNU tar cannot parse `C:\\...` as remote `host:path`, and Windows' bundled
|
|
116
|
-
* bsdtar accepts the same relative argv without GNU-only `--force-local`.
|
|
117
|
-
*/
|
|
118
|
-
export declare function listTarball(tarballPath: string, opts?: Pick<SafeExtractOptions, 'platform' | 'execFileSync'>): TarEntry[];
|
|
119
111
|
/**
|
|
120
112
|
* Pre-flight validation against the entry headers. Enforces (a) containment of
|
|
121
113
|
* every entry name under `targetDir`, (b) containment of every link target
|
|
@@ -123,14 +115,6 @@ export declare function listTarball(tarballPath: string, opts?: Pick<SafeExtract
|
|
|
123
115
|
* UnsafeArchiveError on the first violation — default-deny.
|
|
124
116
|
*/
|
|
125
117
|
export declare function validateEntries(entries: TarEntry[], targetDir: string, caps?: ExtractCaps): void;
|
|
126
|
-
/**
|
|
127
|
-
* Walk everything actually written under `stagingDir` and assert that no entry
|
|
128
|
-
* — and, for symlinks, no link target — escapes. This is the defense-in-depth
|
|
129
|
-
* backstop: it does not trust the pre-flight scan OR the tar binary. Uses
|
|
130
|
-
* realpath on the link's PARENT (which exists) plus lstat to inspect links
|
|
131
|
-
* without following them off-tree.
|
|
132
|
-
*/
|
|
133
|
-
export declare function assertExtractContained(stagingDir: string): void;
|
|
134
118
|
export interface SafeExtractOptions {
|
|
135
119
|
caps?: ExtractCaps;
|
|
136
120
|
/** Test seam for platform-specific argv construction. */
|
|
@@ -158,4 +142,5 @@ export interface SafeExtractOptions {
|
|
|
158
142
|
* so a half-extracted pack can never be wired.
|
|
159
143
|
*/
|
|
160
144
|
export declare function safeExtractTarball(tarballPath: string, finalDir: string, opts?: SafeExtractOptions): void;
|
|
145
|
+
export {};
|
|
161
146
|
//# sourceMappingURL=safe-extract.d.ts.map
|
|
@@ -219,7 +219,7 @@ function tarballArgvLocation(tarballPath, platform) {
|
|
|
219
219
|
* GNU tar cannot parse `C:\\...` as remote `host:path`, and Windows' bundled
|
|
220
220
|
* bsdtar accepts the same relative argv without GNU-only `--force-local`.
|
|
221
221
|
*/
|
|
222
|
-
|
|
222
|
+
function listTarball(tarballPath, opts = {}) {
|
|
223
223
|
const platform = opts.platform ?? process.platform;
|
|
224
224
|
const location = tarballArgvLocation(tarballPath, platform);
|
|
225
225
|
const out = (opts.execFileSync ?? execFileSync)('tar', ['-tvf', location.archiveArg], {
|
|
@@ -277,7 +277,7 @@ export function validateEntries(entries, targetDir, caps = DEFAULT_CAPS) {
|
|
|
277
277
|
* realpath on the link's PARENT (which exists) plus lstat to inspect links
|
|
278
278
|
* without following them off-tree.
|
|
279
279
|
*/
|
|
280
|
-
|
|
280
|
+
function assertExtractContained(stagingDir) {
|
|
281
281
|
const realRoot = fs.realpathSync(stagingDir);
|
|
282
282
|
const walk = (dir) => {
|
|
283
283
|
for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
|
|
2
|
+
type SearchMode = 'keyword' | 'semantic' | 'hybrid';
|
|
3
3
|
export type SearchOptions = {
|
|
4
4
|
mode?: SearchMode;
|
|
5
5
|
collection?: string;
|
|
@@ -32,4 +32,5 @@ export declare function normalizeQueryForQmd(query: string): string;
|
|
|
32
32
|
export declare function buildSearchArgs(query: string, options?: SearchOptions): string[];
|
|
33
33
|
export declare function buildGetArgs(document: string, options?: Pick<SearchOptions, 'collection'>): string[];
|
|
34
34
|
export declare function registerSearchCommand(program: Command): void;
|
|
35
|
+
export {};
|
|
35
36
|
//# sourceMappingURL=search.d.ts.map
|
package/dist/lazy-commands.d.ts
CHANGED
|
@@ -5,12 +5,6 @@ export type LazyCommand = {
|
|
|
5
5
|
/** Registers every contributor for this root and no unrelated root. */
|
|
6
6
|
register: (program: Command) => Promise<void>;
|
|
7
7
|
};
|
|
8
|
-
/**
|
|
9
|
-
* One entry for every root, including groups that are assembled by several
|
|
10
|
-
* registrar modules. The routing metadata itself is generated from the eager
|
|
11
|
-
* command graph and checked in tests and the build.
|
|
12
|
-
*/
|
|
13
|
-
export declare const LAZY_COMMANDS: readonly LazyCommand[];
|
|
14
8
|
/**
|
|
15
9
|
* Keep the registration seam at register-all.js. Besides making one place own
|
|
16
10
|
* the graph, this lets the torn-install recovery wrap failures while an update
|
package/dist/lazy-commands.js
CHANGED
|
@@ -9,7 +9,7 @@ const commandCatalog = COMMAND_CATALOG;
|
|
|
9
9
|
* registrar modules. The routing metadata itself is generated from the eager
|
|
10
10
|
* command graph and checked in tests and the build.
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
const LAZY_COMMANDS = commandCatalog.map((command) => ({
|
|
13
13
|
name: command.name,
|
|
14
14
|
register: async (program) => {
|
|
15
15
|
const registered = await registerCommandRoot(program, command.name);
|
|
@@ -54,10 +54,34 @@ export function writeSeenIds(paths, seen) {
|
|
|
54
54
|
const list = [...seen].slice(-MAX_SEEN_IDS);
|
|
55
55
|
writePrivateFile(path.join(paths.inboxDir, SEEN_IDS_NAME), JSON.stringify(list), 0o600);
|
|
56
56
|
}
|
|
57
|
+
function inboxJournalContains(paths, id) {
|
|
58
|
+
const journal = path.join(paths.inboxDir, INBOX_JSONL_NAME);
|
|
59
|
+
if (!fs.existsSync(journal))
|
|
60
|
+
return false;
|
|
61
|
+
for (const line of fs.readFileSync(journal, "utf8").split("\n")) {
|
|
62
|
+
if (!line)
|
|
63
|
+
continue;
|
|
64
|
+
let entry;
|
|
65
|
+
try {
|
|
66
|
+
entry = JSON.parse(line);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// An interrupted append can leave one malformed final record; it does
|
|
70
|
+
// not prove that this item made it into the durable journal.
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (entry && typeof entry === "object" && entry.id === id)
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
57
78
|
export function mirrorInboxItem(paths, item) {
|
|
58
79
|
const dest = path.join(paths.inboxDir, `${item.id}.json`);
|
|
80
|
+
const recoveringInterruptedMirror = fs.existsSync(dest);
|
|
59
81
|
writePrivateFile(dest, `${JSON.stringify(item.raw, null, 2)}\n`, 0o600);
|
|
60
|
-
|
|
82
|
+
if (!recoveringInterruptedMirror || !inboxJournalContains(paths, item.id)) {
|
|
83
|
+
fs.appendFileSync(path.join(paths.inboxDir, INBOX_JSONL_NAME), `${JSON.stringify({ id: item.id, mirroredAt: new Date().toISOString(), ...item.raw })}\n`, { mode: 0o600 });
|
|
84
|
+
}
|
|
61
85
|
return dest;
|
|
62
86
|
}
|
|
63
87
|
export const INBOX_ERROR_BODY_MAX = 300;
|
|
@@ -148,7 +172,6 @@ export async function pollInboxOnce(deps) {
|
|
|
148
172
|
const fresh = extractInboxItems(res.body).filter((i) => !seen.has(i.id));
|
|
149
173
|
for (const item of fresh) {
|
|
150
174
|
mirrorInboxItem(deps.paths, item);
|
|
151
|
-
seen.add(item.id);
|
|
152
175
|
if (deps.ack) {
|
|
153
176
|
try {
|
|
154
177
|
const status = await ackItem(token, item.id);
|
|
@@ -159,9 +182,12 @@ export async function pollInboxOnce(deps) {
|
|
|
159
182
|
deps.log("warn", `ack ${item.id} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
160
183
|
}
|
|
161
184
|
}
|
|
185
|
+
// Persist each completed item before starting another one. If shutdown
|
|
186
|
+
// interrupts a later ack or mirror, earlier items will not be replayed.
|
|
187
|
+
seen.add(item.id);
|
|
188
|
+
writeSeenIds(deps.paths, seen);
|
|
162
189
|
}
|
|
163
190
|
if (fresh.length > 0) {
|
|
164
|
-
writeSeenIds(deps.paths, seen);
|
|
165
191
|
deps.log("info", `inbox mirrored ${fresh.length} item(s)`);
|
|
166
192
|
try {
|
|
167
193
|
await warmCache(token, deps.agentUid);
|
|
@@ -27,6 +27,7 @@ import type { ControlRequest } from "./requests.js";
|
|
|
27
27
|
import { syncPassResult } from "./sync-runner.js";
|
|
28
28
|
export declare const STOP_ESCALATION_MS = 10000;
|
|
29
29
|
export declare const CLI_UPDATE_SYNC_IDLE_MAX_MS: number;
|
|
30
|
+
export declare const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60000;
|
|
30
31
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
31
32
|
export declare const HANDOFF_STAGGER_MS = 15000;
|
|
32
33
|
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
@@ -28,6 +28,7 @@ const BACKOFF_MAX_MS = 60_000;
|
|
|
28
28
|
const HEALTHY_RUN_MS = 60_000;
|
|
29
29
|
export const STOP_ESCALATION_MS = 10_000;
|
|
30
30
|
export const CLI_UPDATE_SYNC_IDLE_MAX_MS = 10 * 60_000;
|
|
31
|
+
export const CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS = 60_000;
|
|
31
32
|
const MESH_OWNER_RECHECK_MS = 15_000;
|
|
32
33
|
const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
|
|
33
34
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
@@ -92,6 +93,7 @@ export function createSupervisor(deps) {
|
|
|
92
93
|
let deferredCliRestart = false;
|
|
93
94
|
let deferredCliRestartTimer;
|
|
94
95
|
let cliRestartInProgress = false;
|
|
96
|
+
let pendingSyncCommandRestart;
|
|
95
97
|
const snapshot = () => ({
|
|
96
98
|
version: 1,
|
|
97
99
|
pid: deps.pid,
|
|
@@ -125,15 +127,26 @@ export function createSupervisor(deps) {
|
|
|
125
127
|
clearT(deferredCliRestartTimer);
|
|
126
128
|
deferredCliRestartTimer = undefined;
|
|
127
129
|
};
|
|
128
|
-
const
|
|
130
|
+
const clearPendingSyncCommandRestart = (slot) => {
|
|
131
|
+
if (!pendingSyncCommandRestart || (slot && pendingSyncCommandRestart.slot !== slot))
|
|
132
|
+
return;
|
|
133
|
+
if (pendingSyncCommandRestart.timer !== undefined)
|
|
134
|
+
clearT(pendingSyncCommandRestart.timer);
|
|
135
|
+
pendingSyncCommandRestart = undefined;
|
|
136
|
+
};
|
|
137
|
+
const runDeferredCliRestart = (idleTimeout = false) => {
|
|
129
138
|
if (!deferredCliRestart)
|
|
130
139
|
return;
|
|
140
|
+
clearPendingSyncCommandRestart();
|
|
131
141
|
clearDeferredCliRestart();
|
|
132
142
|
cliRestartInProgress = true;
|
|
133
143
|
const sync = [...slots.values()].find((slot) => slot.plan.service === "sync" && slot.child);
|
|
134
|
-
if (sync)
|
|
135
|
-
terminate(sync);
|
|
136
|
-
|
|
144
|
+
if (sync) {
|
|
145
|
+
terminate(sync, undefined, false, idleTimeout ? CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS : STOP_ESCALATION_MS);
|
|
146
|
+
}
|
|
147
|
+
deps.log(idleTimeout ? "warn" : "info", idleTimeout
|
|
148
|
+
? "sync pass remained active for 10 minutes; restarting daemon onto the updated CLI"
|
|
149
|
+
: "sync runner is between passes; restarting daemon onto the updated CLI");
|
|
137
150
|
const releaseHold = () => {
|
|
138
151
|
cliRestartInProgress = false;
|
|
139
152
|
const currentSync = [...slots.values()].find((slot) => slot.plan.service === "sync");
|
|
@@ -163,6 +176,17 @@ export function createSupervisor(deps) {
|
|
|
163
176
|
releaseHold();
|
|
164
177
|
}
|
|
165
178
|
};
|
|
179
|
+
const finishPendingSyncCommandRestart = (idleTimeout = false) => {
|
|
180
|
+
const pending = pendingSyncCommandRestart;
|
|
181
|
+
if (!pending)
|
|
182
|
+
return;
|
|
183
|
+
clearPendingSyncCommandRestart(pending.slot);
|
|
184
|
+
if (deferredCliRestart) {
|
|
185
|
+
runDeferredCliRestart(idleTimeout);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
pending.onFinish(idleTimeout);
|
|
189
|
+
};
|
|
166
190
|
const requestCliUpdateRestart = () => {
|
|
167
191
|
if (!deps.restartDaemon)
|
|
168
192
|
return;
|
|
@@ -173,7 +197,10 @@ export function createSupervisor(deps) {
|
|
|
173
197
|
}
|
|
174
198
|
if (sync.syncPassActive === false) {
|
|
175
199
|
deferredCliRestart = true;
|
|
176
|
-
|
|
200
|
+
if (pendingSyncCommandRestart?.slot === sync)
|
|
201
|
+
finishPendingSyncCommandRestart();
|
|
202
|
+
else
|
|
203
|
+
runDeferredCliRestart();
|
|
177
204
|
return;
|
|
178
205
|
}
|
|
179
206
|
if (deferredCliRestart) {
|
|
@@ -182,7 +209,25 @@ export function createSupervisor(deps) {
|
|
|
182
209
|
}
|
|
183
210
|
deferredCliRestart = true;
|
|
184
211
|
deps.log("info", "deferring CLI-update restart until sync is idle (maximum 10 minutes)");
|
|
185
|
-
|
|
212
|
+
if (pendingSyncCommandRestart?.slot === sync)
|
|
213
|
+
return;
|
|
214
|
+
deferredCliRestartTimer = setT(() => runDeferredCliRestart(true), CLI_UPDATE_SYNC_IDLE_MAX_MS);
|
|
215
|
+
};
|
|
216
|
+
const deferSyncCommandRestart = (slot, onFinish) => {
|
|
217
|
+
if (slot.syncPassActive === false) {
|
|
218
|
+
onFinish(false);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (pendingSyncCommandRestart?.slot === slot)
|
|
222
|
+
return;
|
|
223
|
+
clearPendingSyncCommandRestart();
|
|
224
|
+
const pending = { slot, onFinish };
|
|
225
|
+
pendingSyncCommandRestart = pending;
|
|
226
|
+
deps.log("info", `deferring restart of ${slot.plan.id} until its sync pass is idle (maximum 10 minutes)`);
|
|
227
|
+
// A pending CLI restart already owns the same 10-minute idle window.
|
|
228
|
+
if (!deferredCliRestart) {
|
|
229
|
+
pending.timer = setT(() => finishPendingSyncCommandRestart(true), CLI_UPDATE_SYNC_IDLE_MAX_MS);
|
|
230
|
+
}
|
|
186
231
|
};
|
|
187
232
|
const setBlocked = (slot) => {
|
|
188
233
|
const block = slot.plan.blocked;
|
|
@@ -204,7 +249,8 @@ export function createSupervisor(deps) {
|
|
|
204
249
|
* `drain`, a unit that has a drain signal gets it first and SIGTERM only
|
|
205
250
|
* after HANDOFF_DRAIN_MAX_MS; SIGKILL follows SIGTERM after STOP_ESCALATION_MS.
|
|
206
251
|
*/
|
|
207
|
-
const terminate = (slot, then, drain = false) => {
|
|
252
|
+
const terminate = (slot, then, drain = false, killGraceMs = STOP_ESCALATION_MS) => {
|
|
253
|
+
clearPendingSyncCommandRestart(slot);
|
|
208
254
|
clearTimer(slot);
|
|
209
255
|
clearHeartbeatTimer(slot);
|
|
210
256
|
const child = slot.child;
|
|
@@ -227,9 +273,9 @@ export function createSupervisor(deps) {
|
|
|
227
273
|
slot.killTimer = undefined;
|
|
228
274
|
if (slot.child !== child)
|
|
229
275
|
return;
|
|
230
|
-
deps.log("warn", `${slot.plan.id} did not exit within ${
|
|
276
|
+
deps.log("warn", `${slot.plan.id} did not exit within ${killGraceMs / 1_000} s of SIGTERM; sending SIGKILL${lastProtocolHint(slot)}`);
|
|
231
277
|
signalChild(slot, child, "SIGKILL");
|
|
232
|
-
},
|
|
278
|
+
}, killGraceMs);
|
|
233
279
|
};
|
|
234
280
|
const drainSignal = drain ? slot.plan.drainSignal : undefined;
|
|
235
281
|
if (drainSignal) {
|
|
@@ -309,8 +355,12 @@ export function createSupervisor(deps) {
|
|
|
309
355
|
slot.syncPassActive = true;
|
|
310
356
|
}
|
|
311
357
|
}
|
|
312
|
-
if (
|
|
313
|
-
|
|
358
|
+
if (slot.syncPassActive === false) {
|
|
359
|
+
if (pendingSyncCommandRestart?.slot === slot)
|
|
360
|
+
finishPendingSyncCommandRestart();
|
|
361
|
+
else if (deferredCliRestart)
|
|
362
|
+
runDeferredCliRestart();
|
|
363
|
+
}
|
|
314
364
|
}
|
|
315
365
|
const at = now();
|
|
316
366
|
slot.state.lastHeartbeatAt = new Date(at).toISOString();
|
|
@@ -357,6 +407,11 @@ export function createSupervisor(deps) {
|
|
|
357
407
|
if (slot.child !== child)
|
|
358
408
|
return;
|
|
359
409
|
slot.child = undefined;
|
|
410
|
+
if (pendingSyncCommandRestart?.slot === slot) {
|
|
411
|
+
clearPendingSyncCommandRestart(slot);
|
|
412
|
+
if (deferredCliRestart)
|
|
413
|
+
runDeferredCliRestart();
|
|
414
|
+
}
|
|
360
415
|
clearHeartbeatTimer(slot);
|
|
361
416
|
if (slot.killTimer !== undefined) {
|
|
362
417
|
clearT(slot.killTimer);
|
|
@@ -628,6 +683,7 @@ export function createSupervisor(deps) {
|
|
|
628
683
|
existing.blockedReads = 0;
|
|
629
684
|
const adopted = existing.adopted === true;
|
|
630
685
|
existing.adopted = false;
|
|
686
|
+
const hadDrainSignal = existing.plan.drainSignal !== undefined;
|
|
631
687
|
const key = planKey(plan, deps.cliVersion);
|
|
632
688
|
const wasBlocked = existing.plan.blocked !== undefined;
|
|
633
689
|
const changed = key !== existing.key;
|
|
@@ -652,6 +708,34 @@ export function createSupervisor(deps) {
|
|
|
652
708
|
else
|
|
653
709
|
activate(existing);
|
|
654
710
|
}
|
|
711
|
+
else if (changed && cliRestartInProgress && plan.service === "sync" && existing.child) {
|
|
712
|
+
// The deferred CLI restart owns this stop; do not queue a second
|
|
713
|
+
// replacement through the adopted-unit handoff path.
|
|
714
|
+
terminate(existing);
|
|
715
|
+
}
|
|
716
|
+
else if (changed && plan.service === "sync" && existing.child) {
|
|
717
|
+
existing.attempts = 0;
|
|
718
|
+
existing.state.restarts = 0;
|
|
719
|
+
const replaceWhenIdle = () => {
|
|
720
|
+
if (adopted) {
|
|
721
|
+
deps.log("info", `will replace ${plan.id}: it runs an older CLI or command`);
|
|
722
|
+
handoffQueue.push(existing);
|
|
723
|
+
pumpHandoff();
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
deps.log("info", `restarting ${plan.id}: its command changed`);
|
|
727
|
+
terminate(existing, () => activate(existing));
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
deferSyncCommandRestart(existing, (idleTimeout) => {
|
|
731
|
+
if (!idleTimeout) {
|
|
732
|
+
replaceWhenIdle();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
deps.log("warn", `${plan.id} sync pass did not become idle within 10 minutes; stopping it to apply the changed command`);
|
|
736
|
+
terminate(existing, () => activate(existing), false, CLI_UPDATE_SYNC_IDLE_KILL_GRACE_MS);
|
|
737
|
+
});
|
|
738
|
+
}
|
|
655
739
|
else if (changed && adopted && existing.child) {
|
|
656
740
|
deps.log("info", `will replace ${plan.id}: it runs an older CLI or command`);
|
|
657
741
|
handoffQueue.push(existing);
|
|
@@ -663,7 +747,7 @@ export function createSupervisor(deps) {
|
|
|
663
747
|
if (cliRestartInProgress && plan.service === "sync")
|
|
664
748
|
terminate(existing);
|
|
665
749
|
else
|
|
666
|
-
terminate(existing, () => activate(existing));
|
|
750
|
+
terminate(existing, () => activate(existing), hadDrainSignal);
|
|
667
751
|
}
|
|
668
752
|
}
|
|
669
753
|
slots = next;
|
|
@@ -878,6 +962,7 @@ export function createSupervisor(deps) {
|
|
|
878
962
|
if (handoffTimer !== undefined)
|
|
879
963
|
clearT(handoffTimer);
|
|
880
964
|
clearDeferredCliRestart();
|
|
965
|
+
clearPendingSyncCommandRestart();
|
|
881
966
|
const childOwners = new Map();
|
|
882
967
|
for (const slot of slots.values()) {
|
|
883
968
|
if (slot.child)
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* which stands up a throwaway HQ tree with `mktemp -d` and stubbed hook scripts.
|
|
26
26
|
*/
|
|
27
27
|
/** Claude/Codex lifecycle events a hook can be registered against. */
|
|
28
|
-
|
|
28
|
+
type HookEventName = "PreToolUse" | "PostToolUse" | "UserPromptSubmit" | "SessionStart" | "Stop" | "SubagentStop" | "PreCompact" | "Notification";
|
|
29
29
|
/** The three `hook-gate.sh` allowlist profiles. */
|
|
30
30
|
export type GateProfile = "minimal" | "standard" | "strict";
|
|
31
31
|
/** Canonical ordering of the gate profiles. */
|
|
@@ -35,7 +35,7 @@ export declare const GATE_PROFILES: readonly GateProfile[];
|
|
|
35
35
|
* canonical `.claude/hooks/` scripts; only Codex runs duplicated copies, so the
|
|
36
36
|
* Codex mirror is the entire drift surface the doctor has to watch.
|
|
37
37
|
*/
|
|
38
|
-
|
|
38
|
+
interface FakeCodexMirrorSpec {
|
|
39
39
|
/** Whether the mirror file is written to `.codex/hooks/`. Default: true. */
|
|
40
40
|
present?: boolean;
|
|
41
41
|
/**
|
|
@@ -99,7 +99,7 @@ export interface FakeHookSpec {
|
|
|
99
99
|
* written by default (declare a hook's `codex` spec explicitly, or set
|
|
100
100
|
* `legacyHooksJson`, to model pre-adapter leftovers).
|
|
101
101
|
*/
|
|
102
|
-
|
|
102
|
+
interface FakeCodexAdapterSpec {
|
|
103
103
|
/** Events wired to the adapter in config.toml. Default: the shipped eight. */
|
|
104
104
|
events?: string[];
|
|
105
105
|
/** Whether the adapter script is written. Default: true. */
|
|
@@ -111,10 +111,8 @@ export interface FakeCodexAdapterSpec {
|
|
|
111
111
|
/** Whether a leftover `.codex/hooks.json` is still written. Default: false. */
|
|
112
112
|
legacyHooksJson?: boolean;
|
|
113
113
|
}
|
|
114
|
-
/** The lifecycle events the shipped `.codex/config.toml` wires. */
|
|
115
|
-
export declare const ADAPTER_DEFAULT_EVENTS: readonly string[];
|
|
116
114
|
/** Declares the `.grok/hooks/` state. */
|
|
117
|
-
|
|
115
|
+
interface FakeGrokSpec {
|
|
118
116
|
/** Whether the `.grok/hooks/` scaffold is created at all. Default: true. */
|
|
119
117
|
present?: boolean;
|
|
120
118
|
/** Whether `hq-grok-hook-adapter.sh` carries the executable bit. Default: true. */
|
|
@@ -147,7 +145,7 @@ export interface FakeHqTreeSpec {
|
|
|
147
145
|
prefix?: string;
|
|
148
146
|
}
|
|
149
147
|
/** Manifest entry describing a materialised Codex mirror. */
|
|
150
|
-
|
|
148
|
+
interface FakeCodexManifestEntry {
|
|
151
149
|
/** Absolute path where the mirror lives (whether or not it was written). */
|
|
152
150
|
scriptPath: string;
|
|
153
151
|
/** Whether the mirror file exists on disk. */
|
|
@@ -162,7 +160,7 @@ export interface FakeCodexManifestEntry {
|
|
|
162
160
|
drifted: boolean;
|
|
163
161
|
}
|
|
164
162
|
/** Manifest entry describing a materialised hook across all platforms. */
|
|
165
|
-
|
|
163
|
+
interface FakeHookManifestEntry {
|
|
166
164
|
id: string;
|
|
167
165
|
/** Absolute path of the Claude script (whether or not it was written). */
|
|
168
166
|
scriptPath: string;
|
|
@@ -184,7 +182,7 @@ export interface FakeHookManifestEntry {
|
|
|
184
182
|
codex: FakeCodexManifestEntry | null;
|
|
185
183
|
}
|
|
186
184
|
/** A structured description of everything {@link buildFakeHqTree} wrote. */
|
|
187
|
-
|
|
185
|
+
interface FakeHqTreeManifest {
|
|
188
186
|
root: string;
|
|
189
187
|
claudeSettingsPath: string;
|
|
190
188
|
claudeHooksDir: string;
|
|
@@ -224,4 +222,5 @@ export declare function trackedFakeHqTreeRoots(): string[];
|
|
|
224
222
|
* when the process exits.
|
|
225
223
|
*/
|
|
226
224
|
export declare function buildFakeHqTree(spec?: FakeHqTreeSpec): FakeHqTree;
|
|
225
|
+
export {};
|
|
227
226
|
//# sourceMappingURL=fake-hq-tree.d.ts.map
|
|
@@ -39,7 +39,7 @@ const EXECUTABLE_MODE = 0o755;
|
|
|
39
39
|
const NON_EXECUTABLE_MODE = 0o644;
|
|
40
40
|
const DEFAULT_PREFIX = "hq-doctor-fake-";
|
|
41
41
|
/** The lifecycle events the shipped `.codex/config.toml` wires. */
|
|
42
|
-
|
|
42
|
+
const ADAPTER_DEFAULT_EVENTS = [
|
|
43
43
|
"SessionStart",
|
|
44
44
|
"UserPromptSubmit",
|
|
45
45
|
"PreToolUse",
|