@indigoai-us/hq-cli 5.131.0 → 5.132.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 +111 -0
  2. package/dist/command-catalog.generated.d.ts +12 -0
  3. package/dist/command-catalog.generated.js +16 -0
  4. package/dist/commands/lanes.d.ts +13 -2
  5. package/dist/commands/lanes.js +394 -42
  6. package/dist/commands/mesh.js +20 -1
  7. package/dist/commands/update.js +1 -1
  8. package/dist/lib/bot/paths.d.ts +7 -5
  9. package/dist/lib/bot/paths.js +8 -1
  10. package/dist/lib/bot/prompt.d.ts +0 -2
  11. package/dist/lib/bot/prompt.js +5 -11
  12. package/dist/lib/bot/room-policy.d.ts +1 -24
  13. package/dist/lib/bot/room-policy.js +2 -35
  14. package/dist/lib/bot/run.d.ts +7 -8
  15. package/dist/lib/bot/run.js +21 -33
  16. package/dist/lib/bot/session.d.ts +1 -1
  17. package/dist/lib/bot/session.js +1 -1
  18. package/dist/lib/lanes/accounts.d.ts +6 -0
  19. package/dist/lib/lanes/accounts.js +2 -1
  20. package/dist/lib/lanes/config.d.ts +4 -0
  21. package/dist/lib/lanes/config.js +9 -0
  22. package/dist/lib/lanes/index.d.ts +10 -3
  23. package/dist/lib/lanes/index.js +5 -1
  24. package/dist/lib/lanes/mesh-events.js +4 -0
  25. package/dist/lib/lanes/models.d.ts +78 -0
  26. package/dist/lib/lanes/models.js +205 -0
  27. package/dist/lib/lanes/move-account.d.ts +56 -0
  28. package/dist/lib/lanes/move-account.js +193 -0
  29. package/dist/lib/lanes/provider-rejection.d.ts +86 -0
  30. package/dist/lib/lanes/provider-rejection.js +258 -0
  31. package/dist/lib/lanes/quota.d.ts +17 -0
  32. package/dist/lib/lanes/quota.js +24 -0
  33. package/dist/lib/lanes/reconcile-install.d.ts +24 -3
  34. package/dist/lib/lanes/reconcile-install.js +48 -12
  35. package/dist/lib/lanes/reconcile.js +41 -11
  36. package/dist/lib/lanes/spawn.js +26 -5
  37. package/dist/lib/lanes/store.js +10 -0
  38. package/dist/lib/lanes/types.d.ts +49 -2
  39. package/dist/lib/lanes/types.js +4 -3
  40. package/dist/lib/lanes/watcher.d.ts +27 -0
  41. package/dist/lib/lanes/watcher.js +68 -5
  42. package/dist/lib/mesh/live/backfill-held.d.ts +4 -3
  43. package/dist/lib/mesh/live/backfill-held.js +37 -14
  44. package/dist/lib/mesh/live/daemon/doctor.js +5 -2
  45. package/dist/lib/mesh/live/daemon/install.d.ts +5 -0
  46. package/dist/lib/mesh/live/daemon/install.js +84 -1
  47. package/dist/lib/mesh/live/daemon/run.d.ts +5 -0
  48. package/dist/lib/mesh/live/daemon/run.js +14 -4
  49. package/dist/lib/mesh/live/emit-evidence.d.ts +19 -0
  50. package/dist/lib/mesh/live/emit-evidence.js +20 -2
  51. package/dist/lib/work-context/company.d.ts +16 -0
  52. package/dist/lib/work-context/company.js +46 -14
  53. package/dist/utils/self-update.js +4 -1
  54. package/package.json +1 -1
@@ -17,13 +17,15 @@ import { vaultApiFetch } from "../utils/vault-api.js";
17
17
  import { openMeshTransport } from "../lib/mesh/client.js";
18
18
  import { createLaneMesh, emitLaneDecisionEvents, } from "../lib/lanes/mesh-events.js";
19
19
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
20
- import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, isLoggedOut, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
20
+ import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, isLoggedOut, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasRecordedSessionId, providerSupportsSessionResume, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
21
21
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
22
22
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
23
23
  import { providerCompactAfterMs } from "../lib/lanes/config.js";
24
24
  import { parseCliEffort, resolveLaneEffort } from "../lib/lanes/effort.js";
25
25
  import { LanesError } from "../lib/lanes/errors.js";
26
- import { accountQuotaView, loadQuotaState } from "../lib/lanes/quota.js";
26
+ import { accountQuotaView, loadQuotaState, quotaBlockFor } from "../lib/lanes/quota.js";
27
+ import { SESSION_MOVE_REASON, copySessionFiles, planSessionCopy } from "../lib/lanes/move-account.js";
28
+ import { defaultProbe, validateLaneModel, } from "../lib/lanes/models.js";
27
29
  import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, stopLaneProcesses, } from "../lib/lanes/spawn.js";
28
30
  export { spawnLaneWorker };
29
31
  export { questionBodyAbsPath };
@@ -201,6 +203,7 @@ function resolveDeps(deps = {}) {
201
203
  };
202
204
  }),
203
205
  resolveMachineIdentity: deps.resolveMachineIdentity ?? lookupMachineIdentity,
206
+ probeProvider: deps.probeProvider ?? defaultProbe,
204
207
  runCompaction: deps.runCompaction,
205
208
  locateTranscript: deps.locateTranscript,
206
209
  stageOutpostPayload: deps.stageOutpostPayload ?? defaultStageOutpostPayload,
@@ -1047,6 +1050,7 @@ export const PICKUP_TERMINAL_REASONS = [
1047
1050
  "account_not_logged_in",
1048
1051
  "unknown_account",
1049
1052
  "account_disabled",
1053
+ "model_unsupported",
1050
1054
  ];
1051
1055
  export const DISPATCH_TERMINAL_REASON = "outpost_dispatch_failed";
1052
1056
  function parsePickupTerminalReason(value) {
@@ -1069,28 +1073,38 @@ function markDispatchFailure(hqRoot, laneId, details) {
1069
1073
  };
1070
1074
  });
1071
1075
  }
1072
- function markPickupFailure(hqRoot, laneId, reasonRaw, details) {
1076
+ /**
1077
+ * The CAS updater a pickup failure applies. A lane that is already terminal
1078
+ * for any reason (a cancel that landed after the caller's last read, an
1079
+ * earlier pickup failure) is returned unchanged: the guard lives inside the
1080
+ * same compare-and-swap that writes the failure, so no read-then-write gap
1081
+ * can overwrite another writer's terminal decision.
1082
+ */
1083
+ export function pickupFailureUpdater(reasonRaw, details) {
1073
1084
  const reason = parsePickupTerminalReason(reasonRaw);
1074
- return casUpdateLane(hqRoot, laneId, (current) => {
1075
- const existing = current
1076
- .terminal_reason;
1077
- if (isTerminalLaneState(current.state) && existing === reason) {
1085
+ return (current) => {
1086
+ // A running record is never overwritten either: a worker that was
1087
+ // admitted meanwhile keeps its lane, and the watcher classifies it.
1088
+ if (isTerminalLaneState(current.state) || current.state === "running")
1078
1089
  return current;
1079
- }
1080
1090
  return {
1081
1091
  ...current,
1082
1092
  state: "failed",
1083
1093
  queued_reason: undefined,
1094
+ pickup_validation_pending: undefined,
1084
1095
  terminal_reason: reason,
1085
1096
  terminal_details: details,
1086
1097
  };
1087
- });
1098
+ };
1099
+ }
1100
+ function markPickupFailure(hqRoot, laneId, reasonRaw, details) {
1101
+ return casUpdateLane(hqRoot, laneId, pickupFailureUpdater(reasonRaw, details));
1088
1102
  }
1089
1103
  /**
1090
1104
  * Outpost-side account bind (spec §5.2). Writes a named terminal reason,
1091
1105
  * never a plain `failed`.
1092
1106
  */
1093
- export function applyOutpostAccountPickup(hqRoot, laneId) {
1107
+ export function applyOutpostAccountPickup(hqRoot, laneId, modelDeps = {}) {
1094
1108
  const lane = readLane(hqRoot, laneId);
1095
1109
  const config = loadLanesConfig(hqRoot);
1096
1110
  try {
@@ -1104,16 +1118,55 @@ export function applyOutpostAccountPickup(hqRoot, laneId) {
1104
1118
  account: resolved.id,
1105
1119
  });
1106
1120
  }
1107
- if (lane.account === resolved.id)
1108
- return lane;
1121
+ // The executing host is the first place the model can be checked
1122
+ // against this account's provider list: an outpost create without
1123
+ // --account has no local account home to validate against.
1124
+ // The probe can block for up to MODEL_PROBE_TIMEOUT_MS. A cancel or any
1125
+ // other terminal decision taken meanwhile is final: after every probe
1126
+ // result the lane is re-read and a terminal lane is returned as it is
1127
+ // (so runPickup never proceeds into admission for it). The failure
1128
+ // write itself refuses a terminal record inside its CAS
1129
+ // (pickupFailureUpdater), so a cancel landing between this re-read and
1130
+ // that write is kept too.
1131
+ let current = lane;
1132
+ if (lane.model !== undefined) {
1133
+ let unsupported = null;
1134
+ try {
1135
+ validateLaneModel(lane.provider, lane.model, resolved.home, modelDeps);
1136
+ }
1137
+ catch (err) {
1138
+ if (isLanesError(err) && err.error === "model_unsupported") {
1139
+ unsupported = err;
1140
+ }
1141
+ else {
1142
+ throw err;
1143
+ }
1144
+ }
1145
+ current = readLane(hqRoot, laneId);
1146
+ if (isTerminalLaneState(current.state) || current.state === "running")
1147
+ return current;
1148
+ if (unsupported) {
1149
+ return markPickupFailure(hqRoot, laneId, "model_unsupported", {
1150
+ ...unsupported.details,
1151
+ account: resolved.id,
1152
+ });
1153
+ }
1154
+ }
1155
+ if (current.account === resolved.id && current.pickup_validation_pending === undefined) {
1156
+ return current;
1157
+ }
1109
1158
  // The bind is the executing host's pre-spawn account resolution, the same
1110
1159
  // write spawnLaneWorker makes for a local lane, so it carries the spawn
1111
1160
  // writer. `account` stays closed to `command`: an operator command never
1112
- // re-pins the account of a lane it does not execute.
1161
+ // re-pins the account of a lane it does not execute. The bind also
1162
+ // clears the pickup marker: from here the reconcile pass may admit it.
1113
1163
  return retryUpdateLane(hqRoot, laneId, "spawn", (current) => {
1114
- if (current.account === resolved.id)
1164
+ if (isTerminalLaneState(current.state) || current.state === "running")
1165
+ return current;
1166
+ if (current.account === resolved.id && current.pickup_validation_pending === undefined) {
1115
1167
  return current;
1116
- return { ...current, account: resolved.id };
1168
+ }
1169
+ return { ...current, account: resolved.id, pickup_validation_pending: undefined };
1117
1170
  });
1118
1171
  }
1119
1172
  catch (err) {
@@ -1255,6 +1308,64 @@ async function runCreate(ctx, opts) {
1255
1308
  }
1256
1309
  const now = ctx.now();
1257
1310
  const createdAt = now.toISOString();
1311
+ // `--model` is validated against the provider's own model list (models.ts)
1312
+ // before anything is written or posted: ahead of the mesh thread, so a
1313
+ // refusal leaves no orphan thread, and ahead of the admission lock, so a
1314
+ // slow `grok models` / `claude --help` probe never holds the lock other
1315
+ // creates wait on. A pinned create checks the pinned account's home. An
1316
+ // unpinned create is not bound to an account here (persist re-resolves the
1317
+ // balanced account under the lock, so overlapping creates still spread),
1318
+ // so it checks the balanced candidate first and then every other enabled
1319
+ // account the locked selection could land on (codex catalogs are per home
1320
+ // and can differ). `validatedAccounts` records which accepted the model
1321
+ // or had no readable list, and the balanced pick under the lock is
1322
+ // restricted to that set (resolveAccount's allowedAccounts): an account
1323
+ // whose list refused the model is never selected, so nothing has to be
1324
+ // refused after the mesh thread exists, and no probe or catalog read runs
1325
+ // while the lock is held. Every outpost create,
1326
+ // pinned or not, is validated at pickup on the executing host instead:
1327
+ // its provider list and CLI are the ones that run the lane.
1328
+ const modelDeps = {
1329
+ readFileSync: ctx.readFileSync,
1330
+ probe: ctx.probeProvider,
1331
+ env: ctx.env,
1332
+ };
1333
+ const validatedAccounts = new Set();
1334
+ if (cliModel !== undefined && !outpostId) {
1335
+ const accounts = config.providers[provider].accounts;
1336
+ const candidate = pinnedAccount ??
1337
+ resolveAccount(provider, undefined, config, {
1338
+ hqRoot,
1339
+ existsSync: ctx.existsSync,
1340
+ cliModel,
1341
+ }).id;
1342
+ const toValidate = pinnedAccount !== undefined
1343
+ ? [candidate]
1344
+ : [
1345
+ candidate,
1346
+ ...Object.keys(accounts)
1347
+ .filter((id) => id !== candidate && accounts[id]?.enabled)
1348
+ .sort(),
1349
+ ];
1350
+ for (const id of toValidate) {
1351
+ const home = accounts[id]?.home;
1352
+ if (home === undefined)
1353
+ continue;
1354
+ try {
1355
+ validateLaneModel(provider, cliModel, home, modelDeps);
1356
+ validatedAccounts.add(id);
1357
+ }
1358
+ catch (err) {
1359
+ if (!(isLanesError(err) && err.error === "model_unsupported"))
1360
+ throw err;
1361
+ // The account that would run the lane refuses it: refuse now, before
1362
+ // the other lists are read. Another account's refusal only drops it
1363
+ // from the locked selection.
1364
+ if (id === candidate)
1365
+ throw err;
1366
+ }
1367
+ }
1368
+ }
1258
1369
  const threadId = company
1259
1370
  ? await ctx.mesh.ensureProjectThread(company, project)
1260
1371
  : undefined;
@@ -1266,6 +1377,7 @@ async function runCreate(ctx, opts) {
1266
1377
  hqRoot,
1267
1378
  existsSync: ctx.existsSync,
1268
1379
  cliModel,
1380
+ ...(validatedAccounts.size > 0 ? { allowedAccounts: [...validatedAccounts] } : {}),
1269
1381
  }).id;
1270
1382
  }
1271
1383
  const accountCfg = pinnedAccount !== undefined
@@ -1531,7 +1643,13 @@ function ingestPickupPayload(hqRoot, payload) {
1531
1643
  throw new LanesError("outpost_payload_invalid", "Pickup payload lane.lane_id is required", {});
1532
1644
  }
1533
1645
  const existing = tryReadLane(hqRoot, laneId);
1534
- const lane = existing ?? createLane(hqRoot, payload.lane);
1646
+ // The lane is stored ineligible for admission until the account bind
1647
+ // clears the marker (see LaneRecord.pickup_validation_pending).
1648
+ const lane = existing === null
1649
+ ? createLane(hqRoot, { ...payload.lane, pickup_validation_pending: true })
1650
+ : existing.state === "queued" && existing.pickup_validation_pending === undefined
1651
+ ? retryUpdateLane(hqRoot, laneId, "command", (current) => current.state === "queued" ? { ...current, pickup_validation_pending: true } : current)
1652
+ : existing;
1535
1653
  const dest = runDir(hqRoot, lane.lane_id);
1536
1654
  ensureDir(path.join(dest, "logs"));
1537
1655
  ensureDir(path.join(dest, "envelopes"));
@@ -1599,7 +1717,11 @@ async function runPickup(ctx, opts) {
1599
1717
  throw err;
1600
1718
  }
1601
1719
  }
1602
- const afterAccount = applyOutpostAccountPickup(hqRoot, ingested.lane_id);
1720
+ const afterAccount = applyOutpostAccountPickup(hqRoot, ingested.lane_id, {
1721
+ readFileSync: ctx.readFileSync,
1722
+ probe: ctx.probeProvider,
1723
+ env: ctx.env,
1724
+ });
1603
1725
  if (isTerminalLaneState(afterAccount.state)) {
1604
1726
  printJson({
1605
1727
  ok: true,
@@ -1741,23 +1863,182 @@ async function runShow(ctx, laneId) {
1741
1863
  provider_session_id_absent: lane.provider_session_id_absent ?? null,
1742
1864
  envelope_repair_mode: lane.envelope_repair?.mode ?? null,
1743
1865
  last_resume: lane.last_resume ?? null,
1866
+ account: lane.account ?? null,
1867
+ account_history: lane.account_history ?? [],
1868
+ pending_resume: lane.pending_resume ?? null,
1744
1869
  last_run_usage: lane.last_run_usage ?? null,
1745
1870
  model: lane.model ?? null,
1746
1871
  effort: lane.effort ?? null,
1747
1872
  effort_source: lane.effort_source ?? null,
1873
+ terminal_reason: lane.terminal_reason ?? null,
1874
+ terminal_details: lane.terminal_details ?? null,
1748
1875
  last_message_at: lane.last_message_at ?? null,
1749
1876
  last_compaction: lane.last_compaction
1750
1877
  ? { ...lane.last_compaction, after: compactionAfterUsage(lane) }
1751
1878
  : null,
1752
1879
  });
1753
1880
  }
1881
+ /**
1882
+ * US-023: move the lane's provider session to `--account` (same provider)
1883
+ * before a resume or a message resume. Every check runs before anything is
1884
+ * copied or written; the copy runs before the record changes, so a failed
1885
+ * copy leaves the lane on its source account. The turn is started by the
1886
+ * caller, or parked as `pending_resume` when the target is at its cap so the
1887
+ * reconcile pass resumes it there.
1888
+ */
1889
+ async function prepareAccountMove(ctx, lane, input) {
1890
+ const hqRoot = ctx.hqRoot();
1891
+ const now = ctx.now();
1892
+ const laneId = lane.lane_id;
1893
+ if (lane.state === "running") {
1894
+ throw new LanesError("lane_running", `Lane ${laneId} is running; stop it or wait for it to exit before moving its session`, {
1895
+ lane_id: laneId,
1896
+ state: lane.state,
1897
+ });
1898
+ }
1899
+ // `--account <id>` or `--account <provider>:<id>`: a provider prefix that
1900
+ // is not the lane's is a cross-provider move, which no session survives.
1901
+ const raw = input.account.trim();
1902
+ const colon = raw.indexOf(":");
1903
+ const requestedProvider = colon > 0 ? raw.slice(0, colon) : undefined;
1904
+ const target = colon > 0 ? raw.slice(colon + 1) : raw;
1905
+ if (requestedProvider !== undefined && requestedProvider !== lane.provider) {
1906
+ throw new LanesError("account_provider_mismatch", `Lane ${laneId} runs on ${lane.provider}; account ${JSON.stringify(raw)} names provider ${requestedProvider}. Sessions do not move between providers`, { lane_id: laneId, provider: lane.provider, requested_provider: requestedProvider, account: target });
1907
+ }
1908
+ if (!target) {
1909
+ throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(input.account)} for ${lane.provider}`, {
1910
+ provider: lane.provider,
1911
+ account: input.account,
1912
+ enabled: [],
1913
+ });
1914
+ }
1915
+ if (target === lane.account) {
1916
+ throw new LanesError("account_unchanged", `Lane ${laneId} already runs on account ${target}`, {
1917
+ lane_id: laneId,
1918
+ account: target,
1919
+ });
1920
+ }
1921
+ if (!providerSupportsSessionResume(lane.provider)) {
1922
+ throw new LanesError("provider_session_not_portable", `Provider ${lane.provider} has no session resume, so a lane cannot move between its accounts`, { lane_id: laneId, provider: lane.provider });
1923
+ }
1924
+ if (!hasRecordedSessionId(lane)) {
1925
+ throw new LanesError("no_provider_session", `Lane ${laneId} has no recorded provider session id, so there is no session to move`, { lane_id: laneId, provider: lane.provider });
1926
+ }
1927
+ const sessionId = lane.provider_session_id;
1928
+ const config = loadLanesConfig(hqRoot);
1929
+ const providerCfg = config.providers[lane.provider];
1930
+ const targetCfg = providerCfg.accounts[target];
1931
+ if (targetCfg === undefined) {
1932
+ throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(target)} for ${lane.provider}. Configured: ${Object.keys(providerCfg.accounts).join(", ") || "(none)"}`, { provider: lane.provider, account: target, enabled: Object.keys(providerCfg.accounts).filter((id) => providerCfg.accounts[id]?.enabled) });
1933
+ }
1934
+ if (!targetCfg.enabled) {
1935
+ throw new AccountResolutionError("account_disabled", `Account ${JSON.stringify(target)} is disabled${targetCfg.disabled_reason ? `: ${targetCfg.disabled_reason}` : ""}`, { provider: lane.provider, account: target, reason: targetCfg.disabled_reason ?? "" });
1936
+ }
1937
+ if (isLoggedOut(accountStatus(lane.provider, target, config, { existsSync: ctx.existsSync }))) {
1938
+ throw new LanesError("account_not_logged_in", `Account ${target} for ${lane.provider} is not logged in (no credential file)`, { provider: lane.provider, account: target, lane_id: laneId });
1939
+ }
1940
+ // The model the moved run would use, resolved the way spawn resolves it:
1941
+ // the operator's --model, else the lane's own, else the target account's
1942
+ // default, else the provider's. The quota and validation checks use this
1943
+ // effective model so a per-model hold on the target's default is seen
1944
+ // here, before anything is copied. A target that pins a different model
1945
+ // refuses the lane's model unless the operator names one for the move.
1946
+ const model = resolveLaneModel({
1947
+ cliModel: input.cliModel ?? lane.model,
1948
+ accountModel: targetCfg.model,
1949
+ providerModel: providerCfg.model,
1950
+ });
1951
+ if (input.cliModel === undefined && lane.model !== undefined && targetCfg.model !== undefined && targetCfg.model !== lane.model) {
1952
+ throw new LanesError("model_unsupported", `Account ${target} pins model ${targetCfg.model}; lane ${laneId} runs ${lane.model}. Pass --model ${targetCfg.model} to move it onto that model`, { provider: lane.provider, account: target, model: lane.model, account_model: targetCfg.model, lane_id: laneId });
1953
+ }
1954
+ const block = quotaBlockFor(loadQuotaState(hqRoot), lane.provider, target, model, now);
1955
+ if (block.blocked) {
1956
+ throw new LanesError("account_quota_held", `Account ${target} for ${lane.provider} is held${block.quota_reset_at ? ` until ${block.quota_reset_at}` : ""}${block.marker ? ` (${block.marker})` : ""}`, { provider: lane.provider, account: target, quota_reset_at: block.quota_reset_at ?? null, marker: block.marker ?? null, model: model ?? null });
1957
+ }
1958
+ if (model !== undefined) {
1959
+ validateLaneModel(lane.provider, model, targetCfg.home, {
1960
+ readFileSync: ctx.readFileSync,
1961
+ probe: ctx.probeProvider,
1962
+ env: ctx.env,
1963
+ });
1964
+ }
1965
+ const sourceCfg = lane.account !== undefined ? providerCfg.accounts[lane.account] : undefined;
1966
+ if (sourceCfg === undefined) {
1967
+ throw new AccountResolutionError("unknown_account", `Lane ${laneId} records account ${JSON.stringify(lane.account)} which is not configured for ${lane.provider}`, { provider: lane.provider, account: lane.account ?? "", enabled: [] });
1968
+ }
1969
+ // Checks done. Copy the session files, then write the record.
1970
+ const copy = copySessionFiles(planSessionCopy(lane.provider, {
1971
+ sourceHome: sourceCfg.home,
1972
+ targetHome: targetCfg.home,
1973
+ cwd: hqRoot,
1974
+ sessionId,
1975
+ }));
1976
+ const at = now.toISOString();
1977
+ const from = lane.account ?? null;
1978
+ // The capacity decision and the record write run under the target
1979
+ // account's admission lock, the same serialization lane creation uses. A
1980
+ // slot is taken by a running lane or by a lane another move has already
1981
+ // pointed at the account whose spawn has not claimed it yet
1982
+ // (`pending_resume` set by this write, cleared by the claim), so two moves
1983
+ // into one free slot cannot both start.
1984
+ return withAdmissionLock(admissionLockPath(hqRoot, lane.provider, target), async () => {
1985
+ const lanes = loadAllLanes(hqRoot);
1986
+ // A queued lane with a pending resume waits for admission and holds
1987
+ // no slot; one in any other state with a pending resume is a move
1988
+ // whose spawn is about to claim, and holds the slot it was granted.
1989
+ const occupies = (row) => row.lane_id !== laneId &&
1990
+ (row.state === "running" || (row.pending_resume !== undefined && row.state !== "queued"));
1991
+ const onProvider = lanes.filter((row) => row.provider === lane.provider && occupies(row));
1992
+ const atCap = onProvider.length >= providerCfg.max_running ||
1993
+ onProvider.filter((row) => row.account === target).length >= accountMaxRunning(providerCfg, target);
1994
+ const moved = casUpdateLane(hqRoot, laneId, (current) => {
1995
+ // The copy and every check above were made from `lane`; a record
1996
+ // that moved on since (another move, a spawn, a cancel) invalidates
1997
+ // them, so the write is refused rather than applied to a state it
1998
+ // was not prepared for.
1999
+ if (current.version !== lane.version) {
2000
+ throw new LanesError("account_move_conflict", `Lane ${laneId} changed while the move was prepared (version ${lane.version} → ${current.version}, state ${current.state}, account ${current.account ?? "(none)"}); run the move again`, { lane_id: laneId, prepared_version: lane.version, version: current.version, state: current.state, account: current.account ?? null });
2001
+ }
2002
+ const next = {
2003
+ ...current,
2004
+ account: target,
2005
+ ...(input.cliModel !== undefined ? { model: input.cliModel } : {}),
2006
+ account_history: appendCappedHistory(current.account_history ?? [], {
2007
+ from,
2008
+ to: target,
2009
+ at,
2010
+ reason: SESSION_MOVE_REASON,
2011
+ }),
2012
+ pending_resume: { session_id: sessionId, at, ...input.pending },
2013
+ };
2014
+ if (atCap) {
2015
+ next.state = "queued";
2016
+ next.queued_reason = "cap";
2017
+ }
2018
+ return next;
2019
+ });
2020
+ return { lane: moved, from, to: target, copy, queued: atCap };
2021
+ }, ctx.sleep);
2022
+ }
1754
2023
  async function runMessage(ctx, laneId, opts) {
1755
2024
  const hqRoot = ctx.hqRoot();
1756
2025
  const text = opts.text;
1757
2026
  if (!text) {
1758
2027
  throw new LanesError("missing_text", "--text is required", {});
1759
2028
  }
1760
- const lane = readLane(hqRoot, laneId);
2029
+ let lane = readLane(hqRoot, laneId);
2030
+ // An account move is refused on its own terms first: a running lane and a
2031
+ // provider without session resume get the move's named errors, not the
2032
+ // generic delivery refusal below.
2033
+ if (opts.account !== undefined && lane.state === "running") {
2034
+ throw new LanesError("lane_running", `Lane ${laneId} is running; stop it or wait for it to exit before moving its session`, {
2035
+ lane_id: laneId,
2036
+ state: lane.state,
2037
+ });
2038
+ }
2039
+ if (opts.account !== undefined && !providerSupportsSessionResume(lane.provider)) {
2040
+ throw new LanesError("provider_session_not_portable", `Provider ${lane.provider} has no session resume, so a lane cannot move between its accounts`, { lane_id: laneId, provider: lane.provider });
2041
+ }
1761
2042
  // One decision point for how a follow-up reaches this lane (delivery.ts).
1762
2043
  // `none` refuses before anything is written: no inbox append, no spawn.
1763
2044
  const mechanism = laneDeliveryMechanism(lane);
@@ -1770,6 +2051,9 @@ async function runMessage(ctx, laneId, opts) {
1770
2051
  message,
1771
2052
  });
1772
2053
  }
2054
+ if (opts.model !== undefined && opts.account === undefined) {
2055
+ throw new LanesError("model_requires_account", "--model on hq lanes message is only accepted together with --account", {});
2056
+ }
1773
2057
  const messageId = mintMessageId();
1774
2058
  const at = ctx.now().toISOString();
1775
2059
  const projectId = opts.project?.trim() || lane.project_id;
@@ -1782,7 +2066,24 @@ async function runMessage(ctx, laneId, opts) {
1782
2066
  story_id: storyId,
1783
2067
  };
1784
2068
  let resumed = null;
1785
- if (mechanism === "dropbox") {
2069
+ let move = null;
2070
+ if (opts.account !== undefined) {
2071
+ // A moved message is always a resume on the target account: a running
2072
+ // lane (the dropbox mechanism) is refused inside prepareAccountMove.
2073
+ move = await prepareAccountMove(ctx, lane, {
2074
+ account: opts.account,
2075
+ cliModel: opts.model?.trim() || undefined,
2076
+ pending: { trigger: "message", text, message_id: messageId, compact: opts.compact !== false },
2077
+ });
2078
+ lane = move.lane;
2079
+ if (move.queued) {
2080
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
2081
+ }
2082
+ }
2083
+ if (move?.queued) {
2084
+ // Parked at the target's cap: reconcile resumes the session there.
2085
+ }
2086
+ else if (mechanism === "dropbox") {
1786
2087
  // Live (or about-to-be-live) process: the PreToolUse drain delivers it.
1787
2088
  if (!laneAcceptsOperatorMessage(lane)) {
1788
2089
  throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
@@ -1841,7 +2142,11 @@ async function runMessage(ctx, laneId, opts) {
1841
2142
  // started from; `resume` has started a new worker with the message as its
1842
2143
  // prompt. Neither is "delivered", so the response never says so.
1843
2144
  const drainHook = drainHookStatusFor(ctx, next);
1844
- const deliveryStatus = mechanism === "dropbox" ? "awaiting_next_tool_call" : "worker_spawned";
2145
+ const deliveryStatus = move?.queued
2146
+ ? "queued_on_target_account"
2147
+ : mechanism === "dropbox"
2148
+ ? "awaiting_next_tool_call"
2149
+ : "worker_spawned";
1845
2150
  const warning = mechanism === "dropbox" && drainHook !== null && !drainHook.registered
1846
2151
  ? `The drain hook is not registered in ${drainHook.path}; the running worker will not receive this message. It stays in the inbox and is delivered on the lane's next start.`
1847
2152
  : mechanism === "dropbox" && next.stalled === true
@@ -1863,11 +2168,24 @@ async function runMessage(ctx, laneId, opts) {
1863
2168
  compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
1864
2169
  ? resumed.last_compaction
1865
2170
  : null,
2171
+ account_move: move ? accountMoveSummary(move) : null,
1866
2172
  project_id: next.project_id,
1867
2173
  story_id: next.story_id,
1868
2174
  lane: next,
1869
2175
  });
1870
2176
  }
2177
+ function accountMoveSummary(move) {
2178
+ return {
2179
+ from: move.from,
2180
+ to: move.to,
2181
+ queued: move.queued,
2182
+ copied: move.copy.copied,
2183
+ sources: move.copy.sources,
2184
+ targets: move.copy.targets,
2185
+ source_dir: move.copy.source_dir,
2186
+ target_dir: move.copy.target_dir,
2187
+ };
2188
+ }
1871
2189
  /**
1872
2190
  * `hq lanes resume <lane-id> [--text <msg>]`: the explicit opt-in that
1873
2191
  * continues a lane in `interrupted`, `done` or `blocked` on its recorded
@@ -1879,7 +2197,19 @@ async function runMessage(ctx, laneId, opts) {
1879
2197
  */
1880
2198
  async function runResume(ctx, laneId, opts) {
1881
2199
  const hqRoot = ctx.hqRoot();
1882
- const lane = readLane(hqRoot, laneId);
2200
+ let lane = readLane(hqRoot, laneId);
2201
+ if (opts.model !== undefined && opts.account === undefined) {
2202
+ throw new LanesError("model_requires_account", "--model on hq lanes resume is only accepted together with --account", {});
2203
+ }
2204
+ if (opts.account !== undefined && lane.state === "running") {
2205
+ throw new LanesError("lane_running", `Lane ${laneId} is running; stop it or wait for it to exit before moving its session`, {
2206
+ lane_id: laneId,
2207
+ state: lane.state,
2208
+ });
2209
+ }
2210
+ if (opts.account !== undefined && !providerSupportsSessionResume(lane.provider)) {
2211
+ throw new LanesError("provider_session_not_portable", `Provider ${lane.provider} has no session resume, so a lane cannot move between its accounts`, { lane_id: laneId, provider: lane.provider });
2212
+ }
1883
2213
  const refusal = resumeCommandRefusal(lane);
1884
2214
  if (refusal !== null) {
1885
2215
  throw new LanesError(refusal.error, refusal.message, {
@@ -1896,25 +2226,42 @@ async function runResume(ctx, laneId, opts) {
1896
2226
  const storyId = opts.story?.trim() || lane.story_id;
1897
2227
  const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
1898
2228
  const messageId = text !== undefined || attributionChanged ? mintMessageId() : undefined;
1899
- const resumed = spawnLaneWorker(hqRoot, lane, {
1900
- env: ctx.env,
1901
- existsSync: ctx.existsSync,
1902
- spawnProcess: ctx.spawnProcess,
1903
- proveDetached: ctx.proveDetached,
1904
- killProcess: ctx.killProcess,
1905
- now: ctx.now,
1906
- resolveMachineIdentity: ctx.resolveMachineIdentity,
1907
- runCompaction: ctx.runCompaction,
1908
- locateTranscript: ctx.locateTranscript,
1909
- }, {
1910
- trigger: {
1911
- kind: "resume",
1912
- from: resumedFrom,
1913
- ...(text !== undefined ? { text } : {}),
1914
- ...(messageId !== undefined ? { message_id: messageId } : {}),
1915
- },
1916
- compact: opts.compact !== false,
1917
- });
2229
+ let move = null;
2230
+ if (opts.account !== undefined) {
2231
+ move = await prepareAccountMove(ctx, lane, {
2232
+ account: opts.account,
2233
+ cliModel: opts.model?.trim() || undefined,
2234
+ pending: {
2235
+ trigger: "resume",
2236
+ resumed_from: resumedFrom,
2237
+ ...(text !== undefined ? { text } : {}),
2238
+ ...(messageId !== undefined ? { message_id: messageId } : {}),
2239
+ compact: opts.compact !== false,
2240
+ },
2241
+ });
2242
+ lane = move.lane;
2243
+ }
2244
+ const resumed = move?.queued
2245
+ ? lane
2246
+ : spawnLaneWorker(hqRoot, lane, {
2247
+ env: ctx.env,
2248
+ existsSync: ctx.existsSync,
2249
+ spawnProcess: ctx.spawnProcess,
2250
+ proveDetached: ctx.proveDetached,
2251
+ killProcess: ctx.killProcess,
2252
+ now: ctx.now,
2253
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
2254
+ runCompaction: ctx.runCompaction,
2255
+ locateTranscript: ctx.locateTranscript,
2256
+ }, {
2257
+ trigger: {
2258
+ kind: "resume",
2259
+ from: resumedFrom,
2260
+ ...(text !== undefined ? { text } : {}),
2261
+ ...(messageId !== undefined ? { message_id: messageId } : {}),
2262
+ },
2263
+ compact: opts.compact !== false,
2264
+ });
1918
2265
  if (text !== undefined && messageId !== undefined) {
1919
2266
  // Same audit copy a message resume leaves; nothing drains it. Archive only
1920
2267
  // after a successful spawn so a concurrent resume cannot leave a phantom.
@@ -1961,7 +2308,8 @@ async function runResume(ctx, laneId, opts) {
1961
2308
  message_id: messageId ?? null,
1962
2309
  text: text ?? null,
1963
2310
  delivery: "resume",
1964
- delivery_status: "worker_spawned",
2311
+ delivery_status: move?.queued ? "queued_on_target_account" : "worker_spawned",
2312
+ account_move: move ? accountMoveSummary(move) : null,
1965
2313
  resumed_session_id: resumed.last_resume?.session_id ?? null,
1966
2314
  last_resume: next.last_resume ?? null,
1967
2315
  compaction: resumed.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
@@ -2692,7 +3040,7 @@ async function runReconcileInstall(ctx, opts) {
2692
3040
  hqRoot: ctx.hqRoot(),
2693
3041
  enable: Boolean(opts.enable),
2694
3042
  runCommand: opts.enable ? ctx.runSystemctl : undefined,
2695
- pathEnv: providerPath.path,
3043
+ providerDirs: providerPath.resolved_dirs,
2696
3044
  });
2697
3045
  printJson({
2698
3046
  ...result,
@@ -2781,6 +3129,8 @@ export function registerLanesCommand(parent, deps = {}) {
2781
3129
  .requiredOption("--text <msg>", "Message text")
2782
3130
  .option("--project <slug>", "Re-point project attribution from this message")
2783
3131
  .option("--story <id>", "Re-point story attribution from this message")
3132
+ .option("--account <id>", "Move the lane's session to this account of its provider before the message resumes it")
3133
+ .option("--model <id>", "Model for the moved lane (with --account only)")
2784
3134
  .option("--no-compact", "Skip the resume-time compaction check for this message")
2785
3135
  .action(wrap(async (laneId, opts) => {
2786
3136
  await runMessage(ctx, laneId, opts);
@@ -2792,6 +3142,8 @@ export function registerLanesCommand(parent, deps = {}) {
2792
3142
  .option("--text <msg>", "Operator message for the resumed turn")
2793
3143
  .option("--project <slug>", "Re-point project attribution from this resume")
2794
3144
  .option("--story <id>", "Re-point story attribution from this resume")
3145
+ .option("--account <id>", "Move the lane's session to this account of its provider before resuming")
3146
+ .option("--model <id>", "Model for the moved lane (with --account only)")
2795
3147
  .option("--no-compact", "Skip the resume-time compaction check for this resume")
2796
3148
  .action(wrap(async (laneId, opts) => {
2797
3149
  await runResume(ctx, laneId, opts);
@@ -23,7 +23,8 @@ import { flushSessionEvents } from "../lib/mesh/live/flush.js";
23
23
  import { backfillHeldSessions } from "../lib/mesh/live/backfill-held.js";
24
24
  import { createSessionEventsPoster, resolveVaultApiBase, } from "../lib/mesh/live/session-events-client.js";
25
25
  const MEMBERSHIP_VALIDATION_TIMEOUT_MS = 5_000;
26
- import { buildEmitEvidence } from "../lib/mesh/live/emit-evidence.js";
26
+ import { buildEmitEvidence, machineEmitDisposition } from "../lib/mesh/live/emit-evidence.js";
27
+ import { appendHeldLine } from "../lib/mesh/live/spool.js";
27
28
  import { createEmitPoster, buildMeshEmitEvent } from "../lib/mesh/live/emit-client.js";
28
29
  import { emitEvents, formatEmitRejectionLines } from "../lib/mesh/live/emit.js";
29
30
  import { readEmitRetry, writeEmitRetry } from "../lib/mesh/live/emit-retry.js";
@@ -1070,6 +1071,7 @@ async function runSessionEnqueue(cliKind, opts) {
1070
1071
  companySlug: opts.companySlug,
1071
1072
  project: opts.project,
1072
1073
  task: opts.task,
1074
+ env: process.env,
1073
1075
  });
1074
1076
  const event = buildMeshEmitEvent({
1075
1077
  eventId: opts.eventId?.trim() || generateUlid(Date.now()),
@@ -1088,6 +1090,23 @@ async function runSessionEnqueue(cliKind, opts) {
1088
1090
  evidence,
1089
1091
  });
1090
1092
  const meshRoot = workMeshHomeRoot();
1093
+ // A machine credential with no company must not be posted. The server files
1094
+ // that as company NONE. Hold it and say why.
1095
+ if (machineEmitDisposition({ machine: isMachineIdentity(), companySlug: evidence.companySlug }) === "hold") {
1096
+ appendHeldLine(JSON.stringify({
1097
+ event,
1098
+ heldReason: "NEEDS_COMPANY",
1099
+ heldAt: new Date().toISOString(),
1100
+ reason: "machine session has no company; event was not sent",
1101
+ }), meshRoot);
1102
+ const heldMessage = `work-mesh: held ${kind}; needs_company (machine session has no company; not sent)`;
1103
+ if (opts.json) {
1104
+ console.log(JSON.stringify({ ok: true, action: "emit", kind, held: true, reason: "needs_company" }, null, 2));
1105
+ return;
1106
+ }
1107
+ console.error(heldMessage);
1108
+ return;
1109
+ }
1091
1110
  // Resolve a token non-interactively (same path as every CLI call). If the box
1092
1111
  // is not logged in, retain the event locally for the next invocation to drain.
1093
1112
  let token;