@indigoai-us/hq-cli 5.154.0 → 5.156.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,54 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.156.0] — 2026-09-24
6
+
7
+ ### Fixed
8
+
9
+ - Project-thread lookup treats an omitted server `kind` as the project thread,
10
+ preventing duplicate project threads when a newer lane thread is present.
11
+ - Lane admission reservations are refreshed after Work Mesh lane-thread
12
+ registration so slow registration cannot age the reservation before spawn.
13
+
14
+ ### Added
15
+
16
+ - `hq mesh project register --channel` and `project set --channel` can bind an
17
+ existing channel to project events. `hq mesh check --project` shows lane
18
+ identity and status, including terminal lane threads.
19
+ - Lane mesh threads keep a stable lane id across resume and account moves.
20
+ Events include the worker profile, model, account, parent lane, and senior
21
+ owner. Stop and dead-host recovery events close terminal threads.
22
+
23
+ ### Tests
24
+
25
+ - Cover server-shaped project-thread results and reservation timing after
26
+ lane-thread registration.
27
+
28
+ ## [5.155.0] — 2026-09-24
29
+
30
+ ### Fixed
31
+
32
+ - Lane respawns keep their recorded account to preserve provider sessions and
33
+ prompt caches. Automatic moves now record why they happened and are limited
34
+ to model changes, usage limits, and unavailable or logged-out accounts.
35
+ - Account retries share the provider admission lock and the host-scoped provider,
36
+ account, and profile checks used by normal admission. Active reservations and
37
+ earlier eligible queue entries keep their place; blocked retries remain queued
38
+ for normal admission. Retries skip accounts already rejected for login and
39
+ retain message, resume, and answer context when queued.
40
+ - Watcher retries resolve the current process hostname and Outpost identity, so
41
+ local-kind and Outpost-kind lanes on one Outpost share admission caps.
42
+ - Account enable and registration now require a positive login check. Provider
43
+ startup messages for logged-out accounts trigger failover to another eligible
44
+ account or a named `account_not_logged_in` failure.
45
+
46
+ ### Added
47
+
48
+ - Provider config can list accounts in preference order for a lane's first
49
+ assignment. Disabled, logged-out, usage-limited, and capped accounts are
50
+ skipped. If no preferred account is eligible, hq-cli chooses the least-loaded
51
+ eligible account. Running and resumed lanes keep their recorded account.
52
+
5
53
  ## [5.154.0] — 2026-09-24
6
54
 
7
55
  ### Fixed
@@ -5708,6 +5708,9 @@ export declare const COMMAND_CATALOG: readonly [{
5708
5708
  }, {
5709
5709
  readonly flags: "--repo <path[@branch]>";
5710
5710
  readonly description: "Repository binding (repeatable)";
5711
+ }, {
5712
+ readonly flags: "--channel <channelId>";
5713
+ readonly description: "Bind the project to an existing company or project channel";
5711
5714
  }, {
5712
5715
  readonly flags: "--create";
5713
5716
  readonly description: "Create the Board view when the GET is a 404";
@@ -5754,7 +5757,7 @@ export declare const COMMAND_CATALOG: readonly [{
5754
5757
  readonly description: "Company slug or cloud uid";
5755
5758
  }, {
5756
5759
  readonly flags: "--channel <channelId>";
5757
- readonly description: "Reserved: the current server chooses the project channel";
5760
+ readonly description: "Bind the project to an existing company or project channel";
5758
5761
  }, {
5759
5762
  readonly flags: "--json";
5760
5763
  readonly description: "Print machine-readable JSON";
@@ -7367,6 +7367,10 @@ export const COMMAND_CATALOG = [
7367
7367
  "flags": "--repo <path[@branch]>",
7368
7368
  "description": "Repository binding (repeatable)"
7369
7369
  },
7370
+ {
7371
+ "flags": "--channel <channelId>",
7372
+ "description": "Bind the project to an existing company or project channel"
7373
+ },
7370
7374
  {
7371
7375
  "flags": "--create",
7372
7376
  "description": "Create the Board view when the GET is a 404"
@@ -7427,7 +7431,7 @@ export const COMMAND_CATALOG = [
7427
7431
  },
7428
7432
  {
7429
7433
  "flags": "--channel <channelId>",
7430
- "description": "Reserved: the current server chooses the project channel"
7434
+ "description": "Bind the project to an existing company or project channel"
7431
7435
  },
7432
7436
  {
7433
7437
  "flags": "--json",
@@ -6,6 +6,9 @@
6
6
  * `watch` and the monitor reminder are the line-oriented text surfaces.
7
7
  */
8
8
  import { Command } from "commander";
9
+ import type { OutpostIdentity } from "../lib/lanes/admission-host.js";
10
+ export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
11
+ export type { OutpostIdentity } from "../lib/lanes/admission-host.js";
9
12
  import { type OutpostDispatchResult } from "../lib/lanes/outpost-dispatch.js";
10
13
  import { emitLaneDecisionEvents, type LaneMesh } from "../lib/lanes/mesh-events.js";
11
14
  import { MAX_LANE_DEPTH, type LaneCompanyRef, type LaneRecord, type QuestionRecord, type ResolvedLaneWorker, type SeniorRef, type LivenessDeps, type MachineIdentity, type MonitorEvidenceReader, type RunCommand, type ThrottleDeps } from "../lib/lanes/index.js";
@@ -16,17 +19,7 @@ import { spawnLaneWorker, type KillProcessFn, type ProveDetachedFn, type SpawnPr
16
19
  export { spawnLaneWorker };
17
20
  export { questionBodyAbsPath };
18
21
  export { MAX_LANE_DEPTH };
19
- export declare const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
20
- export declare const OUTPOST_DIR = "/etc/outpost";
21
22
  export declare const LANES_RUNS_REL: string;
22
- export type OutpostIdentity = {
23
- kind: "id";
24
- id: string;
25
- } | {
26
- kind: "unknown";
27
- } | {
28
- kind: "not_outpost";
29
- };
30
23
  export interface SpawnLoginResult {
31
24
  status: number;
32
25
  }
@@ -79,7 +72,6 @@ export interface LanesCommandDeps {
79
72
  locateTranscript?: (home: string, sessionId: string) => string | null;
80
73
  monitorEvidence?: MonitorEvidenceReader;
81
74
  }
82
- export declare function readOutpostIdentity(existsSync?: (p: string) => boolean, readFileSync?: (p: string) => string): OutpostIdentity;
83
75
  export declare function parseSeniorRef(raw: string): SeniorRef;
84
76
  export declare function resolveAnswerChoice(choice: string, options: string[], freeform: boolean): string;
85
77
  /**
@@ -12,13 +12,15 @@ import * as os from "node:os";
12
12
  import * as path from "node:path";
13
13
  import { ensureCognitoToken } from "../utils/cognito-session.js";
14
14
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
15
+ import { readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
16
+ export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
15
17
  import { buildOutpostLaneCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
16
18
  import { parseUserYaml } from "../utils/user-yaml-error.js";
17
19
  import { vaultApiFetch } from "../utils/vault-api.js";
18
20
  import { openMeshTransport } from "../lib/mesh/client.js";
19
- import { createLaneMesh, emitLaneDecisionEvents, } from "../lib/lanes/mesh-events.js";
21
+ import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
20
22
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
21
- import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, isLoggedOut, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
23
+ import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, LANE_STATES, PROVIDER_HOME_ENV, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installDrainHook, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_UNCOVERED_EXIT_CODE, MAX_WATCHED_LANES, monitorReminderText, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readDaemonPresenceConnected, readLane, retryUpdateLane, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, runReconcilePass, runLaneWatch, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, writeLanesConfig, appendDropboxMessage, answerDropboxMessage, deliveryRefusalReason, resumeCommandRefusal, dropboxDir, dropboxInboxPath, laneAcceptsDropboxDelivery, laneAcceptsOperatorMessage, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, withLaneAdmissionLock, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
22
24
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
23
25
  import { questionBodyAbsPath } from "../lib/lanes/questions.js";
24
26
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
@@ -33,8 +35,6 @@ import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ON
33
35
  export { spawnLaneWorker };
34
36
  export { questionBodyAbsPath };
35
37
  export { MAX_LANE_DEPTH };
36
- export const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
37
- export const OUTPOST_DIR = "/etc/outpost";
38
38
  export const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
39
39
  const VENDOR_LOGIN = {
40
40
  claude: { command: "claude", args: ["auth", "login"] },
@@ -133,27 +133,6 @@ async function defaultFetchText(url) {
133
133
  }
134
134
  return res.text();
135
135
  }
136
- export function readOutpostIdentity(existsSync = (p) => fs.existsSync(p), readFileSync = (p) => fs.readFileSync(p, "utf8")) {
137
- const hasMarker = existsSync(OUTPOST_ID_PATH);
138
- const hasDir = existsSync(OUTPOST_DIR);
139
- if (hasMarker) {
140
- let text;
141
- try {
142
- text = readFileSync(OUTPOST_ID_PATH);
143
- }
144
- catch (err) {
145
- process.stderr.write(`hq lanes: failed to read ${OUTPOST_ID_PATH}: ${err instanceof Error ? err.message : String(err)}\n`);
146
- throw err;
147
- }
148
- const id = text.trim();
149
- if (!id)
150
- return hasDir ? { kind: "unknown" } : { kind: "not_outpost" };
151
- return { kind: "id", id };
152
- }
153
- if (hasDir)
154
- return { kind: "unknown" };
155
- return { kind: "not_outpost" };
156
- }
157
136
  function defaultSpawnLogin(opts) {
158
137
  return new Promise((resolve, reject) => {
159
138
  const child = spawn(opts.command, opts.args, {
@@ -263,11 +242,10 @@ function livenessDepsFromCtx(ctx) {
263
242
  };
264
243
  }
265
244
  function reconcileHostFromCtx(ctx) {
266
- const identity = ctx.readOutpostIdentity();
267
- return {
268
- hostname: ctx.hostname(),
269
- outpostId: identity.kind === "id" ? identity.id : null,
270
- };
245
+ return resolveAdmissionHost({
246
+ hostname: ctx.hostname,
247
+ readOutpostIdentity: ctx.readOutpostIdentity,
248
+ });
271
249
  }
272
250
  export function parseSeniorRef(raw) {
273
251
  const trimmed = raw.trim();
@@ -1172,18 +1150,45 @@ function askDeliveryFailedPath(hqRoot, laneId) {
1172
1150
  return path.join(runDir(hqRoot, laneId), "ask_delivery_failed.json");
1173
1151
  }
1174
1152
  async function emitLaneMeshEvent(ctx, company, threadId, event, payload) {
1175
- if (!threadId || !company)
1153
+ if (!company)
1176
1154
  return;
1177
- await ctx.mesh.appendThreadEvent(company, threadId, event, payload);
1155
+ let targetThreadId = threadId;
1156
+ let eventPayload = payload;
1157
+ const laneId = typeof payload.lane_id === "string" ? payload.lane_id.trim() : "";
1158
+ if (laneId && ctx.mesh.ensureLaneThread) {
1159
+ const lane = readLane(ctx.hqRoot(), laneId);
1160
+ const ready = await ensureLaneThreadRecord(ctx, lane, lane.project_id, lane.story_id, "running");
1161
+ targetThreadId = ready.thread_id;
1162
+ eventPayload = { ...laneMeshEventIdentity(ready), ...payload };
1163
+ }
1164
+ if (!targetThreadId)
1165
+ return;
1166
+ await ctx.mesh.appendThreadEvent(company, targetThreadId, event, eventPayload);
1167
+ }
1168
+ async function emitLaneTerminalStateSoft(ctx, lane) {
1169
+ if (lane.state !== "failed" && lane.state !== "interrupted" &&
1170
+ lane.state !== "cancelled" && lane.state !== "killed")
1171
+ return;
1172
+ try {
1173
+ await emitLaneTerminalStateEvent(ctx.mesh, lane, lane.state, lane.terminal_reason, AbortSignal.timeout(10_000));
1174
+ }
1175
+ catch (err) {
1176
+ process.stderr.write(`hq lanes: terminal mesh update failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
1177
+ }
1178
1178
  }
1179
1179
  async function emitLaneStartedForStories(ctx, company, lane, threadId) {
1180
- if (!threadId)
1180
+ const ready = ctx.mesh.ensureLaneThread
1181
+ ? await ensureLaneThreadRecord(ctx, lane, lane.project_id, lane.story_id, "running")
1182
+ : lane;
1183
+ const stableThreadId = ready.thread_id ?? threadId;
1184
+ if (!stableThreadId)
1181
1185
  return;
1182
1186
  const failures = [];
1183
- for (const story of laneStories(lane)) {
1187
+ for (const story of laneStories(ready)) {
1184
1188
  try {
1185
- await emitLaneMeshEvent(ctx, company, threadId, "lane_started", {
1186
- lane_id: lane.lane_id,
1189
+ await emitLaneMeshEvent(ctx, company, stableThreadId, "lane_started", {
1190
+ ...laneMeshEventIdentity(ready),
1191
+ lane_id: ready.lane_id,
1187
1192
  project_id: story.project_id,
1188
1193
  story_id: story.story_id,
1189
1194
  });
@@ -1261,9 +1266,17 @@ export function applyOutpostAccountPickup(hqRoot, laneId, modelDeps = {}) {
1261
1266
  try {
1262
1267
  const resolved = resolveAccount(lane.provider, lane.account, config, {
1263
1268
  hqRoot,
1269
+ accountDeps: {
1270
+ probe: modelDeps.probe,
1271
+ env: modelDeps.env,
1272
+ },
1273
+ });
1274
+ const status = accountStatus(lane.provider, resolved.id, config, {
1275
+ existsSync: (p) => fs.existsSync(p),
1276
+ probe: modelDeps.probe,
1277
+ env: modelDeps.env,
1264
1278
  });
1265
- const status = accountStatus(lane.provider, resolved.id, config);
1266
- if (isLoggedOut(status)) {
1279
+ if (status.logged_in !== true) {
1267
1280
  return markPickupFailure(hqRoot, laneId, "account_not_logged_in", {
1268
1281
  provider: lane.provider,
1269
1282
  account: resolved.id,
@@ -1549,8 +1562,10 @@ async function runCreate(ctx, opts) {
1549
1562
  resolveAccount(provider, undefined, config, {
1550
1563
  hqRoot,
1551
1564
  existsSync: ctx.existsSync,
1565
+ accountDeps: { probe: ctx.probeProvider, env: ctx.env },
1552
1566
  cliModel,
1553
1567
  model: modelToValidate,
1568
+ preferredAccounts: config.providers[provider].preferred_accounts ?? [],
1554
1569
  ...(profileAccounts !== undefined
1555
1570
  ? { allowedAccounts: profileAccounts }
1556
1571
  : {}),
@@ -1563,7 +1578,11 @@ async function runCreate(ctx, opts) {
1563
1578
  .filter((id) => id !== candidate &&
1564
1579
  accounts[id]?.enabled &&
1565
1580
  (profileAccounts === undefined || profileAccounts.includes(id)) &&
1566
- accountStatus(provider, id, config, { existsSync: ctx.existsSync }).logged_in === true)
1581
+ accountStatus(provider, id, config, {
1582
+ existsSync: ctx.existsSync,
1583
+ probe: ctx.probeProvider,
1584
+ env: ctx.env,
1585
+ }).logged_in === true)
1567
1586
  .sort(),
1568
1587
  ];
1569
1588
  const checkedAccounts = [];
@@ -1638,9 +1657,8 @@ async function runCreate(ctx, opts) {
1638
1657
  (profileAccounts === undefined || profileAccounts.includes(id)))
1639
1658
  .map(([id]) => id))]
1640
1659
  : undefined;
1641
- const threadId = meshCompany
1642
- ? await ctx.mesh.ensureProjectThread(meshCompany, project)
1643
- : undefined;
1660
+ // The lane id is created in `persist`; its Work Mesh thread is ensured
1661
+ // immediately afterward from that stable identity.
1644
1662
  const persist = async () => {
1645
1663
  let admission;
1646
1664
  let validation;
@@ -1648,8 +1666,10 @@ async function runCreate(ctx, opts) {
1648
1666
  pinnedAccount = resolveAccount(provider, undefined, config, {
1649
1667
  hqRoot,
1650
1668
  existsSync: ctx.existsSync,
1669
+ accountDeps: { probe: ctx.probeProvider, env: ctx.env },
1651
1670
  cliModel,
1652
1671
  model: cliModel ?? profileModel,
1672
+ preferredAccounts: config.providers[provider].preferred_accounts ?? [],
1653
1673
  ...(allowedAccounts !== undefined ? { allowedAccounts } : {}),
1654
1674
  }).id;
1655
1675
  }
@@ -1718,7 +1738,6 @@ async function runCreate(ctx, opts) {
1718
1738
  added_at: createdAt,
1719
1739
  updated_at: createdAt,
1720
1740
  })),
1721
- thread_id: threadId,
1722
1741
  depth: laneDepth,
1723
1742
  senior,
1724
1743
  senior_history: [],
@@ -1771,7 +1790,35 @@ async function runCreate(ctx, opts) {
1771
1790
  else {
1772
1791
  persisted = await withLaneAdmissionLock(hqRoot, provider, persist, ctx.sleep);
1773
1792
  }
1774
- const { lane, admission, validation } = persisted;
1793
+ let lane = persisted.lane;
1794
+ const { admission, validation } = persisted;
1795
+ if (meshCompany) {
1796
+ try {
1797
+ lane = await ensureLaneThreadRecord(ctx, lane);
1798
+ }
1799
+ catch {
1800
+ try {
1801
+ lane = casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => ({
1802
+ ...current,
1803
+ state: "failed",
1804
+ queued_reason: undefined,
1805
+ terminal_reason: "mesh_thread_registration_failed",
1806
+ }));
1807
+ }
1808
+ catch {
1809
+ throw new LanesError("mesh_thread_registration_failed", `Work Mesh lane registration failed for ${lane.lane_id}, and its local record could not be marked terminal.`, { lane_id: lane.lane_id });
1810
+ }
1811
+ throw new LanesError("mesh_thread_registration_failed", `Work Mesh lane registration failed; lane ${lane.lane_id} was marked failed.`, { lane_id: lane.lane_id, state: lane.state });
1812
+ }
1813
+ }
1814
+ // Thread registration runs after persistence; anchor the reservation after
1815
+ // its network work so a slow response cannot age the slot before spawn.
1816
+ if (meshCompany && admission.action === "start" && lane.admission_reserved_at) {
1817
+ lane = casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => ({
1818
+ ...current,
1819
+ admission_reserved_at: ctx.now().toISOString(),
1820
+ }));
1821
+ }
1775
1822
  const dest = runDir(hqRoot, lane.lane_id);
1776
1823
  ensureDir(path.join(dest, "logs"));
1777
1824
  ensureDir(path.join(dest, "envelopes"));
@@ -1797,6 +1844,7 @@ async function runCreate(ctx, opts) {
1797
1844
  probe: ctx.probeProvider,
1798
1845
  env: ctx.env,
1799
1846
  },
1847
+ currentModelValidated: !outpostId && modelToValidate !== undefined,
1800
1848
  existsSync: ctx.existsSync,
1801
1849
  spawnProcess: ctx.spawnProcess,
1802
1850
  proveDetached: ctx.proveDetached,
@@ -1807,7 +1855,7 @@ async function runCreate(ctx, opts) {
1807
1855
  locateTranscript: ctx.locateTranscript,
1808
1856
  });
1809
1857
  if (meshCompany)
1810
- await emitLaneStartedForStories(ctx, meshCompany, started, threadId);
1858
+ await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id);
1811
1859
  }
1812
1860
  else if (outpostId) {
1813
1861
  started = await dispatchCreatedOutpostLane(ctx, {
@@ -1869,13 +1917,15 @@ async function dispatchCreatedOutpostLane(ctx, input) {
1869
1917
  const dispatched = await ctx.dispatchOutpostExec({ outpostId, command });
1870
1918
  const remote = parseRemotePickupJson(dispatched.stdout);
1871
1919
  if (remote?.error === "outpost_self_target") {
1872
- return casUpdateLane(hqRoot, lane.lane_id, (current) => ({
1920
+ const cancelled = casUpdateLane(hqRoot, lane.lane_id, (current) => ({
1873
1921
  ...current,
1874
1922
  state: "cancelled",
1875
1923
  queued_reason: undefined,
1876
1924
  terminal_reason: "outpost_self_target",
1877
1925
  outpost_command_id: dispatched.commandId,
1878
1926
  }));
1927
+ await emitLaneTerminalStateSoft(ctx, cancelled);
1928
+ return cancelled;
1879
1929
  }
1880
1930
  const stdoutTrimmed = dispatched.stdout.trim();
1881
1931
  if (remote === null && stdoutTrimmed !== "" && dispatched.status !== "Success") {
@@ -1887,6 +1937,7 @@ async function dispatchCreatedOutpostLane(ctx, input) {
1887
1937
  stderr: dispatched.stderr.slice(0, 2000),
1888
1938
  parse_error: "unparseable_pickup_stdout",
1889
1939
  });
1940
+ await emitLaneTerminalStateSoft(ctx, failed);
1890
1941
  throw new LanesError(DISPATCH_TERMINAL_REASON, "Outpost pickup command failed", {
1891
1942
  outpost_id: outpostId,
1892
1943
  lane_id: failed.lane_id,
@@ -1904,6 +1955,7 @@ async function dispatchCreatedOutpostLane(ctx, input) {
1904
1955
  status: dispatched.status,
1905
1956
  stderr: dispatched.stderr.slice(0, 2000),
1906
1957
  });
1958
+ await emitLaneTerminalStateSoft(ctx, failed);
1907
1959
  throw new LanesError(DISPATCH_TERMINAL_REASON, "Outpost pickup command failed", {
1908
1960
  outpost_id: outpostId,
1909
1961
  lane_id: failed.lane_id,
@@ -1928,6 +1980,7 @@ async function dispatchCreatedOutpostLane(ctx, input) {
1928
1980
  outpost_id: outpostId,
1929
1981
  message,
1930
1982
  });
1983
+ await emitLaneTerminalStateSoft(ctx, failed);
1931
1984
  throw new LanesError(DISPATCH_TERMINAL_REASON, `Failed to dispatch lane to Outpost ${outpostId}: ${message}`, {
1932
1985
  outpost_id: outpostId,
1933
1986
  lane_id: failed.lane_id,
@@ -2131,6 +2184,7 @@ async function runPickup(ctx, opts) {
2131
2184
  env: ctx.env,
2132
2185
  });
2133
2186
  if (isTerminalLaneState(afterAccount.state)) {
2187
+ await emitLaneTerminalStateSoft(ctx, afterAccount);
2134
2188
  printJson({
2135
2189
  ok: true,
2136
2190
  action: "pickup",
@@ -2196,6 +2250,7 @@ async function runPickup(ctx, opts) {
2196
2250
  probe: ctx.probeProvider,
2197
2251
  env: ctx.env,
2198
2252
  },
2253
+ currentModelValidated: afterAccount.model !== undefined,
2199
2254
  existsSync: ctx.existsSync,
2200
2255
  spawnProcess: ctx.spawnProcess,
2201
2256
  proveDetached: ctx.proveDetached,
@@ -2512,7 +2567,11 @@ async function prepareAccountMove(ctx, lane, input) {
2512
2567
  if (!targetCfg.enabled) {
2513
2568
  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 ?? "" });
2514
2569
  }
2515
- if (isLoggedOut(accountStatus(lane.provider, target, config, { existsSync: ctx.existsSync }))) {
2570
+ if (accountStatus(lane.provider, target, config, {
2571
+ existsSync: ctx.existsSync,
2572
+ probe: ctx.probeProvider,
2573
+ env: ctx.env,
2574
+ }).logged_in !== true) {
2516
2575
  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 });
2517
2576
  }
2518
2577
  const profile = lane.worker_profile !== undefined
@@ -3150,17 +3209,32 @@ function callerMayVerifyStory(ctx, lane) {
3150
3209
  return false;
3151
3210
  return sessionIdFromEnv(ctx.env) === lane.senior.id;
3152
3211
  }
3153
- async function ensureStoryThread(ctx, lane, projectId) {
3212
+ async function ensureStoryThread(ctx, lane, projectId, storyId = lane.story_id) {
3213
+ const company = lane.mesh_company ?? lane.company;
3214
+ if (!company)
3215
+ return lane;
3216
+ return ensureLaneThreadRecord(ctx, lane, projectId, storyId);
3217
+ }
3218
+ async function ensureLaneThreadRecord(ctx, lane, projectId = lane.project_id, storyId = lane.story_id, laneStatus = lane.state) {
3154
3219
  const company = lane.mesh_company ?? lane.company;
3155
3220
  if (!company)
3156
3221
  return lane;
3157
- const threadId = await ctx.mesh.ensureProjectThread(company, projectId);
3158
- if (threadId === lane.thread_id)
3222
+ const descriptorLane = { ...lane, project_id: projectId, story_id: storyId };
3223
+ const threadId = ctx.mesh.ensureLaneThread
3224
+ ? await ctx.mesh.ensureLaneThread(company, descriptorLane, laneStatus)
3225
+ : await ctx.mesh.ensureProjectThread(company, projectId);
3226
+ if (threadId === lane.thread_id && !lane.thread_id.startsWith("proj-"))
3159
3227
  return lane;
3160
- return casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => ({
3228
+ const updated = casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => ({
3161
3229
  ...current,
3162
3230
  thread_id: threadId,
3163
3231
  }));
3232
+ // The CAS changes the lane version. Refresh once so the server descriptor
3233
+ // and the durable local record converge before the worker starts.
3234
+ if (ctx.mesh.ensureLaneThread) {
3235
+ await ctx.mesh.ensureLaneThread(company, { ...updated, project_id: projectId, story_id: storyId }, laneStatus);
3236
+ }
3237
+ return updated;
3164
3238
  }
3165
3239
  async function emitStoryTransition(ctx, lane, projectId, storyId, event, status, payload = {}) {
3166
3240
  const company = lane.mesh_company ?? lane.company;
@@ -3174,6 +3248,7 @@ async function emitStoryTransition(ctx, lane, projectId, storyId, event, status,
3174
3248
  project_id: projectId,
3175
3249
  story_id: storyId,
3176
3250
  ...payload,
3251
+ lane_status: lane.state,
3177
3252
  });
3178
3253
  await ctx.mesh.patchStoryStatus(company, projectId, storyId, status === "blocked" ? "in_progress" : status);
3179
3254
  }
@@ -3514,6 +3589,7 @@ async function runStop(ctx, laneId) {
3514
3589
  sleepMs: ctx.stopSleepMs,
3515
3590
  });
3516
3591
  }
3592
+ await emitLaneTerminalStateSoft(ctx, updated);
3517
3593
  const cancelled = cancelPendingQuestionsForLane(hqRoot, laneId, at);
3518
3594
  printJson({
3519
3595
  ok: true,
@@ -3829,7 +3905,11 @@ async function runAccountsList(ctx, opts) {
3829
3905
  // model and effort are what a spawn on each account would start from
3830
3906
  // (spec: --model/--effort, then the account, then the provider).
3831
3907
  const quota = loadQuotaState(hqRoot);
3832
- const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config).map((status) => {
3908
+ const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config, {
3909
+ existsSync: ctx.existsSync,
3910
+ probe: ctx.probeProvider,
3911
+ env: ctx.env,
3912
+ }).map((status) => {
3833
3913
  const accountCfg = config.providers[provider].accounts[status.id];
3834
3914
  const model = resolveLaneModel({
3835
3915
  accountModel: accountCfg?.model,
@@ -3930,8 +4010,17 @@ async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
3930
4010
  home,
3931
4011
  enabled: true,
3932
4012
  };
4013
+ const status = accountStatus(provider, id, config, {
4014
+ existsSync: ctx.existsSync,
4015
+ probe: ctx.probeProvider,
4016
+ env: ctx.env,
4017
+ });
4018
+ if (status.logged_in !== true) {
4019
+ throw new LanesError(status.logged_in === false ? "account_not_logged_in" : "account_login_status_unknown", status.logged_in === false
4020
+ ? `Account ${id} for ${provider} is not logged in`
4021
+ : `Could not verify that account ${id} for ${provider} is logged in`, { provider, account: id, home });
4022
+ }
3933
4023
  writeLanesConfig(hqRoot, config);
3934
- const status = accountStatus(provider, id, loadLanesConfig(hqRoot));
3935
4024
  printJson({ ok: true, action: "accounts.login", account: status });
3936
4025
  }
3937
4026
  async function runAccountsEnable(ctx, providerRaw, accountId) {
@@ -3948,13 +4037,27 @@ async function runAccountsEnable(ctx, providerRaw, accountId) {
3948
4037
  .map(([id]) => id),
3949
4038
  });
3950
4039
  }
4040
+ const status = accountStatus(provider, accountId, config, {
4041
+ existsSync: ctx.existsSync,
4042
+ probe: ctx.probeProvider,
4043
+ env: ctx.env,
4044
+ });
4045
+ if (status.logged_in !== true) {
4046
+ throw new LanesError(status.logged_in === false ? "account_not_logged_in" : "account_login_status_unknown", status.logged_in === false
4047
+ ? `Account ${accountId} for ${provider} is not logged in`
4048
+ : `Could not verify that account ${accountId} for ${provider} is logged in`, { provider, account: accountId });
4049
+ }
3951
4050
  account.enabled = true;
3952
4051
  delete account.disabled_reason;
3953
4052
  writeLanesConfig(hqRoot, config);
3954
4053
  printJson({
3955
4054
  ok: true,
3956
4055
  action: "accounts.enable",
3957
- account: accountStatus(provider, accountId, loadLanesConfig(hqRoot)),
4056
+ account: accountStatus(provider, accountId, loadLanesConfig(hqRoot), {
4057
+ existsSync: ctx.existsSync,
4058
+ probe: ctx.probeProvider,
4059
+ env: ctx.env,
4060
+ }),
3958
4061
  });
3959
4062
  }
3960
4063
  async function runAccountsDisable(ctx, providerRaw, accountId, opts) {
@@ -3978,7 +4081,11 @@ async function runAccountsDisable(ctx, providerRaw, accountId, opts) {
3978
4081
  printJson({
3979
4082
  ok: true,
3980
4083
  action: "accounts.disable",
3981
- account: accountStatus(provider, accountId, loadLanesConfig(hqRoot)),
4084
+ account: accountStatus(provider, accountId, loadLanesConfig(hqRoot), {
4085
+ existsSync: ctx.existsSync,
4086
+ probe: ctx.probeProvider,
4087
+ env: ctx.env,
4088
+ }),
3982
4089
  });
3983
4090
  }
3984
4091
  async function runSessionsList(ctx, opts) {