@indigoai-us/hq-cli 5.278.0 → 5.279.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.279.0] — 2026-09-29
6
+
7
+ ### Fixed
8
+
9
+ - Claude keep-alive resumes use the same account selection as manual resumes,
10
+ including profile pins and the optional recent-use tiebreak. If no eligible
11
+ account can be selected, the lane resumes on its current account and records
12
+ the failure.
13
+
5
14
  ## [5.278.0] — 2026-09-29
6
15
 
7
16
  ### Changed
@@ -5534,12 +5534,12 @@ async function prepareAccountMove(ctx, lane, input) {
5534
5534
  const at = now.toISOString();
5535
5535
  const from = lane.account ?? null;
5536
5536
  // The capacity decision and the record write run under the target
5537
- // account's admission lock, the same serialization lane creation uses. A
5538
- // slot is taken by a running lane or by a lane another move has already
5539
- // pointed at the account whose spawn has not claimed it yet
5540
- // (`pending_resume` set by this write, cleared by the claim), so two moves
5541
- // into one free slot cannot both start.
5542
- return withLaneAdmissionLock(hqRoot, lane.provider, async () => {
5537
+ // account's admission lock, or while reconcile holds all provider
5538
+ // admission locks. A slot is taken by a running lane or a lane another move
5539
+ // has pointed at its target account but whose spawn has not claimed it yet
5540
+ // (`pending_resume` is set by this write and cleared by the claim), so two
5541
+ // moves into one free slot cannot both start.
5542
+ const writeMove = async () => {
5543
5543
  const admission = input.deferTurnStart
5544
5544
  ? undefined
5545
5545
  : await assessLaneAdmission(ctx, lane, {
@@ -5602,7 +5602,10 @@ async function prepareAccountMove(ctx, lane, input) {
5602
5602
  return next;
5603
5603
  }), input.grantContext);
5604
5604
  return { lane: moved, from, to: target, copy, queued };
5605
- }, ctx.sleep);
5605
+ };
5606
+ return input.admissionLocksHeld
5607
+ ? writeMove()
5608
+ : withLaneAdmissionLock(hqRoot, lane.provider, writeMove, ctx.sleep);
5606
5609
  }
5607
5610
  async function selectAutomaticClaudeResumeAccount(ctx, lane, workerProfile, options = {}) {
5608
5611
  if (lane.provider !== "claude")
@@ -6993,6 +6996,93 @@ async function runReconcile(ctx, opts, recoverQueuedMessages) {
6993
6996
  recoverQueuedMessages,
6994
6997
  mesh: ctx.mesh,
6995
6998
  env: ctx.env,
6999
+ prepareKeepAliveClaudeResume: async (lane) => {
7000
+ const recoveredLane = laneWithRecoveredProviderSession(hqRoot, lane);
7001
+ const pendingModel = recoveredLane.pending_settings?.model;
7002
+ const selectionLane = pendingModel === undefined
7003
+ ? recoveredLane
7004
+ : { ...recoveredLane, model: pendingModel, model_source: undefined };
7005
+ let automatic;
7006
+ try {
7007
+ automatic = await selectAutomaticClaudeResumeAccount(ctx, selectionLane, undefined, {
7008
+ repairUnavailableAccount: true,
7009
+ });
7010
+ }
7011
+ catch (err) {
7012
+ return {
7013
+ lane: recoveredLane,
7014
+ selectionFailure: err instanceof LanesError
7015
+ ? `${err.error}: ${err.message}`
7016
+ : err instanceof Error
7017
+ ? err.message
7018
+ : String(err),
7019
+ };
7020
+ }
7021
+ if (automatic === undefined)
7022
+ return { lane: recoveredLane };
7023
+ const { selection } = automatic;
7024
+ if (opts.dryRun) {
7025
+ return {
7026
+ lane: {
7027
+ ...selectionLane,
7028
+ account: selection.account,
7029
+ account_selection: selection,
7030
+ ...(pendingModel !== undefined
7031
+ ? { model: pendingModel, model_source: "cli" }
7032
+ : automatic.preservedModel !== undefined
7033
+ ? { model: automatic.preservedModel, model_source: "preserved" }
7034
+ : {}),
7035
+ },
7036
+ };
7037
+ }
7038
+ let latest;
7039
+ try {
7040
+ latest = readLane(hqRoot, lane.lane_id);
7041
+ }
7042
+ catch (err) {
7043
+ throw new LanesError("keep_alive_account_selection_lane_unreadable", `Cannot re-read lane ${lane.lane_id} before applying Claude account selection: ${err instanceof Error ? err.message : String(err)}`, { lane_id: lane.lane_id });
7044
+ }
7045
+ if (latest.version !== lane.version ||
7046
+ latest.state !== lane.state ||
7047
+ latest.keep_alive !== true ||
7048
+ latest.keep_alive_opted_in_at !== lane.keep_alive_opted_in_at ||
7049
+ latest.provider_session_id !== lane.provider_session_id ||
7050
+ latest.last_resume?.session_id !== lane.last_resume?.session_id)
7051
+ return { lane: latest };
7052
+ if (selection.account === lane.account) {
7053
+ return { lane: persistClaudeAccountSelection(hqRoot, recoveredLane, selection) };
7054
+ }
7055
+ try {
7056
+ const moved = await prepareAccountMove(ctx, recoveredLane, {
7057
+ account: selection.account,
7058
+ cliModel: pendingModel,
7059
+ accountHistoryReason: "keep_alive_resume",
7060
+ ...(pendingModel === undefined && automatic.preservedModel !== undefined
7061
+ ? { preservedModel: automatic.preservedModel }
7062
+ : {}),
7063
+ deferTurnStart: true,
7064
+ admissionLocksHeld: true,
7065
+ accountSelection: selection,
7066
+ });
7067
+ return { lane: moved.lane };
7068
+ }
7069
+ catch (err) {
7070
+ let latest;
7071
+ try {
7072
+ latest = readLane(hqRoot, lane.lane_id);
7073
+ }
7074
+ catch (readErr) {
7075
+ return {
7076
+ lane,
7077
+ selectionFailure: `account move failed: ${err instanceof Error ? err.message : String(err)}; lane re-read failed: ${readErr instanceof Error ? readErr.message : String(readErr)}`,
7078
+ };
7079
+ }
7080
+ return {
7081
+ lane: latest,
7082
+ selectionFailure: `account move failed: ${err instanceof LanesError ? `${err.error}: ${err.message}` : err instanceof Error ? err.message : String(err)}`,
7083
+ };
7084
+ }
7085
+ },
6996
7086
  });
6997
7087
  printJson(summary);
6998
7088
  if (!summary.ok) {
@@ -92,6 +92,11 @@ export interface ReconcileDeps {
92
92
  meshEmitTimeoutMs?: number;
93
93
  /** Fresh keep-alive eligibility read; injected to exercise opt-in changes before queueing. */
94
94
  readKeepAliveRecheckLane?: (hqRoot: string, laneId: string) => LaneRecord;
95
+ /** Reuse the command's Claude resume selection and session-move path for keep-alive. */
96
+ prepareKeepAliveClaudeResume?: (lane: LaneRecord) => Promise<{
97
+ lane: LaneRecord;
98
+ selectionFailure?: string;
99
+ }>;
95
100
  /** Process-table snapshot seam for the opt-in runaway-search guard. */
96
101
  runawayToolProcessLister?: () => readonly RunawayProcessInfo[];
97
102
  /** Test seam: prevent real process signals while exercising the guard. */
@@ -1001,7 +1001,7 @@ export async function runPassBody(hqRoot, deps) {
1001
1001
  await withAllLaneAdmissionLocks(hqRoot, async () => {
1002
1002
  const freshLanes = loadLaneRecords(hqRoot, summary);
1003
1003
  const freshQuestions = loadQuestionRecords(hqRoot, summary);
1004
- resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, startDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany);
1004
+ await resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, startDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany);
1005
1005
  const drainLanes = loadLaneRecords(hqRoot, summary);
1006
1006
  await admitQueued(hqRoot, drainLanes, summary, startDeps, now);
1007
1007
  await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary, startDeps);
@@ -1723,7 +1723,7 @@ function latestEnvelopeMtimeMs(hqRoot, lane, ref) {
1723
1723
  throw new LanesError("keep_alive_envelope_timestamp_unavailable", `Cannot stat latest envelope for lane ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}`, { lane_id: lane.lane_id, path: filePath });
1724
1724
  }
1725
1725
  }
1726
- function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCompany, supersessionLineageAwareByCompany) {
1726
+ async function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCompany, supersessionLineageAwareByCompany) {
1727
1727
  const nowMs = now.getTime();
1728
1728
  const nowIso = now.toISOString();
1729
1729
  const config = loadLanesConfig(hqRoot);
@@ -2108,6 +2108,44 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
2108
2108
  });
2109
2109
  continue;
2110
2110
  }
2111
+ const seniorId = current.senior.kind === "lane" ? current.senior.id : undefined;
2112
+ if (seniorId !== undefined) {
2113
+ let grantPreflight;
2114
+ try {
2115
+ grantPreflight = withHireGrantAdmission(hqRoot, seniorId, (snapshot) => {
2116
+ const latestLane = readLane(hqRoot, current.lane_id);
2117
+ if (latestLane.version !== current.version ||
2118
+ latestLane.state !== current.state ||
2119
+ latestLane.keep_alive !== true ||
2120
+ latestLane.keep_alive_opted_in_at !== current.keep_alive_opted_in_at ||
2121
+ latestLane.senior.kind !== "lane" ||
2122
+ latestLane.senior.id !== seniorId)
2123
+ return { kind: "stale" };
2124
+ const refusal = terminalChildResumeHireGrantError(latestLane, snapshot);
2125
+ return refusal === null
2126
+ ? { kind: "eligible" }
2127
+ : { kind: "refused", error: refusal.error, message: refusal.message };
2128
+ });
2129
+ }
2130
+ catch (err) {
2131
+ markDirty(summary, {
2132
+ kind: "lane",
2133
+ id: current.lane_id,
2134
+ why: `keep-alive hire admission preflight failed: ${err instanceof Error ? err.message : String(err)}`,
2135
+ });
2136
+ continue;
2137
+ }
2138
+ if (grantPreflight.kind === "stale")
2139
+ continue;
2140
+ if (grantPreflight.kind === "refused") {
2141
+ summary.skipped.push({
2142
+ kind: "lane",
2143
+ id: current.lane_id,
2144
+ why: `keep_alive_hire_admission: ${grantPreflight.error}: ${grantPreflight.message}`,
2145
+ });
2146
+ continue;
2147
+ }
2148
+ }
2111
2149
  const resumedFrom = current.state === "done" ? "done" : "interrupted";
2112
2150
  const resumeText = resumedFrom === "interrupted"
2113
2151
  ? LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT
@@ -2118,6 +2156,114 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
2118
2156
  if (carriesAnsweredQuestions && !deps.dryRun) {
2119
2157
  writeAnsweredQuestions(hqRoot, current, questions);
2120
2158
  }
2159
+ let accountSelectionFailure;
2160
+ if (current.provider === "claude" && deps.prepareKeepAliveClaudeResume !== undefined) {
2161
+ const optInBeforePreparation = current.keep_alive_opted_in_at;
2162
+ let preparedLane;
2163
+ try {
2164
+ const prepared = await deps.prepareKeepAliveClaudeResume(current);
2165
+ preparedLane = prepared.lane;
2166
+ accountSelectionFailure = prepared.selectionFailure;
2167
+ }
2168
+ catch (err) {
2169
+ // Account balancing is best-effort for keep-alive. A failure to read
2170
+ // balance inputs must not prevent the existing session from resuming.
2171
+ try {
2172
+ preparedLane = readLane(hqRoot, current.lane_id);
2173
+ }
2174
+ catch (readErr) {
2175
+ markDirty(summary, {
2176
+ kind: "lane",
2177
+ id: current.lane_id,
2178
+ why: `keep-alive account selection failed and lane could not be re-read: ${readErr instanceof Error ? readErr.message : String(readErr)}`,
2179
+ });
2180
+ continue;
2181
+ }
2182
+ accountSelectionFailure = err instanceof Error ? err.message : String(err);
2183
+ }
2184
+ const preparedSessionId = preparedLane.provider_session_id ?? preparedLane.last_resume?.session_id;
2185
+ if (preparedLane.state !== initialState ||
2186
+ preparedLane.keep_alive !== true ||
2187
+ !laneRunsHere(preparedLane, deps.host) ||
2188
+ preparedSessionId !== currentSessionId)
2189
+ continue;
2190
+ let latest;
2191
+ try {
2192
+ latest = deps.dryRun
2193
+ ? (deps.readKeepAliveRecheckLane?.(hqRoot, current.lane_id) ?? readLane(hqRoot, current.lane_id))
2194
+ : readLane(hqRoot, current.lane_id);
2195
+ }
2196
+ catch (err) {
2197
+ markDirty(summary, {
2198
+ kind: "lane",
2199
+ id: current.lane_id,
2200
+ why: `keep-alive eligibility recheck failed after account selection: ${err instanceof Error ? err.message : String(err)}`,
2201
+ });
2202
+ continue;
2203
+ }
2204
+ if (latest.state !== initialState || !laneRunsHere(latest, deps.host))
2205
+ continue;
2206
+ if (latest.keep_alive !== true) {
2207
+ recordKeepAliveSkip(hqRoot, latest, "not_opted_in", summary, deps);
2208
+ continue;
2209
+ }
2210
+ if (latest.keep_alive_opted_in_at !== optInBeforePreparation) {
2211
+ summary.skipped.push({
2212
+ kind: "lane",
2213
+ id: latest.lane_id,
2214
+ why: "keep_alive_eligibility_changed_during_account_selection",
2215
+ });
2216
+ continue;
2217
+ }
2218
+ const latestSessionId = latest.provider_session_id ?? latest.last_resume?.session_id;
2219
+ if (latestSessionId !== currentSessionId)
2220
+ continue;
2221
+ let latestEligibilityAtMs;
2222
+ if (initialState === "done") {
2223
+ const envelope = readLatestEnvelopeRecord(hqRoot, latest.lane_id);
2224
+ if (!envelope || envelope.envelope.decision !== "done")
2225
+ continue;
2226
+ if (doneKeepAliveVerdict(latest, envelope.envelope, latest.keep_alive_standing === true && hasPendingKeepAliveWork(hqRoot, latest, questions)) !== "eligible")
2227
+ continue;
2228
+ try {
2229
+ latestEligibilityAtMs = latestEnvelopeMtimeMs(hqRoot, latest, envelope.ref);
2230
+ }
2231
+ catch (err) {
2232
+ markDirty(summary, {
2233
+ kind: "lane",
2234
+ id: latest.lane_id,
2235
+ why: `keep-alive envelope timestamp unavailable after account selection: ${err instanceof Error ? err.message : String(err)}`,
2236
+ });
2237
+ continue;
2238
+ }
2239
+ }
2240
+ else {
2241
+ latestEligibilityAtMs = Date.parse(latest.interrupted_at ?? "");
2242
+ }
2243
+ const latestOptedInAtMs = Date.parse(latest.keep_alive_opted_in_at ?? "");
2244
+ if (!Number.isFinite(latestEligibilityAtMs) || !Number.isFinite(latestOptedInAtMs))
2245
+ continue;
2246
+ if (latestEligibilityAtMs <= latestOptedInAtMs) {
2247
+ recordKeepAliveSkip(hqRoot, latest, initialState === "done" ? "envelope_before_opt_in" : "interruption_before_opt_in", summary, deps);
2248
+ continue;
2249
+ }
2250
+ current = deps.dryRun ? preparedLane : latest;
2251
+ }
2252
+ if (accountSelectionFailure !== undefined) {
2253
+ summary.skipped.push({
2254
+ kind: "lane",
2255
+ id: current.lane_id,
2256
+ why: `keep_alive_account_selection_failed: ${accountSelectionFailure}`,
2257
+ });
2258
+ if (!deps.dryRun) {
2259
+ recordKeepAliveEvent(hqRoot, current.lane_id, summary, {
2260
+ event: "keep_alive_account_selection_failed",
2261
+ at: nowIso,
2262
+ account: current.account ?? null,
2263
+ reason: accountSelectionFailure,
2264
+ });
2265
+ }
2266
+ }
2121
2267
  const queue = (expectedSeniorId) => casLaneIf(hqRoot, current.lane_id, (latestLane) => latestLane.version === current.version &&
2122
2268
  latestLane.state === current.state &&
2123
2269
  latestLane.keep_alive === true &&
@@ -2145,7 +2291,6 @@ function resumeKeepAlive(hqRoot, lanes, questions, summary, deps, now, flagByCom
2145
2291
  keep_alive_exhausted_at: undefined,
2146
2292
  keep_alive_blocked_reason: undefined,
2147
2293
  }), deps.dryRun);
2148
- const seniorId = current.senior.kind === "lane" ? current.senior.id : undefined;
2149
2294
  let queued;
2150
2295
  if (seniorId) {
2151
2296
  try {
@@ -500,9 +500,8 @@ export interface LaneRecord {
500
500
  /** Eligible accounts for a queue entry created without an explicit account. */
501
501
  admission_accounts?: string[];
502
502
  /**
503
- * Every operator move of the lane's session to another account of the
504
- * same provider (`hq lanes resume|message --account`), newest last, capped
505
- * like the other histories.
503
+ * Moves of the lane's session to another account of the same provider,
504
+ * newest last and capped like the other histories.
506
505
  */
507
506
  account_history?: AccountHistoryEntry[];
508
507
  /** Bounded audit history for explicitly authorized resumes from failed. */
@@ -665,7 +664,7 @@ export interface AccountHistoryEntry {
665
664
  from: string | null;
666
665
  to: string;
667
666
  at: string;
668
- reason: "first_assignment" | "model_change" | "usage_limit" | "account_unavailable" | "recorded_account_logged_out" | "load_balance" | "operator_move";
667
+ reason: "first_assignment" | "model_change" | "usage_limit" | "account_unavailable" | "recorded_account_logged_out" | "load_balance" | "keep_alive_resume" | "operator_move";
669
668
  }
670
669
  export interface FailedResumeHistoryEntry {
671
670
  at: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.278.0",
3
+ "version": "5.279.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {