@indigoai-us/hq-cli 5.126.0 → 5.127.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 (76) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
  3. package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
  4. package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
  5. package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
  6. package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
  7. package/dist/command-catalog.generated.d.ts +245 -11
  8. package/dist/command-catalog.generated.js +323 -11
  9. package/dist/command-registration-plan.d.ts +1 -0
  10. package/dist/command-registration-plan.js +11 -2
  11. package/dist/commands/agents.d.ts +12 -0
  12. package/dist/commands/agents.js +46 -2
  13. package/dist/commands/lanes.d.ts +55 -16
  14. package/dist/commands/lanes.js +1187 -250
  15. package/dist/commands/mesh.js +254 -19
  16. package/dist/lib/lanes/accounts.d.ts +67 -7
  17. package/dist/lib/lanes/accounts.js +233 -43
  18. package/dist/lib/lanes/compaction.d.ts +96 -0
  19. package/dist/lib/lanes/compaction.js +197 -0
  20. package/dist/lib/lanes/config.d.ts +15 -0
  21. package/dist/lib/lanes/config.js +65 -8
  22. package/dist/lib/lanes/delivery.d.ts +151 -0
  23. package/dist/lib/lanes/delivery.js +354 -0
  24. package/dist/lib/lanes/dropbox.d.ts +96 -0
  25. package/dist/lib/lanes/dropbox.js +408 -0
  26. package/dist/lib/lanes/effort.d.ts +50 -0
  27. package/dist/lib/lanes/effort.js +109 -0
  28. package/dist/lib/lanes/errors.d.ts +2 -2
  29. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  30. package/dist/lib/lanes/hooks-install.js +326 -0
  31. package/dist/lib/lanes/hq-root.d.ts +19 -0
  32. package/dist/lib/lanes/hq-root.js +28 -0
  33. package/dist/lib/lanes/index.d.ts +35 -5
  34. package/dist/lib/lanes/index.js +19 -4
  35. package/dist/lib/lanes/liveness.d.ts +7 -1
  36. package/dist/lib/lanes/liveness.js +64 -5
  37. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  38. package/dist/lib/lanes/machine-identity.js +92 -0
  39. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  40. package/dist/lib/lanes/mesh-events.js +275 -0
  41. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  42. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  43. package/dist/lib/lanes/questions.d.ts +75 -0
  44. package/dist/lib/lanes/questions.js +243 -0
  45. package/dist/lib/lanes/quota.d.ts +95 -0
  46. package/dist/lib/lanes/quota.js +320 -0
  47. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  48. package/dist/lib/lanes/reconcile-install.js +221 -0
  49. package/dist/lib/lanes/reconcile.d.ts +102 -0
  50. package/dist/lib/lanes/reconcile.js +985 -0
  51. package/dist/lib/lanes/registry.d.ts +18 -5
  52. package/dist/lib/lanes/registry.js +293 -19
  53. package/dist/lib/lanes/spawn.d.ts +274 -0
  54. package/dist/lib/lanes/spawn.js +1363 -0
  55. package/dist/lib/lanes/store.d.ts +7 -3
  56. package/dist/lib/lanes/store.js +150 -32
  57. package/dist/lib/lanes/types.d.ts +346 -4
  58. package/dist/lib/lanes/types.js +138 -5
  59. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  60. package/dist/lib/lanes/watcher-main.js +22 -0
  61. package/dist/lib/lanes/watcher.d.ts +216 -0
  62. package/dist/lib/lanes/watcher.js +859 -0
  63. package/dist/lib/mesh/api.d.ts +71 -7
  64. package/dist/lib/mesh/api.js +167 -28
  65. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  66. package/dist/lib/mesh/live/backfill-held.js +66 -21
  67. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  68. package/dist/lib/mesh/live/daemon/run.js +111 -54
  69. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  70. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  71. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  72. package/dist/lib/scan-packages/index.d.ts +2 -0
  73. package/dist/lib/scan-packages/index.js +1 -1
  74. package/dist/lib/work-context/state.d.ts +12 -0
  75. package/dist/lib/work-context/state.js +131 -3
  76. package/package.json +3 -1
@@ -4,21 +4,29 @@
4
4
  * Every command prints JSON on stdout and takes no interactive input, except
5
5
  * `accounts login`, which hands the terminal to the vendor's own sign-in.
6
6
  */
7
- import { spawn } from "node:child_process";
8
- import { createHash, randomBytes } from "node:crypto";
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ import { randomBytes } from "node:crypto";
9
9
  import * as fs from "node:fs";
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
- import { OutpostsClient } from "@indigoai-us/hq-cloud/outposts";
13
12
  import { ensureCognitoToken } from "../utils/cognito-session.js";
14
- import { findHqRoot } from "../utils/manifest.js";
13
+ import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
14
+ import { buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
15
15
  import { parseUserYaml } from "../utils/user-yaml-error.js";
16
16
  import { vaultApiFetch } from "../utils/vault-api.js";
17
17
  import { openMeshTransport } from "../lib/mesh/client.js";
18
- import { appendThreadEvent, ensureProjectThread, patchStoryStatus, } from "../lib/mesh/api.js";
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_BODY_STORES, QUESTION_STATUSES, SENIOR_KINDS, accountMaxRunning, accountStatus, appendCappedHistory, canonicalizeHome, createLane, createQuestion, decideAdmission, defaultThrottleDeps, isLanesError, isLaneVersionConflict, isTerminalLaneState, listAccountStatuses, listLaneIds, listQuestionIds, listSessionLiveness, loadLanesConfig, loadLivenessIndex, parseLaneProvider, parseLaneState, parseLivenessState, parseQuestionStatus, parseSeniorKind, readLane, readQuestion, resolveAccount, resolveLaneWorker, sessionLiveness, tryReadLane, updateLane, updateQuestion, writeLanesConfig, } 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, laneIdForAnswerDelivery, operatorMessageRefusalReason, raiseLaneQuestion, sha256Text, writeQuestionBody, } from "../lib/lanes/index.js";
21
+ import { questionBodyAbsPath } from "../lib/lanes/questions.js";
22
+ import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
23
+ import { providerCompactAfterMs } from "../lib/lanes/config.js";
24
+ import { parseCliEffort, resolveLaneEffort } from "../lib/lanes/effort.js";
21
25
  import { LanesError } from "../lib/lanes/errors.js";
26
+ import { accountQuotaView, loadQuotaState } from "../lib/lanes/quota.js";
27
+ import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, stopLaneProcesses, } from "../lib/lanes/spawn.js";
28
+ export { spawnLaneWorker };
29
+ export { questionBodyAbsPath };
22
30
  export const MAX_LANE_DEPTH = 2;
23
31
  export const OUTPOST_ID_PATH = "/etc/outpost/outpost-id";
24
32
  export const OUTPOST_DIR = "/etc/outpost";
@@ -28,14 +36,7 @@ const VENDOR_LOGIN = {
28
36
  codex: { command: "codex", args: ["login"] },
29
37
  grok: { command: "grok", args: ["login"] },
30
38
  };
31
- /**
32
- * Spawn seam for the worker process (spec §5). Process spawning and the
33
- * reconcile daemon are out of scope for this command surface; calling this
34
- * throws rather than faking a running lane.
35
- */
36
- export function spawnLaneWorker(lane) {
37
- throw new LanesError("not_implemented", `Lane process spawn is not implemented (lane ${lane.lane_id})`, { lane_id: lane.lane_id });
38
- }
39
+ export { emitLaneDecisionEvents };
39
40
  function printJson(value) {
40
41
  console.log(JSON.stringify(value, null, 2));
41
42
  }
@@ -66,14 +67,36 @@ async function defaultResolveCompany(company) {
66
67
  const { company: mesh } = await openMeshTransport({ company });
67
68
  return { slug: mesh.companySlug, uid: mesh.companyUid };
68
69
  }
69
- async function defaultListOutpostIds() {
70
- const client = new OutpostsClient({
70
+ function defaultOutpostsClient() {
71
+ return createOutpostsClient({
71
72
  transport: vaultApiFetch,
72
73
  token: () => ensureCognitoToken({ interactive: false }),
73
74
  });
74
- const rows = await client.list();
75
+ }
76
+ async function defaultListOutpostIds() {
77
+ const rows = await defaultOutpostsClient().list();
75
78
  return rows.map((row) => row.outpostId);
76
79
  }
80
+ async function defaultStageOutpostPayload(outpostId, bytes) {
81
+ return stageOutpostPayload(defaultOutpostsClient(), outpostId, bytes);
82
+ }
83
+ async function defaultDispatchOutpostExec(input) {
84
+ try {
85
+ return await dispatchOutpostPickup(defaultOutpostsClient(), input);
86
+ }
87
+ catch (err) {
88
+ if (err instanceof LanesError)
89
+ throw err;
90
+ throw new LanesError("outpost_dispatch_failed", `Outpost exec failed: ${dispatchErrorMessage(err)}`, { outpost_id: input.outpostId, message: dispatchErrorMessage(err) });
91
+ }
92
+ }
93
+ async function defaultFetchText(url) {
94
+ const res = await fetch(url);
95
+ if (!res.ok) {
96
+ throw new LanesError("outpost_payload_invalid", `Failed to download pickup payload: HTTP ${res.status}`, { status: res.status });
97
+ }
98
+ return res.text();
99
+ }
77
100
  export function readOutpostIdentity(existsSync = (p) => fs.existsSync(p), readFileSync = (p) => fs.readFileSync(p, "utf8")) {
78
101
  const hasMarker = existsSync(OUTPOST_ID_PATH);
79
102
  const hasDir = existsSync(OUTPOST_DIR);
@@ -110,35 +133,42 @@ function defaultSpawnLogin(opts) {
110
133
  });
111
134
  });
112
135
  }
113
- function toMeshCompany(company) {
114
- return { companyUid: company.uid, companySlug: company.slug };
115
- }
116
136
  function createDefaultMesh() {
117
- return {
118
- async ensureProjectThread(company, projectId) {
119
- const { token } = await openMeshTransport({ company: company.slug });
120
- const ensured = await ensureProjectThread(token, toMeshCompany(company), projectId, { summary: `HQ lane ${projectId}` });
121
- return ensured.threadId;
122
- },
123
- async appendThreadEvent(company, threadId, eventKind, payload) {
124
- const { token } = await openMeshTransport({ company: company.slug });
125
- await appendThreadEvent(token, company.uid, threadId, eventKind, payload);
126
- },
127
- async patchStoryStatus(company, projectId, storyId, status) {
128
- const { token } = await openMeshTransport({ company: company.slug });
129
- await patchStoryStatus(token, company.uid, projectId, storyId, status);
130
- },
131
- };
137
+ // Interactive: the command runs in the operator's shell, where a browser
138
+ // login is acceptable. The watcher builds its own non-interactive mesh.
139
+ return createLaneMesh({ interactive: true });
132
140
  }
133
141
  function defaultSleep(ms) {
134
142
  return new Promise((resolve) => {
135
143
  setTimeout(resolve, ms);
136
144
  });
137
145
  }
146
+ function resolveCallerPath(callerCwd, filePath) {
147
+ return path.resolve(callerCwd, filePath);
148
+ }
138
149
  function resolveDeps(deps = {}) {
139
150
  const env = deps.env ?? process.env;
151
+ const cwd = deps.cwd ?? (() => process.cwd());
152
+ let capturedCallerCwd;
153
+ const callerCwd = () => {
154
+ if (capturedCallerCwd === undefined)
155
+ capturedCallerCwd = cwd();
156
+ return capturedCallerCwd;
157
+ };
158
+ const bindProcessCwd = deps.hqRoot === undefined;
159
+ const resolveRoot = deps.hqRoot ?? (() => resolveLanesHqRoot({ env, cwd: cwd() }));
160
+ const hqRoot = () => {
161
+ callerCwd();
162
+ const root = resolveRoot();
163
+ if (bindProcessCwd && process.cwd() !== root) {
164
+ process.chdir(root);
165
+ }
166
+ return root;
167
+ };
140
168
  return {
141
- hqRoot: deps.hqRoot ?? (() => findHqRoot()),
169
+ cwd,
170
+ callerCwd,
171
+ hqRoot,
142
172
  env,
143
173
  now: deps.now ?? (() => new Date()),
144
174
  hostname: deps.hostname ?? (() => os.hostname()),
@@ -152,6 +182,48 @@ function resolveDeps(deps = {}) {
152
182
  homedir: deps.homedir ?? (() => os.homedir()),
153
183
  mesh: deps.mesh ?? createDefaultMesh(),
154
184
  sleep: deps.sleep ?? defaultSleep,
185
+ spawnProcess: deps.spawnProcess,
186
+ proveDetached: deps.proveDetached,
187
+ killProcess: deps.killProcess,
188
+ isAlive: deps.isAlive,
189
+ stopSleepMs: deps.stopSleepMs,
190
+ livenessDeps: deps.livenessDeps,
191
+ presenceConnected: deps.presenceConnected,
192
+ systemdRoot: deps.systemdRoot,
193
+ runSystemctl: deps.runSystemctl ??
194
+ ((command, args) => {
195
+ const result = spawnSync(command, args, { encoding: "utf8" });
196
+ return {
197
+ status: result.status ?? 1,
198
+ stdout: result.stdout ?? "",
199
+ stderr: result.stderr ??
200
+ (result.error instanceof Error ? result.error.message : ""),
201
+ };
202
+ }),
203
+ resolveMachineIdentity: deps.resolveMachineIdentity ?? lookupMachineIdentity,
204
+ runCompaction: deps.runCompaction,
205
+ locateTranscript: deps.locateTranscript,
206
+ stageOutpostPayload: deps.stageOutpostPayload ?? defaultStageOutpostPayload,
207
+ dispatchOutpostExec: deps.dispatchOutpostExec ?? defaultDispatchOutpostExec,
208
+ fetchText: deps.fetchText ?? defaultFetchText,
209
+ };
210
+ }
211
+ function livenessDepsFromCtx(ctx) {
212
+ const base = ctx.livenessDeps ?? defaultLivenessDeps();
213
+ return {
214
+ ...base,
215
+ now: () => ctx.now().getTime(),
216
+ homedir: ctx.homedir,
217
+ env: ctx.env,
218
+ existsSync: ctx.existsSync,
219
+ readFileSync: ctx.readFileSync,
220
+ };
221
+ }
222
+ function reconcileHostFromCtx(ctx) {
223
+ const identity = ctx.readOutpostIdentity();
224
+ return {
225
+ hostname: ctx.hostname(),
226
+ outpostId: identity.kind === "id" ? identity.id : null,
155
227
  };
156
228
  }
157
229
  export function parseSeniorRef(raw) {
@@ -220,9 +292,6 @@ function parseOptionalInt(raw, flag) {
220
292
  function runDir(hqRoot, laneId) {
221
293
  return path.join(hqRoot, LANES_RUNS_REL, laneId);
222
294
  }
223
- function sha256Text(text) {
224
- return `sha256:${createHash("sha256").update(text).digest("hex")}`;
225
- }
226
295
  function ensureDir(dir) {
227
296
  fs.mkdirSync(dir, { recursive: true });
228
297
  }
@@ -241,30 +310,6 @@ function readJsonFile(filePath) {
241
310
  throw err;
242
311
  }
243
312
  }
244
- function parseQuestionBodyStore(value) {
245
- if (typeof value === "string" &&
246
- QUESTION_BODY_STORES.includes(value)) {
247
- return value;
248
- }
249
- throw new LanesError("unknown_body_store", `Unknown question body store ${JSON.stringify(value)}. Accepted: ${QUESTION_BODY_STORES.join(", ")}`, { accepted: [...QUESTION_BODY_STORES], value });
250
- }
251
- function bodyStoreForLane(lane) {
252
- return lane.company.slug ? "company" : "personal";
253
- }
254
- /** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
255
- export function questionBodyAbsPath(hqRoot, question) {
256
- const store = parseQuestionBodyStore(question.body.store);
257
- if (store === "company") {
258
- if (!question.company.slug) {
259
- throw new LanesError("question_body_store_unresolved", "Company question body is missing a company slug", { question_id: question.question_id });
260
- }
261
- return path.join(hqRoot, "companies", question.company.slug, "lanes", "questions", `${question.question_id}.json`);
262
- }
263
- return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
264
- }
265
- function questionBodyRef(questionId) {
266
- return path.posix.join("lanes", "questions", `${questionId}.json`);
267
- }
268
313
  function parseQuestionBody(raw) {
269
314
  if (raw === null || typeof raw !== "object" || Array.isArray(raw))
270
315
  return null;
@@ -333,18 +378,6 @@ function requireQuestionBody(hqRoot, question) {
333
378
  }
334
379
  return body;
335
380
  }
336
- function writeQuestionBody(hqRoot, question, body) {
337
- const store = parseQuestionBodyStore(question.body.store);
338
- const filePath = questionBodyAbsPath(hqRoot, question);
339
- const serialized = `${JSON.stringify(body, null, 2)}\n`;
340
- ensureDir(path.dirname(filePath));
341
- fs.writeFileSync(filePath, serialized, "utf8");
342
- return {
343
- store,
344
- ref: questionBodyRef(question.question_id),
345
- digest: sha256Text(serialized),
346
- };
347
- }
348
381
  function loadAllLanes(hqRoot) {
349
382
  return listLaneIds(hqRoot).map((id) => readLane(hqRoot, id));
350
383
  }
@@ -354,15 +387,36 @@ function loadAllQuestions(hqRoot) {
354
387
  function sessionMetaPath(hqRoot, sessionId) {
355
388
  return path.join(hqRoot, "workspace", "sessions", sessionId, "meta.yaml");
356
389
  }
357
- function readSessionMeta(hqRoot, sessionId, ctx) {
390
+ function loadSessionMeta(hqRoot, sessionId, ctx) {
358
391
  const filePath = sessionMetaPath(hqRoot, sessionId);
359
392
  if (!ctx.existsSync(filePath))
393
+ return { status: "missing" };
394
+ let text;
395
+ try {
396
+ text = ctx.readFileSync(filePath, "utf8");
397
+ }
398
+ catch (err) {
399
+ process.stderr.write(`hq lanes: failed to read session meta ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
400
+ return { status: "unreadable" };
401
+ }
402
+ let doc;
403
+ try {
404
+ doc = parseUserYaml(text, filePath);
405
+ }
406
+ catch (err) {
407
+ process.stderr.write(`hq lanes: failed to parse session meta ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`);
408
+ return { status: "malformed" };
409
+ }
410
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
411
+ return { status: "malformed" };
412
+ }
413
+ return { status: "ok", doc: doc };
414
+ }
415
+ function readSessionMeta(hqRoot, sessionId, ctx) {
416
+ const loaded = loadSessionMeta(hqRoot, sessionId, ctx);
417
+ if (loaded.status !== "ok")
360
418
  return null;
361
- const text = ctx.readFileSync(filePath, "utf8");
362
- const doc = parseUserYaml(text, filePath);
363
- if (doc === null || typeof doc !== "object" || Array.isArray(doc))
364
- return null;
365
- return doc;
419
+ return loaded.doc;
366
420
  }
367
421
  function seniorOfSession(hqRoot, sessionId, ctx) {
368
422
  const meta = readSessionMeta(hqRoot, sessionId, ctx);
@@ -468,28 +522,63 @@ function defaultSenior(ctx) {
468
522
  }
469
523
  function companyOfSenior(hqRoot, ref, ctx) {
470
524
  if (ref.kind === "user")
471
- return null;
525
+ return { binding: "unknown" };
472
526
  if (ref.kind === "lane") {
473
527
  if (!ref.id)
474
- return null;
528
+ return { binding: "unknown" };
475
529
  const lane = readLane(hqRoot, ref.id);
476
- return lane.company;
530
+ if (!lane.company)
531
+ return { binding: "none" };
532
+ return {
533
+ binding: "company",
534
+ slug: lane.company.slug,
535
+ uid: lane.company.uid,
536
+ };
477
537
  }
478
538
  if (!ref.id)
479
- return null;
480
- const meta = readSessionMeta(hqRoot, ref.id, ctx);
481
- if (!meta)
482
- return null;
483
- const slug = typeof meta.company_slug === "string" ? meta.company_slug : undefined;
484
- const uid = typeof meta.company_uid === "string" ? meta.company_uid : undefined;
539
+ return { binding: "unknown" };
540
+ const loaded = loadSessionMeta(hqRoot, ref.id, ctx);
541
+ if (loaded.status !== "ok")
542
+ return { binding: "unknown" };
543
+ return companyBindingFromSessionMeta(loaded.doc);
544
+ }
545
+ function companyBindingFromSessionMeta(meta) {
546
+ const slugRaw = meta.company_slug;
547
+ const uidRaw = meta.company_uid;
548
+ if (slugRaw !== undefined && slugRaw !== null && typeof slugRaw !== "string") {
549
+ return { binding: "unknown" };
550
+ }
551
+ if (uidRaw !== undefined && uidRaw !== null && typeof uidRaw !== "string") {
552
+ return { binding: "unknown" };
553
+ }
554
+ const slug = typeof slugRaw === "string" ? slugRaw : undefined;
555
+ const uid = typeof uidRaw === "string" ? uidRaw : undefined;
485
556
  if (!slug && !uid)
557
+ return { binding: "none" };
558
+ return { binding: "company", slug, uid };
559
+ }
560
+ function serializeSeniorCompany(seniorCompany) {
561
+ if (seniorCompany.binding === "unknown")
486
562
  return null;
487
- return { slug, uid };
563
+ if (seniorCompany.binding === "none")
564
+ return { kind: "none" };
565
+ const out = {};
566
+ if (seniorCompany.slug)
567
+ out.slug = seniorCompany.slug;
568
+ if (seniorCompany.uid)
569
+ out.uid = seniorCompany.uid;
570
+ return out;
488
571
  }
489
572
  function sameTenant(laneCompany, senior, seniorCompany) {
490
573
  if (senior.kind === "user")
491
574
  return true;
492
- if (!seniorCompany || (!seniorCompany.slug && !seniorCompany.uid))
575
+ // Unknown company stays a refusal for every lane, including no-company.
576
+ if (seniorCompany.binding === "unknown")
577
+ return false;
578
+ if (!laneCompany) {
579
+ return seniorCompany.binding === "none";
580
+ }
581
+ if (seniorCompany.binding !== "company")
493
582
  return false;
494
583
  if (seniorCompany.uid && seniorCompany.uid === laneCompany.uid)
495
584
  return true;
@@ -500,15 +589,20 @@ function sameTenant(laneCompany, senior, seniorCompany) {
500
589
  function assertSameTenant(laneCompany, senior, seniorCompany, extra = {}) {
501
590
  if (sameTenant(laneCompany, senior, seniorCompany))
502
591
  return;
503
- throw new LanesError("tenancy_mismatch", seniorCompany
504
- ? "Cannot attach a lane to a senior in a different company"
505
- : "Cannot attach a lane to a senior whose company is unknown", {
592
+ throw new LanesError("tenancy_mismatch", seniorCompany.binding === "unknown"
593
+ ? "Cannot attach a lane to a senior whose company is unknown"
594
+ : "Cannot attach a lane to a senior in a different company", {
506
595
  lane_company: laneCompany,
507
596
  senior,
508
- senior_company: seniorCompany,
597
+ senior_company: serializeSeniorCompany(seniorCompany),
509
598
  ...extra,
510
599
  });
511
600
  }
601
+ function recordMatchesCompany(recordCompany, filter) {
602
+ if (!recordCompany)
603
+ return false;
604
+ return recordCompany.uid === filter.uid || recordCompany.slug === filter.slug;
605
+ }
512
606
  function walkHitsLane(hqRoot, start, laneId, ctx) {
513
607
  const seen = new Set();
514
608
  let current = start;
@@ -599,7 +693,7 @@ function compareQueueOrder(a, b) {
599
693
  function admitCreate(input) {
600
694
  const { hqRoot, provider, pinnedAccount, priority, createdAt, config } = input;
601
695
  const providerCfg = config.providers[provider];
602
- const lanes = loadAllLanes(hqRoot);
696
+ const lanes = loadAllLanes(hqRoot).filter((lane) => lane.lane_id !== input.excludeLaneId);
603
697
  const runningProvider = lanes.filter((lane) => lane.provider === provider && lane.state === "running");
604
698
  const queuedSame = lanes.filter((lane) => {
605
699
  if (lane.provider !== provider || lane.state !== "queued")
@@ -686,16 +780,10 @@ async function applyThrottle(hqRoot, config, ctx, current, provider, pinnedAccou
686
780
  }
687
781
  return { action: "queue", reason: "throttle" };
688
782
  }
689
- function assertKnownOutpost(requested, known, identity) {
783
+ function assertKnownOutpost(requested, known) {
690
784
  if (!known.includes(requested)) {
691
785
  throw new LanesError("unknown_outpost", `Unknown Outpost ${JSON.stringify(requested)}. Accepted: ${known.join(", ") || "(none)"}`, { accepted: known, value: requested });
692
786
  }
693
- if (identity.kind === "unknown") {
694
- throw new LanesError("outpost_identity_unknown", "This host is an Outpost but its id is unknown; refusing all --outpost targets", { outpost: requested });
695
- }
696
- if (identity.kind === "id" && identity.id === requested) {
697
- throw new LanesError("outpost_self_target", `A host may not target its own Outpost id ${JSON.stringify(requested)}`, { outpost: requested });
698
- }
699
787
  }
700
788
  function copyHookRails(fromHome, toHome) {
701
789
  const src = path.join(fromHome, "hooks");
@@ -709,7 +797,14 @@ function railsPresent(home) {
709
797
  }
710
798
  const ADMISSION_LOCK_STALE_MS = 30_000;
711
799
  const ADMISSION_LOCK_TIMEOUT_MS = 5_000;
712
- const ASK_BACKOFF_MS = [50, 100, 200, 400];
800
+ /**
801
+ * Spec §5.1: `hq lanes ask` from inside a lane can hit a binary or dist
802
+ * being replaced mid-call (ENOENT / ETXTBSY / a module that no longer
803
+ * resolves) or a record written by a newer CLI. Three attempts, waits of
804
+ * 5 s then 15 s, 20 s of backoff in total, inside the 30 s bound.
805
+ */
806
+ export const ASK_BACKOFF_MS = [5_000, 15_000];
807
+ export const ASK_ATTEMPTS = ASK_BACKOFF_MS.length + 1;
713
808
  function yieldEventLoop() {
714
809
  return new Promise((resolve) => {
715
810
  setImmediate(resolve);
@@ -815,7 +910,7 @@ export function casUpdateQuestion(hqRoot, questionId, updater) {
815
910
  export function casUpdateLane(hqRoot, laneId, updater) {
816
911
  return withCasRetry(() => {
817
912
  const current = readLane(hqRoot, laneId);
818
- return updateLane(hqRoot, laneId, current.version, updater);
913
+ return updateLane(hqRoot, laneId, current.version, "command", updater);
819
914
  });
820
915
  }
821
916
  function throwIfQuestionNotPending(current) {
@@ -836,6 +931,29 @@ function formatUnknownValue(value) {
836
931
  }
837
932
  return "";
838
933
  }
934
+ /**
935
+ * Spec §5.3: an approval gate ends the run. The lane stops at each
936
+ * checkpoint with a `decision: ask` envelope whose question names it; the
937
+ * reconcile pass resumes the lane once every question is answered.
938
+ */
939
+ export function checkpointInstruction(worker) {
940
+ const names = [
941
+ ...(worker.approvalRequired ? ["approval_required"] : []),
942
+ ...worker.humanCheckpoints,
943
+ ];
944
+ if (names.length === 0)
945
+ return "";
946
+ return [
947
+ "Each checkpoint above is an approval gate that ends this run. When you reach a checkpoint, " +
948
+ "stop and write the envelope with decision \"ask\" and story_status \"in_progress\". " +
949
+ "Put exactly one entry in questions for the checkpoint; its question text must start with " +
950
+ "the checkpoint name, followed by what you need approved, and its options must include \"approve\". " +
951
+ "Do not continue past a checkpoint in the same run. When this run is resumed, the answer to " +
952
+ "that question is an operator decision that outranks the brief; continue from the checkpoint.",
953
+ "Checkpoint names, in order:",
954
+ ...names.map((name) => `- ${name}`),
955
+ ].join("\n");
956
+ }
839
957
  export function composeWorkerBrief(worker, taskBrief) {
840
958
  const yaml = worker.workerYaml;
841
959
  const name = yaml.worker?.name ?? worker.id;
@@ -864,10 +982,31 @@ export function composeWorkerBrief(worker, taskBrief) {
864
982
  "",
865
983
  "## Verification checkpoints",
866
984
  checkpoints || "(none)",
985
+ ...(checkpoints ? ["", checkpointInstruction(worker)] : []),
867
986
  "",
868
987
  "## Task",
869
988
  taskBrief.replace(/\s+$/, ""),
870
989
  "",
990
+ "## Standing authorization",
991
+ STANDING_AUTHORIZATION,
992
+ "",
993
+ FOREGROUND_ONLY_RULE,
994
+ "",
995
+ ].join("\n");
996
+ }
997
+ /**
998
+ * A lane with no worker still carries the standing authorization paragraph
999
+ * and the foreground-only rule.
1000
+ */
1001
+ export function composeGeneralBrief(taskBrief) {
1002
+ return [
1003
+ taskBrief.replace(/\s+$/, ""),
1004
+ "",
1005
+ "## Standing authorization",
1006
+ STANDING_AUTHORIZATION,
1007
+ "",
1008
+ FOREGROUND_ONLY_RULE,
1009
+ "",
871
1010
  ].join("\n");
872
1011
  }
873
1012
  export function parseMaxRuntimeSeconds(raw, workerId) {
@@ -899,36 +1038,37 @@ export function parseMaxRuntimeSeconds(raw, workerId) {
899
1038
  function askDeliveryFailedPath(hqRoot, laneId) {
900
1039
  return path.join(runDir(hqRoot, laneId), "ask_delivery_failed.json");
901
1040
  }
902
- async function emitLaneMeshEvent(ctx, company, threadId, eventKind, payload) {
903
- if (!threadId)
1041
+ async function emitLaneMeshEvent(ctx, company, threadId, event, payload) {
1042
+ if (!threadId || !company)
904
1043
  return;
905
- await ctx.mesh.appendThreadEvent(company, threadId, eventKind, payload);
906
- }
907
- export async function emitLaneDecisionEvents(mesh, lane, decision, extra = {}) {
908
- const eventKind = decision === "blocked" ? "lane_blocked" : "lane_done";
909
- if (lane.thread_id) {
910
- await mesh.appendThreadEvent(lane.company, lane.thread_id, eventKind, {
911
- lane_id: lane.lane_id,
912
- project_id: lane.project_id,
913
- story_id: lane.story_id,
914
- ...extra,
915
- });
916
- }
917
- if (decision === "done") {
918
- await mesh.patchStoryStatus(lane.company, lane.project_id, lane.story_id, "review");
919
- }
1044
+ await ctx.mesh.appendThreadEvent(company, threadId, event, payload);
920
1045
  }
921
1046
  export const PICKUP_TERMINAL_REASONS = [
922
1047
  "account_not_logged_in",
923
1048
  "unknown_account",
924
1049
  "account_disabled",
925
1050
  ];
1051
+ export const DISPATCH_TERMINAL_REASON = "outpost_dispatch_failed";
926
1052
  function parsePickupTerminalReason(value) {
927
1053
  if (PICKUP_TERMINAL_REASONS.includes(value)) {
928
1054
  return value;
929
1055
  }
930
1056
  throw new LanesError("unknown_pickup_reason", `Unknown Outpost pickup reason ${JSON.stringify(value)}. Accepted: ${PICKUP_TERMINAL_REASONS.join(", ")}`, { accepted: [...PICKUP_TERMINAL_REASONS], value });
931
1057
  }
1058
+ function markDispatchFailure(hqRoot, laneId, details) {
1059
+ return casUpdateLane(hqRoot, laneId, (current) => {
1060
+ if (isTerminalLaneState(current.state) || current.state === "running") {
1061
+ return current;
1062
+ }
1063
+ return {
1064
+ ...current,
1065
+ state: "failed",
1066
+ queued_reason: undefined,
1067
+ terminal_reason: DISPATCH_TERMINAL_REASON,
1068
+ terminal_details: details,
1069
+ };
1070
+ });
1071
+ }
932
1072
  function markPickupFailure(hqRoot, laneId, reasonRaw, details) {
933
1073
  const reason = parsePickupTerminalReason(reasonRaw);
934
1074
  return casUpdateLane(hqRoot, laneId, (current) => {
@@ -954,9 +1094,11 @@ export function applyOutpostAccountPickup(hqRoot, laneId) {
954
1094
  const lane = readLane(hqRoot, laneId);
955
1095
  const config = loadLanesConfig(hqRoot);
956
1096
  try {
957
- const resolved = resolveAccount(lane.provider, lane.account, config);
1097
+ const resolved = resolveAccount(lane.provider, lane.account, config, {
1098
+ hqRoot,
1099
+ });
958
1100
  const status = accountStatus(lane.provider, resolved.id, config);
959
- if (!status.logged_in) {
1101
+ if (isLoggedOut(status)) {
960
1102
  return markPickupFailure(hqRoot, laneId, "account_not_logged_in", {
961
1103
  provider: lane.provider,
962
1104
  account: resolved.id,
@@ -964,7 +1106,11 @@ export function applyOutpostAccountPickup(hqRoot, laneId) {
964
1106
  }
965
1107
  if (lane.account === resolved.id)
966
1108
  return lane;
967
- return casUpdateLane(hqRoot, laneId, (current) => {
1109
+ // The bind is the executing host's pre-spawn account resolution, the same
1110
+ // write spawnLaneWorker makes for a local lane, so it carries the spawn
1111
+ // writer. `account` stays closed to `command`: an operator command never
1112
+ // re-pins the account of a lane it does not execute.
1113
+ return retryUpdateLane(hqRoot, laneId, "spawn", (current) => {
968
1114
  if (current.account === resolved.id)
969
1115
  return current;
970
1116
  return { ...current, account: resolved.id };
@@ -1020,9 +1166,6 @@ async function runCreate(ctx, opts) {
1020
1166
  const project = opts.project?.trim();
1021
1167
  const story = opts.story?.trim();
1022
1168
  const briefFile = opts.briefFile?.trim();
1023
- if (!companyFlag) {
1024
- throw new LanesError("missing_company", "--company is required", {});
1025
- }
1026
1169
  if (!project) {
1027
1170
  throw new LanesError("missing_project", "--project is required", {});
1028
1171
  }
@@ -1044,7 +1187,7 @@ async function runCreate(ctx, opts) {
1044
1187
  catch (err) {
1045
1188
  throw new LanesError("outpost_list_unavailable", `Failed to list Outpost ids: ${err instanceof Error ? err.message : String(err)}`, {});
1046
1189
  }
1047
- assertKnownOutpost(requested, known, ctx.readOutpostIdentity());
1190
+ assertKnownOutpost(requested, known);
1048
1191
  }
1049
1192
  const depth = callerDepth(hqRoot, ctx);
1050
1193
  if (depth >= MAX_LANE_DEPTH) {
@@ -1056,14 +1199,17 @@ async function runCreate(ctx, opts) {
1056
1199
  if (laneDepth > MAX_LANE_DEPTH) {
1057
1200
  throw new LanesError("max_lane_depth", `Lane depth ${laneDepth} exceeds cap ${MAX_LANE_DEPTH}`, { depth: laneDepth, max: MAX_LANE_DEPTH });
1058
1201
  }
1059
- const company = await ctx.resolveCompany(companyFlag);
1202
+ // A no-company lane must not call company resolution (openMeshTransport).
1203
+ const company = companyFlag
1204
+ ? await ctx.resolveCompany(companyFlag)
1205
+ : null;
1060
1206
  const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
1061
1207
  assertSameTenant(company, senior, seniorCompany);
1062
1208
  let resolvedWorker;
1063
1209
  if (opts.worker) {
1064
- resolvedWorker = resolveLaneWorker(hqRoot, opts.worker, company.slug);
1210
+ resolvedWorker = resolveLaneWorker(hqRoot, opts.worker, company?.slug);
1065
1211
  }
1066
- const briefAbs = path.resolve(briefFile);
1212
+ const briefAbs = resolveCallerPath(ctx.callerCwd(), briefFile);
1067
1213
  if (!ctx.existsSync(briefAbs)) {
1068
1214
  throw new LanesError("brief_not_found", `Brief file not found: ${briefAbs}`, {
1069
1215
  path: briefAbs,
@@ -1071,9 +1217,23 @@ async function runCreate(ctx, opts) {
1071
1217
  }
1072
1218
  const config = loadLanesConfig(hqRoot);
1073
1219
  const outpostId = opts.outpost?.trim() || undefined;
1220
+ let originHost;
1221
+ let originHostSource;
1222
+ if (outpostId) {
1223
+ const identity = ctx.resolveMachineIdentity();
1224
+ if (identity.status !== "ok") {
1225
+ throw new LanesError("origin_host_unresolvable", "Cannot record origin_host; this machine's identity is unresolvable", { identity });
1226
+ }
1227
+ originHost = identity.id;
1228
+ originHostSource = identity.source;
1229
+ }
1230
+ const cliModel = parseCliModel(opts.model);
1231
+ // Validated here because `claude --effort <unknown>` only warns and runs.
1232
+ const cliEffort = parseCliEffort(provider, opts.effort);
1074
1233
  let pinnedAccount;
1234
+ const accountWasExplicit = Boolean(opts.account && opts.account.trim() !== "");
1075
1235
  if (outpostId) {
1076
- if (opts.account !== undefined && opts.account.trim() !== "") {
1236
+ if (accountWasExplicit) {
1077
1237
  pinnedAccount = opts.account.trim();
1078
1238
  }
1079
1239
  }
@@ -1082,14 +1242,12 @@ async function runCreate(ctx, opts) {
1082
1242
  if (!requested) {
1083
1243
  throw new AccountResolutionError("unknown_account", `Unknown account ${JSON.stringify(opts.account)} for ${provider}`, { provider, account: opts.account, enabled: [] });
1084
1244
  }
1085
- const resolved = resolveAccount(provider, requested, config);
1245
+ const resolved = resolveAccount(provider, requested, config, {
1246
+ hqRoot,
1247
+ cliModel,
1248
+ });
1086
1249
  pinnedAccount = resolved.id;
1087
1250
  }
1088
- else {
1089
- // Unpinned: still refuse at the bind point when every account is disabled
1090
- // (spec §5.5). The chosen login is not stored; admission binds later.
1091
- resolveAccount(provider, undefined, config);
1092
- }
1093
1251
  const priority = parsePriority(opts.priority);
1094
1252
  let timeoutSecs = parseOptionalInt(opts.timeout, "--timeout");
1095
1253
  if (timeoutSecs === undefined && resolvedWorker) {
@@ -1097,10 +1255,35 @@ async function runCreate(ctx, opts) {
1097
1255
  }
1098
1256
  const now = ctx.now();
1099
1257
  const createdAt = now.toISOString();
1100
- const threadId = await ctx.mesh.ensureProjectThread(company, project);
1258
+ const threadId = company
1259
+ ? await ctx.mesh.ensureProjectThread(company, project)
1260
+ : undefined;
1101
1261
  const persist = async () => {
1102
1262
  let admission;
1103
1263
  let validation;
1264
+ if (!outpostId && pinnedAccount === undefined) {
1265
+ pinnedAccount = resolveAccount(provider, undefined, config, {
1266
+ hqRoot,
1267
+ existsSync: ctx.existsSync,
1268
+ cliModel,
1269
+ }).id;
1270
+ }
1271
+ const accountCfg = pinnedAccount !== undefined
1272
+ ? config.providers[provider].accounts[pinnedAccount]
1273
+ : undefined;
1274
+ const model = resolveLaneModel({
1275
+ cliModel,
1276
+ accountModel: accountCfg?.model,
1277
+ providerModel: config.providers[provider].model,
1278
+ });
1279
+ // Recorded now so a queued lane already shows it; spawn re-resolves
1280
+ // every source except `cli`.
1281
+ const effort = resolveLaneEffort({
1282
+ cliEffort,
1283
+ accountEffort: accountCfg?.effort,
1284
+ providerEffort: config.providers[provider].effort,
1285
+ model,
1286
+ });
1104
1287
  if (outpostId) {
1105
1288
  admission = { action: "queue" };
1106
1289
  validation = "pending";
@@ -1138,9 +1321,18 @@ async function runCreate(ctx, opts) {
1138
1321
  queued_reason: queuedReason,
1139
1322
  priority,
1140
1323
  worker: resolvedWorker?.id ?? opts.worker,
1324
+ ...(resolvedWorker ? { worker_path: resolvedWorker.path } : {}),
1325
+ ...(resolvedWorker?.pack ? { worker_pack: resolvedWorker.pack } : {}),
1141
1326
  provider,
1142
1327
  account: pinnedAccount,
1328
+ ...(model !== undefined ? { model } : {}),
1329
+ ...(effort.effort !== undefined ? { effort: effort.effort } : {}),
1330
+ effort_source: effort.source,
1143
1331
  host,
1332
+ ...(originHost !== undefined ? { origin_host: originHost } : {}),
1333
+ ...(originHostSource !== undefined
1334
+ ? { origin_host_source: originHostSource }
1335
+ : {}),
1144
1336
  attribution_history: [],
1145
1337
  });
1146
1338
  return { lane, admission, validation };
@@ -1150,7 +1342,7 @@ async function runCreate(ctx, opts) {
1150
1342
  persisted = await persist();
1151
1343
  }
1152
1344
  else {
1153
- persisted = await withAdmissionLock(admissionLockPath(hqRoot, provider, pinnedAccount), persist, ctx.sleep);
1345
+ persisted = await withAdmissionLock(admissionLockPath(hqRoot, provider, accountWasExplicit ? pinnedAccount : undefined), persist, ctx.sleep);
1154
1346
  }
1155
1347
  const { lane, admission, validation } = persisted;
1156
1348
  const dest = runDir(hqRoot, lane.lane_id);
@@ -1161,7 +1353,7 @@ async function runCreate(ctx, opts) {
1161
1353
  const taskBrief = fs.readFileSync(briefAbs, "utf8");
1162
1354
  const briefText = resolvedWorker
1163
1355
  ? composeWorkerBrief(resolvedWorker, taskBrief)
1164
- : taskBrief;
1356
+ : composeGeneralBrief(taskBrief);
1165
1357
  fs.writeFileSync(path.join(dest, "brief.md"), briefText);
1166
1358
  if (timeoutSecs !== undefined) {
1167
1359
  writeJsonFile(timeoutPath(hqRoot, lane.lane_id), {
@@ -1169,40 +1361,323 @@ async function runCreate(ctx, opts) {
1169
1361
  deadline_at: new Date(now.getTime() + timeoutSecs * 1000).toISOString(),
1170
1362
  });
1171
1363
  }
1364
+ let started = lane;
1172
1365
  if (admission.action === "start") {
1173
- await emitLaneMeshEvent(ctx, company, threadId, "lane_started", {
1174
- lane_id: lane.lane_id,
1175
- project_id: project,
1176
- story_id: story,
1366
+ started = spawnLaneWorker(hqRoot, lane, {
1367
+ env: ctx.env,
1368
+ existsSync: ctx.existsSync,
1369
+ spawnProcess: ctx.spawnProcess,
1370
+ proveDetached: ctx.proveDetached,
1371
+ killProcess: ctx.killProcess,
1372
+ now: ctx.now,
1373
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
1374
+ runCompaction: ctx.runCompaction,
1375
+ locateTranscript: ctx.locateTranscript,
1376
+ });
1377
+ if (company) {
1378
+ await emitLaneMeshEvent(ctx, company, threadId, "lane_started", {
1379
+ lane_id: started.lane_id,
1380
+ project_id: project,
1381
+ story_id: story,
1382
+ });
1383
+ await ctx.mesh.patchStoryStatus(company, project, story, "in_progress");
1384
+ }
1385
+ }
1386
+ else if (outpostId) {
1387
+ started = await dispatchCreatedOutpostLane(ctx, {
1388
+ hqRoot,
1389
+ lane,
1390
+ outpostId,
1391
+ briefText,
1392
+ timeout: timeoutSecs !== undefined
1393
+ ? {
1394
+ timeout_secs: timeoutSecs,
1395
+ deadline_at: new Date(now.getTime() + timeoutSecs * 1000).toISOString(),
1396
+ }
1397
+ : undefined,
1177
1398
  });
1178
- await ctx.mesh.patchStoryStatus(company, project, story, "in_progress");
1179
1399
  }
1180
- // spawnLaneWorker is the process-launch seam. Create never calls it: spawn
1181
- // and the watcher are owned by the reconcile daemon (spec §5.7). Admitted
1182
- // lanes stay queued until that seam is implemented.
1183
1400
  printJson({
1184
1401
  ok: true,
1185
1402
  action: "create",
1186
- lane_id: lane.lane_id,
1187
- state: lane.state,
1188
- queued_reason: lane.queued_reason ?? null,
1189
- // An admitted lane still reports why it has not started. Without this a
1190
- // caller sees state=queued with queued_reason=null and cannot tell an
1191
- // admitted-but-unspawnable lane from a bug.
1192
- pending_start: admission.action === "start" ? "spawn_not_implemented" : null,
1193
- priority: lane.priority,
1194
- provider: lane.provider,
1195
- account: lane.account ?? null,
1196
- host: lane.host,
1197
- company: lane.company,
1198
- project_id: lane.project_id,
1199
- story_id: lane.story_id,
1200
- thread_id: lane.thread_id ?? null,
1201
- senior: lane.senior,
1202
- depth: lane.depth,
1403
+ lane_id: started.lane_id,
1404
+ state: started.state,
1405
+ queued_reason: started.queued_reason ?? null,
1406
+ priority: started.priority,
1407
+ provider: started.provider,
1408
+ account: started.account ?? null,
1409
+ model: started.model ?? null,
1410
+ effort: started.effort ?? null,
1411
+ effort_source: started.effort_source ?? null,
1412
+ host: started.host,
1413
+ company: started.company,
1414
+ project_id: started.project_id,
1415
+ story_id: started.story_id,
1416
+ thread_id: started.thread_id ?? null,
1417
+ senior: started.senior,
1418
+ depth: started.depth,
1203
1419
  timeout_secs: timeoutSecs ?? null,
1204
1420
  ...(validation ? { validation } : {}),
1421
+ ...(started.outpost_command_id
1422
+ ? {
1423
+ outpost_command_id: started
1424
+ .outpost_command_id,
1425
+ }
1426
+ : {}),
1427
+ lane: started,
1428
+ });
1429
+ }
1430
+ async function dispatchCreatedOutpostLane(ctx, input) {
1431
+ const { hqRoot, lane, outpostId, briefText, timeout } = input;
1432
+ const payload = {
1205
1433
  lane,
1434
+ brief: briefText,
1435
+ timeout,
1436
+ };
1437
+ const bytes = Buffer.from(`${JSON.stringify(payload)}\n`, "utf8");
1438
+ try {
1439
+ const staged = await ctx.stageOutpostPayload(outpostId, bytes);
1440
+ const command = buildOutpostPickupCommand(staged.getUrl);
1441
+ const dispatched = await ctx.dispatchOutpostExec({ outpostId, command });
1442
+ const remote = parseRemotePickupJson(dispatched.stdout);
1443
+ if (remote?.error === "outpost_self_target") {
1444
+ return casUpdateLane(hqRoot, lane.lane_id, (current) => ({
1445
+ ...current,
1446
+ state: "cancelled",
1447
+ queued_reason: undefined,
1448
+ terminal_reason: "outpost_self_target",
1449
+ outpost_command_id: dispatched.commandId,
1450
+ }));
1451
+ }
1452
+ const stdoutTrimmed = dispatched.stdout.trim();
1453
+ if (remote === null && stdoutTrimmed !== "" && dispatched.status !== "Success") {
1454
+ const failed = markDispatchFailure(hqRoot, lane.lane_id, {
1455
+ outpost_id: outpostId,
1456
+ command_id: dispatched.commandId,
1457
+ exit_code: dispatched.exitCode,
1458
+ status: dispatched.status,
1459
+ stderr: dispatched.stderr.slice(0, 2000),
1460
+ parse_error: "unparseable_pickup_stdout",
1461
+ });
1462
+ throw new LanesError(DISPATCH_TERMINAL_REASON, "Outpost pickup command failed", {
1463
+ outpost_id: outpostId,
1464
+ lane_id: failed.lane_id,
1465
+ state: failed.state,
1466
+ terminal_reason: DISPATCH_TERMINAL_REASON,
1467
+ command_id: dispatched.commandId,
1468
+ });
1469
+ }
1470
+ const dispatchOk = dispatched.status === "Success" || dispatched.exitCode === 0;
1471
+ if (!dispatchOk) {
1472
+ const failed = markDispatchFailure(hqRoot, lane.lane_id, {
1473
+ outpost_id: outpostId,
1474
+ command_id: dispatched.commandId,
1475
+ exit_code: dispatched.exitCode,
1476
+ status: dispatched.status,
1477
+ stderr: dispatched.stderr.slice(0, 2000),
1478
+ });
1479
+ throw new LanesError(DISPATCH_TERMINAL_REASON, "Outpost pickup command failed", {
1480
+ outpost_id: outpostId,
1481
+ lane_id: failed.lane_id,
1482
+ state: failed.state,
1483
+ terminal_reason: DISPATCH_TERMINAL_REASON,
1484
+ command_id: dispatched.commandId,
1485
+ });
1486
+ }
1487
+ return casUpdateLane(hqRoot, lane.lane_id, (current) => ({
1488
+ ...current,
1489
+ outpost_command_id: dispatched.commandId,
1490
+ }));
1491
+ }
1492
+ catch (err) {
1493
+ if (isLanesError(err) &&
1494
+ err.error === DISPATCH_TERMINAL_REASON &&
1495
+ readLane(hqRoot, lane.lane_id).state === "failed") {
1496
+ throw err;
1497
+ }
1498
+ const message = dispatchErrorMessage(err);
1499
+ const failed = markDispatchFailure(hqRoot, lane.lane_id, {
1500
+ outpost_id: outpostId,
1501
+ message,
1502
+ });
1503
+ throw new LanesError(DISPATCH_TERMINAL_REASON, `Failed to dispatch lane to Outpost ${outpostId}: ${message}`, {
1504
+ outpost_id: outpostId,
1505
+ lane_id: failed.lane_id,
1506
+ state: failed.state,
1507
+ terminal_reason: DISPATCH_TERMINAL_REASON,
1508
+ });
1509
+ }
1510
+ }
1511
+ function parseRemotePickupJson(stdout) {
1512
+ const trimmed = stdout.trim();
1513
+ if (!trimmed)
1514
+ return null;
1515
+ try {
1516
+ const parsed = JSON.parse(trimmed);
1517
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
1518
+ return parsed;
1519
+ }
1520
+ process.stderr.write("hq lanes: Outpost pickup stdout is not a JSON object\n");
1521
+ }
1522
+ catch (err) {
1523
+ process.stderr.write(`hq lanes: failed to parse Outpost pickup JSON: ${err instanceof Error ? err.message : String(err)}\n`);
1524
+ return null;
1525
+ }
1526
+ return null;
1527
+ }
1528
+ function ingestPickupPayload(hqRoot, payload) {
1529
+ const laneId = payload.lane.lane_id;
1530
+ if (!laneId) {
1531
+ throw new LanesError("outpost_payload_invalid", "Pickup payload lane.lane_id is required", {});
1532
+ }
1533
+ const existing = tryReadLane(hqRoot, laneId);
1534
+ const lane = existing ?? createLane(hqRoot, payload.lane);
1535
+ const dest = runDir(hqRoot, lane.lane_id);
1536
+ ensureDir(path.join(dest, "logs"));
1537
+ ensureDir(path.join(dest, "envelopes"));
1538
+ ensureDir(path.join(dest, "dropbox"));
1539
+ ensureDir(path.join(dest, "questions"));
1540
+ fs.writeFileSync(path.join(dest, "brief.md"), payload.brief);
1541
+ if (payload.timeout) {
1542
+ writeJsonFile(timeoutPath(hqRoot, lane.lane_id), payload.timeout);
1543
+ }
1544
+ return lane;
1545
+ }
1546
+ async function runPickup(ctx, opts) {
1547
+ const hqRoot = ctx.hqRoot();
1548
+ const payloadFile = opts.payloadFile?.trim();
1549
+ const payloadUrl = opts.payloadUrl?.trim();
1550
+ if (!payloadFile && !payloadUrl) {
1551
+ throw new LanesError("missing_payload", "--payload-file or --payload-url is required", {});
1552
+ }
1553
+ if (payloadFile && payloadUrl) {
1554
+ throw new LanesError("invalid_payload", "Pass only one of --payload-file or --payload-url", {});
1555
+ }
1556
+ let rawText;
1557
+ if (payloadUrl) {
1558
+ rawText = await ctx.fetchText(payloadUrl);
1559
+ }
1560
+ else {
1561
+ const abs = resolveCallerPath(ctx.callerCwd(), payloadFile);
1562
+ if (!ctx.existsSync(abs)) {
1563
+ throw new LanesError("brief_not_found", `Payload file not found: ${abs}`, {
1564
+ path: abs,
1565
+ });
1566
+ }
1567
+ rawText = ctx.readFileSync(abs, "utf8");
1568
+ }
1569
+ let parsedJson;
1570
+ try {
1571
+ parsedJson = JSON.parse(rawText);
1572
+ }
1573
+ catch (err) {
1574
+ throw new LanesError("outpost_payload_invalid", `Pickup payload is not JSON: ${err instanceof Error ? err.message : String(err)}`, {});
1575
+ }
1576
+ const payload = parseOutpostPickupPayload(parsedJson);
1577
+ const ingested = ingestPickupPayload(hqRoot, payload);
1578
+ if (ingested.host.kind === "outpost") {
1579
+ try {
1580
+ refuseOutpostSelfTarget(hqRoot, ingested, {
1581
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
1582
+ runCompaction: ctx.runCompaction,
1583
+ locateTranscript: ctx.locateTranscript,
1584
+ });
1585
+ }
1586
+ catch (err) {
1587
+ if (isLanesError(err) && err.error === "outpost_self_target") {
1588
+ const cancelled = readLane(hqRoot, ingested.lane_id);
1589
+ printJson({
1590
+ ok: true,
1591
+ action: "pickup",
1592
+ lane_id: cancelled.lane_id,
1593
+ state: cancelled.state,
1594
+ error: "outpost_self_target",
1595
+ lane: cancelled,
1596
+ });
1597
+ return;
1598
+ }
1599
+ throw err;
1600
+ }
1601
+ }
1602
+ const afterAccount = applyOutpostAccountPickup(hqRoot, ingested.lane_id);
1603
+ if (isTerminalLaneState(afterAccount.state)) {
1604
+ printJson({
1605
+ ok: true,
1606
+ action: "pickup",
1607
+ lane_id: afterAccount.lane_id,
1608
+ state: afterAccount.state,
1609
+ terminal_reason: afterAccount
1610
+ .terminal_reason,
1611
+ lane: afterAccount,
1612
+ });
1613
+ return;
1614
+ }
1615
+ const config = loadLanesConfig(hqRoot);
1616
+ let admission = admitCreate({
1617
+ hqRoot,
1618
+ provider: afterAccount.provider,
1619
+ pinnedAccount: afterAccount.account,
1620
+ priority: afterAccount.priority,
1621
+ createdAt: afterAccount.created_at,
1622
+ config,
1623
+ excludeLaneId: afterAccount.lane_id,
1624
+ });
1625
+ if (admission.action === "refuse" && admission.error) {
1626
+ throw admission.error;
1627
+ }
1628
+ admission = await applyThrottle(hqRoot, config, ctx, admission, afterAccount.provider, afterAccount.account);
1629
+ if (admission.action === "refuse" && admission.error) {
1630
+ throw admission.error;
1631
+ }
1632
+ let started;
1633
+ if (admission.action === "queue") {
1634
+ started = casUpdateLane(hqRoot, afterAccount.lane_id, (current) => {
1635
+ if (isTerminalLaneState(current.state))
1636
+ return current;
1637
+ return {
1638
+ ...current,
1639
+ state: "queued",
1640
+ queued_reason: admission.reason,
1641
+ };
1642
+ });
1643
+ }
1644
+ else {
1645
+ try {
1646
+ started = spawnLaneWorker(hqRoot, afterAccount, {
1647
+ env: ctx.env,
1648
+ existsSync: ctx.existsSync,
1649
+ spawnProcess: ctx.spawnProcess,
1650
+ proveDetached: ctx.proveDetached,
1651
+ killProcess: ctx.killProcess,
1652
+ now: ctx.now,
1653
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
1654
+ runCompaction: ctx.runCompaction,
1655
+ locateTranscript: ctx.locateTranscript,
1656
+ });
1657
+ }
1658
+ catch (err) {
1659
+ if (isLanesError(err) && err.error === "outpost_self_target") {
1660
+ const cancelled = readLane(hqRoot, afterAccount.lane_id);
1661
+ printJson({
1662
+ ok: true,
1663
+ action: "pickup",
1664
+ lane_id: cancelled.lane_id,
1665
+ state: cancelled.state,
1666
+ error: "outpost_self_target",
1667
+ lane: cancelled,
1668
+ });
1669
+ return;
1670
+ }
1671
+ throw err;
1672
+ }
1673
+ }
1674
+ printJson({
1675
+ ok: true,
1676
+ action: "pickup",
1677
+ lane_id: started.lane_id,
1678
+ state: started.state,
1679
+ queued_reason: started.queued_reason ?? null,
1680
+ lane: started,
1206
1681
  });
1207
1682
  }
1208
1683
  async function runList(ctx, opts) {
@@ -1222,7 +1697,7 @@ async function runList(ctx, opts) {
1222
1697
  return false;
1223
1698
  if (opts.story && lane.story_id !== opts.story)
1224
1699
  return false;
1225
- if (company && lane.company.uid !== company.uid && lane.company.slug !== company.slug) {
1700
+ if (company && !recordMatchesCompany(lane.company, company)) {
1226
1701
  return false;
1227
1702
  }
1228
1703
  if (senior && seniorKey(lane.senior) !== seniorKey(senior))
@@ -1232,7 +1707,19 @@ async function runList(ctx, opts) {
1232
1707
  .sort(compareQueueOrder)
1233
1708
  .map((lane) => ({
1234
1709
  ...lane,
1235
- overdue: isOverdue(hqRoot, lane.lane_id, now),
1710
+ overdue: Boolean(lane.overdue) || isOverdue(hqRoot, lane.lane_id, now),
1711
+ stalled: Boolean(lane.stalled),
1712
+ // Listing is observational. Reconcile owns durable liveness transitions.
1713
+ senior_lost: lane.state === "running" && lane.senior.kind === "session" && lane.senior.id
1714
+ ? (() => {
1715
+ const state = sessionLiveness(hqRoot, lane.senior.id, {
1716
+ nowMs: now.getTime(),
1717
+ deps: livenessDepsFromCtx(ctx),
1718
+ presenceConnected: ctx.presenceConnected,
1719
+ });
1720
+ return state === null || state === "ended" || state === "lost";
1721
+ })()
1722
+ : false,
1236
1723
  }));
1237
1724
  printJson({ ok: true, action: "list", lanes });
1238
1725
  }
@@ -1244,8 +1731,24 @@ async function runShow(ctx, laneId) {
1244
1731
  ok: true,
1245
1732
  action: "show",
1246
1733
  lane,
1247
- overdue: isOverdue(hqRoot, lane.lane_id, ctx.now()),
1734
+ overdue: Boolean(lane.overdue) || isOverdue(hqRoot, lane.lane_id, ctx.now()),
1735
+ stalled: Boolean(lane.stalled),
1736
+ stalled_at: lane.stalled_at ?? null,
1737
+ stalled_reason: lane.stalled_reason ?? null,
1248
1738
  ask_delivery_failed: askDeliveryFailed,
1739
+ delivery: laneDeliveryMechanism(lane),
1740
+ provider_session_id: lane.provider_session_id ?? null,
1741
+ provider_session_id_absent: lane.provider_session_id_absent ?? null,
1742
+ envelope_repair_mode: lane.envelope_repair?.mode ?? null,
1743
+ last_resume: lane.last_resume ?? null,
1744
+ last_run_usage: lane.last_run_usage ?? null,
1745
+ model: lane.model ?? null,
1746
+ effort: lane.effort ?? null,
1747
+ effort_source: lane.effort_source ?? null,
1748
+ last_message_at: lane.last_message_at ?? null,
1749
+ last_compaction: lane.last_compaction
1750
+ ? { ...lane.last_compaction, after: compactionAfterUsage(lane) }
1751
+ : null,
1249
1752
  });
1250
1753
  }
1251
1754
  async function runMessage(ctx, laneId, opts) {
@@ -1255,14 +1758,64 @@ async function runMessage(ctx, laneId, opts) {
1255
1758
  throw new LanesError("missing_text", "--text is required", {});
1256
1759
  }
1257
1760
  const lane = readLane(hqRoot, laneId);
1761
+ // One decision point for how a follow-up reaches this lane (delivery.ts).
1762
+ // `none` refuses before anything is written: no inbox append, no spawn.
1763
+ const mechanism = laneDeliveryMechanism(lane);
1764
+ if (mechanism === "none") {
1765
+ const message = deliveryRefusalReason(lane);
1766
+ throw new LanesError("lane_not_accepting_messages", message, {
1767
+ lane_id: laneId,
1768
+ state: lane.state,
1769
+ provider: lane.provider,
1770
+ message,
1771
+ });
1772
+ }
1258
1773
  const messageId = mintMessageId();
1259
1774
  const at = ctx.now().toISOString();
1260
- const drop = path.join(runDir(hqRoot, laneId), "dropbox", "inbox.jsonl");
1261
- ensureDir(path.dirname(drop));
1262
1775
  const projectId = opts.project?.trim() || lane.project_id;
1263
1776
  const storyId = opts.story?.trim() || lane.story_id;
1264
- fs.appendFileSync(drop, `${JSON.stringify({ at, message_id: messageId, text, project_id: projectId, story_id: storyId })}\n`, "utf8");
1265
- let next = lane;
1777
+ const dropboxMessage = {
1778
+ at,
1779
+ message_id: messageId,
1780
+ text,
1781
+ project_id: projectId,
1782
+ story_id: storyId,
1783
+ };
1784
+ let resumed = null;
1785
+ if (mechanism === "dropbox") {
1786
+ // Live (or about-to-be-live) process: the PreToolUse drain delivers it.
1787
+ if (!laneAcceptsOperatorMessage(lane)) {
1788
+ throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
1789
+ }
1790
+ appendDropboxMessage(hqRoot, laneId, dropboxMessage);
1791
+ }
1792
+ else {
1793
+ // Exited Claude worker with a recorded session: the message becomes the
1794
+ // next turn of that session. It is archived under delivered/ (never the
1795
+ // inbox, which would hand it to the drain hook a second time) and the
1796
+ // spawn happens before any attribution write so a failed spawn leaves
1797
+ // the record untouched.
1798
+ archiveResumeMessage(hqRoot, laneId, dropboxMessage);
1799
+ resumed = spawnLaneWorker(hqRoot, lane, {
1800
+ env: ctx.env,
1801
+ existsSync: ctx.existsSync,
1802
+ spawnProcess: ctx.spawnProcess,
1803
+ proveDetached: ctx.proveDetached,
1804
+ killProcess: ctx.killProcess,
1805
+ now: ctx.now,
1806
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
1807
+ runCompaction: ctx.runCompaction,
1808
+ locateTranscript: ctx.locateTranscript,
1809
+ }, {
1810
+ trigger: { kind: "message", text, message_id: messageId },
1811
+ compact: opts.compact !== false,
1812
+ });
1813
+ }
1814
+ // Every accepted message moves the compaction reference forward.
1815
+ let next = casUpdateLane(hqRoot, laneId, (current) => ({
1816
+ ...current,
1817
+ last_message_at: at,
1818
+ }));
1266
1819
  if (projectId !== lane.project_id || storyId !== lane.story_id) {
1267
1820
  next = casUpdateLane(hqRoot, laneId, (current) => {
1268
1821
  const already = current.attribution_history.some((entry) => entry.message_id === messageId);
@@ -1281,16 +1834,171 @@ async function runMessage(ctx, laneId, opts) {
1281
1834
  };
1282
1835
  });
1283
1836
  }
1837
+ // What this command can claim when it returns: the message is durably
1838
+ // written (accepted). Delivery to the model is asynchronous in both
1839
+ // mechanisms. `dropbox` delivers on the worker's next tool call, and only
1840
+ // if the drain hook is registered in the account home the worker was
1841
+ // started from; `resume` has started a new worker with the message as its
1842
+ // prompt. Neither is "delivered", so the response never says so.
1843
+ const drainHook = drainHookStatusFor(ctx, next);
1844
+ const deliveryStatus = mechanism === "dropbox" ? "awaiting_next_tool_call" : "worker_spawned";
1845
+ const warning = mechanism === "dropbox" && drainHook !== null && !drainHook.registered
1846
+ ? `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
+ : mechanism === "dropbox" && next.stalled === true
1848
+ ? `The worker has made no tool call since ${next.stalled_at ?? "an unknown time"} (${next.stalled_reason ?? "stalled"}); delivery waits on its next tool call.`
1849
+ : null;
1284
1850
  printJson({
1285
1851
  ok: true,
1286
1852
  action: "message",
1287
1853
  lane_id: laneId,
1288
1854
  message_id: messageId,
1855
+ accepted: true,
1856
+ delivered: false,
1857
+ delivery: mechanism,
1858
+ delivery_status: deliveryStatus,
1859
+ inbox_path: mechanism === "dropbox" ? dropboxInboxPath(hqRoot, laneId) : null,
1860
+ drain_hook: drainHook,
1861
+ warning,
1862
+ resumed_session_id: resumed?.last_resume?.session_id ?? null,
1863
+ compaction: resumed?.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
1864
+ ? resumed.last_compaction
1865
+ : null,
1289
1866
  project_id: next.project_id,
1290
1867
  story_id: next.story_id,
1291
1868
  lane: next,
1292
1869
  });
1293
1870
  }
1871
+ /**
1872
+ * `hq lanes resume <lane-id> [--text <msg>]`: the explicit opt-in that
1873
+ * continues a lane in `interrupted`, `done` or `blocked` on its recorded
1874
+ * provider session. `hq lanes message` keeps refusing `interrupted`; this
1875
+ * command is the path its refusal names. The launch is the same
1876
+ * `spawnLaneWorker` a message resume uses, so the resume-time compaction
1877
+ * rule and the watcher's mesh events are the same. The record gains
1878
+ * `last_resume` with `trigger: "resume"`, `resumed_from` and the text.
1879
+ */
1880
+ async function runResume(ctx, laneId, opts) {
1881
+ const hqRoot = ctx.hqRoot();
1882
+ const lane = readLane(hqRoot, laneId);
1883
+ const refusal = resumeCommandRefusal(lane);
1884
+ if (refusal !== null) {
1885
+ throw new LanesError(refusal.error, refusal.message, {
1886
+ lane_id: laneId,
1887
+ state: lane.state,
1888
+ provider: lane.provider,
1889
+ message: refusal.message,
1890
+ });
1891
+ }
1892
+ const resumedFrom = lane.state;
1893
+ const text = opts.text?.trim() ? opts.text : undefined;
1894
+ const at = ctx.now().toISOString();
1895
+ const projectId = opts.project?.trim() || lane.project_id;
1896
+ const storyId = opts.story?.trim() || lane.story_id;
1897
+ const attributionChanged = projectId !== lane.project_id || storyId !== lane.story_id;
1898
+ 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
+ });
1918
+ if (text !== undefined && messageId !== undefined) {
1919
+ // Same audit copy a message resume leaves; nothing drains it. Archive only
1920
+ // after a successful spawn so a concurrent resume cannot leave a phantom.
1921
+ archiveResumeMessage(hqRoot, laneId, {
1922
+ at,
1923
+ message_id: messageId,
1924
+ text,
1925
+ project_id: projectId,
1926
+ story_id: storyId,
1927
+ resumed_from: resumedFrom,
1928
+ });
1929
+ }
1930
+ let next = resumed;
1931
+ if (text !== undefined) {
1932
+ // An operator text moves the compaction reference forward, as a message does.
1933
+ next = casUpdateLane(hqRoot, laneId, (current) => ({
1934
+ ...current,
1935
+ last_message_at: at,
1936
+ }));
1937
+ }
1938
+ if (messageId !== undefined && attributionChanged) {
1939
+ next = casUpdateLane(hqRoot, laneId, (current) => {
1940
+ const already = current.attribution_history.some((entry) => entry.message_id === messageId);
1941
+ if (already)
1942
+ return current;
1943
+ return {
1944
+ ...current,
1945
+ project_id: projectId,
1946
+ story_id: storyId,
1947
+ attribution_history: appendCappedHistory(current.attribution_history, {
1948
+ project_id: projectId,
1949
+ story_id: storyId,
1950
+ message_id: messageId,
1951
+ at,
1952
+ }),
1953
+ };
1954
+ });
1955
+ }
1956
+ printJson({
1957
+ ok: true,
1958
+ action: "resume",
1959
+ lane_id: laneId,
1960
+ resumed_from: resumedFrom,
1961
+ message_id: messageId ?? null,
1962
+ text: text ?? null,
1963
+ delivery: "resume",
1964
+ delivery_status: "worker_spawned",
1965
+ resumed_session_id: resumed.last_resume?.session_id ?? null,
1966
+ last_resume: next.last_resume ?? null,
1967
+ compaction: resumed.last_compaction && resumed.last_compaction.resume_at === resumed.last_resume?.at
1968
+ ? resumed.last_compaction
1969
+ : null,
1970
+ project_id: next.project_id,
1971
+ story_id: next.story_id,
1972
+ lane: next,
1973
+ });
1974
+ }
1975
+ /**
1976
+ * Registration state of the drain hook in the lane's account home, read the
1977
+ * way the provider reads it. Null when the lane has no bound account yet
1978
+ * (queued): spawn installs the hook before the worker starts.
1979
+ */
1980
+ function drainHookStatusFor(ctx, lane) {
1981
+ if (lane.account === undefined)
1982
+ return null;
1983
+ const hqRoot = ctx.hqRoot();
1984
+ const config = loadLanesConfig(hqRoot);
1985
+ const account = config.providers[lane.provider].accounts[lane.account];
1986
+ if (account === undefined)
1987
+ return null;
1988
+ const home = canonicalizeHome(account.home, {
1989
+ homedir: ctx.homedir,
1990
+ realpathSync: (p) => fs.realpathSync(p),
1991
+ existsSync: (p) => fs.existsSync(p),
1992
+ });
1993
+ const probe = probeDrainHookRegistration(lane.provider, home);
1994
+ return { registered: probe.registered, kind: probe.kind, path: probe.path };
1995
+ }
1996
+ /** Audit copy of a message delivered by resume. Not the inbox: nothing drains this. */
1997
+ function archiveResumeMessage(hqRoot, laneId, message) {
1998
+ const dir = path.join(dropboxDir(hqRoot, laneId), "delivered");
1999
+ fs.mkdirSync(dir, { recursive: true });
2000
+ fs.writeFileSync(path.join(dir, `${message.message_id}.resume.jsonl`), `${JSON.stringify(message)}\n`, "utf8");
2001
+ }
1294
2002
  async function runReparent(ctx, laneId, opts) {
1295
2003
  const hqRoot = ctx.hqRoot();
1296
2004
  if (!opts.senior) {
@@ -1368,6 +2076,12 @@ async function runReparent(ctx, laneId, opts) {
1368
2076
  pending_questions: pending.length,
1369
2077
  });
1370
2078
  }
2079
+ const CANONICAL_LOG_STREAMS = [
2080
+ "watcher.err",
2081
+ "watcher.out",
2082
+ "worker.err",
2083
+ "worker.out",
2084
+ ];
1371
2085
  async function runLogs(ctx, laneId, opts) {
1372
2086
  const hqRoot = ctx.hqRoot();
1373
2087
  readLane(hqRoot, laneId);
@@ -1376,24 +2090,37 @@ async function runLogs(ctx, laneId, opts) {
1376
2090
  if (tail === 0) {
1377
2091
  throw new LanesError("invalid_integer", "--tail must be a positive integer", { flag: "--tail", value: opts.tail });
1378
2092
  }
1379
- if (!fs.existsSync(logsDir)) {
1380
- printJson({ ok: true, action: "logs", lane_id: laneId, lines: [] });
1381
- return;
1382
- }
1383
- const files = fs
1384
- .readdirSync(logsDir)
1385
- .filter((name) => !name.startsWith("."))
1386
- .sort();
1387
- const lines = [];
1388
- for (const name of files) {
1389
- const text = fs.readFileSync(path.join(logsDir, name), "utf8");
1390
- for (const line of text.split("\n")) {
1391
- if (line.length > 0)
1392
- lines.push(line);
2093
+ const names = new Set(CANONICAL_LOG_STREAMS);
2094
+ if (fs.existsSync(logsDir)) {
2095
+ for (const name of fs.readdirSync(logsDir)) {
2096
+ if (!name.startsWith("."))
2097
+ names.add(name);
1393
2098
  }
1394
2099
  }
1395
- const sliced = tail === undefined ? lines : lines.slice(-tail);
1396
- printJson({ ok: true, action: "logs", lane_id: laneId, lines: sliced });
2100
+ const streams = [...names].sort().map((name) => {
2101
+ const filePath = path.join(logsDir, name);
2102
+ let bytes = 0;
2103
+ let text = "";
2104
+ try {
2105
+ const st = fs.statSync(filePath);
2106
+ if (st.isFile()) {
2107
+ bytes = st.size;
2108
+ text = fs.readFileSync(filePath, "utf8");
2109
+ }
2110
+ }
2111
+ catch (err) {
2112
+ const code = err.code;
2113
+ if (code !== "ENOENT")
2114
+ throw err;
2115
+ }
2116
+ const lines = text.split("\n").filter((line) => line.length > 0);
2117
+ const sliced = tail === undefined ? lines : lines.slice(-tail);
2118
+ return { name, path: filePath, bytes, lines: sliced };
2119
+ });
2120
+ printJson({ ok: true, action: "logs", lane_id: laneId, streams });
2121
+ }
2122
+ export function stopCommittedState(state) {
2123
+ return state === "queued" ? "cancelled" : "killed";
1397
2124
  }
1398
2125
  async function runStop(ctx, laneId) {
1399
2126
  const hqRoot = ctx.hqRoot();
@@ -1401,20 +2128,24 @@ async function runStop(ctx, laneId) {
1401
2128
  if (isTerminalLaneState(lane.state)) {
1402
2129
  throw new LanesError("already_terminal", `Lane ${laneId} is already ${lane.state}`, { lane_id: laneId, state: lane.state });
1403
2130
  }
1404
- const nextState = lane.state === "queued" ? "cancelled" : "killed";
1405
2131
  const at = ctx.now().toISOString();
1406
2132
  const updated = casUpdateLane(hqRoot, laneId, (current) => {
1407
2133
  if (isTerminalLaneState(current.state)) {
1408
- if (current.state === nextState)
1409
- return current;
1410
2134
  throw new LanesError("already_terminal", `Lane ${laneId} is already ${current.state}`, { lane_id: laneId, state: current.state });
1411
2135
  }
1412
2136
  return {
1413
2137
  ...current,
1414
- state: nextState,
2138
+ state: stopCommittedState(current.state),
1415
2139
  queued_reason: undefined,
1416
2140
  };
1417
2141
  });
2142
+ if (updated.state === "killed") {
2143
+ stopLaneProcesses(hqRoot, laneId, {
2144
+ killProcess: ctx.killProcess,
2145
+ isAlive: ctx.isAlive,
2146
+ sleepMs: ctx.stopSleepMs,
2147
+ });
2148
+ }
1418
2149
  const cancelled = cancelPendingQuestionsForLane(hqRoot, laneId, at);
1419
2150
  printJson({
1420
2151
  ok: true,
@@ -1447,49 +2178,20 @@ async function runAsk(ctx, opts) {
1447
2178
  if (opts.recommended !== undefined)
1448
2179
  body.recommended = opts.recommended;
1449
2180
  const at = ctx.now().toISOString();
1450
- const store = bodyStoreForLane(lane);
1451
2181
  let questionId;
1452
2182
  let lastErr;
1453
- const attempts = ASK_BACKOFF_MS.length + 1;
2183
+ const attempts = ASK_ATTEMPTS;
1454
2184
  for (let i = 0; i < attempts; i += 1) {
1455
2185
  try {
1456
- if (!questionId) {
1457
- const created = createQuestion(hqRoot, {
1458
- lane_id: lane.lane_id,
1459
- company: lane.company,
1460
- project_id: lane.project_id,
1461
- story_id: lane.story_id,
1462
- thread_id: lane.thread_id,
1463
- raised_via: "cli",
1464
- asked_at: at,
1465
- origin: { kind: "lane", id: lane.lane_id },
1466
- hop: 1,
1467
- status: "pending",
1468
- body: { store, ref: "", digest: "" },
1469
- routing_history: [],
1470
- });
1471
- questionId = created.question_id;
1472
- }
1473
- const stored = writeQuestionBody(hqRoot, {
1474
- company: lane.company,
1475
- question_id: questionId,
1476
- body: { store, ref: "", digest: "" },
1477
- }, body);
1478
- const withBody = casUpdateQuestion(hqRoot, questionId, (current) => {
1479
- const asked = current.routing_history.some((entry) => entry.reason === "asked");
1480
- return {
1481
- ...current,
1482
- body: stored,
1483
- routing_history: asked
1484
- ? current.routing_history
1485
- : appendCappedHistory(current.routing_history, {
1486
- hop: 1,
1487
- audience: lane.senior,
1488
- at,
1489
- reason: "asked",
1490
- }),
1491
- };
2186
+ const withBody = raiseLaneQuestion({
2187
+ hqRoot,
2188
+ lane,
2189
+ body,
2190
+ raisedVia: "cli",
2191
+ at,
2192
+ questionId,
1492
2193
  });
2194
+ questionId = withBody.question_id;
1493
2195
  await emitLaneMeshEvent(ctx, lane.company, lane.thread_id, "lane_question", {
1494
2196
  lane_id: lane.lane_id,
1495
2197
  question_id: withBody.question_id,
@@ -1513,9 +2215,10 @@ async function runAsk(ctx, opts) {
1513
2215
  }
1514
2216
  catch (err) {
1515
2217
  lastErr = err;
2218
+ process.stderr.write(`hq lanes ask: attempt ${i + 1}/${attempts} failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
1516
2219
  if (i === attempts - 1)
1517
2220
  break;
1518
- await ctx.sleep(ASK_BACKOFF_MS[i] ?? 400);
2221
+ await ctx.sleep(ASK_BACKOFF_MS[i] ?? ASK_BACKOFF_MS[ASK_BACKOFF_MS.length - 1]);
1519
2222
  }
1520
2223
  }
1521
2224
  const failure = {
@@ -1556,7 +2259,7 @@ async function runQuestionsList(ctx, opts) {
1556
2259
  return false;
1557
2260
  if (opts.story && q.story_id !== opts.story)
1558
2261
  return false;
1559
- if (company && q.company.uid !== company.uid && q.company.slug !== company.slug) {
2262
+ if (company && !recordMatchesCompany(q.company, company)) {
1560
2263
  return false;
1561
2264
  }
1562
2265
  if (forFilter) {
@@ -1569,6 +2272,7 @@ async function runQuestionsList(ctx, opts) {
1569
2272
  .map((q) => ({
1570
2273
  ...q,
1571
2274
  audience: resolveAudience(hqRoot, q, ctx),
2275
+ ...(q.question_kind === "continue" ? { flag: "continue-loop" } : {}),
1572
2276
  }));
1573
2277
  printJson({ ok: true, action: "questions.list", questions });
1574
2278
  }
@@ -1586,14 +2290,20 @@ async function runQuestionsShow(ctx, questionId) {
1586
2290
  }
1587
2291
  async function runQuestionsAnswer(ctx, questionId, opts) {
1588
2292
  const hqRoot = ctx.hqRoot();
1589
- if (!opts.choice) {
1590
- throw new LanesError("missing_choice", "--choice is required", {});
1591
- }
1592
2293
  const question = readQuestion(hqRoot, questionId);
1593
2294
  throwIfQuestionNotPending(question);
1594
2295
  await yieldEventLoop();
1595
2296
  const body = requireQuestionBody(hqRoot, question);
1596
- const choice = resolveAnswerChoice(opts.choice, body.options, Boolean(opts.freeform));
2297
+ // A trimmed-empty --freeform is absent: it must not win over --choice and
2298
+ // must not become a freeform answer of "".
2299
+ const freeform = opts.freeform?.trim() || undefined;
2300
+ if (freeform !== undefined && body.options.length === 0) {
2301
+ throw new LanesError("freeform_not_allowed", "--freeform is only allowed for a question with options", { question_id: questionId });
2302
+ }
2303
+ if (!freeform && !opts.choice) {
2304
+ throw new LanesError("missing_choice", "--choice or --freeform is required", {});
2305
+ }
2306
+ const choice = freeform ?? resolveAnswerChoice(opts.choice, body.options, false);
1597
2307
  const at = ctx.now().toISOString();
1598
2308
  const answeredBy = sessionIdFromEnv(ctx.env)
1599
2309
  ? `session:${sessionIdFromEnv(ctx.env)}`
@@ -1616,6 +2326,7 @@ async function runQuestionsAnswer(ctx, questionId, opts) {
1616
2326
  status: "answered",
1617
2327
  answer: {
1618
2328
  choice,
2329
+ answer_kind: freeform ? "freeform" : "option",
1619
2330
  notes_ref: notesRef,
1620
2331
  answered_by: answeredBy,
1621
2332
  answered_at: at,
@@ -1623,7 +2334,49 @@ async function runQuestionsAnswer(ctx, questionId, opts) {
1623
2334
  },
1624
2335
  };
1625
2336
  });
1626
- printJson({ ok: true, action: "questions.answer", question: updated });
2337
+ const delivery = deliverAnswerToAskingLane(hqRoot, updated, choice, at);
2338
+ printJson({
2339
+ ok: true,
2340
+ action: "questions.answer",
2341
+ question: updated,
2342
+ delivery: delivery.status,
2343
+ });
2344
+ }
2345
+ function writeAnswerDeliveryFailed(hqRoot, laneId, questionId, err, at) {
2346
+ const failure = {
2347
+ schema_version: "hq-lane-ask-delivery-failed.v1",
2348
+ at,
2349
+ lane_id: laneId,
2350
+ question_id: questionId,
2351
+ error: err instanceof Error
2352
+ ? { name: err.name, message: err.message }
2353
+ : { name: "Error", message: String(err) },
2354
+ };
2355
+ try {
2356
+ writeJsonFile(askDeliveryFailedPath(hqRoot, laneId), failure);
2357
+ }
2358
+ catch (writeErr) {
2359
+ process.stderr.write(`hq lanes: failed to write ask_delivery_failed for ${laneId}: ${writeErr instanceof Error ? writeErr.message : String(writeErr)}\n`);
2360
+ }
2361
+ }
2362
+ /**
2363
+ * Record is already durable. Delivery is best-effort onto the asking lane's
2364
+ * drop box (spec §6.5). Target is question.lane_id, never a project/story lookup.
2365
+ */
2366
+ function deliverAnswerToAskingLane(hqRoot, question, choice, at) {
2367
+ const laneId = laneIdForAnswerDelivery(question);
2368
+ try {
2369
+ const lane = readLane(hqRoot, laneId);
2370
+ if (!laneAcceptsDropboxDelivery(lane)) {
2371
+ return { status: "recorded_only" };
2372
+ }
2373
+ appendDropboxMessage(hqRoot, laneId, answerDropboxMessage(question, choice, at, mintMessageId()));
2374
+ return { status: "dropped" };
2375
+ }
2376
+ catch (err) {
2377
+ writeAnswerDeliveryFailed(hqRoot, laneId, question.question_id, err, at);
2378
+ return { status: "failed" };
2379
+ }
1627
2380
  }
1628
2381
  async function runQuestionsEscalate(ctx, questionId, opts) {
1629
2382
  const hqRoot = ctx.hqRoot();
@@ -1705,10 +2458,45 @@ async function runAccountsList(ctx, opts) {
1705
2458
  const providers = opts.provider
1706
2459
  ? [parseLaneProvider(opts.provider)]
1707
2460
  : [...LANE_PROVIDERS];
1708
- const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config));
1709
- printJson({ ok: true, action: "accounts.list", accounts });
2461
+ // model and effort are what a spawn on each account would start from
2462
+ // (spec: --model/--effort, then the account, then the provider).
2463
+ const quota = loadQuotaState(hqRoot);
2464
+ const accounts = providers.flatMap((provider) => listAccountStatuses(provider, config).map((status) => {
2465
+ const accountCfg = config.providers[provider].accounts[status.id];
2466
+ const model = resolveLaneModel({
2467
+ accountModel: accountCfg?.model,
2468
+ providerModel: config.providers[provider].model,
2469
+ });
2470
+ const effort = resolveLaneEffort({
2471
+ accountEffort: accountCfg?.effort,
2472
+ providerEffort: config.providers[provider].effort,
2473
+ model,
2474
+ });
2475
+ return {
2476
+ ...status,
2477
+ model: accountCfg?.model ?? null,
2478
+ effort: accountCfg?.effort ?? null,
2479
+ effective_model: model ?? null,
2480
+ effective_effort: effort.effort ?? null,
2481
+ effective_effort_source: effort.source,
2482
+ ...accountQuotaView(quota, provider, status.id),
2483
+ };
2484
+ }));
2485
+ const providerDefaults = providers.map((provider) => ({
2486
+ provider,
2487
+ model: config.providers[provider].model ?? null,
2488
+ effort: config.providers[provider].effort ?? null,
2489
+ compact_after_ms: providerCompactAfterMs(config.providers[provider]),
2490
+ }));
2491
+ printJson({
2492
+ ok: true,
2493
+ action: "accounts.list",
2494
+ providers: providerDefaults,
2495
+ accounts,
2496
+ });
1710
2497
  }
1711
2498
  async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
2499
+ const callerCwd = ctx.callerCwd();
1712
2500
  const hqRoot = ctx.hqRoot();
1713
2501
  const provider = parseLaneProvider(providerRaw);
1714
2502
  const config = loadLanesConfig(hqRoot);
@@ -1720,7 +2508,10 @@ async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
1720
2508
  (id === "a"
1721
2509
  ? DEFAULT_ACCOUNT_HOMES[provider]
1722
2510
  : `~/.${provider}-${id}`);
1723
- const home = canonicalizeHome(homeRaw, {
2511
+ const homeResolved = homeRaw.startsWith("~") || path.isAbsolute(homeRaw)
2512
+ ? homeRaw
2513
+ : resolveCallerPath(callerCwd, homeRaw);
2514
+ const home = canonicalizeHome(homeResolved, {
1724
2515
  homedir: ctx.homedir,
1725
2516
  realpathSync: (p) => fs.realpathSync(p),
1726
2517
  existsSync: (p) => fs.existsSync(p),
@@ -1734,13 +2525,18 @@ async function runAccountsLogin(ctx, providerRaw, accountId, opts) {
1734
2525
  }
1735
2526
  ensureDir(home);
1736
2527
  const defaultId = config.providers[provider].default_account;
1737
- const defaultHome = config.providers[provider].accounts[defaultId]?.home ??
2528
+ const defaultHome = (defaultId !== undefined
2529
+ ? config.providers[provider].accounts[defaultId]?.home
2530
+ : undefined) ??
2531
+ Object.values(config.providers[provider].accounts).find((account) => account.enabled)
2532
+ ?.home ??
1738
2533
  canonicalizeHome(DEFAULT_ACCOUNT_HOMES[provider], {
1739
2534
  homedir: ctx.homedir,
1740
2535
  realpathSync: (p) => fs.realpathSync(p),
1741
2536
  existsSync: (p) => fs.existsSync(p),
1742
2537
  });
1743
2538
  const copied = defaultHome !== home ? copyHookRails(defaultHome, home) : railsPresent(home);
2539
+ installDrainHook(provider, home);
1744
2540
  const envVar = PROVIDER_HOME_ENV[provider];
1745
2541
  const login = VENDOR_LOGIN[provider];
1746
2542
  const result = await ctx.spawnLogin({
@@ -1814,7 +2610,13 @@ async function runAccountsDisable(ctx, providerRaw, accountId, opts) {
1814
2610
  async function runSessionsList(ctx, opts) {
1815
2611
  const hqRoot = ctx.hqRoot();
1816
2612
  const state = opts.state ? parseLivenessState(opts.state) : undefined;
1817
- const rows = listSessionLiveness(hqRoot).filter((row) => {
2613
+ const livenessDeps = livenessDepsFromCtx(ctx);
2614
+ const presenceConnected = ctx.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
2615
+ const rows = listSessionLiveness(hqRoot, {
2616
+ nowMs: ctx.now().getTime(),
2617
+ presenceConnected,
2618
+ deps: livenessDeps,
2619
+ }).filter((row) => {
1818
2620
  if (state && row.state !== state)
1819
2621
  return false;
1820
2622
  if (!opts.company)
@@ -1828,7 +2630,13 @@ async function runSessionsList(ctx, opts) {
1828
2630
  }
1829
2631
  async function runSessionsShow(ctx, sessionId) {
1830
2632
  const hqRoot = ctx.hqRoot();
1831
- const state = sessionLiveness(hqRoot, sessionId);
2633
+ const livenessDeps = livenessDepsFromCtx(ctx);
2634
+ const presenceConnected = ctx.presenceConnected ?? readDaemonPresenceConnected(livenessDeps);
2635
+ const state = sessionLiveness(hqRoot, sessionId, {
2636
+ nowMs: ctx.now().getTime(),
2637
+ presenceConnected,
2638
+ deps: livenessDeps,
2639
+ });
1832
2640
  if (state === null) {
1833
2641
  throw new LanesError("session_not_found", `Session ${sessionId} is not in the liveness index`, { session_id: sessionId });
1834
2642
  }
@@ -1842,21 +2650,104 @@ async function runSessionsShow(ctx, sessionId) {
1842
2650
  company_slug: typeof meta?.company_slug === "string" ? meta.company_slug : null,
1843
2651
  });
1844
2652
  }
2653
+ async function runReconcile(ctx, opts) {
2654
+ const fromEnv = ctx.env.HQ_ROOT?.trim();
2655
+ const hqRoot = fromEnv && ctx.existsSync(fromEnv) ? fromEnv : ctx.hqRoot();
2656
+ const summary = await runReconcilePass(hqRoot, {
2657
+ now: ctx.now,
2658
+ host: reconcileHostFromCtx(ctx),
2659
+ dryRun: Boolean(opts.dryRun),
2660
+ spawnLaneDeps: {
2661
+ env: ctx.env,
2662
+ existsSync: ctx.existsSync,
2663
+ spawnProcess: ctx.spawnProcess,
2664
+ proveDetached: ctx.proveDetached,
2665
+ killProcess: ctx.killProcess,
2666
+ now: ctx.now,
2667
+ resolveMachineIdentity: ctx.resolveMachineIdentity,
2668
+ runCompaction: ctx.runCompaction,
2669
+ locateTranscript: ctx.locateTranscript,
2670
+ },
2671
+ throttleDeps: ctx.throttleDeps,
2672
+ livenessDeps: livenessDepsFromCtx(ctx),
2673
+ presenceConnected: ctx.presenceConnected,
2674
+ env: ctx.env,
2675
+ });
2676
+ printJson(summary);
2677
+ if (!summary.ok) {
2678
+ process.exit(1);
2679
+ }
2680
+ }
2681
+ async function runReconcileInstall(ctx, opts) {
2682
+ const callerCwd = ctx.callerCwd();
2683
+ const systemdRootRaw = opts.systemdRoot ?? ctx.systemdRoot ?? defaultSystemdUserDir(ctx.homedir());
2684
+ const systemdRoot = opts.systemdRoot !== undefined
2685
+ ? resolveCallerPath(callerCwd, opts.systemdRoot)
2686
+ : systemdRootRaw;
2687
+ const config = loadLanesConfig(ctx.hqRoot());
2688
+ const activeProviders = LANE_PROVIDERS.filter((provider) => Object.values(config.providers[provider].accounts).some((account) => account.enabled));
2689
+ const providerPath = providerPathEnv(activeProviders, ctx.env.PATH);
2690
+ const result = installReconcileTimer({
2691
+ systemdRoot,
2692
+ hqRoot: ctx.hqRoot(),
2693
+ enable: Boolean(opts.enable),
2694
+ runCommand: opts.enable ? ctx.runSystemctl : undefined,
2695
+ pathEnv: providerPath.path,
2696
+ });
2697
+ printJson({
2698
+ ...result,
2699
+ providers_resolved: providerPath.resolved,
2700
+ providers_unresolved: providerPath.unresolved,
2701
+ ...(providerPath.unresolved.length > 0
2702
+ ? {
2703
+ warnings: providerPath.unresolved.map((provider) => `provider binary ${provider} is not on PATH; lanes for ${provider} stay queued with provider_binary_missing until it is installed and the timer is reinstalled`),
2704
+ }
2705
+ : {}),
2706
+ });
2707
+ }
2708
+ async function runReconcileUninstall(ctx, opts) {
2709
+ const callerCwd = ctx.callerCwd();
2710
+ const systemdRootRaw = opts.systemdRoot ?? ctx.systemdRoot ?? defaultSystemdUserDir(ctx.homedir());
2711
+ const systemdRoot = opts.systemdRoot !== undefined
2712
+ ? resolveCallerPath(callerCwd, opts.systemdRoot)
2713
+ : systemdRootRaw;
2714
+ const result = uninstallReconcileTimer({
2715
+ systemdRoot,
2716
+ disable: Boolean(opts.disable),
2717
+ runCommand: opts.disable ? ctx.runSystemctl : undefined,
2718
+ });
2719
+ printJson(result);
2720
+ }
2721
+ function commandHasAction(cmd) {
2722
+ return (typeof cmd._actionHandler ===
2723
+ "function");
2724
+ }
2725
+ /** Spec §7: every command prints JSON. Accept `--json` as a no-op everywhere. */
2726
+ function attachJsonNoop(cmd) {
2727
+ const hasJson = (cmd.options ?? []).some((opt) => opt.long === "--json");
2728
+ if (commandHasAction(cmd) && !hasJson) {
2729
+ cmd.option("--json", "JSON on stdout (always)");
2730
+ }
2731
+ for (const child of cmd.commands)
2732
+ attachJsonNoop(child);
2733
+ }
1845
2734
  export function registerLanesCommand(parent, deps = {}) {
1846
2735
  const ctx = resolveDeps(deps);
1847
2736
  const lanes = parent
1848
- .command("lanes")
2737
+ .command("lanes", { hidden: true })
1849
2738
  .description("Create, supervise, and steer worker lanes; routed decision queue");
1850
2739
  lanes
1851
2740
  .command("create")
1852
2741
  .description("Register a lane, run admission, and return immediately")
1853
- .requiredOption("--company <slug|uid>", "Company slug or cloud uid")
2742
+ .option("--company <slug|uid>", "Company slug or cloud uid; omit for a core/personal lane")
1854
2743
  .requiredOption("--project <slug>", "HQ project slug")
1855
2744
  .requiredOption("--story <id>", "Story id from the project's prd.json")
1856
2745
  .requiredOption("--brief-file <path>", "Path to the lane brief")
1857
2746
  .option("--worker <name>", "HQ worker id from the registry")
1858
2747
  .option("--provider <name>", "claude | codex | grok")
1859
2748
  .option("--account <id>", "Provider account id")
2749
+ .option("--model <id>", "Model id for the provider CLI")
2750
+ .option("--effort <level>", "Effort level; claude: low|medium|high|xhigh|max, codex: minimal|low|medium|high|xhigh|max|ultra, grok: low|medium|high|xhigh")
1860
2751
  .option("--senior <ref>", "session:<id> | lane:<id> | user")
1861
2752
  .option("--timeout <secs>", "Deadline in seconds (warns, does not kill)")
1862
2753
  .option("--priority <n>", "Queue priority; higher runs first", "1")
@@ -1872,6 +2763,7 @@ export function registerLanesCommand(parent, deps = {}) {
1872
2763
  .option("--story <id>", "Filter by story")
1873
2764
  .option("--state <state>", `Filter by state (${LANE_STATES.join("|")})`)
1874
2765
  .option("--senior <ref>", "Filter by senior ref")
2766
+ .option("--json", "JSON on stdout (always)")
1875
2767
  .action(wrap(async (opts) => {
1876
2768
  await runList(ctx, opts);
1877
2769
  }));
@@ -1889,9 +2781,21 @@ export function registerLanesCommand(parent, deps = {}) {
1889
2781
  .requiredOption("--text <msg>", "Message text")
1890
2782
  .option("--project <slug>", "Re-point project attribution from this message")
1891
2783
  .option("--story <id>", "Re-point story attribution from this message")
2784
+ .option("--no-compact", "Skip the resume-time compaction check for this message")
1892
2785
  .action(wrap(async (laneId, opts) => {
1893
2786
  await runMessage(ctx, laneId, opts);
1894
2787
  }));
2788
+ lanes
2789
+ .command("resume")
2790
+ .description("Resume an interrupted, done or blocked lane on its recorded provider session")
2791
+ .argument("<lane-id>", "Lane id")
2792
+ .option("--text <msg>", "Operator message for the resumed turn")
2793
+ .option("--project <slug>", "Re-point project attribution from this resume")
2794
+ .option("--story <id>", "Re-point story attribution from this resume")
2795
+ .option("--no-compact", "Skip the resume-time compaction check for this resume")
2796
+ .action(wrap(async (laneId, opts) => {
2797
+ await runResume(ctx, laneId, opts);
2798
+ }));
1895
2799
  lanes
1896
2800
  .command("reparent")
1897
2801
  .description("Rewrite a lane's senior edge")
@@ -1952,9 +2856,9 @@ export function registerLanesCommand(parent, deps = {}) {
1952
2856
  .command("answer")
1953
2857
  .description("Answer a pending question")
1954
2858
  .argument("<question-id>", "Question id")
1955
- .requiredOption("--choice <label|N>", "1-based index or option label")
2859
+ .option("--choice <label|N>", "1-based index or option label")
1956
2860
  .option("--notes <text>", "Free-text notes stored with the body")
1957
- .option("--freeform", "Allow a choice that is not on the menu")
2861
+ .option("--freeform <text>", "Supply a free-form answer for a question with options")
1958
2862
  .action(wrap(async (questionId, opts) => {
1959
2863
  await runQuestionsAnswer(ctx, questionId, opts);
1960
2864
  }));
@@ -2028,5 +2932,38 @@ export function registerLanesCommand(parent, deps = {}) {
2028
2932
  .action(wrap(async (sessionId) => {
2029
2933
  await runSessionsShow(ctx, sessionId);
2030
2934
  }));
2935
+ lanes
2936
+ .command("pickup", { hidden: true })
2937
+ .description("Ingest an Outpost-dispatched lane payload and start it on this host")
2938
+ .option("--payload-file <path>", "Local pickup payload JSON")
2939
+ .option("--payload-url <url>", "HTTPS URL of a staged pickup payload")
2940
+ .action(wrap(async (opts) => {
2941
+ await runPickup(ctx, opts);
2942
+ }));
2943
+ const reconcile = lanes
2944
+ .command("reconcile", { hidden: true })
2945
+ .description("Run one machine-owned reconcile pass and exit")
2946
+ .option("--dry-run", "Report what would change and write nothing")
2947
+ .action(wrap(async (opts) => {
2948
+ await runReconcile(ctx, opts);
2949
+ }));
2950
+ reconcile
2951
+ .command("install")
2952
+ .description("Write systemd user unit and timer files (idempotent)")
2953
+ .option("--systemd-root <dir>", "Directory for unit files (default ~/.config/systemd/user)")
2954
+ .option("--enable", "Enable the timer via systemctl --user")
2955
+ .action(wrap(async (opts) => {
2956
+ await runReconcileInstall(ctx, opts);
2957
+ }));
2958
+ reconcile
2959
+ .command("uninstall")
2960
+ .description("Remove systemd user unit and timer files (idempotent)")
2961
+ .option("--systemd-root <dir>", "Directory for unit files")
2962
+ .option("--disable", "Disable the timer via systemctl --user")
2963
+ .action(wrap(async (opts) => {
2964
+ await runReconcileUninstall(ctx, opts);
2965
+ }));
2966
+ attachJsonNoop(lanes);
2967
+ return lanes;
2031
2968
  }
2032
2969
  //# sourceMappingURL=lanes.js.map