@indigoai-us/hq-cli 5.328.0 → 5.330.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/command-catalog.generated.d.ts +3699 -3
  3. package/dist/command-catalog.generated.js +5462 -1759
  4. package/dist/command-help.d.ts +2 -0
  5. package/dist/command-help.js +13 -0
  6. package/dist/commands/cloud.d.ts +5 -0
  7. package/dist/commands/cloud.js +51 -1
  8. package/dist/commands/install-global.d.ts +21 -0
  9. package/dist/commands/install-global.js +267 -32
  10. package/dist/commands/lanes-message.js +59 -6
  11. package/dist/commands/lanes-resume.js +66 -15
  12. package/dist/commands/lanes-shared.d.ts +9 -2
  13. package/dist/commands/lanes.js +97 -8
  14. package/dist/commands/mcp-status.js +58 -1
  15. package/dist/commands/pack-install.d.ts +3 -1
  16. package/dist/commands/pack-install.js +17 -4
  17. package/dist/commands/packs.js +17 -0
  18. package/dist/commands/pkg-install.js +13 -1
  19. package/dist/fast-scaffold.d.ts +2 -0
  20. package/dist/fast-scaffold.js +16 -0
  21. package/dist/index.js +19 -2
  22. package/dist/lazy-commands.d.ts +1 -0
  23. package/dist/lazy-commands.js +64 -4
  24. package/dist/lib/cli-flag-keys.d.ts +3 -0
  25. package/dist/lib/cli-flag-keys.js +3 -0
  26. package/dist/lib/daemon/ops/mcp.js +11 -10
  27. package/dist/lib/daemon/ops/policy.d.ts +3 -2
  28. package/dist/lib/daemon/ops/policy.js +12 -6
  29. package/dist/lib/daemon/server.d.ts +3 -1
  30. package/dist/lib/daemon/server.js +63 -7
  31. package/dist/lib/doctor/checks/anywhere.js +58 -4
  32. package/dist/lib/install/chatgpt-targets.d.ts +8 -0
  33. package/dist/lib/install/chatgpt-targets.js +15 -0
  34. package/dist/lib/install/codex-pack-contributions.d.ts +8 -0
  35. package/dist/lib/install/codex-pack-contributions.js +205 -0
  36. package/dist/lib/install/codex-targets.d.ts +1 -1
  37. package/dist/lib/install/codex-targets.js +2 -1
  38. package/dist/lib/install/grok-targets.d.ts +8 -0
  39. package/dist/lib/install/grok-targets.js +15 -0
  40. package/dist/lib/lanes/flag-keys.d.ts +2 -0
  41. package/dist/lib/lanes/flag-keys.js +2 -0
  42. package/dist/lib/lanes/keep-alive-audit.d.ts +3 -2
  43. package/dist/lib/lanes/keep-alive-audit.js +36 -8
  44. package/dist/lib/lanes/spawn.js +14 -10
  45. package/dist/lib/lanes/store.js +29 -2
  46. package/dist/lib/lanes/types.d.ts +10 -0
  47. package/dist/lib/lanes/types.js +1 -1
  48. package/dist/lib/sync-invalid-entity-warning.d.ts +32 -0
  49. package/dist/lib/sync-invalid-entity-warning.js +205 -0
  50. package/dist/main.d.ts +2 -6
  51. package/dist/main.js +3 -41
  52. package/dist/stream-error-boundary.d.ts +7 -0
  53. package/dist/stream-error-boundary.js +33 -0
  54. package/package.json +1 -1
@@ -16,6 +16,8 @@ import { CLI_VERSION } from "../../../cli-version.js";
16
16
  import { computeMcpStatus } from "../../../commands/mcp-status.js";
17
17
  import { HQ_MCP_SERVER_NAME, SKILL_LINK_PREFIX, claudeTargets } from "../../install/claude-targets.js";
18
18
  import { codexTargets } from "../../install/codex-targets.js";
19
+ import { CHATGPT_MODE } from "../../install/chatgpt-targets.js";
20
+ import { GROK_MODE } from "../../install/grok-targets.js";
19
21
  import { pingHqdVersion } from "../../daemon/convergence.js";
20
22
  import { daemonPresenceLabel, heartbeatFile, readHeartbeat } from "../../daemon/heartbeat.js";
21
23
  import { DAEMON_LAUNCHD_LABEL, DAEMON_SYSTEMD_UNIT, DAEMON_WINDOWS_TASK } from "../../daemon/install.js";
@@ -42,10 +44,16 @@ export async function runAnywhereChecks(_context, deps = {}) {
42
44
  const claude = claudeTargets(home);
43
45
  const codex = codexTargets(home);
44
46
  const installed = [claude.hqPointer, claude.stateFile, codex.stateFile].some((p) => fs.existsSync(p));
47
+ const remoteModes = [
48
+ result("WARN", "chatgpt-mode", `ChatGPT integration mode: ${CHATGPT_MODE}; host connector setup is managed in ChatGPT`, "hq install --global --runtime chatgpt"),
49
+ result("WARN", "grok-mode", `Grok integration mode: ${GROK_MODE}; host connector setup is managed in Grok`, "hq install --global --runtime grok"),
50
+ ];
45
51
  if (!installed) {
46
- return [result("NA", "installed", "HQ anywhere is not installed on this machine", REMEDIATION.installClaude)];
52
+ return [...remoteModes, result("NA", "installed", "HQ local anywhere runtime is not installed on this machine", REMEDIATION.installClaude)];
47
53
  }
48
- const out = [];
54
+ const out = [...remoteModes];
55
+ const pluginInstall = readClaudePluginInstall(claude.stateFile);
56
+ out.push(checkInstallMode(claude.stateFile));
49
57
  out.push(checkPointer(claude.hqPointer));
50
58
  out.push(checkRegistry(registryFile(registryDir(home, env))));
51
59
  const service = (deps.serviceState ?? (() => defaultServiceState(home)))();
@@ -75,13 +83,59 @@ export async function runAnywhereChecks(_context, deps = {}) {
75
83
  out.push(checkHeartbeat(heartbeatFile(home, env), (deps.now ?? (() => new Date()))()));
76
84
  const mcp = computeMcpStatus({ home });
77
85
  const server = mcp.servers.find((s) => s.server === HQ_MCP_SERVER_NAME);
78
- out.push(checkRegistration("claude", server?.claude, mcp.runtimes.claude, REMEDIATION.installClaude));
86
+ if (pluginInstall)
87
+ out.push(checkClaudePlugin(claude.settingsJson, pluginInstall));
88
+ else
89
+ out.push(checkRegistration("claude", server?.claude, mcp.runtimes.claude, REMEDIATION.installClaude));
79
90
  out.push(checkRegistration("codex", server?.codex, mcp.runtimes.codex, REMEDIATION.installCodex));
80
- out.push(checkSkillLinks("claude", claude.skillsDir, REMEDIATION.installClaude));
91
+ if (pluginInstall)
92
+ out.push(result("PASS", "claude-skills", `HQ Claude skills are provided by plugin ${pluginInstall.pluginId}`));
93
+ else
94
+ out.push(checkSkillLinks("claude", claude.skillsDir, REMEDIATION.installClaude));
81
95
  if (mcp.runtimes.codex.installed)
82
96
  out.push(checkSkillLinks("codex", codex.skillsDir, REMEDIATION.installCodex));
83
97
  return out;
84
98
  }
99
+ function checkInstallMode(file) {
100
+ try {
101
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
102
+ if (typeof state.plugin?.pluginId === 'string') {
103
+ return result('PASS', 'install-mode', `Claude Code install mode: plugin (${state.plugin.pluginId})`, undefined, file);
104
+ }
105
+ return result('PASS', 'install-mode', 'Claude Code install mode: direct hooks and MCP', undefined, file);
106
+ }
107
+ catch (error) {
108
+ if (error.code === 'ENOENT') {
109
+ return result('WARN', 'install-mode', 'Claude Code install mode is unknown (install state is missing)', REMEDIATION.installClaude, file);
110
+ }
111
+ return result('FAIL', 'install-mode', `Claude Code install state is unreadable: ${error.message}`, REMEDIATION.installClaude, file);
112
+ }
113
+ }
114
+ function readClaudePluginInstall(file) {
115
+ try {
116
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
117
+ const plugin = state.plugin;
118
+ if (typeof plugin?.pluginId === 'string' && typeof plugin.marketplaceName === 'string' && typeof plugin.sourceDir === 'string')
119
+ return plugin;
120
+ }
121
+ catch { /* checkInstallMode reports missing or unreadable state */ }
122
+ return undefined;
123
+ }
124
+ function checkClaudePlugin(settingsFile, plugin) {
125
+ const remediation = `hq install --global --runtime claude --via plugin --marketplace-dir "${plugin.sourceDir}"`;
126
+ try {
127
+ const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
128
+ if (settings.enabledPlugins?.[plugin.pluginId] !== true)
129
+ return result('FAIL', 'claude-registration', `HQ Claude plugin ${plugin.pluginId} is not enabled`, remediation, settingsFile);
130
+ const marketplace = settings.extraKnownMarketplaces?.[plugin.marketplaceName];
131
+ if (marketplace?.source?.source !== 'directory' || marketplace.source.path !== plugin.sourceDir)
132
+ return result('FAIL', 'claude-registration', `HQ Claude plugin marketplace ${plugin.marketplaceName} is not registered from ${plugin.sourceDir}`, remediation, settingsFile);
133
+ return result('PASS', 'claude-registration', `HQ Claude plugin ${plugin.pluginId} is enabled from its recorded marketplace`);
134
+ }
135
+ catch (error) {
136
+ return result('FAIL', 'claude-registration', `Claude plugin settings are unreadable: ${error.message}`, remediation, settingsFile);
137
+ }
138
+ }
85
139
  function checkPointer(file) {
86
140
  let root;
87
141
  try {
@@ -0,0 +1,8 @@
1
+ /** ChatGPT host integration for US-015.
2
+ *
3
+ * ChatGPT currently accepts remote MCP apps, not direct local stdio servers.
4
+ * Keep this target read-only: the user creates the hosted connector in ChatGPT.
5
+ */
6
+ export declare const CHATGPT_MODE: "remote-connector";
7
+ export declare function chatgptConnectorInstructions(endpoint: string): string[];
8
+ //# sourceMappingURL=chatgpt-targets.d.ts.map
@@ -0,0 +1,15 @@
1
+ /** ChatGPT host integration for US-015.
2
+ *
3
+ * ChatGPT currently accepts remote MCP apps, not direct local stdio servers.
4
+ * Keep this target read-only: the user creates the hosted connector in ChatGPT.
5
+ */
6
+ export const CHATGPT_MODE = "remote-connector";
7
+ export function chatgptConnectorInstructions(endpoint) {
8
+ return [
9
+ "ChatGPT supports hosted MCP connectors, not direct local MCP registration.",
10
+ `Hosted HQ MCP URL: ${endpoint}`,
11
+ "Enable it in ChatGPT web: Settings > Apps > Create, then add the URL as a custom MCP app. Workspace developer mode may need an admin to enable it first.",
12
+ "HQ did not write a ChatGPT configuration file.",
13
+ ];
14
+ }
15
+ //# sourceMappingURL=chatgpt-targets.js.map
@@ -0,0 +1,8 @@
1
+ type Doc = Record<string, unknown>;
2
+ export declare function syncCodexPackSkills(packDir: string, packName: string, names: string[], remove?: boolean, home?: string): string[];
3
+ export declare function mergeCodexPackHooks(doc: Doc, packDir: string, items: string[]): Doc;
4
+ export declare function removeCodexPackHooks(doc: Doc, packDir: string, items: string[]): Doc;
5
+ export declare function syncCodexPackHooks(packDir: string, items: string[], remove?: boolean, home?: string): void;
6
+ export declare function readCodexPackHooks(target: string): Doc;
7
+ export {};
8
+ //# sourceMappingURL=codex-pack-contributions.d.ts.map
@@ -0,0 +1,205 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { anywhereRuntimeEnabled } from '../../commands/anywhere-runtime-flag.js';
5
+ import { jsonFormat, readConfigDoc, writeConfigAtomic } from '../../commands/mcp-registration.js';
6
+ const configFormat = {
7
+ parse: jsonFormat.parse,
8
+ serialize: jsonFormat.serialize,
9
+ emptyDoc: () => ({}),
10
+ assertEntry(doc, name) {
11
+ const hooks = doc.hooks;
12
+ if (name.startsWith('hooks-remove:')) {
13
+ const removedRoot = name.slice('hooks-remove:'.length);
14
+ if (hooks && Object.values(hooks).some((groups) => groups.some((group) => group.hooks?.some((hook) => typeof hook.command === 'string' && hook.command.includes(removedRoot))))) {
15
+ throw new Error(`Codex hook registrations for ${removedRoot} remain after removal`);
16
+ }
17
+ return;
18
+ }
19
+ if (!hooks || !Object.values(hooks).some((groups) => groups.some((group) => group.hooks?.some((hook) => typeof hook.command === 'string' && hook.command.includes(name))))) {
20
+ throw new Error(`Codex hook registrations for ${name} were not written`);
21
+ }
22
+ },
23
+ };
24
+ function hooksPath(home) { return path.join(home, '.codex', 'hooks.json'); }
25
+ const ownerFile = '.hq-pack-owner';
26
+ const validContributionName = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
27
+ function assertContributionNames(items) {
28
+ for (const item of items)
29
+ if (!validContributionName.test(item))
30
+ throw new Error(`Invalid Codex pack contribution name: ${item}`);
31
+ }
32
+ function hasPackOwner(target, packName) {
33
+ try {
34
+ const dirStat = fs.lstatSync(target);
35
+ const markerPath = path.join(target, ownerFile);
36
+ const markerStat = fs.lstatSync(markerPath);
37
+ return dirStat.isDirectory() && !dirStat.isSymbolicLink() && markerStat.isFile() && !markerStat.isSymbolicLink() && fs.readFileSync(markerPath, 'utf8') === `${packName}\n`;
38
+ }
39
+ catch (e) {
40
+ if (e.code === 'ENOENT')
41
+ return false;
42
+ throw e;
43
+ }
44
+ }
45
+ export function syncCodexPackSkills(packDir, packName, names, remove = false, home = os.homedir()) {
46
+ assertContributionNames(names);
47
+ if ((!remove && !anywhereRuntimeEnabled()) || !fs.existsSync(path.join(home, '.codex')))
48
+ return [];
49
+ const root = path.join(home, '.codex', 'skills');
50
+ const warnings = [];
51
+ for (const name of names) {
52
+ const source = path.join(packDir, 'skills', name);
53
+ const target = path.join(root, name);
54
+ if (!source.startsWith(`${path.resolve(packDir, 'skills')}${path.sep}`))
55
+ throw new Error(`Invalid Codex skill path: ${name}`);
56
+ if (remove) {
57
+ if (hasPackOwner(target, packName))
58
+ fs.rmSync(target, { recursive: true, force: true });
59
+ continue;
60
+ }
61
+ assertSafeSkillTree(source, packDir, name);
62
+ let targetExists = false;
63
+ try {
64
+ fs.lstatSync(target);
65
+ targetExists = true;
66
+ }
67
+ catch (e) {
68
+ if (e.code !== 'ENOENT')
69
+ throw e;
70
+ }
71
+ if (targetExists) {
72
+ const owned = hasPackOwner(target, packName);
73
+ if (!owned) {
74
+ warnings.push(`left foreign Codex skill in place: ${name}`);
75
+ continue;
76
+ }
77
+ fs.rmSync(target, { recursive: true, force: true });
78
+ }
79
+ fs.mkdirSync(root, { recursive: true });
80
+ copySafeSkillTree(source, target);
81
+ fs.writeFileSync(path.join(target, ownerFile), `${packName}\n`, { flag: 'wx', mode: 0o644 });
82
+ }
83
+ return warnings;
84
+ }
85
+ function assertSafeSkillTree(source, packDir, name) {
86
+ const root = path.resolve(packDir);
87
+ const resolved = path.resolve(source);
88
+ const stat = fs.lstatSync(source);
89
+ if (!resolved.startsWith(`${root}${path.sep}`) || !stat.isDirectory() || stat.isSymbolicLink()) {
90
+ throw new Error(`Codex skill payload must be a real directory inside the pack: ${name}`);
91
+ }
92
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
93
+ const child = path.join(source, entry.name);
94
+ const childStat = fs.lstatSync(child);
95
+ if (childStat.isSymbolicLink())
96
+ throw new Error(`Codex skill payload contains a symlink: ${name}/${entry.name}`);
97
+ if (childStat.isDirectory())
98
+ assertSafeSkillTree(child, packDir, name);
99
+ else if (!childStat.isFile())
100
+ throw new Error(`Codex skill payload contains an unsupported entry: ${name}/${entry.name}`);
101
+ }
102
+ }
103
+ function copySafeSkillTree(source, target) {
104
+ fs.mkdirSync(target, { recursive: false });
105
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
106
+ const from = path.join(source, entry.name);
107
+ const to = path.join(target, entry.name);
108
+ const stat = fs.lstatSync(from);
109
+ if (stat.isSymbolicLink())
110
+ throw new Error(`Codex skill payload contains a symlink: ${entry.name}`);
111
+ if (stat.isDirectory())
112
+ copySafeSkillTree(from, to);
113
+ else if (stat.isFile())
114
+ fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
115
+ else
116
+ throw new Error(`Codex skill payload contains an unsupported entry: ${entry.name}`);
117
+ }
118
+ }
119
+ function readRegistrations(packDir, items) {
120
+ assertContributionNames(items);
121
+ const all = {};
122
+ for (const item of items) {
123
+ const file = path.join(packDir, 'hooks', `${item}-hooks.json`);
124
+ if (!fs.existsSync(file))
125
+ continue;
126
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
127
+ if (!parsed || typeof parsed !== 'object' || !('hooks' in parsed) || !parsed.hooks || typeof parsed.hooks !== 'object' || Array.isArray(parsed.hooks)) {
128
+ throw new Error(`Invalid Codex hook registration template: ${file}`);
129
+ }
130
+ for (const [event, groups] of Object.entries(parsed.hooks)) {
131
+ if (!Array.isArray(groups))
132
+ throw new Error(`Invalid Codex hook event ${event} in ${file}`);
133
+ const resolved = groups.map((group) => ({
134
+ ...group,
135
+ hooks: (group.hooks ?? []).map((hook) => {
136
+ if (typeof hook.command !== 'string' || !hook.command.startsWith('hooks/'))
137
+ throw new Error(`Invalid pack-relative hook command in ${file}`);
138
+ const rel = hook.command.slice('hooks/'.length);
139
+ const abs = path.resolve(packDir, 'hooks', rel);
140
+ if (!abs.startsWith(`${path.resolve(packDir)}${path.sep}`) || !fs.existsSync(abs))
141
+ throw new Error(`Hook target is missing or outside the installed pack: ${hook.command}`);
142
+ const timeout = event === 'SessionEnd' ? 3 : 300;
143
+ return { ...hook, command: `/bin/sh ${JSON.stringify(abs)}`, timeout };
144
+ }),
145
+ }));
146
+ all[event] = [...(all[event] ?? []), ...resolved];
147
+ }
148
+ }
149
+ return all;
150
+ }
151
+ export function mergeCodexPackHooks(doc, packDir, items) {
152
+ const hooks = { ...(doc.hooks ?? {}) };
153
+ const registrations = readRegistrations(packDir, items);
154
+ removeCommands(hooks, registeredCommands(registrations));
155
+ for (const [event, groups] of Object.entries(registrations))
156
+ hooks[event] = [...(hooks[event] ?? []), ...groups];
157
+ return { ...doc, ...(Object.keys(hooks).length ? { hooks } : {}) };
158
+ }
159
+ export function removeCodexPackHooks(doc, packDir, items) {
160
+ const hooks = { ...(doc.hooks ?? {}) };
161
+ removeCommands(hooks, registeredCommands(readRegistrations(packDir, items)));
162
+ const next = { ...doc };
163
+ if (Object.keys(hooks).length)
164
+ next.hooks = hooks;
165
+ else
166
+ delete next.hooks;
167
+ return next;
168
+ }
169
+ function registeredCommands(registrations) {
170
+ const commands = new Set();
171
+ for (const groups of Object.values(registrations))
172
+ for (const group of groups) {
173
+ for (const hook of group.hooks ?? [])
174
+ if (typeof hook.command === 'string')
175
+ commands.add(hook.command);
176
+ }
177
+ return commands;
178
+ }
179
+ function removeCommands(hooks, commands) {
180
+ for (const [event, groups] of Object.entries(hooks)) {
181
+ hooks[event] = groups.flatMap((group) => {
182
+ const kept = (group.hooks ?? []).filter((hook) => typeof hook.command !== 'string' || !commands.has(hook.command));
183
+ return kept.length ? [{ ...group, hooks: kept }] : [];
184
+ });
185
+ if (hooks[event].length === 0)
186
+ delete hooks[event];
187
+ }
188
+ }
189
+ export function syncCodexPackHooks(packDir, items, remove = false, home = os.homedir()) {
190
+ assertContributionNames(items);
191
+ if ((!remove && !anywhereRuntimeEnabled()) || !fs.existsSync(path.join(home, '.codex')))
192
+ return;
193
+ const codexItems = items.filter((item) => fs.existsSync(path.join(packDir, 'hooks', `${item}-hooks.json`)));
194
+ if (codexItems.length === 0)
195
+ return;
196
+ const target = hooksPath(home);
197
+ writeConfigAtomic({
198
+ target, pack: 'hq-anywhere', format: configFormat,
199
+ merge: (doc) => remove ? removeCodexPackHooks(doc, packDir, codexItems) : mergeCodexPackHooks(doc, packDir, codexItems),
200
+ assertName: remove ? `hooks-remove:${path.resolve(packDir)}${path.sep}` : codexItems[0],
201
+ env: { home },
202
+ });
203
+ }
204
+ export function readCodexPackHooks(target) { return readConfigDoc(target, configFormat).doc; }
205
+ //# sourceMappingURL=codex-pack-contributions.js.map
@@ -1,6 +1,6 @@
1
1
  import { type HookTarget } from './claude-targets.js';
2
2
  /** Events the Codex hook adapter is registered for at user scope. */
3
- export declare const CODEX_HOOK_EVENTS: readonly ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"];
3
+ export declare const CODEX_HOOK_EVENTS: readonly ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop", "SessionEnd"];
4
4
  export declare const AGENTS_MD_BEGIN = "<!-- hq-anywhere:begin (managed by `hq install --global --runtime codex`; remove with `hq uninstall --global --runtime codex`) -->";
5
5
  export declare const AGENTS_MD_END = "<!-- hq-anywhere:end -->";
6
6
  /** Name of the legacy ad-hoc skills mirror (~/.codex/skills/hq). */
@@ -19,6 +19,7 @@ export const CODEX_HOOK_EVENTS = [
19
19
  'PreToolUse',
20
20
  'PostToolUse',
21
21
  'Stop',
22
+ 'SessionEnd',
22
23
  ];
23
24
  export const AGENTS_MD_BEGIN = '<!-- hq-anywhere:begin (managed by `hq install --global --runtime codex`; remove with `hq uninstall --global --runtime codex`) -->';
24
25
  export const AGENTS_MD_END = '<!-- hq-anywhere:end -->';
@@ -108,7 +109,7 @@ export function addCodexHooks(doc, hqRoot, target = 'master') {
108
109
  if (present)
109
110
  continue;
110
111
  groups = removeCodexHooks({ hooks: { [event]: groups } }, hqRoot).hooks?.[event] ?? [];
111
- groups.push({ hooks: [{ type: 'command', command: want, timeout: 300 }] });
112
+ groups.push({ hooks: [{ type: 'command', command: want, timeout: event === 'SessionEnd' ? 3 : 300 }] });
112
113
  hooks[event] = groups;
113
114
  changed = true;
114
115
  }
@@ -0,0 +1,8 @@
1
+ /** Grok host integration for US-015.
2
+ *
3
+ * Grok custom MCP connectors are remote URL integrations. The local CLI does
4
+ * not write host configuration; users add the hosted HQ MCP endpoint in Grok.
5
+ */
6
+ export declare const GROK_MODE: "remote-connector";
7
+ export declare function grokConnectorInstructions(endpoint: string): string[];
8
+ //# sourceMappingURL=grok-targets.d.ts.map
@@ -0,0 +1,15 @@
1
+ /** Grok host integration for US-015.
2
+ *
3
+ * Grok custom MCP connectors are remote URL integrations. The local CLI does
4
+ * not write host configuration; users add the hosted HQ MCP endpoint in Grok.
5
+ */
6
+ export const GROK_MODE = "remote-connector";
7
+ export function grokConnectorInstructions(endpoint) {
8
+ return [
9
+ "Grok supports remote custom MCP connectors; it cannot register a local stdio server directly.",
10
+ `Hosted HQ MCP URL: ${endpoint}`,
11
+ "Enable it at grok.com/connectors > New Connector > Custom, then enter the URL and finish authentication.",
12
+ "HQ did not write a Grok configuration file.",
13
+ ];
14
+ }
15
+ //# sourceMappingURL=grok-targets.js.map
@@ -1,5 +1,7 @@
1
1
  /** hq-flags keys used by lane command behavior. */
2
2
  export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
3
+ /** Refresh provider/model/effort from a worker profile when a done lane takes a new story. */
4
+ export declare const LANE_REUSE_REFRESHES_PROFILE_FLAG = "lanes.reuse-refreshes-profile";
3
5
  /** Enables one automatic continuation after a missing-envelope repair. */
4
6
  export declare const LANE_ENVELOPE_REPAIR_CONTINUE_FLAG = "lanes.envelope-repair-continue";
5
7
  /** Count only children with live work against their senior's hire grant. */
@@ -1,5 +1,7 @@
1
1
  /** hq-flags keys used by lane command behavior. */
2
2
  export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
3
+ /** Refresh provider/model/effort from a worker profile when a done lane takes a new story. */
4
+ export const LANE_REUSE_REFRESHES_PROFILE_FLAG = "lanes.reuse-refreshes-profile";
3
5
  /** Enables one automatic continuation after a missing-envelope repair. */
4
6
  export const LANE_ENVELOPE_REPAIR_CONTINUE_FLAG = "lanes.envelope-repair-continue";
5
7
  /** Count only children with live work against their senior's hire grant. */
@@ -1,6 +1,6 @@
1
- type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "self_wake" | "early_by_keep_alive" | "not_restarted" | "keep_alive_off" | "waiting";
1
+ type KeepAliveAuditVerdict = "on_time" | "late" | "early_wake" | "message_wake" | "self_wake" | "early_by_keep_alive" | "restarted_untimed" | "not_restarted" | "keep_alive_off" | "waiting";
2
2
  interface KeepAliveAuditRestart {
3
- at: string;
3
+ at: string | null;
4
4
  trigger: string;
5
5
  caller: string | null;
6
6
  }
@@ -18,6 +18,7 @@ interface KeepAliveAuditRow {
18
18
  interface KeepAliveAuditSummary {
19
19
  by_verdict: Record<KeepAliveAuditVerdict, number>;
20
20
  not_restarted: number;
21
+ restarted_untimed: number;
21
22
  late_over_10m: number;
22
23
  early_wake_by_others: number;
23
24
  self_wake: number;
@@ -4,6 +4,7 @@ import { LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS } from "./keep-alive.js";
4
4
  import { LanesError } from "./errors.js";
5
5
  import { laneRunDir } from "./spawn.js";
6
6
  import { listQuestionIds, readAllLanes, tryReadQuestion } from "./store.js";
7
+ import { HISTORY_CAP } from "./types.js";
7
8
  function readDoneClassifications(hqRoot, laneId) {
8
9
  const root = fs.realpathSync(hqRoot);
9
10
  const watcherLog = path.join(laneRunDir(root, laneId), "logs", "watcher.out");
@@ -56,6 +57,7 @@ const VERDICTS = [
56
57
  "message_wake",
57
58
  "self_wake",
58
59
  "early_by_keep_alive",
60
+ "restarted_untimed",
59
61
  "keep_alive_off",
60
62
  "not_restarted",
61
63
  "waiting",
@@ -285,17 +287,42 @@ function firstRestartAfter(lane, stop, nextEnvelopeMs) {
285
287
  .map((entry) => ({ entry, at: parseTime(entry.at) }))
286
288
  .filter(({ entry, at }) => at !== undefined && at > stop.mtimeMs && (nextEnvelopeMs === undefined || at < nextEnvelopeMs) && typeof entry.trigger === "string")
287
289
  .sort((a, b) => a.at - b.at)[0];
288
- if (!restart || restart.at === undefined)
290
+ if (restart?.at !== undefined) {
291
+ return {
292
+ at: iso(restart.at),
293
+ trigger: restart.entry.trigger,
294
+ caller: typeof restart.entry.caller === "string" ? restart.entry.caller : null,
295
+ };
296
+ }
297
+ // resume_history is capped and may no longer retain the launch that followed
298
+ // an older stop. keep_alive_history is the durable automatic-resume record;
299
+ // only count it when a later envelope confirms the queued turn actually ran.
300
+ if (nextEnvelopeMs === undefined)
289
301
  return null;
290
- return {
291
- at: iso(restart.at),
292
- trigger: restart.entry.trigger,
293
- caller: typeof restart.entry.caller === "string" ? restart.entry.caller : null,
294
- };
302
+ const keepAliveResume = (lane.keep_alive_history ?? [])
303
+ .map((entry) => ({ entry, at: parseTime(entry.at) }))
304
+ .filter(({ entry, at }) => entry.resumed_from === "done" && at !== undefined && at > stop.mtimeMs && at < nextEnvelopeMs)
305
+ .sort((a, b) => a.at - b.at)[0];
306
+ if (keepAliveResume) {
307
+ // This timestamp records when reconcile queued the resume, not when the
308
+ // provider actually launched. The next envelope confirms a turn ran, but
309
+ // cannot establish its launch time.
310
+ return { at: null, trigger: "keep_alive", caller: null };
311
+ }
312
+ const resumeHistory = lane.resume_history ?? [];
313
+ const keepAliveHistory = lane.keep_alive_history ?? [];
314
+ if (resumeHistory.length >= HISTORY_CAP && keepAliveHistory.length >= HISTORY_CAP) {
315
+ // Both append-only histories can roll this resume out. The later envelope
316
+ // proves a turn ran, while its trigger and launch time are no longer known.
317
+ return { at: null, trigger: "unknown", caller: null };
318
+ }
319
+ return null;
295
320
  }
296
321
  function classifyVerdict(input) {
297
322
  const { dueAt, restart, lane, nowMs } = input;
298
323
  if (restart) {
324
+ if (restart.at === null)
325
+ return "restarted_untimed";
299
326
  const restartAt = Date.parse(restart.at);
300
327
  if (restart.caller === lane.lane_id)
301
328
  return "self_wake";
@@ -388,7 +415,7 @@ export function buildKeepAliveAudit(input) {
388
415
  let lateOverTenMinutes = 0;
389
416
  for (const row of rows) {
390
417
  byVerdict[row.verdict] += 1;
391
- if (row.verdict === "late" && row.restart && Date.parse(row.restart.at) - Date.parse(row.due_at) > LATE_KPI_THRESHOLD_MS) {
418
+ if (row.verdict === "late" && row.restart !== null && row.restart.at !== null && Date.parse(row.restart.at) - Date.parse(row.due_at) > LATE_KPI_THRESHOLD_MS) {
392
419
  lateOverTenMinutes += 1;
393
420
  }
394
421
  }
@@ -399,6 +426,7 @@ export function buildKeepAliveAudit(input) {
399
426
  summary: {
400
427
  by_verdict: byVerdict,
401
428
  not_restarted: byVerdict.not_restarted,
429
+ restarted_untimed: byVerdict.restarted_untimed,
402
430
  late_over_10m: lateOverTenMinutes,
403
431
  early_wake_by_others: byVerdict.early_wake,
404
432
  self_wake: byVerdict.self_wake,
@@ -416,7 +444,7 @@ export function formatKeepAliveAuditText(result) {
416
444
  row.stopped_at,
417
445
  row.next_check_at ?? "—",
418
446
  row.due_at,
419
- row.restart ? `${row.restart.at} ${row.restart.trigger}${row.restart.caller ? ` (${row.restart.caller})` : ""}` : "—",
447
+ row.restart ? `${row.restart.at ?? "untimed"} ${row.restart.trigger}${row.restart.caller ? ` (${row.restart.caller})` : ""}` : "—",
420
448
  row.verdict,
421
449
  ]);
422
450
  const widths = headings.map((heading, index) => Math.max(heading.length, ...values.map((row) => row[index].length)));
@@ -1733,8 +1733,8 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1733
1733
  });
1734
1734
  }
1735
1735
  const profileDefaultsApply = profile !== undefined && profile.meta.provider === existing.provider;
1736
- const profileModel = profileDefaultsApply ? profile.meta.model : undefined;
1737
- const profileEffort = profileDefaultsApply ? profile.meta.effort : undefined;
1736
+ const profileModel = profileDefaultsApply && existing.profile_settings_pinned !== true ? profile.meta.model : undefined;
1737
+ const profileEffort = profileDefaultsApply && existing.profile_settings_pinned !== true ? profile.meta.effort : undefined;
1738
1738
  const ignoredProfileFields = profile
1739
1739
  ? ignoredWorkerProfileFields(existing.provider)
1740
1740
  : [];
@@ -1765,7 +1765,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1765
1765
  : undefined;
1766
1766
  // `hq lanes set` on a running lane leaves the change pending until this spawn.
1767
1767
  const pendingSettings = existing.pending_settings;
1768
- let pinnedModel = pendingSettings?.model ?? (existing.model_source === undefined || existing.model_source === "cli" || existing.model_source === "preserved"
1768
+ let pinnedModel = pendingSettings?.model ?? (existing.profile_settings_pinned === true || existing.model_source === undefined || existing.model_source === "cli" || existing.model_source === "preserved"
1769
1769
  ? existing.model
1770
1770
  : undefined);
1771
1771
  const accountDeps = {
@@ -1929,7 +1929,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1929
1929
  : undefined;
1930
1930
  const effort = launch.effort ?? pendingResumeEffort ?? resolveLaneEffort({
1931
1931
  cliEffort: pendingSettings?.effort ?? (existing.effort_source === "cli" ? existing.effort : undefined),
1932
- profileEffort,
1932
+ profileEffort: existing.profile_settings_pinned === true ? existing.effort : profileEffort,
1933
1933
  accountEffort: accountCfg?.effort,
1934
1934
  providerEffort: config.providers[lane.provider].effort,
1935
1935
  model,
@@ -2138,7 +2138,11 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2138
2138
  ? "cli"
2139
2139
  : preservedAccountModel !== undefined || existing.model_source === "preserved"
2140
2140
  ? "preserved"
2141
- : "cli"
2141
+ : existing.model_source === "cli"
2142
+ ? "cli"
2143
+ : existing.profile_settings_pinned === true
2144
+ ? "profile"
2145
+ : "cli"
2142
2146
  : profileModel !== undefined
2143
2147
  ? "profile"
2144
2148
  : accountCfg?.model !== undefined
@@ -2483,8 +2487,8 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2483
2487
  })
2484
2488
  : undefined;
2485
2489
  const profileDefaultsApply = profile !== undefined && profile.meta.provider === lane.provider;
2486
- const profileModel = profileDefaultsApply ? profile.meta.model : undefined;
2487
- const profileEffort = profileDefaultsApply ? profile.meta.effort : undefined;
2490
+ const profileModel = profileDefaultsApply && lane.profile_settings_pinned !== true ? profile.meta.model : undefined;
2491
+ const profileEffort = profileDefaultsApply && lane.profile_settings_pinned !== true ? profile.meta.effort : undefined;
2488
2492
  const ignoredProfileFields = profile
2489
2493
  ? ignoredWorkerProfileFields(lane.provider)
2490
2494
  : [];
@@ -2506,7 +2510,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2506
2510
  ignoredFields: ignoredProfileFields,
2507
2511
  }
2508
2512
  : undefined;
2509
- const pinnedModel = lane.model_source === undefined || lane.model_source === "cli" || lane.model_source === "preserved"
2513
+ const pinnedModel = lane.profile_settings_pinned === true || lane.model_source === undefined || lane.model_source === "cli" || lane.model_source === "preserved"
2510
2514
  ? lane.model
2511
2515
  : undefined;
2512
2516
  const resolved = resolveAccount(lane.provider, lane.account, config, {
@@ -2534,7 +2538,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2534
2538
  });
2535
2539
  const effort = resolveLaneEffort({
2536
2540
  cliEffort: lane.effort_source === "cli" ? lane.effort : undefined,
2537
- profileEffort,
2541
+ profileEffort: lane.profile_settings_pinned === true ? lane.effort : profileEffort,
2538
2542
  accountEffort: accountCfg?.effort,
2539
2543
  providerEffort: config.providers[lane.provider].effort,
2540
2544
  model,
@@ -2618,7 +2622,7 @@ export function spawnEnvelopeRepair(hqRoot, lane, input, deps = {}) {
2618
2622
  if (model !== undefined) {
2619
2623
  next.model = model;
2620
2624
  next.model_source = pinnedModel !== undefined
2621
- ? lane.model_source === "preserved" ? "preserved" : "cli"
2625
+ ? lane.model_source === "preserved" ? "preserved" : lane.model_source === "profile" || lane.profile_settings_pinned === true ? "profile" : "cli"
2622
2626
  : profileModel !== undefined
2623
2627
  ? "profile"
2624
2628
  : accountCfg?.model !== undefined
@@ -652,6 +652,27 @@ export function parseLaneRecord(raw, filePath) {
652
652
  if (!isLaneProvider(raw.provider)) {
653
653
  throw new LaneSchemaError(`Unknown provider ${JSON.stringify(raw.provider)}`, { path: filePath, value: raw.provider });
654
654
  }
655
+ if (raw.provider_source !== undefined && !["cli", "profile", "default"].includes(String(raw.provider_source))) {
656
+ throw new LaneSchemaError("provider_source must be cli, profile, or default", {
657
+ path: filePath,
658
+ field: "provider_source",
659
+ value: raw.provider_source,
660
+ });
661
+ }
662
+ if (raw.account_source !== undefined && !["cli", "profile", "balanced"].includes(String(raw.account_source))) {
663
+ throw new LaneSchemaError("account_source must be cli, profile, or balanced", {
664
+ path: filePath,
665
+ field: "account_source",
666
+ value: raw.account_source,
667
+ });
668
+ }
669
+ if (raw.profile_settings_pinned !== undefined && typeof raw.profile_settings_pinned !== "boolean") {
670
+ throw new LaneSchemaError("profile_settings_pinned must be a boolean", {
671
+ path: filePath,
672
+ field: "profile_settings_pinned",
673
+ value: raw.profile_settings_pinned,
674
+ });
675
+ }
655
676
  if (raw.worker_policy_delivery !== undefined &&
656
677
  raw.worker_policy_delivery !== "hook" &&
657
678
  raw.worker_policy_delivery !== "prompt") {
@@ -867,8 +888,8 @@ export function parseLaneRecord(raw, filePath) {
867
888
  for (const [index, entry] of raw.settings_history.entries()) {
868
889
  const field = `settings_history[${index}]`;
869
890
  if (!isPlainObject(entry) ||
870
- Object.keys(entry).some((key) => !["at", "actor", "model", "effort", "reason"].includes(key)) ||
871
- (entry.model === undefined && entry.effort === undefined)) {
891
+ Object.keys(entry).some((key) => !["at", "actor", "provider", "model", "effort", "reason"].includes(key)) ||
892
+ (entry.provider === undefined && entry.model === undefined && entry.effort === undefined)) {
872
893
  throw new LaneSchemaError(`${field} must be a settings history entry`, { path: filePath, field });
873
894
  }
874
895
  requireTimestamp(entry.at, `${field}.at`, filePath);
@@ -878,6 +899,12 @@ export function parseLaneRecord(raw, filePath) {
878
899
  if (entry.reason !== undefined && (typeof entry.reason !== "string" || entry.reason.trim() === "")) {
879
900
  throw new LaneSchemaError(`${field}.reason must be a non-empty string when present`, { path: filePath, field });
880
901
  }
902
+ if (entry.provider !== undefined && (!isPlainObject(entry.provider) ||
903
+ Object.keys(entry.provider).some((key) => key !== "from" && key !== "to") ||
904
+ !isLaneProvider(entry.provider.from) ||
905
+ !isLaneProvider(entry.provider.to))) {
906
+ throw new LaneSchemaError(`${field}.provider must be a {from, to} change`, { path: filePath, field });
907
+ }
881
908
  for (const key of ["model", "effort"]) {
882
909
  const change = entry[key];
883
910
  if (change === undefined)