@indigoai-us/hq-cli 5.155.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,29 @@
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
+
5
28
  ## [5.155.0] — 2026-09-24
6
29
 
7
30
  ### 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",
@@ -18,7 +18,7 @@ import { buildOutpostLaneCommand, buildOutpostPickupCommand, createOutpostsClien
18
18
  import { parseUserYaml } from "../utils/user-yaml-error.js";
19
19
  import { vaultApiFetch } from "../utils/vault-api.js";
20
20
  import { openMeshTransport } from "../lib/mesh/client.js";
21
- import { createLaneMesh, emitLaneDecisionEvents, } from "../lib/lanes/mesh-events.js";
21
+ import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
22
22
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
23
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";
24
24
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
@@ -1150,18 +1150,45 @@ function askDeliveryFailedPath(hqRoot, laneId) {
1150
1150
  return path.join(runDir(hqRoot, laneId), "ask_delivery_failed.json");
1151
1151
  }
1152
1152
  async function emitLaneMeshEvent(ctx, company, threadId, event, payload) {
1153
- if (!threadId || !company)
1153
+ if (!company)
1154
+ return;
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)
1154
1165
  return;
1155
- await ctx.mesh.appendThreadEvent(company, threadId, event, payload);
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
+ }
1156
1178
  }
1157
1179
  async function emitLaneStartedForStories(ctx, company, lane, threadId) {
1158
- 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)
1159
1185
  return;
1160
1186
  const failures = [];
1161
- for (const story of laneStories(lane)) {
1187
+ for (const story of laneStories(ready)) {
1162
1188
  try {
1163
- await emitLaneMeshEvent(ctx, company, threadId, "lane_started", {
1164
- lane_id: lane.lane_id,
1189
+ await emitLaneMeshEvent(ctx, company, stableThreadId, "lane_started", {
1190
+ ...laneMeshEventIdentity(ready),
1191
+ lane_id: ready.lane_id,
1165
1192
  project_id: story.project_id,
1166
1193
  story_id: story.story_id,
1167
1194
  });
@@ -1630,9 +1657,8 @@ async function runCreate(ctx, opts) {
1630
1657
  (profileAccounts === undefined || profileAccounts.includes(id)))
1631
1658
  .map(([id]) => id))]
1632
1659
  : undefined;
1633
- const threadId = meshCompany
1634
- ? await ctx.mesh.ensureProjectThread(meshCompany, project)
1635
- : undefined;
1660
+ // The lane id is created in `persist`; its Work Mesh thread is ensured
1661
+ // immediately afterward from that stable identity.
1636
1662
  const persist = async () => {
1637
1663
  let admission;
1638
1664
  let validation;
@@ -1712,7 +1738,6 @@ async function runCreate(ctx, opts) {
1712
1738
  added_at: createdAt,
1713
1739
  updated_at: createdAt,
1714
1740
  })),
1715
- thread_id: threadId,
1716
1741
  depth: laneDepth,
1717
1742
  senior,
1718
1743
  senior_history: [],
@@ -1765,7 +1790,35 @@ async function runCreate(ctx, opts) {
1765
1790
  else {
1766
1791
  persisted = await withLaneAdmissionLock(hqRoot, provider, persist, ctx.sleep);
1767
1792
  }
1768
- 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
+ }
1769
1822
  const dest = runDir(hqRoot, lane.lane_id);
1770
1823
  ensureDir(path.join(dest, "logs"));
1771
1824
  ensureDir(path.join(dest, "envelopes"));
@@ -1802,7 +1855,7 @@ async function runCreate(ctx, opts) {
1802
1855
  locateTranscript: ctx.locateTranscript,
1803
1856
  });
1804
1857
  if (meshCompany)
1805
- await emitLaneStartedForStories(ctx, meshCompany, started, threadId);
1858
+ await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id);
1806
1859
  }
1807
1860
  else if (outpostId) {
1808
1861
  started = await dispatchCreatedOutpostLane(ctx, {
@@ -1864,13 +1917,15 @@ async function dispatchCreatedOutpostLane(ctx, input) {
1864
1917
  const dispatched = await ctx.dispatchOutpostExec({ outpostId, command });
1865
1918
  const remote = parseRemotePickupJson(dispatched.stdout);
1866
1919
  if (remote?.error === "outpost_self_target") {
1867
- return casUpdateLane(hqRoot, lane.lane_id, (current) => ({
1920
+ const cancelled = casUpdateLane(hqRoot, lane.lane_id, (current) => ({
1868
1921
  ...current,
1869
1922
  state: "cancelled",
1870
1923
  queued_reason: undefined,
1871
1924
  terminal_reason: "outpost_self_target",
1872
1925
  outpost_command_id: dispatched.commandId,
1873
1926
  }));
1927
+ await emitLaneTerminalStateSoft(ctx, cancelled);
1928
+ return cancelled;
1874
1929
  }
1875
1930
  const stdoutTrimmed = dispatched.stdout.trim();
1876
1931
  if (remote === null && stdoutTrimmed !== "" && dispatched.status !== "Success") {
@@ -1882,6 +1937,7 @@ async function dispatchCreatedOutpostLane(ctx, input) {
1882
1937
  stderr: dispatched.stderr.slice(0, 2000),
1883
1938
  parse_error: "unparseable_pickup_stdout",
1884
1939
  });
1940
+ await emitLaneTerminalStateSoft(ctx, failed);
1885
1941
  throw new LanesError(DISPATCH_TERMINAL_REASON, "Outpost pickup command failed", {
1886
1942
  outpost_id: outpostId,
1887
1943
  lane_id: failed.lane_id,
@@ -1899,6 +1955,7 @@ async function dispatchCreatedOutpostLane(ctx, input) {
1899
1955
  status: dispatched.status,
1900
1956
  stderr: dispatched.stderr.slice(0, 2000),
1901
1957
  });
1958
+ await emitLaneTerminalStateSoft(ctx, failed);
1902
1959
  throw new LanesError(DISPATCH_TERMINAL_REASON, "Outpost pickup command failed", {
1903
1960
  outpost_id: outpostId,
1904
1961
  lane_id: failed.lane_id,
@@ -1923,6 +1980,7 @@ async function dispatchCreatedOutpostLane(ctx, input) {
1923
1980
  outpost_id: outpostId,
1924
1981
  message,
1925
1982
  });
1983
+ await emitLaneTerminalStateSoft(ctx, failed);
1926
1984
  throw new LanesError(DISPATCH_TERMINAL_REASON, `Failed to dispatch lane to Outpost ${outpostId}: ${message}`, {
1927
1985
  outpost_id: outpostId,
1928
1986
  lane_id: failed.lane_id,
@@ -2126,6 +2184,7 @@ async function runPickup(ctx, opts) {
2126
2184
  env: ctx.env,
2127
2185
  });
2128
2186
  if (isTerminalLaneState(afterAccount.state)) {
2187
+ await emitLaneTerminalStateSoft(ctx, afterAccount);
2129
2188
  printJson({
2130
2189
  ok: true,
2131
2190
  action: "pickup",
@@ -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,
@@ -59,14 +59,22 @@ export function formatCheckLines(threads, company, projectId) {
59
59
  const scope = projectId
60
60
  ? `${company.companySlug || company.companyUid}/${projectId}`
61
61
  : company.companySlug || company.companyUid;
62
- const lines = [`Work mesh: ${threads.length} active thread(s) for ${scope}`];
62
+ const lines = [`Work mesh: ${threads.length} thread(s) for ${scope}`];
63
63
  for (const thread of threads.slice(0, 8)) {
64
64
  const owner = thread.ownerUid ? ` owner=${thread.ownerUid}` : "";
65
65
  const summary = thread.progressSummary ||
66
66
  thread.sourceSignalSummary ||
67
67
  thread.blockedReason ||
68
68
  "";
69
- lines.push(`- ${thread.threadStatus ?? "unknown"} ${thread.threadId ?? "?"}${owner}${summary ? `: ${summary}` : ""}`);
69
+ const lane = thread.kind === "lane" ? thread.lane : undefined;
70
+ const laneDetails = lane
71
+ ? ` lane=${lane.laneId} laneStatus=${thread.laneStatus ?? "unknown"}` +
72
+ ` story=${lane.storyId ?? "unknown"} profile=${lane.workerProfile ?? "unknown"}` +
73
+ ` model=${lane.model ?? "unknown"} account=${lane.account ?? "unknown"}` +
74
+ `${lane.parentLaneId ? ` parent=${lane.parentLaneId}` : ""}`
75
+ : "";
76
+ const machine = thread.senderMachineUid ? ` machine=${thread.senderMachineUid}` : "";
77
+ lines.push(`- ${thread.threadStatus ?? "unknown"} ${thread.threadId ?? "?"}${owner}${machine}${laneDetails}${summary ? `: ${summary}` : ""}`);
70
78
  }
71
79
  return lines;
72
80
  }
@@ -320,6 +328,9 @@ function mergeProjectRepos(current, planned) {
320
328
  return [...planned, ...current.filter((repo) => !plannedPaths.has(repo.path))];
321
329
  }
322
330
  async function runProjectSet(projectId, opts) {
331
+ if (opts.channel !== undefined && !opts.channel.trim()) {
332
+ fail("PROJECT_CHANNEL_INVALID: --channel must be a non-empty channel id.");
333
+ }
323
334
  if (opts.storiesFile && opts.storiesJson) {
324
335
  fail("Pass only one of --stories-file or --stories-json.");
325
336
  }
@@ -358,6 +369,7 @@ async function runProjectSet(projectId, opts) {
358
369
  const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
359
370
  const PROJECT_CHANGED = "The Board changed while you were editing. Re-run to pick up the latest.";
360
371
  let view;
372
+ let registration;
361
373
  if (!current) {
362
374
  const ensured = await ensureProjectBoard({
363
375
  token,
@@ -367,10 +379,12 @@ async function runProjectSet(projectId, opts) {
367
379
  description: opts.description !== undefined ? opts.description : source?.description ?? "",
368
380
  stories: source?.stories ?? [],
369
381
  repos: explicitRepos ?? source?.repos ?? [],
382
+ channelId: opts.channel,
370
383
  });
371
384
  view = ensured.view;
385
+ registration = ensured.registration;
372
386
  }
373
- else {
387
+ else if (opts.name !== undefined || opts.description !== undefined || source !== undefined || explicitRepos !== undefined) {
374
388
  try {
375
389
  view = await putProjectView(token, company.companyUid, projectId, {
376
390
  name: opts.name?.trim() || source?.name || current.name,
@@ -395,18 +409,30 @@ async function runProjectSet(projectId, opts) {
395
409
  throw err;
396
410
  }
397
411
  }
412
+ else {
413
+ view = current;
414
+ }
415
+ if (current && opts.channel !== undefined) {
416
+ registration = await registerMeshProject(token, company.companyUid, projectId, opts.channel);
417
+ }
398
418
  if (!current && opts.create) {
399
419
  noteCallingSessionBound(view.projectId, company);
400
420
  }
401
421
  if (opts.json) {
402
- console.log(JSON.stringify({ ok: true, action: "project.set", principalUid, ...view }, null, 2));
422
+ console.log(JSON.stringify({
423
+ ok: true,
424
+ action: "project.set",
425
+ principalUid,
426
+ ...view,
427
+ ...(registration ? { registration } : {}),
428
+ }, null, 2));
403
429
  return;
404
430
  }
405
431
  console.log(`Work mesh: project ${view.projectId} set (${view.name}; ${view.stories.length} stories)`);
406
432
  }
407
433
  async function runProjectRegister(projectId, opts) {
408
- if (opts.channel?.trim()) {
409
- fail("--channel is not supported by the current Work Mesh registration API; omit it to let the server create or bind the project channel.");
434
+ if (opts.channel !== undefined && !opts.channel.trim()) {
435
+ fail("PROJECT_CHANNEL_INVALID: --channel must be a non-empty channel id.");
410
436
  }
411
437
  const { token, company } = await withCompany(opts);
412
438
  const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
@@ -425,7 +451,7 @@ async function runProjectRegister(projectId, opts) {
425
451
  }
426
452
  let registration;
427
453
  try {
428
- registration = await registerMeshProject(token, company.companyUid, projectId);
454
+ registration = await registerMeshProject(token, company.companyUid, projectId, opts.channel);
429
455
  }
430
456
  catch (err) {
431
457
  const failure = err;
@@ -1787,6 +1813,7 @@ export function registerMeshCommand(program) {
1787
1813
  .option("--stories-json <json>", "Story array or JSON object with userStories")
1788
1814
  .option("--repos-json <json>", "Repository array, such as [{path,branch}]")
1789
1815
  .option("--repo <path[@branch]>", "Repository binding (repeatable)", collectRepeatable)
1816
+ .option("--channel <channelId>", "Bind the project to an existing company or project channel")
1790
1817
  .option("--create", "Create the Board view when the GET is a 404")
1791
1818
  .option("--json", "Print machine-readable JSON")
1792
1819
  .action((projectId, opts) => wrap(() => runProjectSet(projectId, opts))());
@@ -1801,7 +1828,7 @@ export function registerMeshCommand(program) {
1801
1828
  .command("register <projectId>")
1802
1829
  .description("Register an existing Board and bind its project channel; use project ensure to create or repair a Board")
1803
1830
  .requiredOption("--company <slug|uid>", "Company slug or cloud uid")
1804
- .option("--channel <channelId>", "Reserved: the current server chooses the project channel")
1831
+ .option("--channel <channelId>", "Bind the project to an existing company or project channel")
1805
1832
  .option("--json", "Print machine-readable JSON")
1806
1833
  .action((projectId, opts) => wrap(() => runProjectRegister(projectId, opts))());
1807
1834
  mesh
@@ -42,11 +42,11 @@
42
42
  * of opening a browser nobody is watching, and the watcher records
43
43
  * `mesh_event_failed` and moves on. No credential file is read here.
44
44
  */
45
- import type { LaneCompanyRef, LaneEnvelope, LaneRecord } from "./types.js";
46
- export declare const LANE_MESH_EVENTS: readonly ["lane_started", "lane_progress", "lane_question", "lane_question_at_user", "lane_ask", "lane_blocked", "lane_done"];
45
+ import type { LaneCompanyRef, LaneEnvelope, LaneRecord, TerminalLaneState } from "./types.js";
46
+ export declare const LANE_MESH_EVENTS: readonly ["lane_started", "lane_progress", "lane_question", "lane_question_at_user", "lane_ask", "lane_blocked", "lane_done", "lane_failed", "lane_interrupted", "lane_cancelled", "lane_killed"];
47
47
  export type LaneMeshEvent = (typeof LANE_MESH_EVENTS)[number];
48
48
  /** Roster kinds this module emits. Subset of the server's `validKinds`. */
49
- export declare const WIRE_EVENT_KINDS: readonly ["run_started", "run_progress", "run_complete", "question", "note"];
49
+ export declare const WIRE_EVENT_KINDS: readonly ["run_started", "run_progress", "run_complete", "blocked", "question", "note"];
50
50
  export type WireEventKind = (typeof WIRE_EVENT_KINDS)[number];
51
51
  /** Server caps: activity title/summary 2000, question text 500, note text 500. */
52
52
  export declare const WIRE_ACTIVITY_TEXT_MAX = 2000;
@@ -70,6 +70,7 @@ export declare function toWireThreadEvent(event: LaneMeshEvent, payload: Record<
70
70
  */
71
71
  export interface LaneMesh {
72
72
  ensureProjectThread: (company: LaneCompanyRef, projectId: string) => Promise<string>;
73
+ ensureLaneThread?: (company: LaneCompanyRef, lane: LaneRecord, laneStatus?: string, signal?: AbortSignal) => Promise<string>;
73
74
  appendThreadEvent: (company: LaneCompanyRef, threadId: string, event: LaneMeshEvent, payload: Record<string, unknown>, signal?: AbortSignal) => Promise<void>;
74
75
  patchStoryStatus: (company: LaneCompanyRef, projectId: string, storyId: string, status: string) => Promise<void>;
75
76
  }
@@ -92,6 +93,7 @@ export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
92
93
  export declare function laneMeshCompany(lane: Pick<LaneRecord, "company" | "mesh_company">): LaneCompanyRef | undefined;
93
94
  /** Company attribution + thread present. Unscoped personal lanes emit nothing. */
94
95
  export declare function laneCanEmit(lane: Pick<LaneRecord, "company" | "mesh_company" | "thread_id">): boolean;
96
+ export declare function laneMeshEventIdentity(lane: LaneRecord): Record<string, unknown>;
95
97
  /**
96
98
  * Spec §8 terminal-envelope events. `done` → lane_done and story → review;
97
99
  * `blocked` → lane_blocked; `ask` → lane_ask with the queue ids. The story is
@@ -101,6 +103,8 @@ export declare function emitLaneDecisionEvents(mesh: LaneMesh, lane: LaneRecord,
101
103
  questionIds?: string[];
102
104
  signal?: AbortSignal;
103
105
  }): Promise<void>;
106
+ /** Emit an abnormal terminal state even when the worker produced no envelope. */
107
+ export declare function emitLaneTerminalStateEvent(mesh: LaneMesh, lane: LaneRecord, state: Exclude<TerminalLaneState, "done">, summary?: string, signal?: AbortSignal): Promise<void>;
104
108
  export declare function emitLaneProgressEvent(mesh: LaneMesh, lane: LaneRecord, detail: string, signal?: AbortSignal): Promise<void>;
105
109
  /**
106
110
  * Throttle for lane_progress (spec §8 progress row). True when no progress
@@ -42,7 +42,7 @@
42
42
  * of opening a browser nobody is watching, and the watcher records
43
43
  * `mesh_event_failed` and moves on. No credential file is read here.
44
44
  */
45
- import { appendThreadEvent, clamp, ensureProjectThread, getProjectView, patchStoryStatus, putProjectView, requireToken, registerMeshProject, resolveMeshCompany, } from "../mesh/api.js";
45
+ import { appendThreadEvent, clamp, ensureLaneMeshThread, ensureProjectThread, getProjectView, patchStoryStatus, putProjectView, requireToken, registerMeshProject, resolveMeshCompany, } from "../mesh/api.js";
46
46
  import { LanesError } from "./errors.js";
47
47
  export const LANE_MESH_EVENTS = [
48
48
  "lane_started",
@@ -52,12 +52,17 @@ export const LANE_MESH_EVENTS = [
52
52
  "lane_ask",
53
53
  "lane_blocked",
54
54
  "lane_done",
55
+ "lane_failed",
56
+ "lane_interrupted",
57
+ "lane_cancelled",
58
+ "lane_killed",
55
59
  ];
56
60
  /** Roster kinds this module emits. Subset of the server's `validKinds`. */
57
61
  export const WIRE_EVENT_KINDS = [
58
62
  "run_started",
59
63
  "run_progress",
60
64
  "run_complete",
65
+ "blocked",
61
66
  "question",
62
67
  "note",
63
68
  ];
@@ -94,6 +99,30 @@ function nonEmpty(text, fallback) {
94
99
  export function toWireThreadEvent(event, payload) {
95
100
  const kind = parseLaneMeshEvent(event);
96
101
  const id = identity(payload);
102
+ const laneId = str(payload.lane_id ?? payload.laneId).trim();
103
+ const senior = payload.senior && typeof payload.senior === "object" && !Array.isArray(payload.senior)
104
+ ? payload.senior
105
+ : undefined;
106
+ const lane = laneId
107
+ ? {
108
+ laneId,
109
+ ...(str(payload.story_id ?? payload.storyId) ? { storyId: str(payload.story_id ?? payload.storyId) } : {}),
110
+ ...(str(payload.worker_profile ?? payload.workerProfile) ? { workerProfile: str(payload.worker_profile ?? payload.workerProfile) } : {}),
111
+ ...(str(payload.model) ? { model: str(payload.model) } : {}),
112
+ ...(str(payload.account) ? { account: str(payload.account) } : {}),
113
+ ...(str(payload.parent_lane_id ?? payload.parentLaneId) ? { parentLaneId: str(payload.parent_lane_id ?? payload.parentLaneId) } : {}),
114
+ ...(senior && ["user", "session", "lane"].includes(String(senior.kind))
115
+ ? { senior: { kind: senior.kind, ...(typeof senior.id === "string" ? { id: senior.id } : {}) } }
116
+ : {}),
117
+ laneVersion: Number.isSafeInteger(Number(payload.lane_version ?? payload.laneVersion)) && Number(payload.lane_version ?? payload.laneVersion) > 0
118
+ ? Number(payload.lane_version ?? payload.laneVersion)
119
+ : 1,
120
+ }
121
+ : undefined;
122
+ const laneFields = (laneStatus) => ({
123
+ ...(lane ? { lane } : {}),
124
+ ...(laneStatus ? { laneStatus } : {}),
125
+ });
97
126
  switch (kind) {
98
127
  case "lane_started":
99
128
  return {
@@ -101,6 +130,7 @@ export function toWireThreadEvent(event, payload) {
101
130
  payload: {
102
131
  title: clamp(`lane_started ${str(payload.lane_id) || "unknown-lane"}`, WIRE_ACTIVITY_TEXT_MAX),
103
132
  summary: clamp(`Lane ${id} started`, WIRE_ACTIVITY_TEXT_MAX),
133
+ ...laneFields("running"),
104
134
  },
105
135
  };
106
136
  case "lane_progress": {
@@ -109,29 +139,47 @@ export function toWireThreadEvent(event, payload) {
109
139
  eventKind: "run_progress",
110
140
  payload: {
111
141
  summary: clamp(nonEmpty(`lane_progress ${id}${detail ? `: ${detail}` : ""}`, "lane_progress"), WIRE_ACTIVITY_TEXT_MAX),
142
+ ...laneFields("running"),
112
143
  },
113
144
  };
114
145
  }
115
146
  case "lane_done": {
116
147
  const summary = str(payload.summary);
148
+ const suppliedStatus = str(payload.lane_status ?? payload.laneStatus);
149
+ const laneStatus = isMeshLaneStatus(suppliedStatus) ? suppliedStatus : "done";
117
150
  return {
118
151
  eventKind: "run_complete",
119
152
  payload: {
120
153
  title: clamp(`lane_done ${str(payload.lane_id) || "unknown-lane"}`, WIRE_ACTIVITY_TEXT_MAX),
121
154
  summary: clamp(nonEmpty(`${id}: ${summary}`.replace(/:\s*$/, ""), `lane_done ${id}`), WIRE_ACTIVITY_TEXT_MAX),
155
+ ...laneFields(laneStatus),
122
156
  },
123
157
  };
124
158
  }
125
159
  case "lane_blocked": {
126
- const reason = str(payload.blocked_reason);
127
- // The roster's `blocked` event kind would flip the shared project
128
- // thread, which spec §8 keeps lane events away from. Use the accepted
129
- // audit note kind to preserve a filterable blocked marker.
160
+ const reason = str(payload.blocked_reason ?? payload.reason);
161
+ const suppliedStatus = str(payload.lane_status ?? payload.laneStatus);
162
+ const laneStatus = isMeshLaneStatus(suppliedStatus) ? suppliedStatus : "blocked";
130
163
  return {
131
- eventKind: "note",
164
+ eventKind: "blocked",
132
165
  payload: {
133
- text: clamp(`lane_blocked ${id}${reason ? `: ${reason}` : ""}`, WIRE_NOTE_TEXT_MAX),
134
- noteKind: "audit",
166
+ reason: clamp(`lane_blocked ${id}${reason ? `: ${reason}` : ""}`, WIRE_NOTE_TEXT_MAX),
167
+ ...laneFields(laneStatus),
168
+ },
169
+ };
170
+ }
171
+ case "lane_failed":
172
+ case "lane_interrupted":
173
+ case "lane_cancelled":
174
+ case "lane_killed": {
175
+ const laneStatus = kind.slice("lane_".length);
176
+ const summary = str(payload.summary ?? payload.reason ?? payload.blocked_reason);
177
+ return {
178
+ eventKind: "run_complete",
179
+ payload: {
180
+ title: clamp(`${kind} ${str(payload.lane_id) || "unknown-lane"}`, WIRE_ACTIVITY_TEXT_MAX),
181
+ summary: clamp(nonEmpty(`${id}: ${summary}`.replace(/:\s*$/, ""), `${kind} ${id}`), WIRE_ACTIVITY_TEXT_MAX),
182
+ ...laneFields(laneStatus),
135
183
  },
136
184
  };
137
185
  }
@@ -144,6 +192,7 @@ export function toWireThreadEvent(event, payload) {
144
192
  };
145
193
  if (questionId)
146
194
  out.questionId = questionId;
195
+ Object.assign(out, laneFields());
147
196
  return { eventKind: "question", payload: out };
148
197
  }
149
198
  case "lane_ask": {
@@ -155,6 +204,7 @@ export function toWireThreadEvent(event, payload) {
155
204
  };
156
205
  if (ids[0])
157
206
  out.questionId = ids[0];
207
+ Object.assign(out, laneFields("awaiting_input"));
158
208
  return { eventKind: "question", payload: out };
159
209
  }
160
210
  default: {
@@ -224,12 +274,28 @@ export function createLaneMesh(opts = {}) {
224
274
  return {
225
275
  async ensureProjectThread(company, projectId) {
226
276
  const tok = await token();
227
- const resolved = await resolveMeshCompany(tok, company.slug);
277
+ const resolved = await resolveMeshCompany(tok, company.uid);
228
278
  const ensured = await ensureProjectThread(tok, resolved, projectId, {
229
279
  summary: `HQ lane ${projectId}`,
230
280
  });
231
281
  return ensured.threadId;
232
282
  },
283
+ async ensureLaneThread(company, lane, laneStatus = lane.state, signal) {
284
+ const tok = await token();
285
+ const resolved = await resolveMeshCompany(tok, company.uid);
286
+ const descriptor = {
287
+ laneId: lane.lane_id,
288
+ ...(lane.story_id ? { storyId: lane.story_id } : {}),
289
+ ...(lane.worker_profile ? { workerProfile: lane.worker_profile } : {}),
290
+ ...(lane.model ? { model: lane.model } : {}),
291
+ ...(lane.account ? { account: lane.account } : {}),
292
+ ...(lane.senior.kind === "lane" ? { parentLaneId: lane.senior.id } : {}),
293
+ senior: { kind: lane.senior.kind, id: lane.senior.id },
294
+ laneVersion: lane.version,
295
+ };
296
+ const ensured = await ensureLaneMeshThread(tok, resolved, lane.project_id, descriptor, isMeshLaneStatus(laneStatus) ? laneStatus : "running", signal);
297
+ return ensured.threadId;
298
+ },
233
299
  async appendThreadEvent(company, threadId, event, payload, signal) {
234
300
  const wire = toWireThreadEvent(event, payload);
235
301
  const tok = await token();
@@ -263,34 +329,51 @@ export function laneMeshCompany(lane) {
263
329
  export function laneCanEmit(lane) {
264
330
  return laneMeshCompany(lane) !== undefined && !!lane.thread_id;
265
331
  }
266
- function baseIdentity(lane) {
332
+ export function laneMeshEventIdentity(lane) {
267
333
  const lastMove = lane.account_history?.[lane.account_history.length - 1];
268
334
  return {
269
335
  lane_id: lane.lane_id,
270
336
  project_id: lane.project_id,
271
337
  story_id: lane.story_id,
338
+ worker_profile: lane.worker_profile,
339
+ model: lane.model,
340
+ lane_version: lane.version,
341
+ senior: { kind: lane.senior.kind, id: lane.senior.id },
342
+ ...(lane.senior.kind === "lane" ? { parent_lane_id: lane.senior.id } : {}),
272
343
  ...(lane.account !== undefined ? { account: lane.account } : {}),
273
344
  // A moved lane names the move on every event its run emits (US-023 §8).
274
345
  ...(lastMove !== undefined ? { account_move: { from: lastMove.from, to: lastMove.to, at: lastMove.at } } : {}),
275
346
  };
276
347
  }
348
+ function isMeshLaneStatus(value) {
349
+ return ["queued", "running", "awaiting_input", "blocked", "done", "failed", "interrupted", "cancelled", "killed"].includes(value);
350
+ }
351
+ // hq-pro derives lane thread ids from company + lane identity; opaque ids may
352
+ // be legacy shared project threads and must be migrated before a lane closes.
353
+ const STABLE_LANE_THREAD_ID = /^lane-[0-9a-f]{32}$/;
354
+ async function ensureLaneEventThread(mesh, company, lane, laneStatus, signal) {
355
+ if (mesh.ensureLaneThread && !STABLE_LANE_THREAD_ID.test(lane.thread_id ?? "")) {
356
+ return mesh.ensureLaneThread(company, lane, laneStatus, signal);
357
+ }
358
+ return lane.thread_id;
359
+ }
277
360
  /**
278
361
  * Spec §8 terminal-envelope events. `done` → lane_done and story → review;
279
362
  * `blocked` → lane_blocked; `ask` → lane_ask with the queue ids. The story is
280
363
  * never set to `done` here: promotion belongs to the senior.
281
364
  */
282
365
  export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
283
- if (!laneCanEmit(lane))
284
- return;
285
366
  const company = laneMeshCompany(lane);
286
- const threadId = lane.thread_id;
367
+ if (!company)
368
+ return;
369
+ const threadId = await ensureLaneEventThread(mesh, company, lane, lane.state, opts.signal);
287
370
  if (!company || !threadId)
288
371
  return;
289
372
  switch (envelope.decision) {
290
373
  case "done": {
291
374
  const outcomes = await Promise.allSettled([
292
375
  mesh.appendThreadEvent(company, threadId, "lane_done", {
293
- ...baseIdentity(lane),
376
+ ...laneMeshEventIdentity(lane),
294
377
  summary: envelope.summary,
295
378
  }, opts.signal),
296
379
  mesh.patchStoryStatus(company, lane.project_id, lane.story_id, "review"),
@@ -304,7 +387,7 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
304
387
  {
305
388
  const outcomes = await Promise.allSettled([
306
389
  mesh.appendThreadEvent(company, threadId, "lane_blocked", {
307
- ...baseIdentity(lane),
390
+ ...laneMeshEventIdentity(lane),
308
391
  blocked_reason: envelope.blocked_reason ?? "",
309
392
  }),
310
393
  mesh.patchStoryStatus(company, lane.project_id, lane.story_id, "in_progress"),
@@ -322,7 +405,7 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
322
405
  for (const questionId of opts.questionIds ?? []) {
323
406
  try {
324
407
  await mesh.appendThreadEvent(company, threadId, "lane_ask", {
325
- ...baseIdentity(lane),
408
+ ...laneMeshEventIdentity(lane),
326
409
  question_ids: [questionId],
327
410
  }, opts.signal);
328
411
  }
@@ -340,15 +423,28 @@ export async function emitLaneDecisionEvents(mesh, lane, envelope, opts = {}) {
340
423
  }
341
424
  }
342
425
  }
343
- export async function emitLaneProgressEvent(mesh, lane, detail, signal) {
344
- if (!laneCanEmit(lane))
426
+ /** Emit an abnormal terminal state even when the worker produced no envelope. */
427
+ export async function emitLaneTerminalStateEvent(mesh, lane, state, summary, signal) {
428
+ const company = laneMeshCompany(lane);
429
+ if (!company)
345
430
  return;
431
+ const threadId = await ensureLaneEventThread(mesh, company, lane, state, signal);
432
+ if (!company || !threadId)
433
+ return;
434
+ await mesh.appendThreadEvent(company, threadId, `lane_${state}`, {
435
+ ...laneMeshEventIdentity(lane),
436
+ ...(summary ? { summary } : {}),
437
+ }, signal);
438
+ }
439
+ export async function emitLaneProgressEvent(mesh, lane, detail, signal) {
346
440
  const company = laneMeshCompany(lane);
347
- const threadId = lane.thread_id;
441
+ if (!company)
442
+ return;
443
+ const threadId = await ensureLaneEventThread(mesh, company, lane, lane.state, signal);
348
444
  if (!company || !threadId)
349
445
  return;
350
446
  await mesh.appendThreadEvent(company, threadId, "lane_progress", {
351
- ...baseIdentity(lane),
447
+ ...laneMeshEventIdentity(lane),
352
448
  detail,
353
449
  }, signal);
354
450
  }
@@ -21,7 +21,7 @@ import { applyThrottleDecision, decideAdmission, defaultThrottleDeps, emptyThrot
21
21
  import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
22
22
  import { answeredQuestionEntries, raiseEnvelopeQuestions } from "./questions.js";
23
23
  import { SILENCE_MS, classifyLaneExit, isPidAlive, readLatestEnvelope, readLatestEnvelopeFile, readLatestEnvelopeRecord, } from "./watcher.js";
24
- import { createLaneMesh, emitLaneDecisionEvents, laneMeshCompany } from "./mesh-events.js";
24
+ import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
25
25
  import { laneStories, storyStatusForEnvelope } from "./stories.js";
26
26
  import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
27
27
  import { providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
@@ -1032,6 +1032,34 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1032
1032
  }
1033
1033
  }
1034
1034
  }
1035
+ else if (!deps.dryRun &&
1036
+ (result.record.state === "failed" || result.record.state === "interrupted" ||
1037
+ result.record.state === "cancelled" || result.record.state === "killed")) {
1038
+ const mesh = deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh;
1039
+ if (mesh) {
1040
+ const meshEmitTimeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
1041
+ let timer;
1042
+ try {
1043
+ await Promise.race([
1044
+ emitLaneTerminalStateEvent(mesh, result.record, result.record.state, result.record.terminal_reason),
1045
+ new Promise((_resolve, reject) => {
1046
+ timer = setTimeout(() => reject(new Error(`terminal mesh emit timed out after ${meshEmitTimeoutMs}ms`)), meshEmitTimeoutMs);
1047
+ }),
1048
+ ]);
1049
+ }
1050
+ catch (err) {
1051
+ markDirty(summary, {
1052
+ kind: "lane",
1053
+ id: lane.lane_id,
1054
+ why: `terminal mesh emit failed: ${err instanceof Error ? err.message : String(err)}`,
1055
+ });
1056
+ }
1057
+ finally {
1058
+ if (timer)
1059
+ clearTimeout(timer);
1060
+ }
1061
+ }
1062
+ }
1035
1063
  }
1036
1064
  }
1037
1065
  catch (err) {
@@ -1060,6 +1088,32 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1060
1088
  to: "interrupted",
1061
1089
  reason: "host_lost",
1062
1090
  });
1091
+ const mesh = !deps.dryRun
1092
+ ? deps.mesh === undefined ? createLaneMesh({ interactive: false }) : deps.mesh
1093
+ : null;
1094
+ if (mesh) {
1095
+ const meshEmitTimeoutMs = deps.meshEmitTimeoutMs ?? 10_000;
1096
+ let timer;
1097
+ try {
1098
+ await Promise.race([
1099
+ emitLaneTerminalStateEvent(mesh, result.record, "interrupted", "host_lost"),
1100
+ new Promise((_resolve, reject) => {
1101
+ timer = setTimeout(() => reject(new Error(`terminal mesh emit timed out after ${meshEmitTimeoutMs}ms`)), meshEmitTimeoutMs);
1102
+ }),
1103
+ ]);
1104
+ }
1105
+ catch (err) {
1106
+ markDirty(summary, {
1107
+ kind: "lane",
1108
+ id: lane.lane_id,
1109
+ why: `terminal mesh emit failed: ${err instanceof Error ? err.message : String(err)}`,
1110
+ });
1111
+ }
1112
+ finally {
1113
+ if (timer)
1114
+ clearTimeout(timer);
1115
+ }
1116
+ }
1063
1117
  }
1064
1118
  }
1065
1119
  catch (err) {
@@ -44,7 +44,7 @@ import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, provide
44
44
  import { readToolActivity, toolActivityPath } from "./dropbox.js";
45
45
  import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
46
46
  import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission-host.js";
47
- import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, progressEventDue, } from "./mesh-events.js";
47
+ import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, progressEventDue, } from "./mesh-events.js";
48
48
  import { envelopeQuestionId } from "./questions.js";
49
49
  import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, retryLaneOnAlternateAccount, } from "./spawn.js";
50
50
  /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
@@ -586,20 +586,27 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
586
586
  const meshRequestTimeoutMs = deps.meshRequestTimeoutMs ?? 10_000;
587
587
  const initialLane = readLaneSoft(hqRoot, laneId);
588
588
  if (mesh && initialLane) {
589
- // Explicit mesh attribution is the repair signal. Ordinary company lanes
590
- // already receive their thread during create; legacy records without one
591
- // stay quiet until an operator or the owning flow repairs them.
592
- const company = initialLane.mesh_company;
593
- if (company && !initialLane.thread_id) {
589
+ // Every armed watcher refreshes the server descriptor. This migrates old
590
+ // shared project threads and picks up account/model changes after resume.
591
+ const company = initialLane.mesh_company ?? initialLane.company ?? undefined;
592
+ if (company &&
593
+ (mesh.ensureLaneThread || !initialLane.thread_id || initialLane.thread_id.startsWith("proj-"))) {
594
594
  let timer;
595
595
  try {
596
596
  const threadId = await Promise.race([
597
- mesh.ensureProjectThread(company, initialLane.project_id),
597
+ mesh.ensureLaneThread
598
+ ? mesh.ensureLaneThread(company, initialLane)
599
+ : mesh.ensureProjectThread(company, initialLane.project_id),
598
600
  new Promise((_resolve, reject) => {
599
601
  timer = setTimeout(() => reject(new Error(`mesh thread ensure timed out after ${meshRequestTimeoutMs}ms`)), meshRequestTimeoutMs);
600
602
  }),
601
603
  ]);
602
- retryUpdateLane(hqRoot, laneId, "spawn", (current) => current.thread_id ? current : { ...current, thread_id: threadId });
604
+ const updated = initialLane.thread_id === threadId
605
+ ? initialLane
606
+ : retryUpdateLane(hqRoot, laneId, "spawn", (current) => current.thread_id === threadId ? current : { ...current, thread_id: threadId });
607
+ if (mesh.ensureLaneThread && updated.version !== initialLane.version) {
608
+ await mesh.ensureLaneThread(company, updated);
609
+ }
603
610
  }
604
611
  catch (err) {
605
612
  const message = err instanceof Error ? err.message : String(err);
@@ -607,7 +614,7 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
607
614
  writeWatcherDecision(outPath, {
608
615
  event: "mesh_event_failed",
609
616
  at: new Date(now()).toISOString(),
610
- mesh_event: "ensure_project_thread",
617
+ mesh_event: "ensure_lane_thread",
611
618
  error: message,
612
619
  }, outMax);
613
620
  }
@@ -889,11 +896,17 @@ export async function runWatcherLoop(hqRoot, laneId, deps = {}) {
889
896
  // Spec §8 terminal rows: done → lane_done + story review; blocked →
890
897
  // lane_blocked; ask → lane_ask with the queue ids. After the state
891
898
  // write, so a mesh failure can never leave the lane unclassified.
892
- if (mesh && envelope && latest && terminalWrite.changed) {
893
- const questionIds = envelope.decision === "ask"
894
- ? envelopeQuestionIds(laneId, latest.ref, envelope)
895
- : undefined;
896
- await emitMeshSoft(`lane_${envelope.decision}`, logDecision, tick.emit.at, (signal) => emitLaneDecisionEvents(mesh, updated, envelope, { questionIds, signal }), meshRequestTimeoutMs);
899
+ if (mesh && terminalWrite.changed) {
900
+ if (envelope && latest) {
901
+ const questionIds = envelope.decision === "ask"
902
+ ? envelopeQuestionIds(laneId, latest.ref, envelope)
903
+ : undefined;
904
+ await emitMeshSoft(`lane_${envelope.decision}`, logDecision, tick.emit.at, (signal) => emitLaneDecisionEvents(mesh, updated, envelope, { questionIds, signal }), meshRequestTimeoutMs);
905
+ }
906
+ else if (updated.state === "failed" || updated.state === "interrupted" ||
907
+ updated.state === "cancelled" || updated.state === "killed") {
908
+ await emitMeshSoft(`lane_${updated.state}`, logDecision, tick.emit.at, (signal) => emitLaneTerminalStateEvent(mesh, updated, updated.state, classification.reason, signal), meshRequestTimeoutMs);
909
+ }
897
910
  }
898
911
  }
899
912
  }
@@ -16,13 +16,35 @@ export interface MeshThread {
16
16
  projectId?: string;
17
17
  companyUid?: string;
18
18
  threadStatus?: string;
19
+ kind?: "project" | "work-session" | "lane" | string;
20
+ lane?: MeshLaneDescriptor;
21
+ laneStatus?: MeshLaneStatus;
22
+ projectChannelId?: string;
19
23
  ownerUid?: string;
24
+ ownerType?: "human" | "agent";
25
+ senderMachineUid?: string;
26
+ senderPrincipalType?: "agent" | "outpost";
27
+ senderAuthenticatedSub?: string;
20
28
  progressSummary?: string;
21
29
  sourceSignalSummary?: string;
22
30
  blockedReason?: string;
23
31
  lastActivityAt?: string;
24
32
  createdAt?: string;
25
33
  }
34
+ export type MeshLaneStatus = "queued" | "running" | "awaiting_input" | "blocked" | "done" | "failed" | "interrupted" | "cancelled" | "killed";
35
+ export interface MeshLaneDescriptor {
36
+ laneId: string;
37
+ storyId?: string;
38
+ workerProfile?: string;
39
+ model?: string;
40
+ account?: string;
41
+ parentLaneId?: string;
42
+ senior?: {
43
+ kind: "user" | "session" | "lane";
44
+ id?: string;
45
+ };
46
+ laneVersion: number;
47
+ }
26
48
  export interface MeshProjectStoryInput {
27
49
  id: string;
28
50
  title: string;
@@ -101,6 +123,10 @@ export declare function ensureProjectThread(token: string, company: MeshCompany,
101
123
  threadId: string;
102
124
  created: boolean;
103
125
  }>;
126
+ /** Ensure the server-owned, stable Work Mesh thread for one lane. */
127
+ export declare function ensureLaneMeshThread(token: string, company: MeshCompany, projectId: string, lane: MeshLaneDescriptor, laneStatus: MeshLaneStatus, signal?: AbortSignal): Promise<{
128
+ threadId: string;
129
+ }>;
104
130
  export declare function appendThreadEvent(token: string, companyUid: string, threadId: string, eventKind: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<{
105
131
  eventId?: string;
106
132
  createdAt?: string;
@@ -138,7 +164,7 @@ export declare function putProjectView(token: string, companyUid: string, projec
138
164
  expectedVersion?: number;
139
165
  }): Promise<MeshProjectView>;
140
166
  /** Register the project genesis thread and deterministic project channel. */
141
- export declare function registerMeshProject(token: string, companyUid: string, projectId: string): Promise<MeshProjectRegistration>;
167
+ export declare function registerMeshProject(token: string, companyUid: string, projectId: string, channelId?: string): Promise<MeshProjectRegistration>;
142
168
  export declare function pairDmUidsFromInbox(inbox: unknown): string[];
143
169
  export interface WarmCacheResult {
144
170
  directory: boolean;
@@ -229,21 +229,23 @@ async function listThreadsByStatus(token, companyUid) {
229
229
  return collected;
230
230
  }
231
231
  /**
232
- * Project threads are often missing from the status index. Prefer
233
- * `projectId` + `status=open`; if that combined query is rejected, fall
234
- * back to `projectId` alone.
232
+ * Project facets contain all project-owned threads, including terminal lane
233
+ * threads that have been removed from the active-status index.
235
234
  */
236
235
  async function listProjectThreads(token, companyUid, projectId) {
237
236
  try {
238
237
  return await listThreadsByQuery(token, {
239
238
  companyUid,
240
239
  projectId,
241
- status: "open",
242
240
  });
243
241
  }
244
242
  catch {
245
243
  try {
246
- return await listThreadsByQuery(token, { companyUid, projectId });
244
+ return await listThreadsByQuery(token, {
245
+ companyUid,
246
+ projectId,
247
+ status: "open",
248
+ });
247
249
  }
248
250
  catch {
249
251
  return [];
@@ -253,7 +255,10 @@ async function listProjectThreads(token, companyUid, projectId) {
253
255
  function mergeProjectAndStatusThreads(projectThreads, statusThreads, projectId) {
254
256
  const seen = new Set();
255
257
  const merged = [];
256
- for (const thread of sortThreadsByActivity(projectThreads.filter((t) => (!t.projectId || t.projectId === projectId) && isActiveThread(t)))) {
258
+ for (const thread of sortThreadsByActivity(projectThreads.filter((t) => (!t.projectId || t.projectId === projectId) &&
259
+ (isActiveThread(t) ||
260
+ (t.kind === "lane" &&
261
+ ["done", "failed", "interrupted", "cancelled", "killed"].includes(String(t.laneStatus))))))) {
257
262
  const key = threadKey(thread);
258
263
  if (key) {
259
264
  if (seen.has(key))
@@ -312,7 +317,7 @@ export function eventPayload(kind, opts, claimedBy) {
312
317
  export async function ensureProjectThread(token, company, projectId, opts) {
313
318
  if (opts.threadId)
314
319
  return { threadId: opts.threadId, created: false };
315
- const existing = (await listActiveThreads(token, company.companyUid, projectId))[0];
320
+ const existing = (await listActiveThreads(token, company.companyUid, projectId)).find((thread) => (thread.kind ?? "project") === "project" && !!thread.threadId);
316
321
  if (existing?.threadId)
317
322
  return { threadId: existing.threadId, created: false };
318
323
  const created = asRecord(await meshJson(token, "/v1/work-mesh/threads", {
@@ -332,6 +337,27 @@ export async function ensureProjectThread(token, company, projectId, opts) {
332
337
  throw new Error("Work mesh did not return a threadId");
333
338
  return { threadId, created: true };
334
339
  }
340
+ /** Ensure the server-owned, stable Work Mesh thread for one lane. */
341
+ export async function ensureLaneMeshThread(token, company, projectId, lane, laneStatus, signal) {
342
+ const result = asRecord(await meshJson(token, "/v1/work-mesh/threads", {
343
+ method: "POST",
344
+ body: {
345
+ companyUid: company.companyUid,
346
+ projectId,
347
+ routing: {
348
+ priority: "normal",
349
+ tags: ["hq-lane", `project:${projectId}`, `lane:${lane.laneId}`],
350
+ },
351
+ lane,
352
+ laneStatus,
353
+ },
354
+ signal,
355
+ }));
356
+ const threadId = typeof result?.threadId === "string" ? result.threadId.trim() : "";
357
+ if (!threadId)
358
+ throw new Error("Work mesh lane registration did not return a threadId");
359
+ return { threadId };
360
+ }
335
361
  export async function appendThreadEvent(token, companyUid, threadId, eventKind, payload, signal) {
336
362
  const data = asRecord(await meshJson(token, `/v1/work-mesh/threads/${encodeURIComponent(threadId)}/events`, {
337
363
  method: "POST",
@@ -443,18 +469,21 @@ export async function putProjectView(token, companyUid, projectId, input) {
443
469
  return result;
444
470
  }
445
471
  /** Register the project genesis thread and deterministic project channel. */
446
- export async function registerMeshProject(token, companyUid, projectId) {
447
- const result = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/register`, { method: "POST", body: { companyUid } }));
472
+ export async function registerMeshProject(token, companyUid, projectId, channelId) {
473
+ if (channelId !== undefined && !channelId.trim()) {
474
+ throw new Error("PROJECT_CHANNEL_INVALID: channelId must be non-empty");
475
+ }
476
+ const result = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/register`, { method: "POST", body: { companyUid, ...(channelId ? { channelId: channelId.trim() } : {}) } }));
448
477
  const threadId = String(result?.threadId || "").trim();
449
- const channelId = String(result?.channelId || "").trim();
450
- if (!threadId || !channelId) {
478
+ const registeredChannelId = String(result?.channelId || "").trim();
479
+ if (!threadId || !registeredChannelId) {
451
480
  throw new Error("Work mesh registration did not return a threadId and channelId");
452
481
  }
453
482
  return {
454
483
  companyUid: String(result?.companyUid || companyUid).trim(),
455
484
  projectId: String(result?.projectId || projectId).trim(),
456
485
  threadId,
457
- channelId,
486
+ channelId: registeredChannelId,
458
487
  channelCreated: result?.channelCreated === true,
459
488
  };
460
489
  }
@@ -41,6 +41,7 @@ export declare function ensureProjectBoard(input: {
41
41
  description?: string;
42
42
  stories: MeshProjectStoryInput[];
43
43
  repos?: MeshProjectRepo[];
44
+ channelId?: string;
44
45
  }): Promise<{
45
46
  view: MeshProjectView;
46
47
  registration: MeshProjectRegistration;
@@ -125,7 +125,7 @@ export async function ensureProjectBoard(input) {
125
125
  stories: input.stories,
126
126
  repos: input.repos ?? [],
127
127
  });
128
- const registration = await registerMeshProject(input.token, input.company.companyUid, input.projectId);
128
+ const registration = await registerMeshProject(input.token, input.company.companyUid, input.projectId, input.channelId);
129
129
  await closeSupersededProjectThread({
130
130
  token: input.token,
131
131
  companyUid: input.company.companyUid,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.155.0",
3
+ "version": "5.156.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {