@indigoai-us/hq-cli 5.345.33 → 5.345.35

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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.35] — 2026-10-07
6
+ - Claude plugin installs now use the HQ packages marketplace by default; local marketplace directory and environment overrides remain available.
7
+ - Pin self-updates to the pnpm version recorded by the global install; preserve store dirs on pnpm 11+ and omit pnpm 10 virtual-store flags.
8
+
9
+ ## [5.345.34] — 2026-10-07
10
+ - Protect live remote-control tmux sessions with socket-scoped creator identity before cleanup.
11
+
12
+ - Cap the shipped hook health checker at 20 seconds when hq reindex runs from a lifecycle hook while keeping the 60-second interactive bound.
13
+ - Allow each company to configure its lane depth cap from 1 through 8. Invalid values fall back to the default cap of 4 with a stderr warning.
14
+ - Mesh services now use the stable HQ launcher after package updates or relocation. Failed service-file replacement or reload restores the previous unit.
15
+
5
16
  ## [5.345.33] — 2026-10-06
6
17
  - Map each PRD ledger state to a Board column and send its original value in `ledgerState`; `hq mesh project reconcile` reports status drift and notes when the Board omits that field.
7
18
 
@@ -47,6 +47,8 @@ export interface CompanyConfig {
47
47
  companySlug: string;
48
48
  bucketName: string;
49
49
  vaultApiUrl: string;
50
+ /** Optional per-company worker lane depth limit (1 through 8). */
51
+ maxLaneDepth?: number;
50
52
  }
51
53
  /** Final stdout JSON shape. Consumers (designate-team, AppBar) parse this. */
52
54
  export interface ProvisionResult {
@@ -19,6 +19,8 @@ export type ResolveCompanyDependencies = {
19
19
  stdin?: string;
20
20
  stdinIsTty?: boolean;
21
21
  spawnHq?: (args: string[], env: NodeJS.ProcessEnv, timeoutMs: number, cwd?: string, command?: string) => Promise<HqResult>;
22
+ onHqSpawn?: (args: readonly string[]) => void;
23
+ onHqExit?: (args: readonly string[]) => void;
22
24
  pid?: number;
23
25
  random?: () => number;
24
26
  };
@@ -1,5 +1,5 @@
1
1
  /** Native port of resolve-company.sh. This module stays on the fast import path. */
2
- import { spawn, spawnSync } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { awkCannotOpenFileDiagnostic } from "../lib/core-utils/awk-diagnostics.js";
@@ -272,30 +272,46 @@ function commandOnPath(name, env) {
272
272
  }
273
273
  return false;
274
274
  }
275
- function runHq(args, env, timeoutMs, cwd, command = "hq") {
275
+ function runHq(args, env, timeoutMs, cwd, command = "hq", onExitObserved) {
276
276
  return new Promise((resolve) => {
277
277
  let settled = false;
278
278
  let stdout = "";
279
279
  let child;
280
280
  let timeoutKill;
281
281
  let timedOut = false;
282
- const signalGroup = (signal) => {
283
- if (!child?.pid)
282
+ const signalGroup = (signal, onComplete) => {
283
+ if (!child?.pid) {
284
+ onComplete?.();
284
285
  return;
286
+ }
285
287
  try {
286
288
  if (process.platform === "win32") {
287
289
  const taskkill = path.join(env.SystemRoot ?? env.WINDIR ?? "C:\\Windows", "System32", "taskkill.exe");
288
- const result = spawnSync(taskkill, ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true });
289
- if (result.error || result.status !== 0)
290
- child.kill(signal);
290
+ let completed = false;
291
+ const complete = (code) => {
292
+ if (completed)
293
+ return;
294
+ completed = true;
295
+ if (code !== 0)
296
+ child?.kill(signal);
297
+ onComplete?.();
298
+ };
299
+ const killer = spawn(taskkill, ["/PID", String(child.pid), "/T", "/F"], {
300
+ stdio: "ignore",
301
+ windowsHide: true,
302
+ });
303
+ killer.once("error", () => complete(1));
304
+ killer.once("exit", (code) => complete(code));
291
305
  }
292
306
  else {
293
307
  process.kill(-child.pid, signal);
308
+ onComplete?.();
294
309
  }
295
310
  }
296
311
  catch (error) {
297
312
  if (errorCode(error) !== "ESRCH")
298
313
  finish({ code: 1, stdout });
314
+ onComplete?.();
299
315
  }
300
316
  };
301
317
  const finish = (result) => {
@@ -310,13 +326,13 @@ function runHq(args, env, timeoutMs, cwd, command = "hq") {
310
326
  const timer = setTimeout(() => {
311
327
  timedOut = true;
312
328
  if (child?.pid) {
313
- signalGroup("SIGTERM");
314
- // The shell wrapper returns shortly after its 2 second deadline. Do
315
- // not wait for inherited stdio handles after killing the process group.
316
- timeoutKill = setTimeout(() => {
317
- signalGroup("SIGKILL");
318
- finish({ code: 124, stdout });
319
- }, 250);
329
+ signalGroup("SIGTERM", () => {
330
+ // The shell wrapper returns shortly after its 2 second deadline. Give
331
+ // the tree time to exit, then force-kill and wait for Windows cleanup.
332
+ timeoutKill = setTimeout(() => {
333
+ signalGroup("SIGKILL", () => finish({ code: 124, stdout }));
334
+ }, 250);
335
+ });
320
336
  }
321
337
  else {
322
338
  finish({ code: 124, stdout });
@@ -349,9 +365,24 @@ function runHq(args, env, timeoutMs, cwd, command = "hq") {
349
365
  child.stdout?.setEncoding("utf8");
350
366
  child.stdout?.on("data", (chunk) => { stdout += chunk; });
351
367
  child.once("error", () => finish({ code: 127, stdout }));
368
+ if (onExitObserved)
369
+ child.once("exit", onExitObserved);
352
370
  child.once("exit", (code, signal) => {
353
- signalGroup("SIGTERM");
354
- finish({ code: timedOut ? 124 : typeof code === "number" ? code : signal ? 128 : 1, stdout });
371
+ if (timedOut)
372
+ return;
373
+ clearTimeout(timer);
374
+ const result = { code: typeof code === "number" ? code : signal ? 128 : 1, stdout };
375
+ if (process.platform === "win32") {
376
+ // Tree cleanup is asynchronous on Windows. Start it after the direct
377
+ // child exits, but let the resolver advance without waiting for
378
+ // taskkill; detached descendants must not hold up the next lookup.
379
+ signalGroup("SIGTERM");
380
+ finish(result);
381
+ return;
382
+ }
383
+ signalGroup("SIGTERM", () => {
384
+ finish(result);
385
+ });
355
386
  });
356
387
  });
357
388
  }
@@ -502,7 +533,13 @@ export async function resolveCompany(args, dependencies) {
502
533
  hqEnv.HQ_ROOT = dependencies.hqRoot;
503
534
  hqEnv.CLAUDE_PROJECT_DIR = dependencies.hqRoot;
504
535
  }
505
- const invoke = dependencies.spawnHq ?? runHq;
536
+ const invokeImplementation = dependencies.spawnHq
537
+ ? dependencies.spawnHq
538
+ : (args, childEnv, timeoutMs, cwd, command) => runHq(args, childEnv, timeoutMs, cwd, command, () => dependencies.onHqExit?.(args));
539
+ const invoke = (args, env, timeoutMs, cwd, command) => {
540
+ dependencies.onHqSpawn?.(args);
541
+ return invokeImplementation(args, env, timeoutMs, cwd, command);
542
+ };
506
543
  const defaultResponse = await invoke(["mesh", "context", "default", "get", "--json"], hqEnv, 2_000, root);
507
544
  const defaultSlug = defaultSlugFromJson(defaultResponse.stdout, knownSlugs);
508
545
  if (!defaultSlug)
@@ -39,7 +39,7 @@ export interface ClaudePluginSource {
39
39
  pluginName: string;
40
40
  pluginId: string;
41
41
  }
42
- /** Resolve the local marketplace source; published hosting remains an operator decision. */
42
+ /** Resolve local overrides first, then the published HQ marketplace. */
43
43
  export declare function resolveClaudePluginSource(explicitDir?: string, env?: NodeJS.ProcessEnv, repository?: string): ClaudePluginSource;
44
44
  export { AnywhereRuntimeDisabledError as AnywhereDisabledError };
45
45
  export declare const isAnywhereEnabled: (_home: string, env?: NodeJS.ProcessEnv) => boolean;
@@ -46,14 +46,15 @@ export function hookTargetFor(hqRoot, home, daemonInstalled, env = process.env)
46
46
  const daemonMatchesRoot = configuredRoot !== undefined && realpathOrSelf(configuredRoot) === realpathOrSelf(hqRoot);
47
47
  return installed && daemonMatchesRoot && fs.existsSync(shimPath(hqRoot)) ? 'shim' : 'master';
48
48
  }
49
- /** Resolve the local marketplace source; published hosting remains an operator decision. */
50
- export function resolveClaudePluginSource(explicitDir, env = process.env, repository) {
49
+ const HQ_PACKAGES_MARKETPLACE_REPOSITORY = 'indigoai-us/hq-packages';
50
+ /** Resolve local overrides first, then the published HQ marketplace. */
51
+ export function resolveClaudePluginSource(explicitDir, env = process.env, repository = HQ_PACKAGES_MARKETPLACE_REPOSITORY) {
51
52
  const configured = explicitDir?.trim() || env.HQ_CLAUDE_PLUGIN_MARKETPLACE?.trim();
52
53
  if (!configured) {
53
- if (repository === 'indigoai-us/hq-packages') {
54
+ if (repository === HQ_PACKAGES_MARKETPLACE_REPOSITORY) {
54
55
  return { sourceDir: repository, marketplaceName: 'hq-packages', pluginName: 'hq', pluginId: 'hq@hq-packages' };
55
56
  }
56
- throw Object.assign(new Error('Published HQ Claude plugin source is not configured; its repository/host awaits an operator decision. Pass --marketplace-dir <dir> or set HQ_CLAUDE_PLUGIN_MARKETPLACE.'), { expected: true });
57
+ throw Object.assign(new Error(`Unsupported published HQ Claude plugin repository "${repository}". Pass --marketplace-dir <dir> or set HQ_CLAUDE_PLUGIN_MARKETPLACE for a local marketplace.`), { expected: true });
57
58
  }
58
59
  const sourceDir = path.resolve(configured);
59
60
  const manifestPath = path.join(sourceDir, '.claude-plugin', 'marketplace.json');
@@ -33,6 +33,7 @@ import { registerLaneReviewCommand } from "./lanes-review.js";
33
33
  import { ensureCognitoToken } from "../utils/cognito-session.js";
34
34
  import { withCliFeatureUsed } from "../utils/cli-telemetry.js";
35
35
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
36
+ import { laneDepthCap } from "../lib/lanes/depth-cap.js";
36
37
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
37
38
  import { runResidentCommandWithPackageLease } from "../utils/resident-package-preload.js";
38
39
  import { readLanesFlagLocalOverride, readLanesRegistryFlag, readLanesRegistryFlagFailOff } from "../lib/lanes/registry-flag.js";
@@ -2070,12 +2071,12 @@ function seniorLaneChain(hqRoot, ref, ctx, visited = new Set(), override) {
2070
2071
  const depth = isOverridden ? override.depth : derivedLaneDepth(hqRoot, lane.senior, ctx, override);
2071
2072
  return [...prefix, { lane_id: lane.lane_id, depth }];
2072
2073
  }
2073
- function maxLaneDepthError(depth, chain, description, extraDetails = {}) {
2074
- const parentLane = [...chain].reverse().find((entry) => entry.depth < MAX_LANE_DEPTH);
2074
+ function maxLaneDepthError(depth, chain, description, cap, extraDetails = {}) {
2075
+ const parentLane = [...chain].reverse().find((entry) => entry.depth < cap.max);
2075
2076
  const nextStep = parentLane
2076
2077
  ? `Ask your senior lane ${parentLane.lane_id} to create this worker instead, or re-parent this lane under a shallower senior.`
2077
2078
  : "Ask a shallower senior to create this lane, or re-parent it to a shallower senior.";
2078
- return new LanesError("max_lane_depth", `${description}: senior chain length ${depth} exceeds cap ${MAX_LANE_DEPTH}`, { ...extraDetails, depth, max: MAX_LANE_DEPTH, senior_chain: chain, next_step: nextStep });
2079
+ return new LanesError("max_lane_depth", `${description}: senior chain length ${depth} exceeds cap ${cap.max} (${cap.source === "company" ? "company config" : "default config"})`, { ...extraDetails, depth, max: cap.max, source: cap.source, senior_chain: chain, next_step: nextStep });
2079
2080
  }
2080
2081
  function callerDepth(hqRoot, ctx) {
2081
2082
  const laneId = ctx.env.HQ_LANE_ID?.trim();
@@ -3068,21 +3069,22 @@ async function runCreate(ctx, opts) {
3068
3069
  }
3069
3070
  assertKnownOutpost(requested, known);
3070
3071
  }
3072
+ let company = companyFlag
3073
+ ? await ctx.resolveCompany(companyFlag)
3074
+ : null;
3075
+ const depthCap = laneDepthCap(hqRoot, company?.slug);
3071
3076
  const depth = callerDepth(hqRoot, ctx);
3072
- if (depth >= MAX_LANE_DEPTH) {
3077
+ if (depth >= depthCap.max) {
3073
3078
  const callerLaneId = ctx.env.HQ_LANE_ID?.trim();
3074
3079
  const chain = callerLaneId
3075
3080
  ? seniorLaneChain(hqRoot, { kind: "lane", id: callerLaneId }, ctx)
3076
3081
  : seniorLaneChain(hqRoot, sessionIdFromEnv(ctx.env)
3077
3082
  ? { kind: "session", id: sessionIdFromEnv(ctx.env) }
3078
3083
  : { kind: "user" }, ctx);
3079
- throw maxLaneDepthError(depth + 1, chain, `A depth-${depth} caller may not spawn a depth-${depth + 1} lane`);
3084
+ throw maxLaneDepthError(depth + 1, chain, `A depth-${depth} caller may not spawn a depth-${depth + 1} lane`, depthCap);
3080
3085
  }
3081
3086
  const senior = opts.senior ? parseSeniorRef(opts.senior) : defaultSenior(ctx);
3082
3087
  assertSeniorExists(hqRoot, senior, ctx);
3083
- let company = companyFlag
3084
- ? await ctx.resolveCompany(companyFlag)
3085
- : null;
3086
3088
  const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
3087
3089
  if (company === null && seniorCompany.binding === "company") {
3088
3090
  const seniorCompanyIdentifier = seniorCompany.slug ?? (seniorCompany.uid
@@ -3104,8 +3106,8 @@ async function runCreate(ctx, opts) {
3104
3106
  }
3105
3107
  const hireGrant = requestedHireGrant ?? 0;
3106
3108
  const laneDepth = derivedLaneDepth(hqRoot, senior, ctx);
3107
- if (laneDepth > MAX_LANE_DEPTH) {
3108
- throw maxLaneDepthError(laneDepth, seniorLaneChain(hqRoot, senior, ctx), `Creating lane at depth ${laneDepth} is refused`);
3109
+ if (laneDepth > depthCap.max) {
3110
+ throw maxLaneDepthError(laneDepth, seniorLaneChain(hqRoot, senior, ctx), `Creating lane at depth ${laneDepth} is refused`, depthCap);
3109
3111
  }
3110
3112
  // A no-company lane must not call company resolution (openMeshTransport).
3111
3113
  if (storyIds.length > 0 && ctx.storyLedgerEnabled()) {
@@ -7249,10 +7251,11 @@ function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount = false
7249
7251
  throw new LanesError("senior_cycle", `Re-parent would create a cycle through ${laneId}`, { lane_id: laneId, senior: nextSenior });
7250
7252
  }
7251
7253
  const newDepth = derivedLaneDepth(hqRoot, nextSenior, ctx);
7252
- if (newDepth > MAX_LANE_DEPTH) {
7254
+ const depthCap = laneDepthCap(hqRoot, lane.company?.slug);
7255
+ if (newDepth > depthCap.max) {
7253
7256
  const chain = seniorLaneChain(hqRoot, nextSenior, ctx);
7254
7257
  chain.push({ lane_id: lane.lane_id, depth: newDepth });
7255
- throw maxLaneDepthError(newDepth, chain, `Re-parent would put lane at depth ${newDepth}`);
7258
+ throw maxLaneDepthError(newDepth, chain, `Re-parent would put lane at depth ${newDepth}`, depthCap);
7256
7259
  }
7257
7260
  const pending = pendingQuestionsForLane(hqRoot, laneId);
7258
7261
  const oldDepth = derivedLaneDepth(hqRoot, lane.senior, ctx);
@@ -7269,10 +7272,11 @@ function runReparentLocked(ctx, laneId, opts, finishedChildrenDoNotCount = false
7269
7272
  assertLaneRunsHereForOperatorMutation(ctx, other, "reparent descendant of");
7270
7273
  const oldDescendantDepth = derivedLaneDepth(hqRoot, other.senior, ctx);
7271
7274
  const descendantDepth = derivedLaneDepth(hqRoot, other.senior, ctx, override);
7272
- if (descendantDepth > MAX_LANE_DEPTH) {
7275
+ const descendantCap = laneDepthCap(hqRoot, other.company?.slug);
7276
+ if (descendantDepth > descendantCap.max) {
7273
7277
  const chain = seniorLaneChain(hqRoot, other.senior, ctx, new Set(), override);
7274
7278
  chain.push({ lane_id: other.lane_id, depth: descendantDepth });
7275
- throw maxLaneDepthError(descendantDepth, chain, `Re-parent would put descendant ${other.lane_id} at depth ${descendantDepth}`, { lane_id: other.lane_id });
7279
+ throw maxLaneDepthError(descendantDepth, chain, `Re-parent would put descendant ${other.lane_id} at depth ${descendantDepth}`, descendantCap, { lane_id: other.lane_id });
7276
7280
  }
7277
7281
  if (descendantDepth > oldDescendantDepth &&
7278
7282
  pendingQuestionsForLane(hqRoot, other.lane_id).length > 0) {
@@ -72,10 +72,12 @@ export function registerPackageInstallCommand(parent) {
72
72
  if (opts.via === 'plugin' &&
73
73
  !marketplaceDir?.trim() &&
74
74
  !process.env.HQ_CLAUDE_PLUGIN_MARKETPLACE?.trim()) {
75
- if (await anywhereRuntimeAllowed(process.env)) {
76
- await runInstallGlobal({ ...opts, marketplaceRepository: 'indigoai-us/hq-packages' });
77
- return;
75
+ if (!(await anywhereRuntimeAllowed(process.env))) {
76
+ console.error(chalk.red('Install failed:'), 'HQ Anywhere plugin installation requires both the hq-anywhere-runtime flag and your person setting.');
77
+ process.exit(1);
78
78
  }
79
+ await runInstallGlobal({ ...opts, marketplaceRepository: 'indigoai-us/hq-packages' });
80
+ return;
79
81
  }
80
82
  await runInstallGlobal({ ...opts, marketplaceDir });
81
83
  return;
@@ -47,6 +47,9 @@ const WORKTREE_HOOK_SWEEP_BUDGET_MS = 5_000;
47
47
  * bounded, which is the point.
48
48
  */
49
49
  const HOOK_CHECK_TIMEOUT_MS = 60_000;
50
+ // Leave ten seconds inside the desktop's 30-second package-lease wait for
51
+ // process startup and cleanup when reindex runs from a lifecycle hook.
52
+ const HOOK_CHECK_FROM_HOOK_TIMEOUT_MS = 20_000;
50
53
  /** Ceiling override, in milliseconds. Present so tests need not wait a minute. */
51
54
  function hookCheckTimeoutMs() {
52
55
  const raw = process.env.HQ_HOOK_CHECK_TIMEOUT_MS?.trim();
@@ -367,11 +370,11 @@ function hasCommandHook(value) {
367
370
  * an older, unscoped checker — report healthy while the drift only this checker
368
371
  * detects goes unseen.
369
372
  */
370
- function runShippedHookCheck(hqRoot) {
373
+ function runShippedHookCheck(hqRoot, timeoutLimitMs) {
371
374
  const checker = path.join(hqRoot, HOOK_CHECK_RELATIVE_PATH);
372
375
  if (!fs.existsSync(checker))
373
376
  return undefined;
374
- const budgetMs = hookCheckTimeoutMs();
377
+ const budgetMs = Math.min(hookCheckTimeoutMs(), timeoutLimitMs ?? Number.POSITIVE_INFINITY);
375
378
  try {
376
379
  const result = spawnSync('bash', [checker, '--root', hqRoot], {
377
380
  encoding: 'utf8',
@@ -406,7 +409,7 @@ function runShippedHookCheck(hqRoot) {
406
409
  * reindex fires on every Stop / PostToolUse hook, that was the single most
407
410
  * expensive discarded result in the command.
408
411
  */
409
- function inspectHookHealth(hqRoot) {
412
+ function inspectHookHealth(hqRoot, timeoutLimitMs) {
410
413
  const settingsPath = path.join(hqRoot, '.claude', 'settings.json');
411
414
  if (!fs.existsSync(settingsPath)) {
412
415
  return { state: 'extreme', reason: '.claude/settings.json is missing' };
@@ -435,7 +438,7 @@ function inspectHookHealth(hqRoot) {
435
438
  reason: `missing command hook wiring for ${HOOK_EVENTS.filter((event) => !commandEvents.includes(event)).join(', ')}`,
436
439
  };
437
440
  }
438
- const checkerResult = runShippedHookCheck(hqRoot);
441
+ const checkerResult = runShippedHookCheck(hqRoot, timeoutLimitMs);
439
442
  if (checkerResult !== undefined && checkerResult.status !== 0) {
440
443
  return {
441
444
  state: 'minor',
@@ -491,12 +494,12 @@ function runSilently(operation) {
491
494
  * configuration is repaired only after a successful reindex; partial and
492
495
  * malformed configurations remain untouched and receive recovery guidance.
493
496
  */
494
- function repairExtremeHookDrift(hqRoot, allowRepair = true) {
497
+ function repairExtremeHookDrift(hqRoot, allowRepair = true, timeoutLimitMs) {
495
498
  // `hq reindex` can still be invoked from an arbitrary directory. Never turn
496
499
  // an absent settings file there into a rescue attempt.
497
500
  if (!isHqRoot(hqRoot))
498
501
  return;
499
- const health = inspectHookHealth(hqRoot);
502
+ const health = inspectHookHealth(hqRoot, timeoutLimitMs);
500
503
  if (health.state === 'healthy')
501
504
  return;
502
505
  printHookHealthWarning(hqRoot, health.reason);
@@ -510,7 +513,7 @@ function repairExtremeHookDrift(hqRoot, allowRepair = true) {
510
513
  paths: ['.claude'],
511
514
  assumeYes: true,
512
515
  }));
513
- const afterRepair = inspectHookHealth(hqRoot);
516
+ const afterRepair = inspectHookHealth(hqRoot, timeoutLimitMs);
514
517
  if (result.status === 0 && afterRepair.state === 'healthy') {
515
518
  console.log('reindex: repaired HQ hook config');
516
519
  return;
@@ -691,7 +694,7 @@ export function registerReindexCommand(program) {
691
694
  const sweepStartedAt = Date.now();
692
695
  removeStaleHqWorktrees(hqRoot, sweepStartedAt, opts.fromHook ? sweepStartedAt + WORKTREE_HOOK_SWEEP_BUDGET_MS : Number.POSITIVE_INFINITY);
693
696
  const { status } = reindex({ repoRoot: opts.repoRoot });
694
- repairExtremeHookDrift(hqRoot, status === 0);
697
+ repairExtremeHookDrift(hqRoot, status === 0, opts.fromHook ? HOOK_CHECK_FROM_HOOK_TIMEOUT_MS : undefined);
695
698
  if (status === 0) {
696
699
  // Only safe once reindex actually ran: a refused --from-hook reindex
697
700
  // means a sync/rescue holds the shared operation lock and may be
@@ -24,6 +24,7 @@ import * as fs from "node:fs";
24
24
  import * as os from "node:os";
25
25
  import * as path from "node:path";
26
26
  const TMUX_SOCKET = "hq-daemon";
27
+ const TMUX_CREATOR_OPTION = "@hq-remote-control-creator";
27
28
  export const TMUX_SESSION = "remote-control";
28
29
  export const CODEX_TMUX_SESSION = "codex-remote-control";
29
30
  export function remoteControlArgs(opts) {
@@ -204,7 +205,56 @@ function safePaneTail(output) {
204
205
  }
205
206
  export async function runRemoteControlHost(deps) {
206
207
  const tmux = (...args) => deps.run(deps.tmux, ["-L", TMUX_SOCKET, ...args]);
207
- const killSession = () => tmux("kill-session", "-t", deps.session);
208
+ const invokerPid = process.pid;
209
+ const killSession = () => {
210
+ if (tmux("has-session", "-t", deps.session).status !== 0)
211
+ return true;
212
+ const rawCreator = tmux("show-option", "-qv", "-t", deps.session, TMUX_CREATOR_OPTION);
213
+ let creator = null;
214
+ let legacyRecordReason = "no creator record";
215
+ if (rawCreator.status === 0 && rawCreator.stdout.trim() !== "") {
216
+ try {
217
+ const value = JSON.parse(rawCreator.stdout);
218
+ if (Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.cwd === "string") {
219
+ creator = { pid: value.pid, cwd: value.cwd };
220
+ }
221
+ else {
222
+ legacyRecordReason = "malformed creator record";
223
+ }
224
+ }
225
+ catch {
226
+ legacyRecordReason = "malformed creator record";
227
+ }
228
+ }
229
+ else if (rawCreator.status !== 0) {
230
+ legacyRecordReason = "malformed creator record";
231
+ }
232
+ if (!creator) {
233
+ deps.log(`remote-control tmux session has ${legacyRecordReason}; using bridge-owner liveness fallback; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
234
+ const owner = foreignClaudeOwner(deps);
235
+ if (owner !== null) {
236
+ deps.log(`remote-control refused to kill legacy tmux session with live Claude bridge owner pid=${owner}; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
237
+ return false;
238
+ }
239
+ const panePid = tmux("display-message", "-p", "-t", deps.session, "#{pane_pid}");
240
+ const paneCwd = tmux("display-message", "-p", "-t", deps.session, "#{pane_current_path}");
241
+ const victimPid = panePid.status === 0 && /^\d+$/.test(panePid.stdout.trim())
242
+ ? panePid.stdout.trim()
243
+ : "unknown";
244
+ const victimCwd = paneCwd.status === 0 && paneCwd.stdout.trim() !== ""
245
+ ? paneCwd.stdout.trim()
246
+ : "unknown";
247
+ deps.log(`remote-control kill tmux session victim pid=${victimPid} cwd=${JSON.stringify(victimCwd)} invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
248
+ return tmux("kill-session", "-t", deps.session).status === 0;
249
+ }
250
+ const sameInvoker = creator.pid === invokerPid && creator.cwd === deps.cwd;
251
+ if (!sameInvoker && (deps.pidAlive ?? processAlive)(creator.pid)) {
252
+ deps.log(`remote-control refused to kill live tmux session creator pid=${creator.pid} cwd=${JSON.stringify(creator.cwd)}; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
253
+ return false;
254
+ }
255
+ deps.log(`remote-control kill tmux session victim pid=${creator.pid} cwd=${JSON.stringify(creator.cwd)} invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
256
+ return tmux("kill-session", "-t", deps.session).status === 0;
257
+ };
208
258
  /** When the session last printed anything, in ms; null when tmux cannot say. */
209
259
  const lastActivity = () => {
210
260
  const r = tmux("display-message", "-p", "-t", deps.session, "#{window_activity}");
@@ -213,7 +263,8 @@ export async function runRemoteControlHost(deps) {
213
263
  };
214
264
  // Remove this daemon's leftover private session before checking the process
215
265
  // pointer, so its previous command is not mistaken for a foreign owner.
216
- killSession();
266
+ if (!killSession())
267
+ return 1;
217
268
  let waitingFor = null;
218
269
  for (;;) {
219
270
  if (deps.signal.aborted)
@@ -233,6 +284,14 @@ export async function runRemoteControlHost(deps) {
233
284
  deps.log(`could not create the tmux session: ${created.stderr.trim() || `tmux exited ${created.status}`}`);
234
285
  return 1;
235
286
  }
287
+ const creatorRecord = JSON.stringify({ pid: invokerPid, cwd: deps.cwd });
288
+ const recorded = tmux("set-option", "-t", deps.session, TMUX_CREATOR_OPTION, creatorRecord);
289
+ if (recorded.status !== 0) {
290
+ deps.log(`remote-control could not record session creator; invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
291
+ deps.log(`remote-control kill tmux session victim pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)} invoker pid=${invokerPid} cwd=${JSON.stringify(deps.cwd)}`);
292
+ tmux("kill-session", "-t", deps.session);
293
+ return 1;
294
+ }
236
295
  const retained = tmux("set-window-option", "-t", deps.session, "remain-on-exit", "on");
237
296
  const started = retained.status === 0
238
297
  ? tmux("respawn-pane", "-k", "-t", deps.session, ...deps.argv)
@@ -242,7 +301,7 @@ export async function runRemoteControlHost(deps) {
242
301
  deps.log(`could not start the tmux session: ${started.stderr.trim() || `tmux exited ${started.status}`}`);
243
302
  return 1;
244
303
  }
245
- deps.log(`${deps.label} started in ${deps.cwd}`);
304
+ deps.log(`${deps.label} started in ${JSON.stringify(deps.cwd)} (creator pid ${invokerPid})`);
246
305
  const startedWith = deps.binaryFingerprint();
247
306
  let updatedAt = null;
248
307
  for (;;) {
@@ -28,7 +28,9 @@ export declare function titleize(slug: string): string;
28
28
  export declare function date(now?: Date): string;
29
29
  export declare function timestamp(now?: Date): string;
30
30
  /** Write via a same-directory temporary file, then atomically replace the destination. */
31
- export declare function atomicWrite(file: string, content: string): void;
31
+ export declare function atomicWrite(file: string, content: string | Buffer, options?: {
32
+ mode?: number;
33
+ }): void;
32
34
  export declare function write(context: RenderContext, relative: string, content: string): string;
33
35
  /** Drop keep fences, the generated stamp, and the rolling date so two generated bodies can be compared. */
34
36
  export declare function generatedFingerprint(content: string): string;
@@ -78,11 +78,14 @@ export function titleize(slug) {
78
78
  export function date(now = new Date()) { return now.toISOString().slice(0, 10); }
79
79
  export function timestamp(now = new Date()) { return now.toISOString().replace(/\.\d{3}Z$/, "Z"); }
80
80
  /** Write via a same-directory temporary file, then atomically replace the destination. */
81
- export function atomicWrite(file, content) {
81
+ export function atomicWrite(file, content, options = {}) {
82
82
  fs.mkdirSync(path.dirname(file), { recursive: true });
83
83
  const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`);
84
84
  try {
85
- fs.writeFileSync(temporary, content, "utf8");
85
+ if (typeof content === "string")
86
+ fs.writeFileSync(temporary, content, { encoding: "utf8", mode: options.mode });
87
+ else
88
+ fs.writeFileSync(temporary, content, { mode: options.mode });
86
89
  fs.renameSync(temporary, file);
87
90
  }
88
91
  catch (error) {
@@ -0,0 +1,7 @@
1
+ export interface LaneDepthCap {
2
+ max: number;
3
+ source: "default" | "company";
4
+ }
5
+ /** Read the optional maxLaneDepth from companies/<slug>/.hq/config.json. */
6
+ export declare function laneDepthCap(hqRoot: string, companySlug?: string | null): LaneDepthCap;
7
+ //# sourceMappingURL=depth-cap.d.ts.map
@@ -0,0 +1,41 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { LanesError } from "./errors.js";
4
+ import { MAX_LANE_DEPTH } from "./constants.js";
5
+ /** Read the optional maxLaneDepth from companies/<slug>/.hq/config.json. */
6
+ export function laneDepthCap(hqRoot, companySlug) {
7
+ if (!companySlug)
8
+ return { max: MAX_LANE_DEPTH, source: "default" };
9
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(companySlug)) {
10
+ throw new LanesError("lane_depth_config_invalid", `Invalid company slug for lane depth config: ${JSON.stringify(companySlug)}`, { company: companySlug });
11
+ }
12
+ const file = path.join(hqRoot, "companies", companySlug, ".hq", "config.json");
13
+ let raw;
14
+ try {
15
+ raw = fs.readFileSync(file, "utf8");
16
+ }
17
+ catch (err) {
18
+ if (err.code === "ENOENT")
19
+ return { max: MAX_LANE_DEPTH, source: "default" };
20
+ throw new LanesError("lane_depth_config_unreadable", `Failed to read company lane depth config ${file}: ${err instanceof Error ? err.message : String(err)}`, { company: companySlug, path: file });
21
+ }
22
+ let parsed;
23
+ try {
24
+ parsed = JSON.parse(raw);
25
+ }
26
+ catch (err) {
27
+ throw new LanesError("lane_depth_config_invalid", `Invalid JSON in company config ${file}: ${err instanceof Error ? err.message : String(err)}`, { company: companySlug, path: file });
28
+ }
29
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
30
+ throw new LanesError("lane_depth_config_invalid", `Company config ${file} must be a JSON object`, { company: companySlug, path: file });
31
+ }
32
+ const value = parsed.maxLaneDepth;
33
+ if (value === undefined)
34
+ return { max: MAX_LANE_DEPTH, source: "default" };
35
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 8) {
36
+ process.stderr.write(`hq lanes: lane_depth_cap_invalid company=${companySlug} maxLaneDepth=${JSON.stringify(value)}; using default ${MAX_LANE_DEPTH}\n`);
37
+ return { max: MAX_LANE_DEPTH, source: "default" };
38
+ }
39
+ return { max: value, source: "company" };
40
+ }
41
+ //# sourceMappingURL=depth-cap.js.map
@@ -132,7 +132,7 @@ export declare function timeoutPath(hqRoot: string, laneId: string): string;
132
132
  export declare function laneRunsHere(lane: LaneRecord, host: ReconcileHost): boolean;
133
133
  export declare function agedAdvanceCount(question: QuestionRecord): number;
134
134
  export declare function currentHopStartedAtMs(question: QuestionRecord): number;
135
- export declare function shouldAgeQuestion(question: QuestionRecord, audience: SeniorRef, nowMs: number, ageWindowMs?: number): boolean;
135
+ export declare function shouldAgeQuestion(question: QuestionRecord, audience: SeniorRef, nowMs: number, ageWindowMs?: number, maxAgedAdvances?: number): boolean;
136
136
  export declare function shouldAdvanceAudienceEnded(audience: SeniorRef, liveness: LivenessState | null): boolean;
137
137
  export declare function readTimeoutDeadline(hqRoot: string, laneId: string): {
138
138
  deadline_at: string;
@@ -14,6 +14,7 @@ import { effectiveAccountHome } from "./account-home.js";
14
14
  import { enforceRegistryApprovalGate } from "./approval-gate.js";
15
15
  import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
16
16
  import { MAX_LANE_DEPTH } from "./constants.js";
17
+ import { laneDepthCap } from "./depth-cap.js";
17
18
  import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
18
19
  import { loadWorkerProfile } from "./profile.js";
19
20
  import { terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./grants.js";
@@ -155,12 +156,12 @@ export function currentHopStartedAtMs(question) {
155
156
  const ms = Date.parse(raw);
156
157
  return Number.isFinite(ms) ? ms : Number.NEGATIVE_INFINITY;
157
158
  }
158
- export function shouldAgeQuestion(question, audience, nowMs, ageWindowMs = QUESTION_AGE_MS) {
159
+ export function shouldAgeQuestion(question, audience, nowMs, ageWindowMs = QUESTION_AGE_MS, maxAgedAdvances = MAX_AGED_ADVANCES) {
159
160
  if (question.status !== "pending")
160
161
  return false;
161
162
  if (audience.kind === "user")
162
163
  return false;
163
- if (agedAdvanceCount(question) >= MAX_AGED_ADVANCES)
164
+ if (agedAdvanceCount(question) >= maxAgedAdvances)
164
165
  return false;
165
166
  const started = currentHopStartedAtMs(question);
166
167
  if (started === Number.NEGATIVE_INFINITY)
@@ -1121,16 +1122,26 @@ export async function runPassBody(hqRoot, deps) {
1121
1122
  }
1122
1123
  continue;
1123
1124
  }
1124
- if (shouldAgeQuestion(question, audience, nowMs, questionAgeWindowMs(audience))) {
1125
+ let maxAgedAdvances;
1126
+ try {
1127
+ maxAgedAdvances = laneDepthCap(hqRoot, question.company?.slug).max * 2;
1128
+ }
1129
+ catch (err) {
1130
+ if (!(err instanceof LanesError))
1131
+ throw err;
1132
+ maxAgedAdvances = MAX_AGED_ADVANCES;
1133
+ process.stderr.write(`hq lanes reconcile: lane depth config failed for company=${JSON.stringify(question.company?.slug)}; using default ${MAX_LANE_DEPTH}: ${err.message}\n`);
1134
+ }
1135
+ if (shouldAgeQuestion(question, audience, nowMs, questionAgeWindowMs(audience), maxAgedAdvances)) {
1125
1136
  const nextHop = question.hop + 1;
1126
1137
  const nextAudience = resolveQuestionAudience({ ...question, hop: nextHop }, readLaneSenior, readSessionSenior);
1127
1138
  const result = casQuestionIf(hqRoot, question.question_id, (current) => {
1128
1139
  if (current.status !== "pending" ||
1129
- agedAdvanceCount(current) >= MAX_AGED_ADVANCES) {
1140
+ agedAdvanceCount(current) >= maxAgedAdvances) {
1130
1141
  return false;
1131
1142
  }
1132
1143
  const currentAudience = resolveQuestionAudience(current, readLaneSenior, readSessionSenior);
1133
- return shouldAgeQuestion(current, currentAudience, nowMs, questionAgeWindowMs(currentAudience));
1144
+ return shouldAgeQuestion(current, currentAudience, nowMs, questionAgeWindowMs(currentAudience), maxAgedAdvances);
1134
1145
  }, (current) => {
1135
1146
  const entry = {
1136
1147
  hop: current.hop + 1,