@indigoai-us/hq-cli 5.300.0 → 5.302.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 (99) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +99 -12
  3. package/assets/scaffold/core/scripts/token-usage-report.sh +9 -0
  4. package/dist/command-catalog.generated.d.ts +32 -3
  5. package/dist/command-catalog.generated.js +39 -3
  6. package/dist/commands/__fixtures__/access-vault.d.ts +2 -0
  7. package/dist/commands/__fixtures__/access-vault.js +1 -1
  8. package/dist/commands/access.d.ts +2 -1
  9. package/dist/commands/access.js +58 -49
  10. package/dist/commands/bot.js +1 -0
  11. package/dist/commands/cloud-provision.js +51 -2
  12. package/dist/commands/core-checkpoint.js +1 -0
  13. package/dist/commands/core-company-index-scope-flag.d.ts +3 -0
  14. package/dist/commands/core-company-index-scope-flag.js +6 -0
  15. package/dist/commands/core-handoff-fast.js +1 -0
  16. package/dist/commands/core-handoff.js +2 -0
  17. package/dist/commands/core-job-native-utils.d.ts +6 -0
  18. package/dist/commands/core-job-native-utils.js +80 -0
  19. package/dist/commands/core-job-native.d.ts +2 -4
  20. package/dist/commands/core-job-native.js +6 -10
  21. package/dist/commands/core-job-notify-native.d.ts +36 -0
  22. package/dist/commands/core-job-notify-native.js +359 -0
  23. package/dist/commands/core-job-remediate-native.d.ts +26 -0
  24. package/dist/commands/core-job-remediate-native.js +374 -0
  25. package/dist/commands/core.js +26 -8
  26. package/dist/commands/deploy-comments.js +5 -2
  27. package/dist/commands/files.js +31 -14
  28. package/dist/commands/integrations-api.d.ts +6 -6
  29. package/dist/commands/integrations-api.js +4 -13
  30. package/dist/commands/integrations-connect.d.ts +3 -0
  31. package/dist/commands/integrations-connect.js +21 -2
  32. package/dist/commands/integrations-manage.js +1 -4
  33. package/dist/commands/lanes-message.js +43 -5
  34. package/dist/commands/lanes-questions.d.ts +4 -2
  35. package/dist/commands/lanes-questions.js +123 -2
  36. package/dist/commands/lanes-resume.js +25 -4
  37. package/dist/commands/lanes-shared.d.ts +15 -1
  38. package/dist/commands/lanes-watch.js +5 -1
  39. package/dist/commands/lanes.d.ts +2 -0
  40. package/dist/commands/lanes.js +92 -9
  41. package/dist/commands/meetings.js +35 -16
  42. package/dist/commands/monitor.js +17 -2
  43. package/dist/commands/report.js +5 -2
  44. package/dist/commands/rescue.d.ts +4 -0
  45. package/dist/commands/rescue.js +27 -17
  46. package/dist/commands/safe-extract.d.ts +4 -4
  47. package/dist/commands/safe-extract.js +6 -7
  48. package/dist/lib/agent-kit/fallback.d.ts +1 -0
  49. package/dist/lib/agent-kit/fallback.js +1 -0
  50. package/dist/lib/bot/daemon.d.ts +5 -1
  51. package/dist/lib/bot/daemon.js +4 -4
  52. package/dist/lib/core-utils/soft-timeout.js +1 -1
  53. package/dist/lib/core-utils/token-usage-report.js +17 -3
  54. package/dist/lib/deploy-apps.d.ts +13 -0
  55. package/dist/lib/deploy-apps.js +24 -0
  56. package/dist/lib/index-render/companies.d.ts +4 -1
  57. package/dist/lib/index-render/companies.js +66 -3
  58. package/dist/lib/index-render/index.d.ts +1 -0
  59. package/dist/lib/index-render/index.js +2 -1
  60. package/dist/lib/index-render/projects.js +82 -5
  61. package/dist/lib/index-render/shared.d.ts +1 -1
  62. package/dist/lib/index-render/shared.js +4 -2
  63. package/dist/lib/lanes/delivery.d.ts +7 -3
  64. package/dist/lib/lanes/delivery.js +18 -8
  65. package/dist/lib/lanes/index.d.ts +1 -1
  66. package/dist/lib/lanes/keep-alive.d.ts +13 -3
  67. package/dist/lib/lanes/keep-alive.js +29 -5
  68. package/dist/lib/lanes/outpost-dispatch.d.ts +5 -0
  69. package/dist/lib/lanes/outpost-dispatch.js +12 -0
  70. package/dist/lib/lanes/reconcile.d.ts +1 -0
  71. package/dist/lib/lanes/reconcile.js +358 -38
  72. package/dist/lib/lanes/spawn.d.ts +1 -0
  73. package/dist/lib/lanes/spawn.js +20 -10
  74. package/dist/lib/lanes/store.js +24 -0
  75. package/dist/lib/lanes/story-ledger.d.ts +2 -0
  76. package/dist/lib/lanes/story-ledger.js +8 -0
  77. package/dist/lib/lanes/types.d.ts +26 -4
  78. package/dist/lib/lanes/types.js +33 -1
  79. package/dist/lib/lanes/watch.d.ts +1 -0
  80. package/dist/lib/lanes/watch.js +19 -6
  81. package/dist/lib/lanes/watcher.js +139 -9
  82. package/dist/lib/mesh/live/daemon/mqtt-events.d.ts +7 -0
  83. package/dist/lib/mesh/live/daemon/mqtt-events.js +2 -0
  84. package/dist/lib/mesh/live/daemon/mqtt-events.test-d.d.ts +2 -0
  85. package/dist/lib/mesh/live/daemon/mqtt-events.test-d.js +10 -0
  86. package/dist/lib/mesh/live/daemon/presence.d.ts +2 -2
  87. package/dist/lib/mesh/live/daemon/run.js +36 -2
  88. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +2 -2
  89. package/dist/lib/search-index/background.d.ts +1 -0
  90. package/dist/lib/search-index/background.js +1 -0
  91. package/dist/utils/self-update.js +51 -4
  92. package/dist/utils/staged-npm-install.d.ts +16 -0
  93. package/dist/utils/staged-npm-install.js +264 -0
  94. package/dist/utils/update-command-supervisor.cjs +54 -1
  95. package/dist/utils/version-gate.d.ts +8 -0
  96. package/dist/utils/version-gate.js +83 -16
  97. package/package.json +2 -2
  98. package/dist/lib/install-path-compatibility-flag.d.ts +0 -9
  99. package/dist/lib/install-path-compatibility-flag.js +0 -12
@@ -333,17 +333,25 @@ function archivedResumeMessageText(hqRoot, laneId, messageId) {
333
333
  return undefined;
334
334
  throw new LanesError("account_retry_context_unreadable", `Could not read the archived message needed to resume lane ${laneId}`, { lane_id: laneId, message_id: messageId });
335
335
  }
336
- const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
337
- if (lines.length !== 1) {
338
- throw new LanesError("account_retry_context_invalid", `The archived message needed to resume lane ${laneId} is malformed`, { lane_id: laneId, message_id: messageId });
339
- }
340
- let value;
341
- try {
342
- value = JSON.parse(lines[0]);
336
+ const records = [];
337
+ for (const line of raw.split(/\r?\n/)) {
338
+ if (line.trim().length === 0)
339
+ continue;
340
+ let parsed;
341
+ try {
342
+ parsed = JSON.parse(line);
343
+ }
344
+ catch {
345
+ continue;
346
+ }
347
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
348
+ continue;
349
+ records.push(parsed);
343
350
  }
344
- catch {
351
+ if (records.length !== 1) {
345
352
  throw new LanesError("account_retry_context_invalid", `The archived message needed to resume lane ${laneId} is malformed`, { lane_id: laneId, message_id: messageId });
346
353
  }
354
+ const value = records[0];
347
355
  if (value === null ||
348
356
  typeof value !== "object" ||
349
357
  value.message_id !== messageId ||
@@ -972,7 +980,7 @@ export function nextEnvelopePath(runDir) {
972
980
  }
973
981
  export function envelopeInstruction(envelopePath) {
974
982
  return (`When finished, write a JSON object to ${envelopePath} using these key names exactly: ` +
975
- `schema_version, decision, summary, story_status, artifacts, story_complete, story_complete_reason, questions, blocked_reason. ` +
983
+ `schema_version, decision, summary, story_status, artifacts, story_complete, story_complete_reason, questions, blocked_reason, next_check_at. ` +
976
984
  `schema_version must be the exact string "hq-lane-envelope.v1". ` +
977
985
  `decision must be one of "done", "ask", "blocked". ` +
978
986
  `summary is a string. ` +
@@ -982,7 +990,8 @@ export function envelopeInstruction(envelopePath) {
982
990
  `story_complete true requires story_status done and a reason stating what was delivered and how it was verified. ` +
983
991
  `story_complete false requires story_status other than done and a reason stating what is still missing. ` +
984
992
  `questions is present and non-empty exactly when decision is "ask". ` +
985
- `blocked_reason is present exactly when decision is "blocked".`);
993
+ `blocked_reason is present exactly when decision is "blocked". ` +
994
+ `next_check_at is optional; when present, use an ISO date-time for the next time-based keep-alive check.`);
986
995
  }
987
996
  /**
988
997
  * Standing authorization carried by every brief and every resume turn. A
@@ -1382,6 +1391,7 @@ function spawnDetached(spawnProcess, command, args, env, cwd, stdoutPath, stderr
1382
1391
  errFd = openLog(stderrPath);
1383
1392
  const child = spawnProcess(command, args, {
1384
1393
  detached: true,
1394
+ windowsHide: true,
1385
1395
  stdio: ["ignore", outFd, errFd],
1386
1396
  env,
1387
1397
  cwd,
@@ -536,6 +536,30 @@ export function parseLaneRecord(raw, filePath) {
536
536
  raw.envelope_validation_errors.some((error) => typeof error !== "string" || error.trim() === ""))) {
537
537
  throw new LaneSchemaError("envelope_validation_errors must be a non-empty array of non-empty strings", { path: filePath, field: "envelope_validation_errors" });
538
538
  }
539
+ if (raw.fallback_question_cancellation_failures !== undefined) {
540
+ if (!Array.isArray(raw.fallback_question_cancellation_failures)) {
541
+ throw new LaneSchemaError("fallback_question_cancellation_failures must be an array", {
542
+ path: filePath,
543
+ field: "fallback_question_cancellation_failures",
544
+ });
545
+ }
546
+ for (const [index, entry] of raw.fallback_question_cancellation_failures.entries()) {
547
+ const field = `fallback_question_cancellation_failures[${index}]`;
548
+ if (!isPlainObject(entry) ||
549
+ Object.keys(entry).some((key) => !["at", "caller", "trigger", "question_ids", "error"].includes(key)) ||
550
+ (entry.trigger !== "message" && entry.trigger !== "resume") ||
551
+ !Array.isArray(entry.question_ids) ||
552
+ entry.question_ids.some((id) => typeof id !== "string" || id.trim() === "")) {
553
+ throw new LaneSchemaError(`${field} must be a fallback question-cancellation failure`, {
554
+ path: filePath,
555
+ field,
556
+ });
557
+ }
558
+ requireTimestamp(entry.at, `${field}.at`, filePath);
559
+ requireString(entry.caller, `${field}.caller`);
560
+ requireString(entry.error, `${field}.error`);
561
+ }
562
+ }
539
563
  requireTimestamp(raw.created_at, "created_at", filePath);
540
564
  requireTimestamp(raw.updated_at, "updated_at", filePath);
541
565
  if (raw.queued_reason !== undefined && !isQueuedReason(raw.queued_reason)) {
@@ -64,6 +64,8 @@ export declare function laneStoryLedgerEnabled(): boolean;
64
64
  export declare function resolveProjectStoryLedger(hqRoot: string, projectId: string, companySlug?: string): ProjectStoryLedger;
65
65
  export declare function validateProjectStories(hqRoot: string, projectId: string, storyIds: readonly string[], companySlug?: string): ProjectStoryLedger;
66
66
  export declare function syncLaneToProjectStoryLedger(hqRoot: string, lane: LaneRecord, questionRefs?: readonly StoryLedgerQuestionRef[], deps?: StoryLedgerWriteDeps): void;
67
+ /** Read canonical ledger states for the story ids currently carried by a lane. */
68
+ export declare function readProjectStoryStates(hqRoot: string, projectId: string, storyIds: readonly string[], companySlug?: string): Map<string, string>;
67
69
  export declare function buildLaneStatusSnapshot(input: {
68
70
  hqRoot: string;
69
71
  projectId: string;
@@ -667,6 +667,14 @@ function storyState(story) {
667
667
  }
668
668
  return story.passes === true ? "done" : "todo";
669
669
  }
670
+ /** Read canonical ledger states for the story ids currently carried by a lane. */
671
+ export function readProjectStoryStates(hqRoot, projectId, storyIds, companySlug) {
672
+ const parsed = readLedger(resolveProjectStoryLedger(hqRoot, projectId, companySlug));
673
+ const wanted = new Set(storyIds);
674
+ return new Map(parsed.userStories
675
+ .filter((story) => wanted.has(story.id.trim()))
676
+ .map((story) => [story.id.trim(), storyState(story)]));
677
+ }
670
678
  function laneStoryRefs(lane, projectId) {
671
679
  return laneStoriesForLedger(lane)
672
680
  .filter((story) => story.project_id === projectId)
@@ -31,6 +31,10 @@ export declare const LANE_ENVELOPE_JSON_SCHEMA: {
31
31
  readonly type: "string";
32
32
  readonly minLength: 1;
33
33
  };
34
+ readonly next_check_at: {
35
+ readonly type: "string";
36
+ readonly description: "Optional ISO date-time for a clock-based keep-alive check; invalid values are ignored.";
37
+ };
34
38
  readonly questions: {
35
39
  readonly type: "array";
36
40
  readonly items: {
@@ -380,6 +384,7 @@ export interface LastEnvelopeRef {
380
384
  /** PR and merge references carried by the worker envelope. */
381
385
  artifacts?: unknown[];
382
386
  blocked_reason?: string;
387
+ next_check_at?: string;
383
388
  }
384
389
  export interface ManagerStatusGate {
385
390
  turn_started_at: string;
@@ -404,9 +409,12 @@ export interface BlockerFreshnessState {
404
409
  stale_labels: string[];
405
410
  /** Hash of the last stale set already escalated to the senior. */
406
411
  escalated_fingerprint?: string;
412
+ /** The same escalation was recorded locally because a session inbox was unavailable. */
413
+ undeliverable_fingerprint?: string;
414
+ undeliverable_reason?: string;
407
415
  }
408
416
  export interface BlockerFreshnessHistoryEntry {
409
- event: "blocker_stale" | "blocker_stale_escalated";
417
+ event: "blocker_stale" | "blocker_stale_escalated" | "blocker_stale_escalation_undeliverable";
410
418
  turn_id: string;
411
419
  path: string;
412
420
  at: string;
@@ -416,6 +424,8 @@ export interface BlockerFreshnessHistoryEntry {
416
424
  resume_queued?: boolean;
417
425
  resume_skipped_reason?: "open_question" | "blocked" | "miss_limit" | "no_provider_session";
418
426
  delivery_failed?: true;
427
+ reason?: string;
428
+ delivery_recovered?: true;
419
429
  }
420
430
  export interface KrFreshnessState {
421
431
  /** Stable fingerprint of the last stalled KPI set, which is resumed once. */
@@ -441,6 +451,13 @@ export interface LaneResumeHistoryEntry {
441
451
  /** HQ lane id for an agent caller, or `user` for an explicit human CLI action. */
442
452
  caller?: string;
443
453
  }
454
+ export interface FallbackQuestionCancellationFailure {
455
+ at: string;
456
+ caller: string;
457
+ trigger: "message" | "resume";
458
+ question_ids: string[];
459
+ error: string;
460
+ }
444
461
  export interface KrFreshnessHistoryEntry {
445
462
  event: "kr_stalled";
446
463
  turn_id: string;
@@ -694,6 +711,8 @@ export interface LaneRecord {
694
711
  last_resume?: LaneResumeRecord;
695
712
  /** Last 50 lane worker starts after the initial start, including actor provenance. */
696
713
  resume_history?: LaneResumeHistoryEntry[];
714
+ /** Bounded failures while closing escalated questions after a fallback resume. */
715
+ fallback_question_cancellation_failures?: FallbackQuestionCancellationFailure[];
697
716
  /** Cache counters from the last exited run's result. Nothing else from the payload. */
698
717
  last_run_usage?: LaneRunUsage;
699
718
  /** Completion time paired with last_run_usage; written by the watcher. */
@@ -750,7 +769,7 @@ export interface PendingResume {
750
769
  trigger: "answered_questions" | "message" | "resume";
751
770
  /** True when this queued resume was initiated automatically by keep-alive. */
752
771
  keep_alive?: true;
753
- resumed_from?: "interrupted" | "done" | "blocked" | "failed";
772
+ resumed_from?: "interrupted" | "done" | "blocked" | "failed" | "awaiting_input";
754
773
  from_failed?: true;
755
774
  /** Senior that authorized a failed-lane resume, rechecked at the claim CAS. */
756
775
  expected_senior?: SeniorRef;
@@ -785,9 +804,10 @@ export interface LaneResumeRecord {
785
804
  monitor_event?: true;
786
805
  /**
787
806
  * `hq lanes resume` only: the state the lane was in when the operator
788
- * resumed it (`interrupted`, `done` or `blocked`) and the text they sent.
807
+ * resumed it (`interrupted`, `done`, `blocked`, `failed` or `awaiting_input`)
808
+ * and the text they sent.
789
809
  */
790
- resumed_from?: "interrupted" | "done" | "blocked" | "failed";
810
+ resumed_from?: "interrupted" | "done" | "blocked" | "failed" | "awaiting_input";
791
811
  from_failed?: true;
792
812
  text?: string;
793
813
  /** False when the continued turn explicitly skipped compaction. */
@@ -888,6 +908,7 @@ export interface LaneEnvelope {
888
908
  schema_version: typeof ENVELOPE_SCHEMA_VERSION;
889
909
  decision: EnvelopeDecision;
890
910
  summary: string;
911
+ next_check_at?: string;
891
912
  questions?: EnvelopeQuestion[];
892
913
  story_status: StoryStatus;
893
914
  artifacts: unknown[];
@@ -993,6 +1014,7 @@ export interface LanesConfig {
993
1014
  /** Age in hours after which a manager KPI is considered stalled. */
994
1015
  kr_freshness_hours: number;
995
1016
  }
1017
+ export declare function isIsoDateTime(value: string): boolean;
996
1018
  export declare function parseMajorSchema(schemaVersion: string, expectedPrefix: string, expectedMajor: number): void;
997
1019
  export declare function parseLaneEnvelope(raw: unknown): LaneEnvelope;
998
1020
  //# sourceMappingURL=types.d.ts.map
@@ -25,6 +25,10 @@ export const LANE_ENVELOPE_JSON_SCHEMA = {
25
25
  schema_version: { const: "hq-lane-envelope.v1" },
26
26
  decision: { enum: ["done", "ask", "blocked"] },
27
27
  summary: { type: "string", minLength: 1 },
28
+ next_check_at: {
29
+ type: "string",
30
+ description: "Optional ISO date-time for a clock-based keep-alive check; invalid values are ignored.",
31
+ },
28
32
  questions: {
29
33
  type: "array",
30
34
  items: {
@@ -226,7 +230,7 @@ export const LANE_FIELD_WRITERS = {
226
230
  pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
227
231
  priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], registry_worker: ["creator", "command", "spawn"], registry_worker_approval_history: ["command", "watcher", "reconcile"], worker_profile_repin_history: ["command"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
228
232
  keep_alive: ["creator", "command"], keep_alive_standing: ["command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], keep_alive_resume_count: ["command", "reconcile"], keep_alive_history: ["reconcile"], keep_alive_blocked_resume_streak: ["reconcile", "spawn", "watcher"], keep_alive_last_resume_at: ["command", "reconcile"], keep_alive_next_eligible_at: ["command", "reconcile"], keep_alive_exhausted_at: ["command", "reconcile"],
229
- provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
233
+ provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], fallback_question_cancellation_failures: ["command"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
230
234
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], ended_at: ["reconcile"], envelope_summary: ["reconcile"], attribution_history: ["command", "watcher", "reconcile"], story_switch_pending: ["command", "watcher", "reconcile"], story_switch_unacknowledged: ["watcher", "reconcile"],
231
235
  last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn", "command"], effort_source: ["creator", "spawn", "command"], fast_mode: ["spawn"],
232
236
  last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher", "reconcile"], blocker_freshness_state: ["watcher"], kr_freshness_state: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
@@ -246,6 +250,24 @@ function requireString(value, field) {
246
250
  }
247
251
  return value;
248
252
  }
253
+ export function isIsoDateTime(value) {
254
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
255
+ if (!match)
256
+ return false;
257
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, , offsetHourText, offsetMinuteText] = match;
258
+ const year = Number(yearText);
259
+ const month = Number(monthText);
260
+ const day = Number(dayText);
261
+ const hour = Number(hourText);
262
+ const minute = Number(minuteText);
263
+ const second = Number(secondText);
264
+ const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText);
265
+ const offsetMinute = offsetMinuteText === undefined ? 0 : Number(offsetMinuteText);
266
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
267
+ return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth &&
268
+ hour <= 23 && minute <= 59 && second <= 59 && offsetHour <= 23 && offsetMinute <= 59 &&
269
+ Number.isFinite(Date.parse(value));
270
+ }
249
271
  export function parseMajorSchema(schemaVersion, expectedPrefix, expectedMajor) {
250
272
  const match = new RegExp(`^${expectedPrefix}\\.v(\\d+)$`).exec(schemaVersion);
251
273
  if (!match) {
@@ -264,6 +286,7 @@ export function parseLaneEnvelope(raw) {
264
286
  "schema_version",
265
287
  "decision",
266
288
  "summary",
289
+ "next_check_at",
267
290
  "questions",
268
291
  "story_status",
269
292
  "artifacts",
@@ -280,6 +303,13 @@ export function parseLaneEnvelope(raw) {
280
303
  throw new LaneSchemaError(`Unknown envelope decision ${JSON.stringify(raw.decision)}. Accepted: ${ENVELOPE_DECISIONS.join(", ")}`, { accepted: [...ENVELOPE_DECISIONS], value: raw.decision });
281
304
  }
282
305
  const summary = requireString(raw.summary, "summary");
306
+ let nextCheckAt;
307
+ if (raw.next_check_at !== undefined) {
308
+ if (typeof raw.next_check_at !== "string") {
309
+ throw new LaneSchemaError("next_check_at must be a string", { field: "next_check_at" });
310
+ }
311
+ nextCheckAt = raw.next_check_at;
312
+ }
283
313
  if (typeof raw.story_status !== "string" ||
284
314
  !STORY_STATUSES.includes(raw.story_status)) {
285
315
  throw new LaneSchemaError(`Unknown story_status ${JSON.stringify(raw.story_status)}. Accepted: ${STORY_STATUSES.join(", ")}`, { accepted: [...STORY_STATUSES], value: raw.story_status });
@@ -344,6 +374,8 @@ export function parseLaneEnvelope(raw) {
344
374
  story_status: storyStatus,
345
375
  artifacts: raw.artifacts,
346
376
  };
377
+ if (nextCheckAt !== undefined)
378
+ envelope.next_check_at = nextCheckAt;
347
379
  if (storyComplete !== undefined) {
348
380
  envelope.story_complete = storyComplete;
349
381
  envelope.story_complete_reason = storyCompleteReason;
@@ -23,6 +23,7 @@ export interface LaneWatchDeps {
23
23
  readQuestion?: (hqRoot: string, questionId: string) => QuestionRecord | null;
24
24
  readAccountStatuses?: (lanes: readonly LaneRecord[]) => Promise<readonly LaneWatchAccountStatus[]>;
25
25
  keepAliveEnabled?: (lane: LaneRecord) => boolean | Promise<boolean>;
26
+ keepAliveNextCheckAtEnabled?: (lane: LaneRecord) => boolean | Promise<boolean>;
26
27
  sleep?: (ms: number) => Promise<void>;
27
28
  intervalMs?: number;
28
29
  maxOutputLines?: number;
@@ -1,7 +1,7 @@
1
1
  import { listQuestionIds, readLane, readQuestion, } from "./store.js";
2
2
  import { LaneNotFoundError, LanesError } from "./errors.js";
3
3
  import { providerSupportsSessionResume } from "./delivery.js";
4
- import { canResumeDoneKeepAliveLane, hasPendingKeepAliveWork, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, } from "./keep-alive.js";
4
+ import { canResumeDoneKeepAliveLane, hasPendingKeepAliveWork, keepAliveNextCheckAtMs, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, } from "./keep-alive.js";
5
5
  import { isTerminalLaneState, } from "./types.js";
6
6
  export const DEFAULT_LANE_WATCH_INTERVAL_MS = 5_000;
7
7
  export const MAX_LANE_WATCH_INTERVAL_MS = 30_000;
@@ -32,7 +32,7 @@ function validateLaneIds(laneIds) {
32
32
  }
33
33
  return unique;
34
34
  }
35
- function canResumeKeepAliveLane(hqRoot, lane) {
35
+ function canResumeKeepAliveLane(hqRoot, lane, nextCheckAtEnabled = false, nowMs = Date.now()) {
36
36
  if (lane.keep_alive !== true ||
37
37
  lane.keep_alive_opted_in_at === undefined ||
38
38
  !Number.isFinite(Date.parse(lane.keep_alive_opted_in_at)) ||
@@ -49,7 +49,15 @@ function canResumeKeepAliveLane(hqRoot, lane) {
49
49
  return lane.terminal_reason === "exited_with_partial_output";
50
50
  }
51
51
  if (lane.state === "done") {
52
- return canResumeDoneKeepAliveLane(lane, { decision: lane.last_envelope?.decision, story_complete: lane.story_complete }, lane.keep_alive_standing === true && hasPendingKeepAliveWork(hqRoot, lane)) &&
52
+ const hasPendingWork = lane.keep_alive_standing === true || nextCheckAtEnabled
53
+ ? hasPendingKeepAliveWork(hqRoot, lane)
54
+ : false;
55
+ const nextCheckAtMs = nextCheckAtEnabled && !hasPendingWork
56
+ ? keepAliveNextCheckAtMs(lane.last_envelope?.next_check_at, nowMs, Date.parse(lane.last_envelope?.at ?? lane.updated_at))
57
+ : undefined;
58
+ if (nextCheckAtMs !== undefined && nowMs < nextCheckAtMs)
59
+ return false;
60
+ return canResumeDoneKeepAliveLane(lane, { decision: lane.last_envelope?.decision, story_complete: lane.story_complete, next_check_at: lane.last_envelope?.next_check_at }, lane.keep_alive_standing === true && hasPendingWork) &&
53
61
  lane.terminal_reason !== "provider_rejected" &&
54
62
  lane.terminal_reason !== "account_not_logged_in";
55
63
  }
@@ -131,9 +139,14 @@ export async function runLaneWatch(hqRoot, laneIds, deps = {}) {
131
139
  const keepAliveWaiting = new Set();
132
140
  if (deps.keepAliveEnabled !== undefined) {
133
141
  for (const record of records) {
134
- if (isTerminalLaneState(record.state) &&
135
- canResumeKeepAliveLane(hqRoot, record) &&
136
- await deps.keepAliveEnabled(record))
142
+ if (!isTerminalLaneState(record.state) || !canResumeKeepAliveLane(hqRoot, record))
143
+ continue;
144
+ if (!await deps.keepAliveEnabled(record))
145
+ continue;
146
+ const nextCheckAtEnabled = deps.keepAliveNextCheckAtEnabled === undefined
147
+ ? false
148
+ : await deps.keepAliveNextCheckAtEnabled(record);
149
+ if (canResumeKeepAliveLane(hqRoot, record, nextCheckAtEnabled))
137
150
  keepAliveWaiting.add(record.lane_id);
138
151
  }
139
152
  }
@@ -424,6 +424,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
424
424
  at,
425
425
  artifacts: envelope.artifacts,
426
426
  ...(envelope.blocked_reason !== undefined ? { blocked_reason: envelope.blocked_reason } : {}),
427
+ ...(envelope.next_check_at !== undefined ? { next_check_at: envelope.next_check_at } : {}),
427
428
  };
428
429
  if (storySwitch.clearPending) {
429
430
  next.story_switch_pending = undefined;
@@ -769,15 +770,30 @@ async function settleBlockerFreshness(hqRoot, lane, manifestTurnId, at, deps, me
769
770
  const turnId = manifestTurnId ?? lane.status_gate?.turn_id ?? `unknown-${at}`;
770
771
  const resumeText = `These open blockers in ${resolution.statusPath} were not re-verified in the last ${resolution.windowHours}h: ${staleLabels.join(", ")}. Check each one now; set verified_at on the ones that still hold and close the rest, then end the turn.`;
771
772
  let deliverEscalation = false;
773
+ let deliverFallback = false;
774
+ let retryUndeliverable = false;
775
+ let undeliverableReason;
772
776
  let escalationText = "";
777
+ const sessionInbox = lane.senior.kind === "session"
778
+ ? lane.senior.id
779
+ ? findBlockerFreshnessSessionInbox(hqRoot, lane.senior.id)
780
+ : { reason: "The session senior has no session id" }
781
+ : undefined;
773
782
  let updated = retryUpdateLane(hqRoot, lane.lane_id, "watcher", (current) => {
774
783
  deliverEscalation = false;
784
+ deliverFallback = false;
785
+ retryUndeliverable = false;
786
+ undeliverableReason = undefined;
775
787
  const prior = current.blocker_freshness_state;
776
788
  const repeated = prior !== undefined && staleKeys.some((key) => prior.stale_keys.includes(key));
777
789
  const history = current.status_gate_history ?? [];
778
790
  const alreadyEscalatedThisTurn = history.some((entry) => entry.event === "blocker_stale_escalated" && entry.turn_id === turnId && entry.fingerprint === fingerprint);
779
791
  const escalate = repeated && prior?.escalated_fingerprint !== fingerprint && !alreadyEscalatedThisTurn;
780
792
  const alreadyEscalatedSet = prior?.escalated_fingerprint === fingerprint;
793
+ const pendingUndeliverable = prior?.undeliverable_fingerprint === fingerprint;
794
+ const failedFallback = history.find((entry) => entry.event === "blocker_stale_escalation_undeliverable" &&
795
+ entry.fingerprint === fingerprint && entry.delivery_failed === true &&
796
+ entry.delivery_recovered !== true);
781
797
  const statusResumeQueued = history.some((entry) => entry.event === "status_missed" && entry.turn_id === turnId && entry.resume_queued === true);
782
798
  let resumeQueued = false;
783
799
  let resumeSkippedReason;
@@ -819,6 +835,37 @@ async function settleBlockerFreshness(hqRoot, lane, manifestTurnId, at, deps, me
819
835
  escalatedFingerprint = fingerprint;
820
836
  deliverEscalation = true;
821
837
  escalationText = `Blocker freshness escalation for ${current.lane_id}: these blockers remain stale after consecutive turns: ${staleLabels.join(", ")}. Status file: ${resolution.statusPath}.`;
838
+ if (lane.senior.kind === "session" && sessionInbox !== undefined && !("inbox" in sessionInbox)) {
839
+ deliverFallback = true;
840
+ undeliverableReason = sessionInbox.reason;
841
+ nextHistory = appendCappedHistory(nextHistory, {
842
+ event: "blocker_stale_escalation_undeliverable",
843
+ turn_id: turnId,
844
+ path: resolution.statusPath,
845
+ at,
846
+ labels: staleLabels,
847
+ count: resolution.stale.length,
848
+ fingerprint,
849
+ reason: sessionInbox.reason,
850
+ });
851
+ }
852
+ }
853
+ else if (pendingUndeliverable && sessionInbox !== undefined && "inbox" in sessionInbox) {
854
+ // A monitor inbox registered after the local fallback appeared; retry delivery once.
855
+ deliverEscalation = true;
856
+ retryUndeliverable = true;
857
+ escalationText = `Blocker freshness escalation for ${current.lane_id}: these blockers remain stale after consecutive turns: ${staleLabels.join(", ")}. Status file: ${resolution.statusPath}.`;
858
+ }
859
+ else if (failedFallback !== undefined && sessionInbox !== undefined && "inbox" in sessionInbox) {
860
+ // Prefer the newly available senior inbox over retrying a failed local fallback.
861
+ deliverEscalation = true;
862
+ retryUndeliverable = true;
863
+ escalationText = `Blocker freshness escalation for ${current.lane_id}: these blockers remain stale after consecutive turns: ${staleLabels.join(", ")}. Status file: ${resolution.statusPath}.`;
864
+ }
865
+ else if (failedFallback !== undefined) {
866
+ deliverFallback = true;
867
+ undeliverableReason = failedFallback.reason ?? prior?.undeliverable_reason ?? "The session senior inbox is unavailable";
868
+ escalationText = `Blocker freshness escalation for ${current.lane_id}: these blockers remain stale after consecutive turns: ${staleLabels.join(", ")}. Status file: ${resolution.statusPath}.`;
822
869
  }
823
870
  else if (prior !== undefined && blockerFreshnessFingerprint(prior.stale_keys.map((key, index) => ({ key, label: prior.stale_labels[index] ?? "" }))) !== fingerprint) {
824
871
  escalatedFingerprint = undefined;
@@ -829,13 +876,68 @@ async function settleBlockerFreshness(hqRoot, lane, manifestTurnId, at, deps, me
829
876
  stale_keys: staleKeys,
830
877
  stale_labels: staleLabels,
831
878
  ...(escalatedFingerprint !== undefined ? { escalated_fingerprint: escalatedFingerprint } : {}),
879
+ ...(undeliverableReason !== undefined
880
+ ? { undeliverable_fingerprint: fingerprint, undeliverable_reason: undeliverableReason }
881
+ : prior?.undeliverable_fingerprint === fingerprint
882
+ ? { undeliverable_fingerprint: fingerprint, undeliverable_reason: prior.undeliverable_reason }
883
+ : {}),
832
884
  },
833
885
  status_gate_history: nextHistory,
834
886
  };
835
887
  });
836
888
  logDecision({ event: "blocker_stale", at, turn_id: turnId, path: resolution.statusPath, labels: staleLabels, count: staleLabels.length });
837
- const escalated = updated.status_gate_history?.find((entry) => entry.event === "blocker_stale_escalated" && entry.turn_id === turnId && entry.fingerprint === fingerprint);
838
- if (escalated && deliverEscalation) {
889
+ const escalated = updated.status_gate_history?.find((entry) => entry.event === "blocker_stale_escalated" && entry.fingerprint === fingerprint &&
890
+ (entry.turn_id === turnId || retryUndeliverable || deliverFallback));
891
+ if (escalated && deliverFallback) {
892
+ const reason = undeliverableReason ?? "The session senior has no unique monitor-session inbox";
893
+ try {
894
+ appendDropboxMessageAtPath(path.join(laneRunDir(hqRoot, lane.lane_id), "dropbox"), {
895
+ at,
896
+ message_id: `blocker-freshness-undeliverable:${lane.lane_id}:${fingerprint}`,
897
+ kind: "blocker_stale_escalation_undeliverable",
898
+ text: `${escalationText} The session senior inbox is unavailable (${reason}); this escalation is recorded in the lane inbox for its next turn.`,
899
+ });
900
+ updated = retryUpdateLane(hqRoot, lane.lane_id, "watcher", (current) => {
901
+ const state = current.blocker_freshness_state;
902
+ if (state === undefined)
903
+ return current;
904
+ return {
905
+ ...current,
906
+ blocker_freshness_state: {
907
+ ...state,
908
+ undeliverable_fingerprint: fingerprint,
909
+ undeliverable_reason: reason,
910
+ },
911
+ status_gate_history: (current.status_gate_history ?? []).map((entry) => entry.event === "blocker_stale_escalation_undeliverable" &&
912
+ entry.fingerprint === fingerprint && entry.delivery_failed === true
913
+ ? { ...entry, delivery_recovered: true }
914
+ : entry),
915
+ };
916
+ });
917
+ logDecision({ event: "blocker_stale_escalation_undeliverable", at, turn_id: turnId, fingerprint, reason, fallback: "lane_dropbox" });
918
+ }
919
+ catch (error) {
920
+ const message = error instanceof Error ? error.message : String(error);
921
+ process.stderr.write(`hq lanes watcher: blocker freshness fallback delivery failed for ${lane.lane_id}: ${message}\n`);
922
+ logDecision({ event: "blocker_stale_escalation_fallback_failed", at, turn_id: turnId, fingerprint, error: message });
923
+ updated = retryUpdateLane(hqRoot, lane.lane_id, "watcher", (current) => {
924
+ const state = current.blocker_freshness_state;
925
+ const retryableState = state === undefined ? undefined : { ...state };
926
+ if (retryableState !== undefined) {
927
+ delete retryableState.undeliverable_fingerprint;
928
+ delete retryableState.undeliverable_reason;
929
+ }
930
+ return {
931
+ ...current,
932
+ ...(retryableState !== undefined ? { blocker_freshness_state: retryableState } : {}),
933
+ status_gate_history: (current.status_gate_history ?? []).map((entry) => entry.event === "blocker_stale_escalation_undeliverable" && entry.fingerprint === fingerprint
934
+ ? { ...entry, delivery_failed: true }
935
+ : entry),
936
+ };
937
+ });
938
+ }
939
+ }
940
+ if ((escalated || retryUndeliverable) && deliverEscalation && !deliverFallback) {
839
941
  try {
840
942
  if (lane.senior.kind === "lane" && lane.senior.id) {
841
943
  if (deps.deliverLaneSeniorMessage === undefined) {
@@ -844,7 +946,9 @@ async function settleBlockerFreshness(hqRoot, lane, manifestTurnId, at, deps, me
844
946
  await deps.deliverLaneSeniorMessage(lane.senior.id, escalationText);
845
947
  }
846
948
  else if (lane.senior.kind === "session" && lane.senior.id) {
847
- const inbox = resolveBlockerFreshnessSessionInbox(hqRoot, lane.senior.id);
949
+ const inbox = sessionInbox !== undefined && "inbox" in sessionInbox
950
+ ? sessionInbox.inbox
951
+ : resolveBlockerFreshnessSessionInbox(hqRoot, lane.senior.id);
848
952
  appendDropboxMessageAtPath(inbox, {
849
953
  at,
850
954
  message_id: `blocker-freshness:${lane.lane_id}:${fingerprint}:${turnId}`,
@@ -856,6 +960,26 @@ async function settleBlockerFreshness(hqRoot, lane, manifestTurnId, at, deps, me
856
960
  throw new LanesError("blocker_freshness_delivery_unavailable", "No durable senior inbox is available for this blocker escalation", { lane_id: lane.lane_id, senior_kind: lane.senior.kind });
857
961
  }
858
962
  logDecision({ event: "blocker_stale_escalation_delivered", at, turn_id: turnId, senior_kind: lane.senior.kind });
963
+ if (retryUndeliverable) {
964
+ updated = retryUpdateLane(hqRoot, lane.lane_id, "watcher", (current) => {
965
+ const state = current.blocker_freshness_state;
966
+ const failedFallbackStillOpen = (current.status_gate_history ?? []).some((entry) => entry.event === "blocker_stale_escalation_undeliverable" &&
967
+ entry.fingerprint === fingerprint && entry.delivery_failed === true &&
968
+ entry.delivery_recovered !== true);
969
+ if (state?.undeliverable_fingerprint !== fingerprint && !failedFallbackStillOpen)
970
+ return current;
971
+ const recoveredHistory = (current.status_gate_history ?? []).map((entry) => entry.event === "blocker_stale_escalation_undeliverable" && entry.fingerprint === fingerprint
972
+ ? { ...entry, delivery_recovered: true }
973
+ : entry);
974
+ if (state?.undeliverable_fingerprint === fingerprint) {
975
+ const recoveredState = { ...state };
976
+ delete recoveredState.undeliverable_fingerprint;
977
+ delete recoveredState.undeliverable_reason;
978
+ return { ...current, blocker_freshness_state: recoveredState, status_gate_history: recoveredHistory };
979
+ }
980
+ return { ...current, status_gate_history: recoveredHistory };
981
+ });
982
+ }
859
983
  }
860
984
  catch (error) {
861
985
  const message = error instanceof Error ? error.message : String(error);
@@ -950,7 +1074,7 @@ async function settleKrFreshness(hqRoot, lane, manifestTurnId, at, deps, logDeci
950
1074
  };
951
1075
  });
952
1076
  }
953
- function resolveBlockerFreshnessSessionInbox(hqRoot, sessionId) {
1077
+ function findBlockerFreshnessSessionInbox(hqRoot, sessionId) {
954
1078
  let candidates;
955
1079
  try {
956
1080
  candidates = LANE_PROVIDERS
@@ -958,14 +1082,20 @@ function resolveBlockerFreshnessSessionInbox(hqRoot, sessionId) {
958
1082
  .filter((sessionDir) => fs.existsSync(sessionDir));
959
1083
  }
960
1084
  catch (error) {
961
- throw new LanesError("blocker_freshness_session_inbox_unavailable", `Could not resolve the monitor inbox for session senior: ${error instanceof Error ? error.message : String(error)}`, { session_id: sessionId });
1085
+ return { reason: `Could not resolve the monitor inbox for session senior: ${error instanceof Error ? error.message : String(error)}` };
962
1086
  }
963
1087
  if (candidates.length !== 1) {
964
- throw new LanesError("blocker_freshness_session_inbox_unavailable", candidates.length === 0
965
- ? "No monitor-session inbox is registered for the session senior"
966
- : "The session senior matches more than one monitor-session inbox", { session_id: sessionId, matches: candidates.length });
1088
+ return { reason: candidates.length === 0
1089
+ ? "No monitor-session inbox is registered for the session senior"
1090
+ : "The session senior matches more than one monitor-session inbox" };
967
1091
  }
968
- return path.join(candidates[0], "dropbox");
1092
+ return { inbox: path.join(candidates[0], "dropbox") };
1093
+ }
1094
+ function resolveBlockerFreshnessSessionInbox(hqRoot, sessionId) {
1095
+ const result = findBlockerFreshnessSessionInbox(hqRoot, sessionId);
1096
+ if ("inbox" in result)
1097
+ return result.inbox;
1098
+ throw new LanesError("blocker_freshness_session_inbox_unavailable", result.reason, { session_id: sessionId });
969
1099
  }
970
1100
  /**
971
1101
  * Wrap one mesh call. A failed emit is logged as `mesh_event_failed` with
@@ -0,0 +1,7 @@
1
+ import type { MqttClientEventCallbacks } from "mqtt";
2
+ /** Event callbacks shared by the daemon's two MQTT client seams. */
3
+ export type MeshMqttEventMap = Pick<MqttClientEventCallbacks, "connect" | "error" | "close" | "message">;
4
+ export interface MeshMqttEventClient {
5
+ on<Event extends keyof MeshMqttEventMap>(event: Event, handler: MeshMqttEventMap[Event]): unknown;
6
+ }
7
+ //# sourceMappingURL=mqtt-events.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mqtt-events.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mqtt-events.test-d.d.ts.map
@@ -0,0 +1,10 @@
1
+ // @ts-expect-error unsupported event names are not part of mqtt's event map
2
+ presence.on("conect", () => { });
3
+ // @ts-expect-error MQTT message handlers receive a string topic
4
+ presence.on("message", (topic) => void topic);
5
+ // @ts-expect-error unsupported event names are not part of mqtt's event map
6
+ workCache.on("conect", () => { });
7
+ // @ts-expect-error MQTT message handlers receive a string topic
8
+ workCache.on("message", (topic) => void topic);
9
+ export {};
10
+ //# sourceMappingURL=mqtt-events.test-d.js.map
@@ -12,6 +12,7 @@
12
12
  * - Server credential refusals use a long retry (default 10m ±20%)
13
13
  */
14
14
  import { type IClientOptions, type MqttClient } from "mqtt";
15
+ import type { MeshMqttEventClient } from "./mqtt-events.js";
15
16
  import { type Contract3Bundle, type CredentialsFetcher, type TimerHost } from "./credentials.js";
16
17
  export type MqttConnectionState = "idle" | "connecting" | "connected" | "reconnecting" | "closed";
17
18
  /**
@@ -49,8 +50,7 @@ export interface PresencePayload {
49
50
  at: string;
50
51
  }
51
52
  /** Structural mqtt client surface used by PresenceClient (test seam). */
52
- export interface MeshMqttClientLike {
53
- on(event: string, handler: (...args: any[]) => void): unknown;
53
+ export interface MeshMqttClientLike extends MeshMqttEventClient {
54
54
  publish(topic: string, message: string | Buffer, opts: {
55
55
  qos: 0 | 1 | 2;
56
56
  retain?: boolean;