@indigoai-us/hq-cli 5.345.45 → 5.345.47
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 +15 -0
- package/dist/commands/files.js +4 -1
- package/dist/commands/install-global.d.ts +5 -0
- package/dist/commands/install-global.js +62 -6
- package/dist/commands/lanes.js +40 -11
- package/dist/lib/anywhere-person-setting.d.ts +11 -0
- package/dist/lib/anywhere-person-setting.js +19 -0
- package/dist/lib/daemon/ops/mcp.d.ts +10 -1
- package/dist/lib/daemon/ops/mcp.js +17 -10
- package/dist/lib/daemon/run.js +8 -2
- package/dist/lib/doctor/checks/sync-runtime.d.ts +2 -0
- package/dist/lib/doctor/checks/sync-runtime.js +26 -12
- package/dist/lib/install/claude-targets.js +7 -2
- package/dist/lib/install/codex-targets.d.ts +6 -0
- package/dist/lib/install/codex-targets.js +25 -0
- package/dist/lib/install/provider-wrappers.d.ts +9 -0
- package/dist/lib/install/provider-wrappers.js +190 -0
- package/dist/lib/lanes/provider-rejection.d.ts +5 -5
- package/dist/lib/lanes/provider-rejection.js +15 -10
- package/dist/lib/lanes/spawn.js +9 -4
- package/dist/lib/lanes/watcher.d.ts +6 -2
- package/dist/lib/lanes/watcher.js +37 -17
- package/dist/utils/hook-trust.d.ts +7 -0
- package/dist/utils/hook-trust.js +40 -14
- package/dist/utils/self-update.js +8 -1
- package/dist/utils/version-gate.d.ts +7 -0
- package/dist/utils/version-gate.js +37 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.47] — 2026-10-08
|
|
6
|
+
- `hq files share` now shows the server's reason when a private folder conflicts with its shared folder glob, for example that the glob still has grants or that only its creator or a company admin can replace it.
|
|
7
|
+
- After `hq install --global`, unauthenticated `claude` and `codex` commands show provider-specific sign-in instructions; Codex HTTP 401 transport text is kept in the HQ log.
|
|
8
|
+
- The CLI now stops before updating when a separate pnpm global install shadows the running `hq` command on PATH, preventing an update from modifying the wrong install tree.
|
|
9
|
+
- Keep lanes resumable when a provider quota refusal includes a known reset, and retry them after reset when quota account failover is enabled. Record generic rate limits separately from account quotas, billing refusals, and auth failures.
|
|
10
|
+
- Windows delegate send parity tests now budget the Bash fallback and reuse the runtime probe.
|
|
11
|
+
|
|
12
|
+
## [5.345.46] — 2026-10-08
|
|
13
|
+
- Global Claude hooks installed by `hq install --global` now point `HQ_ROOT` and `CLAUDE_PROJECT_DIR` at the HQ root. Sessions outside the HQ folder now load HQ policies, and hook state stays inside HQ instead of creating a `workspace/` folder in the project. To update an existing install, re-run `hq install --global --runtime claude`.
|
|
14
|
+
- `hq install --global --runtime codex` now trusts the HQ hooks it registers in `~/.codex/hooks.json`. Codex skips untrusted hooks, so before this HQ hooks never ran in Codex sessions outside the HQ folder. Only HQ entries are trusted; other hooks in the file are left alone. If Codex cannot be reached, the install still completes and tells you to approve the hooks with `/hooks`.
|
|
15
|
+
- The local HQ MCP server (`hq mcp serve`) now uses your existing HQ sign-in when you have not run `hq mcp login`, so HQ tools work in Claude and Codex outside the HQ folder without a second browser login. This needs the matching hq-pro change that lets the hosted connector accept the HQ sign-in.
|
|
16
|
+
- The HQ background service no longer switches HQ Anywhere off when one re-read of your HQ Anywhere setting times out. It keeps the last value it read for up to 30 minutes; turning the setting off still applies on the next successful read.
|
|
17
|
+
- Sync restart checks now count settled restarts across the full one-hour window and leave out only the unsettled tail.
|
|
18
|
+
- `hq lanes stop` and `hq lanes reparent` now resolve lanes on registered Outposts and dispatch mutations to the owning host, including lanes without a primary mirror.
|
|
19
|
+
|
|
5
20
|
## [5.345.45] — 2026-10-07
|
|
6
21
|
- A failed automatic CLI update now pauses automatic updates for 24 hours, including for newer releases, instead of retrying on every command. `hq rescue` still updates on request. Processes started by an update can no longer start another install, and update package-manager processes run with Corepack settings that cannot change your default pnpm version.
|
|
7
22
|
|
package/dist/commands/files.js
CHANGED
|
@@ -835,7 +835,10 @@ async function runDirectGrant(params) {
|
|
|
835
835
|
console.error(chalk.red("ACL record not found — the prefix may not have an ACL yet"));
|
|
836
836
|
}
|
|
837
837
|
else if (res.status === 409 && body.code === "ACL_PATTERN_CONFLICT") {
|
|
838
|
-
|
|
838
|
+
// hq-pro sends the specific reason (the sibling still has grants, or only
|
|
839
|
+
// its creator or an admin may replace it) in `error`; older servers used
|
|
840
|
+
// `message`. Show whichever is present instead of dropping it.
|
|
841
|
+
console.error(chalk.red(formatAclPatternConflict(canonicalPrefix, body.message ?? body.error)));
|
|
839
842
|
}
|
|
840
843
|
else if (res.status === 409) {
|
|
841
844
|
console.error(chalk.red("Concurrent modification — please retry"));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { AnywhereRuntimeDisabledError } from './anywhere-runtime-flag.js';
|
|
3
|
+
import { type RuntimeHookTrustResult } from '../utils/hook-trust.js';
|
|
3
4
|
import { type HookTarget } from '../lib/install/claude-targets.js';
|
|
4
5
|
export interface GlobalInstallOptions {
|
|
5
6
|
/** HQ checkout to install from (absolute). */
|
|
@@ -21,6 +22,8 @@ export interface GlobalInstallOptions {
|
|
|
21
22
|
via?: 'direct' | 'plugin';
|
|
22
23
|
/** Local Claude Code marketplace directory (or HQ_CLAUDE_PLUGIN_MARKETPLACE). */
|
|
23
24
|
marketplaceDir?: string;
|
|
25
|
+
/** Install the provider PATH wrappers after the hq-anywhere flag check. */
|
|
26
|
+
installProviderWrappers?: boolean;
|
|
24
27
|
/** GitHub repository for the published HQ plugin marketplace. */
|
|
25
28
|
marketplaceRepository?: string;
|
|
26
29
|
/** Test seam for the Claude Code CLI; production uses `claude`. */
|
|
@@ -78,6 +81,8 @@ export interface GlobalCommandOpts {
|
|
|
78
81
|
/** Root-discovery seams used by command tests and wrappers. */
|
|
79
82
|
home?: string;
|
|
80
83
|
cwd?: string;
|
|
84
|
+
/** Test seam for trusting the Codex hooks a global install registers. */
|
|
85
|
+
trustCodexHooks?: (cwd: string, keys: readonly string[]) => Promise<RuntimeHookTrustResult>;
|
|
81
86
|
}
|
|
82
87
|
export declare function resolveInstallRoot(explicit: string | undefined, options?: {
|
|
83
88
|
env?: NodeJS.ProcessEnv;
|
|
@@ -23,10 +23,12 @@ import { grokConnectorInstructions } from '../lib/install/grok-targets.js';
|
|
|
23
23
|
import { readRegistry, registryDir, registryFile, writeRegistry } from '../lib/registry/registry.js';
|
|
24
24
|
import { readCompaniesManifestMap } from '../lib/work-context/repo-remote.js';
|
|
25
25
|
import { resolveDefaultHqRoot } from '../utils/hq-root-resolver.js';
|
|
26
|
+
import { trustCodexUserHooks } from '../utils/hook-trust.js';
|
|
26
27
|
import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK, SYSTEM_UNIT_DIR } from '../lib/daemon/install.js';
|
|
27
28
|
import { readDaemonConfig } from '../lib/daemon/config.js';
|
|
28
29
|
import { daemonPaths } from '../lib/daemon/paths.js';
|
|
29
|
-
import {
|
|
30
|
+
import { assertProviderWrappersInstallable, installProviderWrappers, providerWrapperInstallPaths, removeProviderWrappers } from '../lib/install/provider-wrappers.js';
|
|
31
|
+
import { addAgentsMdBlock, addCodexHooks, classifyLegacySkills, codexAdapterPath, codexCharterSource, codexHookTrustKeys, codexTargets, hasAllCodexHooks, HQ_OWNED_MARKER, LEGACY_BACKUP_NAME, LEGACY_SKILLS_NAME, removeAgentsMdBlock, removeCodexHooks, } from '../lib/install/codex-targets.js';
|
|
30
32
|
import { addClaudeMdBlock, addHqHooks, charterPath, claudeTargets, hasAllHqHooks, HQ_ANYWHERE_PACK, HQ_MCP_SERVER_NAME, hqSkillsSource, lineDiff, masterHookPath, shimPath, MCP_SUBPROCESS_PATH, removeClaudeMdBlock, removeHqHooks, SKILL_LINK_PREFIX, } from '../lib/install/claude-targets.js';
|
|
31
33
|
/** True when an hq daemon unit (launchd plist or systemd unit) exists. */
|
|
32
34
|
export function isDaemonInstalled(home, platform = process.platform, queryTask = (task) => spawnSync('schtasks.exe', ['/Query', '/TN', task], { encoding: 'utf8', windowsHide: true }).status === 0) {
|
|
@@ -347,7 +349,14 @@ export function installGlobalClaude(opts) {
|
|
|
347
349
|
const env = opts.env ?? process.env;
|
|
348
350
|
const log = opts.log ?? ((l) => console.log(l));
|
|
349
351
|
const hqRoot = opts.hqRoot;
|
|
352
|
+
const providerBin = path.join(home, '.hq', 'bin');
|
|
353
|
+
// The wrappers are /bin/sh scripts reached through POSIX startup files, so a
|
|
354
|
+
// Windows shell would never pick them up. Skip them until a native shim exists.
|
|
355
|
+
const providerWrappersEnabled = process.platform !== 'win32'
|
|
356
|
+
&& (opts.installProviderWrappers ?? isAnywhereEnabled(home, env));
|
|
350
357
|
assertHqRoot(hqRoot);
|
|
358
|
+
if (providerWrappersEnabled)
|
|
359
|
+
assertProviderWrappersInstallable(providerBin);
|
|
351
360
|
const t = claudeTargets(home);
|
|
352
361
|
const prior = readState(t);
|
|
353
362
|
const pluginSource = opts.via === 'plugin' ? resolveClaudePluginSource(opts.marketplaceDir, env, opts.marketplaceRepository) : undefined;
|
|
@@ -428,6 +437,8 @@ export function installGlobalClaude(opts) {
|
|
|
428
437
|
if (pluginSource)
|
|
429
438
|
touched.push(t.settingsJson);
|
|
430
439
|
touched.push(regFile, t.stateFile);
|
|
440
|
+
if (providerWrappersEnabled)
|
|
441
|
+
touched.push(...providerWrapperInstallPaths(home));
|
|
431
442
|
if (opts.dryRun) {
|
|
432
443
|
log(`hq install --global --runtime claude --dry-run (HQ root ${hqRoot})`);
|
|
433
444
|
log('Would write:');
|
|
@@ -559,6 +570,8 @@ export function installGlobalClaude(opts) {
|
|
|
559
570
|
seeded: [...new Set([...(prior?.seeded ?? []), ...ownedSeeded])].sort(),
|
|
560
571
|
};
|
|
561
572
|
writeFileAtomic(t.stateFile, `${JSON.stringify(state, null, 2)}\n`);
|
|
573
|
+
if (providerWrappersEnabled)
|
|
574
|
+
installProviderWrappers(providerBin, home);
|
|
562
575
|
log(`HQ installed for Claude Code from ${hqRoot}.`);
|
|
563
576
|
log(` skills linked: ${skills.link.length} new, ${skills.already.length} already present, ${skills.skipped.length} skipped`);
|
|
564
577
|
reportSkippedSkills(skills, log, false);
|
|
@@ -633,6 +646,9 @@ export function uninstallGlobalClaude(opts = {}) {
|
|
|
633
646
|
}
|
|
634
647
|
const pointer = readOrNull(t.hqPointer);
|
|
635
648
|
const codexStillInstalled = fs.existsSync(codexTargets(home).stateFile);
|
|
649
|
+
const providerBin = path.join(home, '.hq', 'bin');
|
|
650
|
+
if (!codexStillInstalled)
|
|
651
|
+
touched.push(path.join(providerBin, 'claude'), path.join(providerBin, 'codex'));
|
|
636
652
|
const pointerOwned = !codexStillInstalled && pointer !== null && pointer.trim() === hqRoot;
|
|
637
653
|
if (pointerOwned)
|
|
638
654
|
touched.push(t.hqPointer);
|
|
@@ -655,6 +671,8 @@ export function uninstallGlobalClaude(opts = {}) {
|
|
|
655
671
|
if (pluginSource && state?.plugin?.marketplaceAdded) {
|
|
656
672
|
runClaude(['marketplace', 'remove', '--scope', 'user', pluginSource.marketplaceName], home, env);
|
|
657
673
|
}
|
|
674
|
+
if (!codexStillInstalled)
|
|
675
|
+
removeProviderWrappers(providerBin);
|
|
658
676
|
unregisterClaudeServer({ name: HQ_MCP_SERVER_NAME, pack: HQ_ANYWHERE_PACK, env: { home } });
|
|
659
677
|
if (fs.existsSync(t.settingsJson)) {
|
|
660
678
|
writeConfigAtomic({
|
|
@@ -738,7 +756,14 @@ export function installGlobalCodex(opts) {
|
|
|
738
756
|
const env = opts.env ?? process.env;
|
|
739
757
|
const log = opts.log ?? ((l) => console.log(l));
|
|
740
758
|
const hqRoot = opts.hqRoot;
|
|
759
|
+
const providerBin = path.join(home, '.hq', 'bin');
|
|
760
|
+
// The wrappers are /bin/sh scripts reached through POSIX startup files, so a
|
|
761
|
+
// Windows shell would never pick them up. Skip them until a native shim exists.
|
|
762
|
+
const providerWrappersEnabled = process.platform !== 'win32'
|
|
763
|
+
&& (opts.installProviderWrappers ?? isAnywhereEnabled(home, env));
|
|
741
764
|
assertCodexHqRoot(hqRoot);
|
|
765
|
+
if (providerWrappersEnabled)
|
|
766
|
+
assertProviderWrappersInstallable(providerBin);
|
|
742
767
|
if (!isCodexInstalled({ home })) {
|
|
743
768
|
throw Object.assign(new Error(`Codex is not installed (${path.join(home, '.codex')} is missing).`), {
|
|
744
769
|
expected: true,
|
|
@@ -792,6 +817,8 @@ export function installGlobalCodex(opts) {
|
|
|
792
817
|
if (tomlNext !== tomlDoc || legacyPackServerPresent)
|
|
793
818
|
touched.push(t.configToml);
|
|
794
819
|
touched.push(regFile, t.stateFile);
|
|
820
|
+
if (providerWrappersEnabled)
|
|
821
|
+
touched.push(...providerWrapperInstallPaths(home));
|
|
795
822
|
if (opts.dryRun) {
|
|
796
823
|
log(`hq install --global --runtime codex --dry-run (HQ root ${hqRoot})`);
|
|
797
824
|
log('Would write:');
|
|
@@ -874,6 +901,8 @@ export function installGlobalCodex(opts) {
|
|
|
874
901
|
seeded: [...new Set([...(prior?.seeded ?? []), ...ownedSeeded])].sort(),
|
|
875
902
|
};
|
|
876
903
|
writeFileAtomic(t.stateFile, `${JSON.stringify(state, null, 2)}\n`);
|
|
904
|
+
if (providerWrappersEnabled)
|
|
905
|
+
installProviderWrappers(providerBin, home);
|
|
877
906
|
log(`HQ installed for Codex from ${hqRoot}.`);
|
|
878
907
|
log(` skills linked: ${skills.link.length} new, ${skills.already.length} already present, ${skills.skipped.length} skipped`);
|
|
879
908
|
reportSkippedSkills(skills, log, false);
|
|
@@ -894,8 +923,11 @@ export function uninstallGlobalCodex(opts = {}) {
|
|
|
894
923
|
throw new Error('No HQ global install found (no install state and no ~/.hq/root).');
|
|
895
924
|
const regFile = opts.registryFile ?? registryFile(registryDir(home, env));
|
|
896
925
|
const claudeStillInstalled = fs.existsSync(claudeTargets(home).stateFile);
|
|
926
|
+
const providerBin = path.join(home, '.hq', 'bin');
|
|
897
927
|
const packStillInstalled = fs.existsSync(path.join(hqRoot, 'core', 'packages', 'hq-anywhere', 'package.yaml'));
|
|
898
928
|
const touched = [];
|
|
929
|
+
if (!claudeStillInstalled)
|
|
930
|
+
touched.push(path.join(providerBin, 'claude'), path.join(providerBin, 'codex'));
|
|
899
931
|
const tomlDoc = readConfigDoc(t.configToml, codexConfigFormat).doc;
|
|
900
932
|
const servers = tomlDoc.value.mcp_servers;
|
|
901
933
|
if (!packStillInstalled && servers?.[HQ_MCP_SERVER_NAME]?._hqPack === HQ_ANYWHERE_PACK)
|
|
@@ -953,6 +985,8 @@ export function uninstallGlobalCodex(opts = {}) {
|
|
|
953
985
|
}
|
|
954
986
|
if (!packStillInstalled)
|
|
955
987
|
unregisterCodexServer({ name: HQ_MCP_SERVER_NAME, pack: HQ_ANYWHERE_PACK, env: { home } });
|
|
988
|
+
if (!claudeStillInstalled)
|
|
989
|
+
removeProviderWrappers(providerBin);
|
|
956
990
|
// Removal re-serializes the TOML (dropping comments). When what is left equals
|
|
957
991
|
// the pre-install document, put the original bytes back.
|
|
958
992
|
const tomlOriginal = state?.configToml.original ?? null;
|
|
@@ -1058,11 +1092,11 @@ export function resolveInstallRoot(explicit, options = {}) {
|
|
|
1058
1092
|
export async function runInstallGlobal(opts) {
|
|
1059
1093
|
const runtime = assertRuntime(opts.runtime);
|
|
1060
1094
|
const env = opts.env ?? process.env;
|
|
1095
|
+
const enabled = await anywhereRuntimeAllowed(env, {
|
|
1096
|
+
...(opts.personSettingReader ? { readSetting: opts.personSettingReader } : {}),
|
|
1097
|
+
onError: (name) => (opts.log ?? console.log)(`HQ Anywhere person setting unavailable (${name}); access remains off.`),
|
|
1098
|
+
});
|
|
1061
1099
|
if (runtime === 'chatgpt' || runtime === 'grok' || opts.dryRun) {
|
|
1062
|
-
const enabled = await anywhereRuntimeAllowed(env, {
|
|
1063
|
-
...(opts.personSettingReader ? { readSetting: opts.personSettingReader } : {}),
|
|
1064
|
-
onError: (name) => (opts.log ?? console.log)(`HQ Anywhere person setting unavailable (${name}); access remains off.`),
|
|
1065
|
-
});
|
|
1066
1100
|
if (!enabled)
|
|
1067
1101
|
throw new AnywhereRuntimeDisabledError();
|
|
1068
1102
|
if (runtime === 'chatgpt' || runtime === 'grok') {
|
|
@@ -1074,16 +1108,38 @@ export async function runInstallGlobal(opts) {
|
|
|
1074
1108
|
}
|
|
1075
1109
|
}
|
|
1076
1110
|
const install = runtime === 'codex' ? installGlobalCodex : installGlobalClaude;
|
|
1111
|
+
const hqRoot = resolveInstallRoot(opts.hqRoot, { env, cwd: opts.cwd, home: opts.home });
|
|
1077
1112
|
install({
|
|
1078
|
-
hqRoot
|
|
1113
|
+
hqRoot,
|
|
1079
1114
|
home: opts.home,
|
|
1080
1115
|
dryRun: opts.dryRun,
|
|
1081
1116
|
via: opts.via,
|
|
1082
1117
|
marketplaceDir: opts.marketplaceDir,
|
|
1083
1118
|
marketplaceRepository: opts.marketplaceRepository,
|
|
1119
|
+
installProviderWrappers: enabled,
|
|
1084
1120
|
env,
|
|
1085
1121
|
log: opts.log,
|
|
1086
1122
|
});
|
|
1123
|
+
if (runtime === 'codex' && !opts.dryRun)
|
|
1124
|
+
await trustInstalledCodexHooks(hqRoot, opts);
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Codex skips untrusted hooks, so the hooks.json entries written above do
|
|
1128
|
+
* nothing until they are trusted. Trust exactly those entries; on failure keep
|
|
1129
|
+
* the install and tell the user how to finish.
|
|
1130
|
+
*/
|
|
1131
|
+
async function trustInstalledCodexHooks(hqRoot, opts) {
|
|
1132
|
+
const log = opts.log ?? ((l) => console.log(l));
|
|
1133
|
+
const home = resolveEnv({ home: opts.home }).home;
|
|
1134
|
+
const hooksJson = codexTargets(home).hooksJson;
|
|
1135
|
+
const keys = codexHookTrustKeys(JSON.parse(fs.readFileSync(hooksJson, 'utf-8')), hooksJson, hqRoot);
|
|
1136
|
+
const result = await (opts.trustCodexHooks ?? trustCodexUserHooks)(home, keys);
|
|
1137
|
+
if (result.status === 'trusted')
|
|
1138
|
+
log(` hooks: trusted ${result.trusted} HQ hook(s) in Codex`);
|
|
1139
|
+
else if (result.status === 'failed') {
|
|
1140
|
+
log(` hooks: Codex hooks are not trusted yet (${result.reason ?? 'unknown error'}), so Codex will skip them.`);
|
|
1141
|
+
log(' To finish, open Codex and approve the HQ hooks with /hooks, or re-run `hq install --global --runtime codex`.');
|
|
1142
|
+
}
|
|
1087
1143
|
}
|
|
1088
1144
|
export function registerUninstallCommand(program) {
|
|
1089
1145
|
program
|
package/dist/commands/lanes.js
CHANGED
|
@@ -1856,11 +1856,13 @@ async function resolveCommandLaneTarget(ctx, laneId, options = {}) {
|
|
|
1856
1856
|
const companyMatches = !hostlessUlidLookup || ownerLookupGate === true || (ownerLookupGate === "check-company" &&
|
|
1857
1857
|
laneCompany !== undefined && laneCompany !== null &&
|
|
1858
1858
|
await options.allowHostlessOwnerLookup(laneCompany));
|
|
1859
|
-
|
|
1859
|
+
const exactOutpostOwnedMatch = !hostlessUlidLookup &&
|
|
1860
|
+
lane.lane_id === laneId && lane.host.kind === "outpost" && lane.host.id === outpostId;
|
|
1861
|
+
if (exactOutpostOwnedMatch || (lane.host.kind === "local" && ((!hostlessUlidLookup && lane.lane_id === laneId &&
|
|
1860
1862
|
sanitizeHostComponent(lane.host.id) === hostSuffix) ||
|
|
1861
1863
|
(hostlessUlidLookup && laneUlidPart(lane.lane_id) === requestedUlid &&
|
|
1862
1864
|
sanitizeHostComponent(lane.host.id) === recordedSuffix &&
|
|
1863
|
-
companyMatches))) {
|
|
1865
|
+
companyMatches)))) {
|
|
1864
1866
|
matches.push({ outpostId, lane });
|
|
1865
1867
|
}
|
|
1866
1868
|
}
|
|
@@ -1914,7 +1916,7 @@ function commandLaneNotFound(laneId, outpostHint, details = {}) {
|
|
|
1914
1916
|
const hint = outpostHint
|
|
1915
1917
|
? "; pass --outpost <id> to confirm a registered Outpost"
|
|
1916
1918
|
: "";
|
|
1917
|
-
return new LanesError("lane_not_found", `
|
|
1919
|
+
return new LanesError("lane_not_found", `Could not resolve lane ${laneId}: no matching local record or reachable registered Outpost owner record was found${hint}`, { lane_id: laneId, ...details });
|
|
1918
1920
|
}
|
|
1919
1921
|
async function loadClaudeBalanceHostSnapshots(ctx, hqRoot, config, options = {}) {
|
|
1920
1922
|
const localHost = laneHostFromCtx(ctx);
|
|
@@ -4931,7 +4933,9 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
|
|
|
4931
4933
|
if (action === "reparent") {
|
|
4932
4934
|
for (const descendantId of changedDescendantIds) {
|
|
4933
4935
|
const ownerDescendant = await readOutpostLaneSnapshot(ctx, outpostId, descendantId);
|
|
4934
|
-
descendants.push(
|
|
4936
|
+
descendants.push(behavior.syncPrimary === false
|
|
4937
|
+
? dispatchedOwnerLane(ctx, descendantId, outpostId, ownerDescendant, behavior)
|
|
4938
|
+
: syncLaneRecordFromOwner(ctx.hqRoot(), descendantId, outpostId, ownerDescendant, undefined, { allowNonTerminal: true }));
|
|
4935
4939
|
}
|
|
4936
4940
|
}
|
|
4937
4941
|
const questionCancellation = action === "resume" && lane.state === "awaiting_input" && behavior.afterFallbackResume
|
|
@@ -7515,11 +7519,21 @@ function archiveResumeMessage(hqRoot, laneId, message) {
|
|
|
7515
7519
|
fs.writeFileSync(path.join(dir, `${message.message_id}.resume.jsonl`), `${JSON.stringify(message)}\n`, "utf8");
|
|
7516
7520
|
}
|
|
7517
7521
|
async function runReparent(ctx, laneId, opts) {
|
|
7518
|
-
const hqRoot = ctx.hqRoot();
|
|
7519
7522
|
if (!opts.senior) {
|
|
7520
7523
|
throw new LanesError("missing_senior", "--senior is required", {});
|
|
7521
7524
|
}
|
|
7522
|
-
const
|
|
7525
|
+
const target = await resolveCommandLaneTarget(ctx, laneId);
|
|
7526
|
+
const initial = target.lane;
|
|
7527
|
+
if (target.remoteOnly) {
|
|
7528
|
+
await dispatchOutpostLaneCommand(ctx, initial, "reparent", {
|
|
7529
|
+
senior: opts.senior,
|
|
7530
|
+
reason: opts.reason,
|
|
7531
|
+
}, {
|
|
7532
|
+
syncPrimary: false,
|
|
7533
|
+
expectedOwnerHost: target.ownerHost,
|
|
7534
|
+
});
|
|
7535
|
+
return;
|
|
7536
|
+
}
|
|
7523
7537
|
if (initial.host.kind === "outpost" && !laneRunsHere(initial, reconcileHostFromCtx(ctx))) {
|
|
7524
7538
|
await dispatchOutpostLaneCommand(ctx, initial, "reparent", {
|
|
7525
7539
|
senior: opts.senior,
|
|
@@ -7527,7 +7541,7 @@ async function runReparent(ctx, laneId, opts) {
|
|
|
7527
7541
|
});
|
|
7528
7542
|
return;
|
|
7529
7543
|
}
|
|
7530
|
-
const result = await reparentOnOwner(ctx,
|
|
7544
|
+
const result = await reparentOnOwner(ctx, initial.lane_id, opts);
|
|
7531
7545
|
printJson({
|
|
7532
7546
|
ok: true,
|
|
7533
7547
|
action: "reparent",
|
|
@@ -8138,7 +8152,7 @@ async function dispatchPrimaryLaneMessage(ctx, laneId, options) {
|
|
|
8138
8152
|
printJson(response);
|
|
8139
8153
|
return response;
|
|
8140
8154
|
}
|
|
8141
|
-
async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
|
|
8155
|
+
async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false, behavior = {}) {
|
|
8142
8156
|
if (lane.host.kind !== "outpost") {
|
|
8143
8157
|
throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to dispatch stop to`, { lane_id: lane.lane_id });
|
|
8144
8158
|
}
|
|
@@ -8170,6 +8184,14 @@ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
|
|
|
8170
8184
|
const commandId = dispatched.commandId?.trim();
|
|
8171
8185
|
const ownerError = ownerErrorFromOutput(dispatched.stdout);
|
|
8172
8186
|
if (isDefiniteOutpostLaneNotFound(dispatched, lane.lane_id, ownerError)) {
|
|
8187
|
+
if (behavior.syncPrimary === false) {
|
|
8188
|
+
throw new LanesError("lane_not_found", ownerError.message, {
|
|
8189
|
+
...ownerError.details,
|
|
8190
|
+
lane_id: lane.lane_id,
|
|
8191
|
+
outpost_id: outpostId,
|
|
8192
|
+
command_id: commandId,
|
|
8193
|
+
});
|
|
8194
|
+
}
|
|
8173
8195
|
const at = ctx.now().toISOString();
|
|
8174
8196
|
const updated = markRemoteLaneLost(ctx.hqRoot(), lane.lane_id, outpostId, at, "command", reason);
|
|
8175
8197
|
if (updated.terminal_reason !== "remote_lost") {
|
|
@@ -8284,7 +8306,9 @@ async function dispatchOutpostLaneStop(ctx, lane, reason, quiet = false) {
|
|
|
8284
8306
|
state = ownerLane.state;
|
|
8285
8307
|
cancelledQuestions = parseRemoteCancelledQuestionIds(remote.cancelled_questions, lane.lane_id, outpostId, commandId);
|
|
8286
8308
|
}
|
|
8287
|
-
const updated =
|
|
8309
|
+
const updated = behavior.syncPrimary === false
|
|
8310
|
+
? ownerLane
|
|
8311
|
+
: syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, ownerLane);
|
|
8288
8312
|
if (!quiet)
|
|
8289
8313
|
printJson({
|
|
8290
8314
|
ok: true,
|
|
@@ -8312,9 +8336,14 @@ async function runStop(ctx, laneId, reason, quiet = false) {
|
|
|
8312
8336
|
if (operatorReason !== undefined)
|
|
8313
8337
|
assertNoSecretText("stop reason", operatorReason);
|
|
8314
8338
|
const hqRoot = ctx.hqRoot();
|
|
8315
|
-
const
|
|
8339
|
+
const target = await resolveCommandLaneTarget(ctx, laneId);
|
|
8340
|
+
const lane = target.lane;
|
|
8316
8341
|
if (!quiet && lane.state !== "queued" && !isTerminalLaneState(lane.state)) {
|
|
8317
|
-
process.stderr.write(stopTerminalWarning(
|
|
8342
|
+
process.stderr.write(stopTerminalWarning(lane.lane_id));
|
|
8343
|
+
}
|
|
8344
|
+
if (target.remoteOnly) {
|
|
8345
|
+
await dispatchOutpostLaneStop(ctx, lane, operatorReason, quiet, { syncPrimary: false });
|
|
8346
|
+
return;
|
|
8318
8347
|
}
|
|
8319
8348
|
if (lane.host.kind === "outpost" && !laneRunsHereForOperatorMutation(ctx, lane)) {
|
|
8320
8349
|
await dispatchOutpostLaneStop(ctx, lane, operatorReason, quiet);
|
|
@@ -10,6 +10,17 @@ export interface ReadHqAnywherePersonSettingOptions {
|
|
|
10
10
|
}
|
|
11
11
|
/** Returns null when the person preference cannot be read; callers must fail closed. */
|
|
12
12
|
export declare function readHqAnywherePersonSetting(options?: ReadHqAnywherePersonSettingOptions): Promise<boolean | null>;
|
|
13
|
+
/**
|
|
14
|
+
* Wrap a setting reader so a failed read (null) returns the last successfully
|
|
15
|
+
* read value for `graceMs`. A long-running caller such as hqd re-reads every
|
|
16
|
+
* minute, and one slow request would otherwise switch HQ Anywhere off for
|
|
17
|
+
* every session. A newly read value, including off, always applies at once;
|
|
18
|
+
* with no successful read inside the window the result stays null (closed).
|
|
19
|
+
*/
|
|
20
|
+
export declare function stickyPersonSettingReader(read: () => Promise<boolean | null>, options: {
|
|
21
|
+
graceMs: number;
|
|
22
|
+
now?: () => number;
|
|
23
|
+
}): () => Promise<boolean | null>;
|
|
13
24
|
export declare function evaluateAnywhereRuntime(flagEnabled: boolean, setting: boolean | null): {
|
|
14
25
|
enabled: boolean;
|
|
15
26
|
setting: boolean | null;
|
|
@@ -28,6 +28,25 @@ export async function readHqAnywherePersonSetting(options = {}) {
|
|
|
28
28
|
return null;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Wrap a setting reader so a failed read (null) returns the last successfully
|
|
33
|
+
* read value for `graceMs`. A long-running caller such as hqd re-reads every
|
|
34
|
+
* minute, and one slow request would otherwise switch HQ Anywhere off for
|
|
35
|
+
* every session. A newly read value, including off, always applies at once;
|
|
36
|
+
* with no successful read inside the window the result stays null (closed).
|
|
37
|
+
*/
|
|
38
|
+
export function stickyPersonSettingReader(read, options) {
|
|
39
|
+
const now = options.now ?? Date.now;
|
|
40
|
+
let last = null;
|
|
41
|
+
return async () => {
|
|
42
|
+
const value = await read();
|
|
43
|
+
if (value !== null) {
|
|
44
|
+
last = { value, at: now() };
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
return last && now() - last.at <= options.graceMs ? last.value : null;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
31
50
|
export function evaluateAnywhereRuntime(flagEnabled, setting) {
|
|
32
51
|
return { enabled: flagEnabled && setting === true, setting };
|
|
33
52
|
}
|
|
@@ -13,7 +13,16 @@ export interface McpToolResult {
|
|
|
13
13
|
}
|
|
14
14
|
export declare function jsonResult(value: unknown): McpToolResult;
|
|
15
15
|
export declare function resolveHqMcpEndpoint(env?: NodeJS.ProcessEnv): URL;
|
|
16
|
-
|
|
16
|
+
/** Test seams for {@link defaultMcpToken}. */
|
|
17
|
+
export interface McpTokenDeps {
|
|
18
|
+
tokenSource?: () => string;
|
|
19
|
+
mcpAudienceToken?: () => Promise<string>;
|
|
20
|
+
cognitoToken?: (options: {
|
|
21
|
+
tokenSource: "person" | "machine";
|
|
22
|
+
interactive: false;
|
|
23
|
+
}) => Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
export declare function defaultMcpToken(deps?: McpTokenDeps): Promise<string>;
|
|
17
26
|
export interface HqMcpBridgeOptions {
|
|
18
27
|
endpoint?: URL;
|
|
19
28
|
env?: NodeJS.ProcessEnv;
|
|
@@ -27,24 +27,31 @@ export function resolveHqMcpEndpoint(env = process.env) {
|
|
|
27
27
|
throw error;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
export async function defaultMcpToken() {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
export async function defaultMcpToken(deps = {}) {
|
|
31
|
+
const session = deps.tokenSource && deps.cognitoToken ? null : await import("../../../utils/cognito-session.js");
|
|
32
|
+
const tokenSource = deps.tokenSource ?? session.resolveCognitoTokenSource;
|
|
33
|
+
const cognitoToken = deps.cognitoToken ?? ((options) => session.ensureCognitoToken(options));
|
|
34
|
+
if (tokenSource() !== "machine") {
|
|
35
|
+
const mcpAudienceToken = deps.mcpAudienceToken ?? (async () => (await import("../mcp-oauth.js")).getMcpAudienceToken());
|
|
34
36
|
try {
|
|
35
|
-
return await
|
|
37
|
+
return await mcpAudienceToken();
|
|
36
38
|
}
|
|
37
|
-
catch
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
catch {
|
|
40
|
+
// No `hq mcp login`: hosted hq-mcp also accepts the HQ sign-in client,
|
|
41
|
+
// so reuse the session hq-cli and the desktop app already hold.
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return await cognitoToken({ tokenSource: "person", interactive: false });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new McpOpError("hq_mcp_auth_required", "Sign in to HQ with `hq login`, then retry.");
|
|
41
48
|
}
|
|
42
49
|
}
|
|
43
50
|
try {
|
|
44
51
|
// Send this box's existing machine token. The hosted hq-mcp audience check
|
|
45
52
|
// accepts it only when it was issued for hqMcpMachineClient; otherwise the
|
|
46
53
|
// server rejects it. This helper does not inspect the credential's client ID.
|
|
47
|
-
return await
|
|
54
|
+
return await cognitoToken({ tokenSource: "machine", interactive: false });
|
|
48
55
|
}
|
|
49
56
|
catch {
|
|
50
57
|
throw new McpOpError("hq_mcp_auth_required", "An hq-mcp machine token is unavailable. Configure this box with hq-pro's hqMcpMachineClient ID, then retry the daemon call.");
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
15
15
|
import { CLI_VERSION } from "../../cli-version.js";
|
|
16
16
|
import { anywhereRuntimeEnabled } from "../../commands/anywhere-runtime-flag.js";
|
|
17
|
-
import { anywhereRuntimeAllowed } from "../anywhere-person-setting.js";
|
|
17
|
+
import { anywhereRuntimeAllowed, readHqAnywherePersonSetting, stickyPersonSettingReader } from "../anywhere-person-setting.js";
|
|
18
18
|
import { currentPackageUseLeaseInstall, currentPackageUseUpdateRequested } from "../../utils/package-use-lease.js";
|
|
19
19
|
import { isLocalDependencyInstall, resolveRunningInstall } from "../../utils/version-gate.js";
|
|
20
20
|
import { agentKitPaths } from "../agent-kit/paths.js";
|
|
@@ -77,6 +77,8 @@ export function handoffForPackageUpdateRequest(requested, restartDaemon) {
|
|
|
77
77
|
const LEGACY_PROBE_TTL_MS = 60_000;
|
|
78
78
|
/** A flag read that has not answered by then counts as off. */
|
|
79
79
|
const HANDOFF_FLAG_TIMEOUT_MS = 10_000;
|
|
80
|
+
/** How long hqd keeps the last read HQ Anywhere setting when a re-read fails. */
|
|
81
|
+
const HQD_PERSON_SETTING_GRACE_MS = 30 * 60_000;
|
|
80
82
|
export function syncRunnerSourceForPlan(stagedRunnerPath, hqCloud, legacyBundledRunnerPath = null, staged = { version: null }, currentSyncRunnerSource = null) {
|
|
81
83
|
// A managed copy lives in the daemon directory, so it survives a CLI
|
|
82
84
|
// replacement. Before lease release pickHqCloud returns it only when it is
|
|
@@ -838,13 +840,17 @@ export async function runDaemon(opts) {
|
|
|
838
840
|
// hqd: the session/company socket every runtime talks to (hq-anywhere US-008).
|
|
839
841
|
let hqdShutdown = null;
|
|
840
842
|
let stopHeartbeat = () => { };
|
|
843
|
+
const readPersonSetting = stickyPersonSettingReader(() => readHqAnywherePersonSetting({
|
|
844
|
+
env,
|
|
845
|
+
onError: (name) => log("warn", `hqd person setting read failed (${name}); keeping the last read value for up to ${HQD_PERSON_SETTING_GRACE_MS / 60_000} minutes`),
|
|
846
|
+
}), { graceMs: HQD_PERSON_SETTING_GRACE_MS });
|
|
841
847
|
const hqdListener = await startHqdRuntimeListener({
|
|
842
848
|
isEnabled: async () => shouldStartHqd(await anywhereRuntimeAllowed(env, {
|
|
843
849
|
refreshFlag: async () => {
|
|
844
850
|
const { refreshFlagRegistrySnapshot } = await import("../flag-registry.js");
|
|
845
851
|
await refreshFlagRegistrySnapshot(5_000);
|
|
846
852
|
},
|
|
847
|
-
|
|
853
|
+
readSetting: readPersonSetting,
|
|
848
854
|
})),
|
|
849
855
|
startServer: async () => {
|
|
850
856
|
const startConfig = readDaemonConfig(paths.configPath);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { CheckResult } from "../types.js";
|
|
2
2
|
export declare const SYNC_RUNTIME_WINDOW_MS: number;
|
|
3
|
+
/** Ignore timing and restart signals until a newly started receiver has settled. */
|
|
4
|
+
export declare const SYNC_RUNTIME_RESTART_WARMUP_MS: number;
|
|
3
5
|
export interface SyncRuntimeOptions {
|
|
4
6
|
now: number;
|
|
5
7
|
conflictCount?: number;
|
|
@@ -11,6 +11,8 @@ import * as os from "node:os";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { currentSyncLogFamily, SYNC_LOG_GENERATIONS, syncLogBasePaths, syncLogGenerationPath, } from "../../../utils/sync-log-sources.js";
|
|
13
13
|
export const SYNC_RUNTIME_WINDOW_MS = 60 * 60_000;
|
|
14
|
+
/** Ignore timing and restart signals until a newly started receiver has settled. */
|
|
15
|
+
export const SYNC_RUNTIME_RESTART_WARMUP_MS = 20 * 60_000;
|
|
14
16
|
const LAG_SECONDS = 15 * 60;
|
|
15
17
|
const LAG_MIN_RECEIPTS = 3;
|
|
16
18
|
const RESTART_MIN_STARTS = 4;
|
|
@@ -62,7 +64,9 @@ function lineTimestamp(line) {
|
|
|
62
64
|
/** Classify the last hour of sync log lines into privacy-safe results. */
|
|
63
65
|
export function analyzeSyncRuntimeLog(lines, options) {
|
|
64
66
|
const since = options.now - SYNC_RUNTIME_WINDOW_MS;
|
|
65
|
-
|
|
67
|
+
const contextSince = since - SYNC_RUNTIME_RESTART_WARMUP_MS;
|
|
68
|
+
const slowReceiptTimes = [];
|
|
69
|
+
const receiverStartTimes = [];
|
|
66
70
|
let denied = 0;
|
|
67
71
|
let starts = 0;
|
|
68
72
|
let deviceLimited = 0;
|
|
@@ -70,35 +74,45 @@ export function analyzeSyncRuntimeLog(lines, options) {
|
|
|
70
74
|
const time = lineTimestamp(line);
|
|
71
75
|
if (time === null)
|
|
72
76
|
continue;
|
|
73
|
-
if (!Number.isFinite(time) || time <
|
|
77
|
+
if (!Number.isFinite(time) || time < contextSince || time > options.now)
|
|
74
78
|
continue;
|
|
75
79
|
const stamp = TIMESTAMP.exec(line);
|
|
76
80
|
const rest = stamp ? line.slice(stamp[0].length) : line;
|
|
77
|
-
if (DEVICE_LIMIT.test(rest))
|
|
81
|
+
if (time >= since && DEVICE_LIMIT.test(rest))
|
|
78
82
|
deviceLimited += 1;
|
|
79
83
|
const record = parseRecord(rest);
|
|
80
84
|
if (!record)
|
|
81
85
|
continue;
|
|
82
86
|
const event = record.event;
|
|
83
|
-
if (event === "
|
|
87
|
+
if (event === "receiver.start") {
|
|
88
|
+
receiverStartTimes.push(time);
|
|
89
|
+
const settledAt = time + SYNC_RUNTIME_RESTART_WARMUP_MS;
|
|
90
|
+
// The lower bound preserves a full hour of settled restarts; the upper
|
|
91
|
+
// bound applies the 20-minute settle rule and excludes only the tail.
|
|
92
|
+
if (settledAt >= since && settledAt <= options.now)
|
|
93
|
+
starts += 1;
|
|
94
|
+
}
|
|
95
|
+
else if (event === "fanout.receive" && time >= since) {
|
|
84
96
|
const latency = record.latencySeconds;
|
|
85
97
|
if (typeof latency === "number" && latency > LAG_SECONDS)
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
else if (event === "receiver.start") {
|
|
89
|
-
starts += 1;
|
|
98
|
+
slowReceiptTimes.push(time);
|
|
90
99
|
}
|
|
91
|
-
if (
|
|
92
|
-
(
|
|
100
|
+
if (time >= since &&
|
|
101
|
+
(record.type === "receiver" || record.type === "error" ||
|
|
102
|
+
(typeof event === "string" && event.startsWith("receiver."))) &&
|
|
93
103
|
PERMISSION.test(JSON.stringify(record.err ?? record.message ?? ""))) {
|
|
94
104
|
denied += 1;
|
|
95
105
|
}
|
|
96
106
|
}
|
|
107
|
+
// Startup can temporarily expose queued receive latency and restart events
|
|
108
|
+
// while the receiver is warming its backlog. Only judge receipts outside the
|
|
109
|
+
// warm-up interval following the most recent preceding start.
|
|
110
|
+
const slowReceipts = slowReceiptTimes.filter((time) => !receiverStartTimes.some((start) => start <= time && time - start < SYNC_RUNTIME_RESTART_WARMUP_MS)).length;
|
|
97
111
|
const conflicts = options.conflictCount ?? 0;
|
|
98
112
|
const checks = [
|
|
99
113
|
{ id: "realtime-lag", reasonCode: "realtime-lag-stuck", failing: slowReceipts >= LAG_MIN_RECEIPTS, count: slowReceipts, label: "incoming changes delayed over 15 minutes" },
|
|
100
114
|
{ id: "receiver-permission", reasonCode: "receiver-permission-denied", failing: denied > 0, count: denied, label: "cloud permission refusals" },
|
|
101
|
-
{ id: "runner-restarts", reasonCode: "runner-restart-loop", failing: starts >= RESTART_MIN_STARTS, count: starts, label: "sync runner starts" },
|
|
115
|
+
{ id: "runner-restarts", reasonCode: "runner-restart-loop", failing: starts >= RESTART_MIN_STARTS, count: starts, label: "settled sync runner starts" },
|
|
102
116
|
{ id: "device-limit", reasonCode: "device-limit-backoff", failing: deviceLimited > 0, count: deviceLimited, label: "device-limit backoffs" },
|
|
103
117
|
{ id: "conflicts", reasonCode: "repeated-conflicts", failing: conflicts >= CONFLICT_MIN_COUNT, count: conflicts, label: "unresolved conflicts" },
|
|
104
118
|
];
|
|
@@ -197,7 +211,7 @@ export function defaultSyncRuntimeResults(now, hqHome = path.join(os.homedir(),
|
|
|
197
211
|
const logFile = family === null ? paths.desktop : paths[family];
|
|
198
212
|
let lines;
|
|
199
213
|
try {
|
|
200
|
-
lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS, family === "daemon");
|
|
214
|
+
lines = readWindowLines(logFile, options.now - SYNC_RUNTIME_WINDOW_MS - SYNC_RUNTIME_RESTART_WARMUP_MS, family === "daemon");
|
|
201
215
|
}
|
|
202
216
|
catch {
|
|
203
217
|
return [
|
|
@@ -50,9 +50,14 @@ export function hqSkillsSource(hqRoot) {
|
|
|
50
50
|
/** The exact hook command registered for one event (absolute path, quoted). */
|
|
51
51
|
export function hookCommand(hqRoot, event, target = 'master') {
|
|
52
52
|
const skipInsideRoot = `if [ -d "\${CLAUDE_PROJECT_DIR:-}" ] && [ "\${CLAUDE_PROJECT_DIR}" -ef ${shellQuote(hqRoot)} ]; then exit 0; fi; cwd=$(pwd -P 2>/dev/null || pwd); while [ -n "$cwd" ]; do if [ -f "$cwd/core/core.yaml" ] && [ -f "$cwd/companies/manifest.yaml" ]; then exit 0; fi; [ "$cwd" = / ] && break; cwd=\${cwd%/*}; [ -n "$cwd" ] || cwd=/; done; `;
|
|
53
|
+
// Many HQ hooks take their root from CLAUDE_PROJECT_DIR (some before HQ_ROOT). In a
|
|
54
|
+
// foreign project that is the user's folder, so they load no policies and write
|
|
55
|
+
// workspace/ state there. Point both at HQ; the payload's cwd still names the
|
|
56
|
+
// folder the session is working in.
|
|
57
|
+
const rootEnv = `HQ_ROOT=${shellQuote(hqRoot)} CLAUDE_PROJECT_DIR=${shellQuote(hqRoot)} `;
|
|
53
58
|
if (target === 'shim')
|
|
54
|
-
return `${skipInsideRoot}exec /bin/sh ${shellQuote(shimPath(hqRoot))} ${shellQuote(event)} --runtime claude`;
|
|
55
|
-
return `${skipInsideRoot}BASH_ENV=/dev/null exec bash ${shellQuote(masterHookPath(hqRoot))} ${shellQuote(event)}`;
|
|
59
|
+
return `${skipInsideRoot}${rootEnv}exec /bin/sh ${shellQuote(shimPath(hqRoot))} ${shellQuote(event)} --runtime claude`;
|
|
60
|
+
return `${skipInsideRoot}${rootEnv}BASH_ENV=/dev/null exec bash ${shellQuote(masterHookPath(hqRoot))} ${shellQuote(event)}`;
|
|
56
61
|
}
|
|
57
62
|
/** Quote one argument for the POSIX shell used by Claude Code command hooks. */
|
|
58
63
|
function shellQuote(value) {
|
|
@@ -30,6 +30,12 @@ type Doc = Record<string, unknown>;
|
|
|
30
30
|
export declare function addCodexHooks(doc: Doc, hqRoot: string, target?: HookTarget): Doc;
|
|
31
31
|
/** Remove only HQ adapter entries; drop containers this left empty. */
|
|
32
32
|
export declare function removeCodexHooks(doc: Doc, hqRoot: string): Doc;
|
|
33
|
+
/**
|
|
34
|
+
* Codex trust keys (`<hooks.json>:<snake_event>:<group>:<hook>`) for the HQ
|
|
35
|
+
* entries in a hooks.json document. Foreign entries are left out so install
|
|
36
|
+
* never trusts a hook the user added themselves.
|
|
37
|
+
*/
|
|
38
|
+
export declare function codexHookTrustKeys(doc: Doc, hooksJsonPath: string, hqRoot: string): string[];
|
|
33
39
|
export declare function hasAllCodexHooks(doc: Doc, hqRoot: string, target?: HookTarget): boolean;
|
|
34
40
|
export type LegacySkillsMirror = {
|
|
35
41
|
kind: 'absent';
|