@indigoai-us/hq-cli 5.276.0 → 5.278.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 (83) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/assets/scaffold/core/scripts/hq-delegate-pickup.sh +99 -0
  3. package/assets/scaffold/core/scripts/hq-delegate-repo.sh +158 -0
  4. package/assets/scaffold/core/scripts/hq-delegate-resolve.sh +145 -0
  5. package/assets/scaffold/core/scripts/hq-delegate-secrets.sh +129 -0
  6. package/assets/scaffold/core/scripts/hq-delegate-transfer.sh +175 -0
  7. package/assets/scaffold/core/scripts/lib/secret-patterns.sh +58 -0
  8. package/dist/command-catalog.generated.d.ts +239 -1
  9. package/dist/command-catalog.generated.js +302 -1
  10. package/dist/commands/core-delegate.d.ts +6 -0
  11. package/dist/commands/core-delegate.js +987 -0
  12. package/dist/commands/core.js +10 -0
  13. package/dist/commands/daemon.d.ts +3 -0
  14. package/dist/commands/daemon.js +173 -1
  15. package/dist/commands/dm.d.ts +2 -0
  16. package/dist/commands/dm.js +11 -4
  17. package/dist/commands/lanes-accounts.js +44 -2
  18. package/dist/commands/lanes-message.js +69 -12
  19. package/dist/commands/lanes-resume.js +2 -0
  20. package/dist/commands/lanes-shared.d.ts +3 -0
  21. package/dist/commands/lanes-shared.js +12 -2
  22. package/dist/commands/lanes.d.ts +5 -1
  23. package/dist/commands/lanes.js +399 -47
  24. package/dist/commands/secrets.d.ts +16 -1
  25. package/dist/commands/secrets.js +77 -48
  26. package/dist/commands/sync-mode.d.ts +11 -0
  27. package/dist/commands/sync-mode.js +30 -0
  28. package/dist/fast-scaffold.d.ts +17 -0
  29. package/dist/fast-scaffold.js +91 -0
  30. package/dist/index.js +17 -1
  31. package/dist/lanes-drain-hook.js +154 -7
  32. package/dist/lib/agent-kit/run/inbox.js +3 -2
  33. package/dist/lib/checkpoint-ontology/apply.d.ts +6 -0
  34. package/dist/lib/checkpoint-ontology/apply.js +111 -3
  35. package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
  36. package/dist/lib/checkpoint-ontology/plan.js +23 -0
  37. package/dist/lib/checkpoint-ontology/tree.d.ts +2 -0
  38. package/dist/lib/checkpoint-ontology/tree.js +34 -0
  39. package/dist/lib/core-utils/jq-json-serializer.d.ts +5 -0
  40. package/dist/lib/core-utils/jq-json-serializer.js +10 -0
  41. package/dist/lib/core-utils/secret-patterns.d.ts +41 -0
  42. package/dist/lib/core-utils/secret-patterns.js +33 -0
  43. package/dist/lib/daemon/catalog.d.ts +18 -2
  44. package/dist/lib/daemon/catalog.js +18 -2
  45. package/dist/lib/daemon/config.d.ts +6 -0
  46. package/dist/lib/daemon/config.js +15 -2
  47. package/dist/lib/daemon/requests.d.ts +4 -0
  48. package/dist/lib/daemon/requests.js +2 -0
  49. package/dist/lib/daemon/run.js +94 -8
  50. package/dist/lib/daemon/supervisor.d.ts +8 -0
  51. package/dist/lib/daemon/supervisor.js +101 -1
  52. package/dist/lib/daemon/sync-runner.d.ts +58 -0
  53. package/dist/lib/daemon/sync-runner.js +277 -0
  54. package/dist/lib/doctor/__testing__/run-doctor.d.ts +8 -0
  55. package/dist/lib/doctor/__testing__/run-doctor.js +52 -0
  56. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  57. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  58. package/dist/lib/lanes/claude-balance.d.ts +6 -1
  59. package/dist/lib/lanes/claude-balance.js +46 -8
  60. package/dist/lib/lanes/config.d.ts +2 -1
  61. package/dist/lib/lanes/config.js +31 -0
  62. package/dist/lib/lanes/hire-grant-dispatch.d.ts +2 -0
  63. package/dist/lib/lanes/hire-grant-dispatch.js +41 -0
  64. package/dist/lib/lanes/index.d.ts +1 -1
  65. package/dist/lib/lanes/keep-alive.d.ts +3 -0
  66. package/dist/lib/lanes/keep-alive.js +3 -0
  67. package/dist/lib/lanes/outpost-dispatch.d.ts +4 -0
  68. package/dist/lib/lanes/outpost-dispatch.js +11 -2
  69. package/dist/lib/lanes/reconcile.d.ts +9 -0
  70. package/dist/lib/lanes/reconcile.js +127 -2
  71. package/dist/lib/lanes/runaway-tool-guard.d.ts +38 -0
  72. package/dist/lib/lanes/runaway-tool-guard.js +284 -0
  73. package/dist/lib/lanes/spawn.js +17 -10
  74. package/dist/lib/lanes/store.js +45 -1
  75. package/dist/lib/lanes/types.d.ts +37 -1
  76. package/dist/lib/lanes/types.js +2 -2
  77. package/dist/lib/lanes/watcher.d.ts +2 -0
  78. package/dist/lib/lanes/watcher.js +13 -2
  79. package/dist/secrets-env-fast-path.d.ts +5 -0
  80. package/dist/secrets-env-fast-path.js +16 -0
  81. package/dist/secrets-env-fast-route.d.ts +18 -0
  82. package/dist/secrets-env-fast-route.js +54 -0
  83. package/package.json +2 -2
@@ -3,6 +3,7 @@ import type { HostCapacityAccount, HostCapacitySnapshot } from "./host-capacity.
3
3
  import type { LaneHost, LaneRecord, LanesConfig } from "./types.js";
4
4
  export declare const BALANCE_LIVE_STATES: readonly ["running", "queued", "awaiting_input"];
5
5
  export declare const BALANCE_MOVABLE_STATES: readonly ["interrupted", "done", "awaiting_input"];
6
+ export declare const CLAUDE_RECENT_USAGE_WINDOW_MS: number;
6
7
  export interface BalanceHostSnapshot {
7
8
  capacity: HostCapacitySnapshot;
8
9
  lanes: readonly LaneRecord[];
@@ -39,11 +40,12 @@ export interface ClaudeAccountLoad {
39
40
  live_lanes: number;
40
41
  counts_complete: boolean;
41
42
  usage: NonNullable<HostCapacityAccount["usage"]> | null;
43
+ recent_usage_at: string | null;
42
44
  hosts: AccountHostLoad[];
43
45
  }
44
46
  export interface ClaudeAccountSelection {
45
47
  account: string;
46
- signal: "usage" | "lane_count" | "explicit";
48
+ signal: "usage" | "recent_usage" | "lane_count" | "explicit";
47
49
  reason: "least_loaded" | "explicit";
48
50
  target_host: LaneHost;
49
51
  considered: ClaudeAccountLoad[];
@@ -72,6 +74,9 @@ export declare function selectClaudeAccount(input: {
72
74
  model?: string;
73
75
  /** Keep the recorded account eligible for its already-running CLI model. */
74
76
  recordedModelAccount?: string;
77
+ /** Recent completed-run tiebreak; callers pass the default-off hq-flags result. */
78
+ recentUsageEnabled?: boolean;
79
+ now?: Date;
75
80
  explicitAccount?: string;
76
81
  unavailableHosts?: readonly BalanceHostGap[];
77
82
  }): ClaudeAccountSelection;
@@ -1,6 +1,7 @@
1
1
  import { LanesError } from "./errors.js";
2
2
  export const BALANCE_LIVE_STATES = ["running", "queued", "awaiting_input"];
3
3
  export const BALANCE_MOVABLE_STATES = ["interrupted", "done", "awaiting_input"];
4
+ export const CLAUDE_RECENT_USAGE_WINDOW_MS = 5 * 60 * 60 * 1000;
4
5
  function sameHost(a, b) {
5
6
  return a.kind === b.kind && a.id === b.id;
6
7
  }
@@ -74,10 +75,31 @@ function usageScore(usage) {
74
75
  return null;
75
76
  return Math.max(usage.five_hour_percent, usage.weekly_percent);
76
77
  }
77
- function selectionSignal(loads) {
78
- return loads.length > 0 && loads.every((load) => usageScore(load.usage) !== null)
79
- ? "usage"
80
- : "lane_count";
78
+ function recentUsageAt(hosts, account, now) {
79
+ if (hosts.some((host) => !host.laneListAvailable))
80
+ return null;
81
+ const nowMs = now.getTime();
82
+ const cutoff = nowMs - CLAUDE_RECENT_USAGE_WINDOW_MS;
83
+ let latestAt = null;
84
+ let latestMs = cutoff - 1;
85
+ for (const host of hosts) {
86
+ for (const lane of host.lanes) {
87
+ if (lane.provider !== "claude" || lane.account !== account ||
88
+ lane.last_run_usage === undefined || lane.last_run_usage_at === undefined)
89
+ continue;
90
+ const atMs = Date.parse(lane.last_run_usage_at);
91
+ if (!Number.isFinite(atMs) || atMs < cutoff || atMs > nowMs || atMs <= latestMs)
92
+ continue;
93
+ latestMs = atMs;
94
+ latestAt = lane.last_run_usage_at;
95
+ }
96
+ }
97
+ return latestAt;
98
+ }
99
+ function selectionSignal(loads, recentUsageEnabled, laneSnapshotsComplete) {
100
+ if (loads.length > 0 && loads.every((load) => usageScore(load.usage) !== null))
101
+ return "usage";
102
+ return recentUsageEnabled && laneSnapshotsComplete ? "recent_usage" : "lane_count";
81
103
  }
82
104
  function targetIneligibilityReason(accountId, globalEnabled, allowedAccounts, target) {
83
105
  if (!globalEnabled)
@@ -120,6 +142,7 @@ function buildLoads(input) {
120
142
  live_lanes: knownCounts.reduce((sum, count) => sum + count, 0),
121
143
  counts_complete: hosts.every((host) => host.live_lanes !== null && host.count_source !== "capacity_partial"),
122
144
  usage: input.target.capacity.providers.claude.accounts[account]?.usage ?? null,
145
+ recent_usage_at: recentUsageAt(input.hosts, account, input.now),
123
146
  hosts,
124
147
  };
125
148
  });
@@ -133,7 +156,8 @@ export function selectClaudeAccount(input) {
133
156
  unavailable_hosts: input.unavailableHosts ?? [],
134
157
  });
135
158
  }
136
- const { loads, skipped } = buildLoads({ ...input, target });
159
+ const now = input.now ?? new Date();
160
+ const { loads, skipped } = buildLoads({ ...input, target, now });
137
161
  if (input.explicitAccount !== undefined) {
138
162
  const selected = loads.find((load) => load.account === input.explicitAccount);
139
163
  if (!selected || !selected.eligible) {
@@ -173,14 +197,28 @@ export function selectClaudeAccount(input) {
173
197
  unavailable_hosts: input.unavailableHosts ?? [],
174
198
  });
175
199
  }
176
- const signal = selectionSignal(eligible);
200
+ const signal = selectionSignal(eligible, input.recentUsageEnabled === true, input.hosts.every((host) => host.laneListAvailable) && (input.unavailableHosts?.length ?? 0) === 0);
177
201
  eligible.sort((a, b) => {
178
202
  if (signal === "usage") {
179
203
  const byUsage = usageScore(a.usage) - usageScore(b.usage);
180
204
  if (byUsage !== 0)
181
205
  return byUsage;
182
206
  }
183
- return a.live_lanes - b.live_lanes || a.account.localeCompare(b.account);
207
+ const byLiveLanes = a.live_lanes - b.live_lanes;
208
+ if (byLiveLanes !== 0)
209
+ return byLiveLanes;
210
+ if (signal === "recent_usage") {
211
+ if (a.recent_usage_at === null && b.recent_usage_at !== null)
212
+ return -1;
213
+ if (a.recent_usage_at !== null && b.recent_usage_at === null)
214
+ return 1;
215
+ if (a.recent_usage_at !== null && b.recent_usage_at !== null) {
216
+ const byRecentUsage = Date.parse(a.recent_usage_at) - Date.parse(b.recent_usage_at);
217
+ if (byRecentUsage !== 0)
218
+ return byRecentUsage;
219
+ }
220
+ }
221
+ return a.account.localeCompare(b.account);
184
222
  });
185
223
  return {
186
224
  account: eligible[0].account,
@@ -196,7 +234,7 @@ export function accountLoadsForReport(input) {
196
234
  const first = input.hosts[0];
197
235
  if (!first)
198
236
  return [];
199
- return buildLoads({ hosts: input.hosts, target: first, config: input.config }).loads.map((load) => ({
237
+ return buildLoads({ hosts: input.hosts, target: first, config: input.config, now: new Date() }).loads.map((load) => ({
200
238
  ...load,
201
239
  eligible: false,
202
240
  ineligible_reason: undefined,
@@ -7,7 +7,7 @@
7
7
  * that resolve to the same home are refused (identity-keyed-cap rule).
8
8
  */
9
9
  import * as fs from "node:fs";
10
- import { type AccountConfig, type LaneProvider, type LanesConfig, type LivenessWindows, type ProviderConfig, type TrackerChecksConfig } from "./types.js";
10
+ import { type AccountConfig, type LaneProvider, type LanesConfig, type RunawaySearchGuardConfig, type LivenessWindows, type ProviderConfig, type TrackerChecksConfig } from "./types.js";
11
11
  export declare const LANES_CONFIG_REL: string;
12
12
  export declare const DEFAULT_MAX_QUEUED = 3;
13
13
  export declare const DEFAULT_CPU_BUSY_PAUSE = 0.85;
@@ -44,6 +44,7 @@ export declare const DEFAULT_BLOCKER_FRESHNESS_HOURS = 4;
44
44
  /** Manager key results must be re-measured within six hours by default. */
45
45
  export declare const DEFAULT_KR_FRESHNESS_HOURS = 6;
46
46
  export declare const DEFAULT_TRACKER_CHECKS: TrackerChecksConfig;
47
+ export declare const DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG: RunawaySearchGuardConfig;
47
48
  export declare const DEFAULT_LIVENESS_WINDOWS: LivenessWindows;
48
49
  export declare const DEFAULT_PROVIDER_MAX_RUNNING: Record<LaneProvider, number>;
49
50
  export declare const DEFAULT_ACCOUNT_HOMES: Record<LaneProvider, string>;
@@ -56,6 +56,10 @@ export const DEFAULT_TRACKER_CHECKS = {
56
56
  kpi_value_max_age_ms: 24 * 60 * 60 * 1000,
57
57
  directive_ack_timeout_ms: 30 * 60 * 1000,
58
58
  };
59
+ export const DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG = {
60
+ max_age_seconds: 20,
61
+ find_maxdepth_exempt: 3,
62
+ };
59
63
  export const DEFAULT_LIVENESS_WINDOWS = {
60
64
  live_within_ms: DEFAULT_LIVE_WITHIN_MS,
61
65
  turn_start_lost_ms: DEFAULT_TURN_START_LOST_MS,
@@ -252,6 +256,21 @@ function parsePreferredAccounts(provider, value, accounts) {
252
256
  return [...preferred];
253
257
  }
254
258
  export function assertUniqueAccountHomes(provider, accounts) {
259
+ // An override must not let one account reuse another account's shared default home.
260
+ const declaredHomes = new Map();
261
+ for (const [id, account] of Object.entries(accounts)) {
262
+ const homes = new Set([
263
+ account.home,
264
+ ...Object.values(account.hosts ?? {}).map(({ home }) => home),
265
+ ]);
266
+ for (const home of homes) {
267
+ const prior = declaredHomes.get(home);
268
+ if (prior !== undefined && prior !== id) {
269
+ throw new LanesConfigError("duplicate_account_home", `providers.${provider} accounts ${JSON.stringify(prior)} and ${JSON.stringify(id)} declare the same home ${home}`, { provider, accounts: [prior, id], home });
270
+ }
271
+ declaredHomes.set(home, id);
272
+ }
273
+ }
255
274
  const hostnames = new Set(Object.values(accounts).flatMap((account) => Object.keys(account.hosts ?? {})));
256
275
  const check = (hostname) => {
257
276
  const seen = new Map();
@@ -402,6 +421,16 @@ function parseTrackerChecks(raw) {
402
421
  directive_ack_timeout_ms: positiveInt(checks.directive_ack_timeout_ms, "tracker.checks.directive_ack_timeout_ms", DEFAULT_TRACKER_CHECKS.directive_ack_timeout_ms),
403
422
  };
404
423
  }
424
+ function parseRunawaySearchGuard(raw) {
425
+ const guard = raw === undefined ? {} : raw;
426
+ if (!isPlainObject(guard)) {
427
+ throw new LanesConfigError("invalid_config", "runaway_search_guard must be an object");
428
+ }
429
+ return {
430
+ max_age_seconds: positiveInt(guard.max_age_seconds, "runaway_search_guard.max_age_seconds", DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG.max_age_seconds),
431
+ find_maxdepth_exempt: positiveInt(guard.find_maxdepth_exempt, "runaway_search_guard.find_maxdepth_exempt", DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG.find_maxdepth_exempt),
432
+ };
433
+ }
405
434
  export function documentedDefaultConfig(deps = defaultDeps()) {
406
435
  const providers = {};
407
436
  for (const provider of LANE_PROVIDERS) {
@@ -425,6 +454,7 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
425
454
  },
426
455
  liveness: { ...DEFAULT_LIVENESS_WINDOWS },
427
456
  tracker: { checks: { ...DEFAULT_TRACKER_CHECKS } },
457
+ runaway_search_guard: { ...DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG },
428
458
  session_question_timeout_secs: DEFAULT_SESSION_QUESTION_TIMEOUT_SECS,
429
459
  blocker_freshness_hours: DEFAULT_BLOCKER_FRESHNESS_HOURS,
430
460
  kr_freshness_hours: DEFAULT_KR_FRESHNESS_HOURS,
@@ -469,6 +499,7 @@ export function parseLanesConfig(raw, deps = defaultDeps()) {
469
499
  throttle: parseThrottle(raw.throttle),
470
500
  liveness: parseLiveness(raw.liveness),
471
501
  tracker: { checks: parseTrackerChecks(raw.tracker) },
502
+ runaway_search_guard: parseRunawaySearchGuard(raw.runaway_search_guard),
472
503
  session_question_timeout_secs: positiveInt(raw.session_question_timeout_secs, "session_question_timeout_secs", DEFAULT_SESSION_QUESTION_TIMEOUT_SECS),
473
504
  blocker_freshness_hours: positiveNumber(raw.blocker_freshness_hours, "blocker_freshness_hours", DEFAULT_BLOCKER_FRESHNESS_HOURS),
474
505
  kr_freshness_hours: positiveNumber(raw.kr_freshness_hours, "kr_freshness_hours", DEFAULT_KR_FRESHNESS_HOURS),
@@ -18,6 +18,8 @@ export interface OutpostHireGrantContext {
18
18
  superseded_message_caller_id?: string;
19
19
  superseded_message_session_id?: string;
20
20
  }
21
+ /** Select the owner records needed to reproduce the primary's grant hierarchy. */
22
+ export declare function selectOutpostHireGrantHierarchy(targetLaneId: string, lanes: readonly LaneRecord[]): LaneRecord[];
21
23
  /** Encode only the owner-relevant hierarchy from the primary's complete store. */
22
24
  export declare function createOutpostHireGrantContext(target: LaneRecord, lanes: readonly LaneRecord[], supersededMessageCallerId?: string, supersededMessageSessionId?: string): string;
23
25
  /** Decode and validate an owner grant context, binding it to the target lane. */
@@ -15,6 +15,47 @@ function copySenior(senior) {
15
15
  ? { kind: senior.kind }
16
16
  : { kind: senior.kind, id: senior.id };
17
17
  }
18
+ // Terminal relatives are included so the owner's final state overwrites a stale
19
+ // primary copy; the context builder counts only alive children afterwards.
20
+ function directChildren(lanes, parentLaneId) {
21
+ return lanes.filter((lane) => lane.senior.kind === "lane" && lane.senior.id === parentLaneId);
22
+ }
23
+ /** Select the owner records needed to reproduce the primary's grant hierarchy. */
24
+ export function selectOutpostHireGrantHierarchy(targetLaneId, lanes) {
25
+ const current = lanes.find((lane) => lane.lane_id === targetLaneId);
26
+ if (!current)
27
+ return [];
28
+ const relevant = new Set([current.lane_id]);
29
+ if (current.senior.kind === "lane" && current.senior.id) {
30
+ relevant.add(current.senior.id);
31
+ for (const sibling of directChildren(lanes, current.senior.id)) {
32
+ relevant.add(sibling.lane_id);
33
+ }
34
+ }
35
+ for (const child of directChildren(lanes, current.lane_id)) {
36
+ relevant.add(child.lane_id);
37
+ }
38
+ const companyUid = current.company?.uid;
39
+ const projectId = current.project_id;
40
+ if (companyUid && projectId) {
41
+ // A superseded-message caller must be checked against the same project and
42
+ // company rows that the complete hierarchy builder sees.
43
+ for (const candidate of lanes) {
44
+ if (candidate.company?.uid === companyUid && candidate.project_id === projectId) {
45
+ relevant.add(candidate.lane_id);
46
+ }
47
+ }
48
+ }
49
+ if (current.senior.kind === "session" && companyUid) {
50
+ for (const candidate of lanes) {
51
+ if (candidate.company?.uid === companyUid && ((current.worker_profile !== undefined && candidate.worker_profile === current.worker_profile) ||
52
+ (projectId !== undefined && candidate.project_id === projectId && (candidate.hire_grant ?? 0) > 0))) {
53
+ relevant.add(candidate.lane_id);
54
+ }
55
+ }
56
+ }
57
+ return lanes.filter((lane) => relevant.has(lane.lane_id));
58
+ }
18
59
  function snapshot(lane) {
19
60
  return {
20
61
  lane_id: lane.lane_id,
@@ -10,7 +10,7 @@ export type { AdmissionCapReason, AdmissionHost } from "./admission.js";
10
10
  export { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "./host-capacity.js";
11
11
  export type { HostCapacityAccount, HostCapacityProvider, HostCapacitySnapshot, HostPlacementChoice, } from "./host-capacity.js";
12
12
  export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
13
- export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneAccountSelection, LaneAccountSelectionConsidered, LaneAccountSelectionHostCount, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, WorkerProfileRepinHistoryEntry, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
13
+ export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneAccountSelection, LaneAccountSelectionConsidered, LaneAccountSelectionHostCount, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, WorkerProfileRepinHistoryEntry, LaneSettingsHistoryEntry, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
14
14
  export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
15
15
  export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
16
16
  export type { ConfigLoadDeps, ConfigWriteDeps } from "./config.js";
@@ -4,6 +4,9 @@ export declare const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
4
4
  /** Missing or unreadable registry values retain the legacy supersession behavior. */
5
5
  export declare const LANE_SUPERSESSION_LINEAGE_AWARE_FLAG = "lanes.supersession-lineage-aware";
6
6
  export declare const LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED = false;
7
+ /** Missing or unreadable registry values keep rejecting superseded messages. */
8
+ export declare const LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG = "lanes.running-superseded-message-delivery";
9
+ export declare const LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_DEFAULT_ENABLED = false;
7
10
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
8
11
  export declare const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS: number;
9
12
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS: number;
@@ -8,6 +8,9 @@ export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
8
8
  /** Missing or unreadable registry values retain the legacy supersession behavior. */
9
9
  export const LANE_SUPERSESSION_LINEAGE_AWARE_FLAG = "lanes.supersession-lineage-aware";
10
10
  export const LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED = false;
11
+ /** Missing or unreadable registry values keep rejecting superseded messages. */
12
+ export const LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG = "lanes.running-superseded-message-delivery";
13
+ export const LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_DEFAULT_ENABLED = false;
11
14
  export const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
12
15
  export const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS = 5 * 60 * 1000;
13
16
  export const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS = 60 * 60 * 1000;
@@ -82,6 +82,10 @@ export declare function buildOutpostCapacityCommand(): string;
82
82
  /** Read lane records owned by an Outpost, using its configured HQ binary when set. */
83
83
  export declare function buildOutpostLaneListCommand(input?: {
84
84
  hqBinary?: string;
85
+ laneIds?: readonly string[];
86
+ active?: boolean;
87
+ hierarchy?: string;
88
+ snapshot?: boolean;
85
89
  }): string;
86
90
  /** Read questions on one Outpost, scoped to a company when supplied. */
87
91
  export declare function buildOutpostQuestionListCommand(input?: {
@@ -67,12 +67,21 @@ export function buildOutpostCapacityCommand() {
67
67
  }
68
68
  /** Read lane records owned by an Outpost, using its configured HQ binary when set. */
69
69
  export function buildOutpostLaneListCommand(input = {}) {
70
- return withRemoteHqDir(joinCommandParts([
70
+ const args = [
71
71
  input.hqBinary?.trim() || "hq",
72
72
  "lanes",
73
73
  "list",
74
74
  "--json",
75
- ]));
75
+ ];
76
+ for (const laneId of input.laneIds ?? [])
77
+ args.push("--lane", laneId);
78
+ if (input.active)
79
+ args.push("--active");
80
+ if (input.hierarchy !== undefined)
81
+ args.push("--hierarchy", input.hierarchy);
82
+ if (input.snapshot)
83
+ args.push("--snapshot");
84
+ return withRemoteHqDir(joinCommandParts(args));
76
85
  }
77
86
  /** Read questions on one Outpost, scoped to a company when supplied. */
78
87
  export function buildOutpostQuestionListCommand(input = {}) {
@@ -9,6 +9,7 @@ import { type LivenessDeps } from "./liveness.js";
9
9
  import { type EnvelopeRepairSpawnInput, type SpawnLaneDeps } from "./spawn.js";
10
10
  import { type ThrottleDeps } from "./throttle.js";
11
11
  import { readLanesRegistryFlag } from "./registry-flag.js";
12
+ import { type RunawayProcessInfo } from "./runaway-tool-guard.js";
12
13
  import { type KeepAliveBlockedReason } from "./keep-alive.js";
13
14
  import type { LaneCompanyRef, LaneRecord, LivenessState, QueuedReason, QuestionRecord, SeniorRef } from "./types.js";
14
15
  import { type LaneMesh } from "./mesh-events.js";
@@ -91,6 +92,14 @@ export interface ReconcileDeps {
91
92
  meshEmitTimeoutMs?: number;
92
93
  /** Fresh keep-alive eligibility read; injected to exercise opt-in changes before queueing. */
93
94
  readKeepAliveRecheckLane?: (hqRoot: string, laneId: string) => LaneRecord;
95
+ /** Process-table snapshot seam for the opt-in runaway-search guard. */
96
+ runawayToolProcessLister?: () => readonly RunawayProcessInfo[];
97
+ /** Test seam: prevent real process signals while exercising the guard. */
98
+ runawayToolProcessSignal?: (pid: number, signal: NodeJS.Signals) => void;
99
+ /** Test seam for interleaving a watcher signal with an append-only guard event. */
100
+ runawayToolEventAppend?: (target: string, line: string) => void;
101
+ /** Test seam for validating a candidate PID against its process-table starttime. */
102
+ runawayToolProcessIsCurrent?: (pid: number, expectedStarttime: number) => boolean;
94
103
  /**
95
104
  * Awaited after lane/question records and liveness are loaded, before any
96
105
  * CAS write. Test seam so two in-process passes can share a snapshot.
@@ -18,16 +18,18 @@ import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./err
18
18
  import { loadWorkerProfile } from "./profile.js";
19
19
  import { terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./grants.js";
20
20
  import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
21
- import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecordAlive, laneRunDir, readPidRecord, readSpawnManifest, markSpawnFailure, spawnEnvelopeRepair, spawnLaneWorker, retryLaneOnAlternateAccount, } from "./spawn.js";
21
+ import { ANSWERED_QUESTIONS_FILE, armLaneWatcher, envelopeRepairMode, isPidRecordAlive, laneRunDir, readPidRecord, readProcStarttime, readSpawnManifest, markSpawnFailure, spawnEnvelopeRepair, spawnLaneWorker, retryLaneOnAlternateAccount, } from "./spawn.js";
22
22
  import { appendCappedHistory, lanesStoreRoot, parseLaneRecord, parseQuestionRecord, readLane, readQuestion, updateLane, updateQuestion, } from "./store.js";
23
23
  import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrottleState, evaluateThrottle, isCpuCheckEnabled, readAvailableMemMb, readThrottleState, resolveCpuBusyFraction, throttleStatePath, writeThrottleState, } from "./throttle.js";
24
24
  import { readLanesRegistryFlag } from "./registry-flag.js";
25
25
  import { resolveLaneCompany } from "./lane-company.js";
26
+ import { appendDropboxMessage } from "./dropbox.js";
27
+ import { DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG, listHostProcesses, runRunawayToolGuard, RUNAWAY_TOOL_GUARD_FLAG, } from "./runaway-tool-guard.js";
26
28
  import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, doneKeepAliveVerdict, hasPendingKeepAliveWork, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, undeliveredAnsweredQuestions, } from "./keep-alive.js";
27
29
  import { providerSupportsSessionResume } from "./delivery.js";
28
30
  import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
29
31
  import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
30
- import { SILENCE_MS, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
32
+ import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
31
33
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
32
34
  import { laneStories, storyStatusForEnvelope } from "./stories.js";
33
35
  import { resolveManagerStatusGateForLane } from "./manager-status-gate.js";
@@ -658,6 +660,128 @@ function classifyDeadLocalLane(hqRoot, laneId, now) {
658
660
  lane,
659
661
  };
660
662
  }
663
+ function runawayGuardCompanyKey(company) {
664
+ return company
665
+ ? `${company.uid ?? ""}\u0000${company.slug ?? ""}`
666
+ : "unresolved-company";
667
+ }
668
+ function reportRunawayGuardError(summary, stage, error) {
669
+ summary.ok = false;
670
+ summary.errors.push({
671
+ code: `runaway_tool_guard_${stage}_failed`,
672
+ message: `runaway search guard ${stage} failed; no additional search processes were stopped`,
673
+ });
674
+ process.stderr.write(`${JSON.stringify({
675
+ event: "lanes.runaway_tool_guard.failed",
676
+ stage,
677
+ error: error instanceof Error ? error.name : "UnknownError",
678
+ fallback: "continue_without_argv",
679
+ })}\n`);
680
+ }
681
+ async function runRunawayToolGuardPass(hqRoot, lanes, config, deps, now, summary) {
682
+ if (deps.dryRun)
683
+ return;
684
+ const localRunningLanes = lanes.filter((lane) => lane.state === "running" && laneRunsHere(lane, deps.host));
685
+ if (localRunningLanes.length === 0)
686
+ return;
687
+ const groups = new Map();
688
+ for (const lane of localRunningLanes) {
689
+ let worker;
690
+ try {
691
+ worker = readPidRecord(path.join(laneRunDir(hqRoot, lane.lane_id), "worker.pid"));
692
+ if (!worker ||
693
+ worker.starttime === undefined ||
694
+ !isPidRecordAlive(worker, deps.isAlive))
695
+ continue;
696
+ }
697
+ catch (error) {
698
+ reportRunawayGuardError(summary, "worker_pid_read", error);
699
+ continue;
700
+ }
701
+ let company;
702
+ try {
703
+ company = resolveLaneCompany(hqRoot, lane).company;
704
+ }
705
+ catch (error) {
706
+ reportRunawayGuardError(summary, "company_resolution", error);
707
+ continue;
708
+ }
709
+ const key = runawayGuardCompanyKey(company);
710
+ const group = groups.get(key) ?? { company, lanes: [] };
711
+ group.lanes.push({ laneId: lane.lane_id, agentPid: worker.pid, agentStarttime: worker.starttime });
712
+ groups.set(key, group);
713
+ }
714
+ if (groups.size === 0)
715
+ return;
716
+ const readRegistryFlag = deps.readRegistryFlag ?? readLanesRegistryFlag;
717
+ const enabledGroups = new Set();
718
+ const env = deps.env ?? process.env;
719
+ await Promise.all([...groups.entries()].map(async ([key, group]) => {
720
+ try {
721
+ const enabled = await readRegistryFlag(RUNAWAY_TOOL_GUARD_FLAG, {
722
+ company: group.company ?? undefined,
723
+ env,
724
+ });
725
+ if (enabled)
726
+ enabledGroups.add(key);
727
+ }
728
+ catch (error) {
729
+ reportRunawayGuardError(summary, "flag_read", error);
730
+ }
731
+ }));
732
+ if (enabledGroups.size === 0)
733
+ return;
734
+ const laneProcesses = [...groups.entries()]
735
+ .filter(([key]) => enabledGroups.has(key))
736
+ .flatMap(([, group]) => group.lanes);
737
+ if (laneProcesses.length === 0)
738
+ return;
739
+ let processes;
740
+ try {
741
+ processes = (deps.runawayToolProcessLister ?? listHostProcesses)();
742
+ }
743
+ catch (error) {
744
+ reportRunawayGuardError(summary, "process_table_read", error);
745
+ return;
746
+ }
747
+ const runawayConfig = config.runaway_search_guard ?? DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG;
748
+ try {
749
+ runRunawayToolGuard({
750
+ hqRoot,
751
+ lanes: laneProcesses,
752
+ processes,
753
+ config: runawayConfig,
754
+ now,
755
+ currentUid: process.getuid?.() ?? -1,
756
+ isCurrentProcess: deps.runawayToolProcessIsCurrent ??
757
+ ((pid, expectedStarttime) => readProcStarttime(pid) === expectedStarttime),
758
+ signal: deps.runawayToolProcessSignal ?? ((pid, signal) => process.kill(pid, signal)),
759
+ recordEvent: (laneId, event) => appendWatcherEvent(watcherEventsPath(hqRoot, laneId), event, deps.runawayToolEventAppend),
760
+ notifyLane: (laneId, text) => appendDropboxMessage(hqRoot, laneId, {
761
+ at: now.toISOString(),
762
+ message_id: `runaway-search-guard:${laneId}:${now.getTime()}`,
763
+ kind: "system_notice",
764
+ text,
765
+ }),
766
+ reportError: (stage, errorName) => {
767
+ summary.ok = false;
768
+ summary.errors.push({
769
+ code: `runaway_tool_guard_${stage}_failed`,
770
+ message: `runaway search guard could not ${stage} a stopped process (${errorName})`,
771
+ });
772
+ process.stderr.write(`${JSON.stringify({
773
+ event: "lanes.runaway_tool_guard.failed",
774
+ stage,
775
+ error: errorName,
776
+ fallback: "continue_without_argv",
777
+ })}\n`);
778
+ },
779
+ });
780
+ }
781
+ catch (error) {
782
+ reportRunawayGuardError(summary, "execution", error);
783
+ }
784
+ }
661
785
  /** Unlocked pass body. `runReconcilePass` takes the process lock around this. */
662
786
  export async function runPassBody(hqRoot, deps) {
663
787
  const now = deps.now();
@@ -728,6 +852,7 @@ export async function runPassBody(hqRoot, deps) {
728
852
  : QUESTION_AGE_MS;
729
853
  if (deps.afterLoad)
730
854
  await deps.afterLoad();
855
+ await runRunawayToolGuardPass(hqRoot, lanes, config, deps, now, summary);
731
856
  const startDeps = await prepareManagerStatusGateStarts(hqRoot, lanes, deps, summary);
732
857
  markOverdueLanes(hqRoot, lanes, summary, deps, now);
733
858
  await heartbeatAndDeadHosts(hqRoot, lanes, summary, startDeps, now);
@@ -0,0 +1,38 @@
1
+ import type { RunawaySearchGuardConfig } from "./types.js";
2
+ export declare const RUNAWAY_TOOL_GUARD_FLAG = "lanes.runaway-tool-guard";
3
+ export interface RunawayProcessInfo {
4
+ pid: number;
5
+ ppid: number;
6
+ elapsedSeconds: number;
7
+ uid: number;
8
+ command: string;
9
+ argv: readonly string[];
10
+ cwd: string;
11
+ starttime: number | null;
12
+ }
13
+ export interface RunawayLaneProcess {
14
+ laneId: string;
15
+ agentPid: number;
16
+ agentStarttime: number;
17
+ }
18
+ export interface RunawayToolGuardInput {
19
+ hqRoot: string;
20
+ lanes: readonly RunawayLaneProcess[];
21
+ processes: readonly RunawayProcessInfo[];
22
+ config: RunawaySearchGuardConfig;
23
+ now: Date;
24
+ currentUid: number;
25
+ isCurrentProcess: (pid: number, expectedStarttime: number) => boolean;
26
+ signal: (pid: number, signal: NodeJS.Signals) => void;
27
+ recordEvent: (laneId: string, event: Record<string, unknown>) => void;
28
+ notifyLane: (laneId: string, text: string) => void;
29
+ reportError: (stage: "signal" | "record" | "notify", errorName: string) => void;
30
+ }
31
+ export interface RunawayToolGuardResult {
32
+ stopped: number;
33
+ }
34
+ export declare function runRunawayToolGuard(input: RunawayToolGuardInput): RunawayToolGuardResult;
35
+ /** Read the host process table once. argv and cwd are read only for search tools. */
36
+ export declare function listHostProcesses(): RunawayProcessInfo[];
37
+ export { DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG } from "./config.js";
38
+ //# sourceMappingURL=runaway-tool-guard.d.ts.map