@indigoai-us/hq-cli 5.345.150 → 5.345.152

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.
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.90","bundleSha256":"cb49486573d2d1141ee6e18d73dde825f2b7c02074560f19504e3b1daf7eeba8","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.91","bundleSha256":"ea41f1765d5015a22304787abedc68a531393d601a349bb8bff3c5428413f3a3","formatVersion":1}
@@ -13,6 +13,7 @@ import type { MachineType } from "./machine-type.js";
13
13
  import type { SyncOwner } from "./config.js";
14
14
  import type { RemoteControlPoolsConfig } from "./remote-control-pools.js";
15
15
  import { type SyncRunnerProcess } from "./sync-runner.js";
16
+ import { type VaultSyncSwitches } from "../vault-sync-switches.js";
16
17
  export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "codex-remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
17
18
  export type ServiceName = (typeof SERVICE_NAMES)[number];
18
19
  export declare function isServiceName(value: unknown): value is ServiceName;
@@ -103,6 +104,12 @@ export interface PlanContext {
103
104
  instantSyncEnabled?: boolean;
104
105
  /** Keep the local sync runner stopped until the operator resumes it. */
105
106
  syncPaused: boolean;
107
+ /**
108
+ * The desktop app's Settings > Sync switches on this computer (US-016).
109
+ * Off vaults are skipped by the runner; all off keeps the unit stopped.
110
+ * Absent means every vault is on.
111
+ */
112
+ vaultSync?: VaultSyncSwitches;
106
113
  /** Persisted/effective owner selected for this machine. */
107
114
  syncOwner: SyncOwner;
108
115
  /** A legacy desktop app or sync unit is still managing sync. */
@@ -10,6 +10,7 @@
10
10
  * them instead of silently dropping them.
11
11
  */
12
12
  import { SYNC_RUNNER_OWNER, syncRunnerUnleasedBlockReason } from "./sync-runner.js";
13
+ import { vaultSkipEnv } from "../vault-sync-switches.js";
13
14
  export const SERVICE_NAMES = [
14
15
  "sync",
15
16
  "mesh",
@@ -142,7 +143,11 @@ function planService(service, ctx) {
142
143
  SYNC_RUNNER_OWNER,
143
144
  ],
144
145
  },
145
- env: { ...ctx.legacySyncEnv, HQ_SYNC_PASS_REQUEST_DIR: ctx.syncPassRequestsDir },
146
+ env: {
147
+ ...ctx.legacySyncEnv,
148
+ ...(ctx.vaultSync ? vaultSkipEnv(ctx.vaultSync) : {}),
149
+ HQ_SYNC_PASS_REQUEST_DIR: ctx.syncPassRequestsDir,
150
+ },
146
151
  heartbeatTimeoutMs: ctx.syncHeartbeatTimeoutMs,
147
152
  });
148
153
  let ownerBlock;
@@ -160,6 +165,10 @@ function planService(service, ctx) {
160
165
  }
161
166
  unit.blocked = firstBlock([
162
167
  [ctx.syncPaused, "sync is paused (run `hq daemon sync resume`)"],
168
+ [
169
+ ctx.vaultSync?.allOff === true,
170
+ "cloud sync is off for every vault on this computer (turn it back on in HQ Settings > Sync)",
171
+ ],
163
172
  [!ctx.hqRoot, NO_HQ_ROOT],
164
173
  [!ctx.signedIn, NOT_SIGNED_IN],
165
174
  [!!ownerBlock, ownerBlock ?? ""],
@@ -32,6 +32,7 @@ import { DAEMON_CHILD_BOOTSTRAP_HANDOFF_WATCH_FLAG, DAEMON_LANES_RECONCILE_FILE_
32
32
  import { createLanesReconcileWatcher } from "./lanes-reconcile-watcher.js";
33
33
  import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup } from "./legacy.js";
34
34
  import { readLegacySyncEnv } from "./legacy-sync-env.js";
35
+ import { readVaultSyncSwitches, unreadableSwitchesWarning } from "../vault-sync-switches.js";
35
36
  import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLock } from "./lock.js";
36
37
  import { retireLegacyMeshUnits } from "./migrate.js";
37
38
  import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
@@ -581,6 +582,8 @@ export async function runDaemon(opts) {
581
582
  finishSuccession(succession, tracker.records(), log);
582
583
  /** Service processes this daemon spawned, so a handoff can let go of them. */
583
584
  const spawned = new Set();
585
+ /** Last unreadable-settings warning logged, so it is logged once per change. */
586
+ let lastVaultSyncWarning;
584
587
  const updatable = cliSelfUpdatable();
585
588
  const hostName = os.hostname().split(".")[0] || "hq-host";
586
589
  let legacyCache = null;
@@ -949,6 +952,16 @@ export async function runDaemon(opts) {
949
952
  signedIn: isSignedIn,
950
953
  kitConfigured: fs.existsSync(agentKitPaths(home, env).kitConfigPath),
951
954
  syncPaused: config.syncPaused === true,
955
+ vaultSync: (() => {
956
+ const switches = readVaultSyncSwitches(home);
957
+ // Log once per change, not every reconcile.
958
+ if (switches.unreadable !== lastVaultSyncWarning) {
959
+ if (switches.unreadable)
960
+ log("warn", unreadableSwitchesWarning(switches.unreadable));
961
+ lastVaultSyncWarning = switches.unreadable;
962
+ }
963
+ return switches;
964
+ })(),
952
965
  syncOwner,
953
966
  desktopAppManagesSync: appManagesSync,
954
967
  syncRunnerProcesses,
@@ -267,28 +267,44 @@ function hookMessage(root, pointer) {
267
267
  throw new LaneLinkError("link_pointer_invalid", "Child link pointer has no child name");
268
268
  return { inbox: queueDir(root, pointer.link, pointer.name), role: "child", child: pointer.name };
269
269
  }
270
+ // Same markers as core's conduct-lanes-link-deliver.sh. A parent session id
271
+ // alone is not one: resumed, forked, or launcher-spawned Grok roots carry it,
272
+ // and Grok marks its real children with subagent_type.
273
+ function isChildHookPayload(payload) {
274
+ const nonEmpty = (...keys) => keys.some((key) => typeof payload[key] === "string" && payload[key].length > 0);
275
+ if (["agent_id", "agentId"].some((key) => payload[key] !== undefined && payload[key] !== null && payload[key] !== ""))
276
+ return true;
277
+ if (nonEmpty("parent_thread_id", "parentThreadId", "agent_transcript_path", "agentTranscriptPath", "subagent_type", "subagentType"))
278
+ return true;
279
+ const transcript = payload.transcript_path ?? payload.transcriptPath;
280
+ return typeof transcript === "string" && /[/\\]subagents[/\\][^/\\]+\.jsonl$/.test(transcript);
281
+ }
270
282
  export async function runLaneLink(subcommand, options) {
271
283
  const now = options.now ?? (() => new Date());
272
284
  const env = options.env ?? process.env;
273
285
  const root = options.hqRoot;
274
286
  let sid = "";
287
+ let childContext = false;
275
288
  if (subcommand === "_deliver") {
276
- sid = (() => {
277
- let payload = {};
278
- try {
279
- payload = JSON.parse(options.input ?? "{}");
280
- }
281
- catch { /* malformed hooks are inert */ }
282
- const value = payload.session_id ?? payload.sessionId;
283
- return typeof value === "string" && safeId(value) ? value : "";
284
- })();
289
+ let payload = {};
290
+ try {
291
+ const parsed = JSON.parse(options.input ?? "{}");
292
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
293
+ payload = parsed;
294
+ }
295
+ catch { /* malformed hooks are inert */ }
296
+ const value = payload.session_id ?? payload.sessionId;
297
+ sid = typeof value === "string" && safeId(value) ? value : "";
298
+ childContext = isChildHookPayload(payload);
285
299
  }
286
300
  const requireSid = () => sid || (sid = resolveLinkSessionId(root, options.sessionId, env));
287
301
  const links = rootDir(root);
288
302
  if (subcommand === "_deliver") {
289
- if (!sid || !["PostToolUse", "Stop", "SubagentStop"].includes(options.event ?? ""))
303
+ // Subagent hook events carry the parent's session_id. Delivering there would
304
+ // drain the root's queue into the subagent, so leave the message queued.
305
+ if (!sid || childContext || !["PostToolUse", "Stop"].includes(options.event ?? ""))
290
306
  return {};
291
- if (["Stop", "SubagentStop"].includes(options.event ?? "") && env.HQ_STOP_DECISION_DELIVERABLE === "0")
307
+ if (options.event === "Stop" && env.HQ_STOP_DECISION_DELIVERABLE === "0")
292
308
  return {};
293
309
  const pointer = readPointer(root, sid);
294
310
  if (!pointer)
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The HQ app's Settings > Sync switches on this computer (usage-only-pricing
3
+ * US-016), read from the desktop settings file (`menubar.json` in the HQ state
4
+ * directory):
5
+ *
6
+ * - `cloudPaused: true` turns sync off for every vault;
7
+ * - `personalSyncEnabled: false` turns the personal vault off;
8
+ * - `workspaceSyncEnabled: { "<slug>": false }` turns one company vault off.
9
+ *
10
+ * Dependency-free on purpose (node:fs/os/path only): the `hq daemon` planner
11
+ * reads it every reconcile.
12
+ *
13
+ * A missing file, a missing key, or a value of the wrong type means on, so
14
+ * CLI-only installs and agent boxes keep syncing. A file that exists but
15
+ * cannot be read or parsed also means on, and is reported through
16
+ * `unreadable` so callers can warn instead of turning sync back on silently.
17
+ */
18
+ export interface VaultSyncSwitches {
19
+ /** "Turn off all cloud sync" is on. */
20
+ allOff: boolean;
21
+ /** The personal vault's switch is off. */
22
+ personalOff: boolean;
23
+ /** Lowercased slugs of company vaults whose switch is off. */
24
+ offCompanies: ReadonlySet<string>;
25
+ /**
26
+ * Set when the settings file exists but could not be read or parsed. Every
27
+ * switch then reads as on; callers should warn.
28
+ */
29
+ unreadable?: string;
30
+ }
31
+ export declare function vaultSyncSettingsPath(home?: string): string;
32
+ /** Read every switch. Never throws. */
33
+ export declare function readVaultSyncSwitches(home?: string): VaultSyncSwitches;
34
+ /** The warning printed when the settings file is unreadable. */
35
+ export declare function unreadableSwitchesWarning(reason: string): string;
36
+ /**
37
+ * Environment for an hq-cloud sync runner so it skips off vaults
38
+ * (`HQ_SYNC_SKIP_COMPANIES`, `HQ_SYNC_SKIP_PERSONAL`). Empty when all are on.
39
+ */
40
+ export declare function vaultSkipEnv(switches: VaultSyncSwitches): Record<string, string>;
41
+ //# sourceMappingURL=vault-sync-switches.d.ts.map
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The HQ app's Settings > Sync switches on this computer (usage-only-pricing
3
+ * US-016), read from the desktop settings file (`menubar.json` in the HQ state
4
+ * directory):
5
+ *
6
+ * - `cloudPaused: true` turns sync off for every vault;
7
+ * - `personalSyncEnabled: false` turns the personal vault off;
8
+ * - `workspaceSyncEnabled: { "<slug>": false }` turns one company vault off.
9
+ *
10
+ * Dependency-free on purpose (node:fs/os/path only): the `hq daemon` planner
11
+ * reads it every reconcile.
12
+ *
13
+ * A missing file, a missing key, or a value of the wrong type means on, so
14
+ * CLI-only installs and agent boxes keep syncing. A file that exists but
15
+ * cannot be read or parsed also means on, and is reported through
16
+ * `unreadable` so callers can warn instead of turning sync back on silently.
17
+ */
18
+ import * as fs from "node:fs";
19
+ import * as os from "node:os";
20
+ import * as path from "node:path";
21
+ export function vaultSyncSettingsPath(home = os.homedir()) {
22
+ return path.join(home, ".hq", "menubar.json");
23
+ }
24
+ function allOn(unreadable) {
25
+ return {
26
+ allOff: false,
27
+ personalOff: false,
28
+ offCompanies: new Set(),
29
+ ...(unreadable ? { unreadable } : {}),
30
+ };
31
+ }
32
+ /** Read every switch. Never throws. */
33
+ export function readVaultSyncSwitches(home = os.homedir()) {
34
+ const file = vaultSyncSettingsPath(home);
35
+ let text;
36
+ try {
37
+ text = fs.readFileSync(file, "utf8");
38
+ }
39
+ catch (err) {
40
+ if (err?.code === "ENOENT")
41
+ return allOn();
42
+ return allOn(`could not read ${file}`);
43
+ }
44
+ let raw;
45
+ try {
46
+ raw = JSON.parse(text);
47
+ }
48
+ catch {
49
+ return allOn(`${file} is not valid JSON`);
50
+ }
51
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
52
+ return allOn(`${file} is not a JSON object`);
53
+ }
54
+ const prefs = raw;
55
+ const offCompanies = new Set();
56
+ const map = prefs.workspaceSyncEnabled;
57
+ if (typeof map === "object" && map !== null && !Array.isArray(map)) {
58
+ for (const [slug, enabled] of Object.entries(map)) {
59
+ if (enabled === false && slug.trim())
60
+ offCompanies.add(slug.trim().toLowerCase());
61
+ }
62
+ }
63
+ return {
64
+ allOff: prefs.cloudPaused === true,
65
+ personalOff: prefs.personalSyncEnabled === false,
66
+ offCompanies,
67
+ };
68
+ }
69
+ /** The warning printed when the settings file is unreadable. */
70
+ export function unreadableSwitchesWarning(reason) {
71
+ return `Warning: ${reason}, so HQ cannot tell which vaults have sync turned off on this computer. Every vault will sync. Fix or remove the file, or set the switches again in HQ Settings > Sync.`;
72
+ }
73
+ /**
74
+ * Environment for an hq-cloud sync runner so it skips off vaults
75
+ * (`HQ_SYNC_SKIP_COMPANIES`, `HQ_SYNC_SKIP_PERSONAL`). Empty when all are on.
76
+ */
77
+ export function vaultSkipEnv(switches) {
78
+ const env = {};
79
+ if (switches.offCompanies.size > 0) {
80
+ env.HQ_SYNC_SKIP_COMPANIES = [...switches.offCompanies].sort().join(",");
81
+ }
82
+ if (switches.personalOff)
83
+ env.HQ_SYNC_SKIP_PERSONAL = "1";
84
+ return env;
85
+ }
86
+ //# sourceMappingURL=vault-sync-switches.js.map
@@ -26,6 +26,20 @@ export interface HookTrustDependencies {
26
26
  * our read and our commit — which no other seam can reach.
27
27
  */
28
28
  readConfigText?: (file: string) => string;
29
+ /**
30
+ * Reads the HQ `.codex` hook files exactly as the installed core release
31
+ * ships them. Used only by the post-rescue re-trust; see
32
+ * {@link retrustCodexHooksAfterRescue}.
33
+ */
34
+ loadShippedCodexHooks?: (hqRoot: string) => Promise<ShippedCodexHookFiles>;
35
+ }
36
+ /**
37
+ * The text of the hook files in core's `.codex/` layer at the release a rescue
38
+ * installed. A file the release does not ship is left out.
39
+ */
40
+ export interface ShippedCodexHookFiles {
41
+ 'config.toml'?: string;
42
+ 'hooks.json'?: string;
29
43
  }
30
44
  /** Per-call knobs the `hq reindex` orchestration passes down. */
31
45
  export interface HookTrustOptions {
@@ -62,6 +76,19 @@ export declare function planCodexLaunch(executable: string, args: readonly strin
62
76
  export declare function createCodexAppServerClient(cwd: string, executable?: string, args?: string[]): Promise<CodexRpcClient>;
63
77
  /** Trust only hooks declared by this HQ root's project `.codex/` layer. */
64
78
  export declare function trustCodexProjectHooks(hqRoot: string, deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
79
+ /**
80
+ * Re-trust the Codex hooks HQ core ships after `hq rescue` re-lays core.
81
+ *
82
+ * Codex trusts a hook by the hash of its current command, so an update that
83
+ * changes a hook command leaves Codex skipping it as untrusted until the next
84
+ * trust pass. Rescue runs that pass itself, for core-shipped hooks only: a
85
+ * hook in `<hqRoot>/.codex/` is trusted only when its entry matches the
86
+ * shipped copy of its file (see {@link coreShippedHooks}), so a hand-added
87
+ * entry is never trusted. Unlike `hq reindex`, rescue does not re-enable a
88
+ * shipped hook the user turned off. Already trusted hooks are left as they
89
+ * are, and a machine without Codex is skipped without starting anything.
90
+ */
91
+ export declare function retrustCodexHooksAfterRescue(hqRoot: string, deps?: HookTrustDependencies, codexInstalled?: () => boolean): Promise<RuntimeHookTrustResult>;
65
92
  /**
66
93
  * Trust the user-scope hooks `hq install --global --runtime codex` registered
67
94
  * in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
@@ -3,12 +3,14 @@ import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import * as readline from 'node:readline';
6
+ import { parse as parseToml } from 'smol-toml';
6
7
  // `~/.claude.json` is Claude Code's live user-global state file, so the write
7
8
  // reuses the hardened MCP-registration primitives (advisory lock held across
8
9
  // read->merge->write, backup before the first byte, temp+fsync+rename commit)
9
10
  // rather than a bare readFile/writeFile pair.
10
11
  import { acquireLock, atomicReplace, backupConfig, realpathOrSelf, releaseLock, restoreFromBackup, } from '../commands/mcp-registration.js';
11
12
  import { findExecutableOnPath } from './resolve-executable.js';
13
+ import { loadShippedCodexHookFiles } from './shipped-codex-hooks.js';
12
14
  import { planResolvedCommandSpawn } from './windows-spawn.js';
13
15
  /**
14
16
  * The home directory every runtime's config is resolved under.
@@ -84,6 +86,98 @@ function hqProjectHooks(hqRoot, hooks) {
84
86
  !hook.isManaged &&
85
87
  isWithin(path.resolve(hook.sourcePath), codexLayer));
86
88
  }
89
+ /** Codex's snake_case spelling of a hook event name (`PreToolUse` -> `pre_tool_use`). */
90
+ function snakeEvent(event) {
91
+ return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
92
+ }
93
+ /** Stable JSON for comparing two parsed hook entries regardless of key order. */
94
+ function canonicalJson(value) {
95
+ if (Array.isArray(value))
96
+ return `[${value.map(canonicalJson).join(',')}]`;
97
+ const object = asObject(value);
98
+ if (object) {
99
+ return `{${Object.keys(object)
100
+ .sort()
101
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
102
+ .join(',')}}`;
103
+ }
104
+ return JSON.stringify(value) ?? 'null';
105
+ }
106
+ /**
107
+ * Every hook entry in a Codex hooks file (TOML or JSON), keyed by the
108
+ * `<snake_event>:<group>:<hook>` suffix Codex appends to the file path to form
109
+ * a hook key. Unparseable text yields undefined.
110
+ */
111
+ function codexHookEntries(text, toml) {
112
+ const entries = new Map();
113
+ let doc;
114
+ try {
115
+ doc = toml ? parseToml(text) : JSON.parse(text);
116
+ }
117
+ catch {
118
+ return undefined;
119
+ }
120
+ const hooks = asObject(asObject(doc)?.hooks);
121
+ for (const [event, groups] of Object.entries(hooks ?? {})) {
122
+ if (!Array.isArray(groups))
123
+ continue;
124
+ groups.forEach((group, g) => {
125
+ const handlers = asObject(group)?.hooks;
126
+ if (!Array.isArray(handlers))
127
+ return;
128
+ handlers.forEach((handler, h) => {
129
+ entries.set(`${snakeEvent(event)}:${g}:${h}`, canonicalJson(handler));
130
+ });
131
+ });
132
+ }
133
+ return entries;
134
+ }
135
+ /**
136
+ * The HQ project hooks that ship in core: those whose entry in
137
+ * `<hqRoot>/.codex/config.toml` or `.codex/hooks.json` is identical, at the
138
+ * same position, to the entry in the shipped copy of that file. A hand-added
139
+ * entry, or a shipped entry the user edited, matches nothing and is left out.
140
+ * When an HQ hook comes from a file the release does not ship, or whose
141
+ * shipped copy does not parse, nothing can be compared and the pass fails.
142
+ */
143
+ function coreShippedHooks(hqRoot, hooks, shipped) {
144
+ const codexLayer = path.resolve(hqRoot, '.codex');
145
+ const local = new Map();
146
+ const selected = [];
147
+ for (const hook of hqProjectHooks(hqRoot, hooks)) {
148
+ const sourcePath = path.resolve(hook.sourcePath);
149
+ const file = path.relative(codexLayer, sourcePath);
150
+ if (file !== 'config.toml' && file !== 'hooks.json')
151
+ continue;
152
+ const prefix = `${hook.sourcePath}:`;
153
+ if (!hook.key.startsWith(prefix))
154
+ continue;
155
+ const toml = file.endsWith('.toml');
156
+ const shippedText = shipped[file];
157
+ if (shippedText === undefined)
158
+ return { fail: `the installed core release does not ship .codex/${file}` };
159
+ const theirs = codexHookEntries(shippedText, toml);
160
+ if (!theirs)
161
+ return { fail: `the shipped .codex/${file} is not valid ${toml ? 'TOML' : 'JSON'}` };
162
+ let mine = local.get(file);
163
+ if (!mine) {
164
+ let text;
165
+ try {
166
+ text = fs.readFileSync(sourcePath, 'utf8');
167
+ }
168
+ catch {
169
+ text = undefined;
170
+ }
171
+ mine = (text === undefined ? undefined : codexHookEntries(text, toml)) ?? new Map();
172
+ local.set(file, mine);
173
+ }
174
+ const position = hook.key.slice(prefix.length);
175
+ const entry = mine.get(position);
176
+ if (entry !== undefined && entry === theirs.get(position))
177
+ selected.push(hook);
178
+ }
179
+ return selected.length === 0 ? { skip: 'no core-shipped HQ hooks discovered' } : { hooks: selected };
180
+ }
87
181
  /**
88
182
  * How to launch Codex. npm installs Codex on Windows as a `codex.cmd` shim,
89
183
  * which `spawn('codex')` cannot find (ENOENT) and Node refuses to run without a
@@ -203,6 +297,7 @@ export async function createCodexAppServerClient(cwd, executable = 'codex', args
203
297
  const DEFAULT_DEPS = {
204
298
  createCodexClient: createCodexAppServerClient,
205
299
  homeDir: defaultHomeDir,
300
+ loadShippedCodexHooks: (hqRoot) => loadShippedCodexHookFiles(hqRoot),
206
301
  };
207
302
  /** Trust only hooks declared by this HQ root's project `.codex/` layer. */
208
303
  export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
@@ -216,6 +311,46 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
216
311
  : { hooks: projectHooks };
217
312
  });
218
313
  }
314
+ /** Whether a Codex CLI is on PATH (PATHEXT-expanded on Windows). */
315
+ function codexOnPath() {
316
+ return findExecutableOnPath('codex') !== undefined;
317
+ }
318
+ /**
319
+ * Re-trust the Codex hooks HQ core ships after `hq rescue` re-lays core.
320
+ *
321
+ * Codex trusts a hook by the hash of its current command, so an update that
322
+ * changes a hook command leaves Codex skipping it as untrusted until the next
323
+ * trust pass. Rescue runs that pass itself, for core-shipped hooks only: a
324
+ * hook in `<hqRoot>/.codex/` is trusted only when its entry matches the
325
+ * shipped copy of its file (see {@link coreShippedHooks}), so a hand-added
326
+ * entry is never trusted. Unlike `hq reindex`, rescue does not re-enable a
327
+ * shipped hook the user turned off. Already trusted hooks are left as they
328
+ * are, and a machine without Codex is skipped without starting anything.
329
+ */
330
+ export async function retrustCodexHooksAfterRescue(hqRoot, deps = DEFAULT_DEPS, codexInstalled = codexOnPath) {
331
+ if (!codexInstalled()) {
332
+ return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'Codex CLI not installed' };
333
+ }
334
+ if (!fs.existsSync(path.join(hqRoot, '.codex'))) {
335
+ return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'project .codex layer absent' };
336
+ }
337
+ if (!deps.loadShippedCodexHooks) {
338
+ return { runtime: 'codex', status: 'failed', trusted: 0, reason: 'no source for the shipped Codex hooks' };
339
+ }
340
+ let shipped;
341
+ try {
342
+ shipped = await deps.loadShippedCodexHooks(hqRoot);
343
+ }
344
+ catch (error) {
345
+ return {
346
+ runtime: 'codex',
347
+ status: 'failed',
348
+ trusted: 0,
349
+ reason: `could not read the shipped Codex hooks: ${errorMessage(error)}`,
350
+ };
351
+ }
352
+ return trustCodexHooks(hqRoot, deps, (hooks) => coreShippedHooks(hqRoot, hooks, shipped), { enableDisabled: false });
353
+ }
219
354
  /**
220
355
  * Trust the user-scope hooks `hq install --global --runtime codex` registered
221
356
  * in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
@@ -234,8 +369,12 @@ export async function trustCodexUserHooks(cwd, keys, deps = DEFAULT_DEPS) {
234
369
  return { hooks: keys.map((key) => byKey.get(key)) };
235
370
  });
236
371
  }
237
- /** List hooks for `cwd`, trust and enable the selected ones, then verify. */
238
- async function trustCodexHooks(cwd, deps, select) {
372
+ /**
373
+ * List hooks for `cwd`, trust the selected ones, then verify. By default a
374
+ * selected hook the user disabled is also re-enabled; `enableDisabled: false`
375
+ * keeps each hook's enabled state as it is.
376
+ */
377
+ async function trustCodexHooks(cwd, deps, select, { enableDisabled = true } = {}) {
239
378
  let client;
240
379
  try {
241
380
  client = await deps.createCodexClient(cwd);
@@ -256,17 +395,20 @@ async function trustCodexHooks(cwd, deps, select) {
256
395
  return { runtime: 'codex', status: 'failed', trusted: 0, reason: selection.fail };
257
396
  }
258
397
  const selected = selection.hooks;
259
- // Converge every selected HQ hook to trusted AND enabled. A hook that is
260
- // already trusted but was toggled off would otherwise silently stay
261
- // disabled forever; reindex and install are the convergence points.
262
- const pending = selected.filter((hook) => hook.trustStatus === 'untrusted' || hook.trustStatus === 'modified' || !hook.enabled);
398
+ // Converge every selected HQ hook to trusted AND (unless the caller opted
399
+ // out) enabled. A hook that is already trusted but was toggled off would
400
+ // otherwise silently stay disabled forever; reindex and install are the
401
+ // convergence points. Rescue opts out and only refreshes trust.
402
+ const pending = selected.filter((hook) => hook.trustStatus === 'untrusted' ||
403
+ hook.trustStatus === 'modified' ||
404
+ (enableDisabled && !hook.enabled));
263
405
  if (pending.length === 0) {
264
406
  return { runtime: 'codex', status: 'unchanged', trusted: 0 };
265
407
  }
266
408
  const state = Object.fromEntries(pending.map((hook) => [
267
409
  hook.key,
268
410
  {
269
- enabled: true,
411
+ enabled: enableDisabled ? true : hook.enabled,
270
412
  trusted_hash: hook.currentHash,
271
413
  },
272
414
  ]));
@@ -279,7 +421,7 @@ async function trustCodexHooks(cwd, deps, select) {
279
421
  const stillPending = pending
280
422
  .filter((hook) => {
281
423
  const after = verifiedByKey.get(hook.key);
282
- return after?.trustStatus !== 'trusted' || after.enabled !== true;
424
+ return after?.trustStatus !== 'trusted' || after.enabled !== (enableDisabled || hook.enabled);
283
425
  })
284
426
  .map((hook) => hook.key);
285
427
  if (verified.errors.length > 0 || stillPending.length > 0) {
@@ -0,0 +1,17 @@
1
+ import type { ShippedCodexHookFiles } from './hook-trust.js';
2
+ /** The only repos rescue installs core from: the default and `--staging`. */
3
+ export declare const PROD_SOURCE = "indigoai-us/hq-core";
4
+ export declare const STAGING_SOURCE = "indigoai-us/hq-core-staging";
5
+ /** Resolve a GitHub token: prefer `gh auth token`, fall back to env. */
6
+ export declare function resolveGhToken(): string | undefined;
7
+ /**
8
+ * The upstream repo and commit the last rescue installed, from `core/core.yaml`.
9
+ * Only the HQ core repos are accepted.
10
+ */
11
+ export declare function readCoreSyncStamp(hqRoot: string): {
12
+ source: string;
13
+ sha: string;
14
+ };
15
+ /** The shipped `.codex/config.toml` and `.codex/hooks.json` for this HQ root's installed core. */
16
+ export declare function loadShippedCodexHookFiles(hqRoot: string, token?: string | undefined, fetchImpl?: typeof fetch): Promise<ShippedCodexHookFiles>;
17
+ //# sourceMappingURL=shipped-codex-hooks.d.ts.map
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Reads the Codex hook files HQ core ships, at the exact commit a rescue
3
+ * installed, so the post-rescue re-trust can tell core-shipped hook entries
4
+ * from ones a user added by hand.
5
+ *
6
+ * The commit comes from the sync stamp rescue writes to `core/core.yaml`
7
+ * (`replaced_from_source.source` + `last_sync_sha`); the files come from the
8
+ * GitHub contents API for that repo and commit.
9
+ */
10
+ import { spawnSync } from 'child_process';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import * as yaml from 'js-yaml';
14
+ /** The only repos rescue installs core from: the default and `--staging`. */
15
+ export const PROD_SOURCE = 'indigoai-us/hq-core';
16
+ export const STAGING_SOURCE = 'indigoai-us/hq-core-staging';
17
+ const CORE_SOURCES = [PROD_SOURCE, STAGING_SOURCE];
18
+ /** Resolve a GitHub token: prefer `gh auth token`, fall back to env. */
19
+ export function resolveGhToken() {
20
+ try {
21
+ const res = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8' });
22
+ if (res.status === 0) {
23
+ const tok = res.stdout.trim();
24
+ if (tok)
25
+ return tok;
26
+ }
27
+ }
28
+ catch {
29
+ // gh not installed — fall through to env.
30
+ }
31
+ return process.env.GH_TOKEN || process.env.GITHUB_TOKEN || undefined;
32
+ }
33
+ /**
34
+ * The upstream repo and commit the last rescue installed, from `core/core.yaml`.
35
+ * Only the HQ core repos are accepted.
36
+ */
37
+ export function readCoreSyncStamp(hqRoot) {
38
+ const file = path.join(hqRoot, 'core', 'core.yaml');
39
+ const doc = yaml.load(fs.readFileSync(file, 'utf8'));
40
+ const stamp = doc?.replaced_from_source;
41
+ const source = stamp?.source;
42
+ const sha = stamp?.last_sync_sha;
43
+ if (typeof source !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(source)) {
44
+ throw new Error('core/core.yaml has no replaced_from_source.source repo');
45
+ }
46
+ // Anyone who can edit core.yaml could otherwise point the comparison (and
47
+ // the GitHub token) at a repo of their own and make a foreign hook "shipped".
48
+ if (!CORE_SOURCES.includes(source)) {
49
+ throw new Error(`core/core.yaml names ${source}, which is not an HQ core repo (${CORE_SOURCES.join(' or ')})`);
50
+ }
51
+ if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/.test(sha)) {
52
+ throw new Error('core/core.yaml has no replaced_from_source.last_sync_sha commit');
53
+ }
54
+ return { source, sha };
55
+ }
56
+ // Same bound as rescue's own GitHub requests.
57
+ const GITHUB_REQUEST_TIMEOUT_MS = 30_000;
58
+ /** One file's raw text at `sha`, or undefined when the commit has no such file. */
59
+ async function fetchRawFile(repo, sha, file, token, fetchImpl) {
60
+ const headers = {
61
+ Accept: 'application/vnd.github.raw',
62
+ 'User-Agent': 'hq-cli-rescue',
63
+ 'X-GitHub-Api-Version': '2022-11-28',
64
+ };
65
+ if (token)
66
+ headers.Authorization = `Bearer ${token}`;
67
+ const url = `https://api.github.com/repos/${repo}/contents/${file}?ref=${sha}`;
68
+ const response = await fetchImpl(url, { headers, signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) });
69
+ if (response.status === 404)
70
+ return undefined;
71
+ if (!response.ok)
72
+ throw new Error(`GitHub contents ${file}@${sha.slice(0, 12)} for ${repo}: HTTP ${response.status}`);
73
+ return response.text();
74
+ }
75
+ /** The shipped `.codex/config.toml` and `.codex/hooks.json` for this HQ root's installed core. */
76
+ export async function loadShippedCodexHookFiles(hqRoot, token = resolveGhToken(), fetchImpl = fetch) {
77
+ const { source, sha } = readCoreSyncStamp(hqRoot);
78
+ const shipped = {};
79
+ for (const name of ['config.toml', 'hooks.json']) {
80
+ const text = await fetchRawFile(source, sha, `.codex/${name}`, token, fetchImpl);
81
+ if (text !== undefined)
82
+ shipped[name] = text;
83
+ }
84
+ return shipped;
85
+ }
86
+ //# sourceMappingURL=shipped-codex-hooks.js.map