@indigoai-us/hq-cli 5.298.0 → 5.300.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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/command-catalog.generated.d.ts +27 -4
  3. package/dist/command-catalog.generated.js +34 -4
  4. package/dist/commands/cloud.js +12 -7
  5. package/dist/commands/daemon.js +1 -0
  6. package/dist/commands/files.d.ts +1 -0
  7. package/dist/commands/files.js +59 -0
  8. package/dist/commands/lanes-accounts.d.ts +3 -0
  9. package/dist/commands/lanes-accounts.js +25 -1
  10. package/dist/commands/lanes-message.js +68 -35
  11. package/dist/commands/lanes-resume.js +247 -210
  12. package/dist/commands/lanes-shared.d.ts +3 -1
  13. package/dist/commands/lanes.d.ts +2 -0
  14. package/dist/commands/lanes.js +16 -4
  15. package/dist/lib/daemon/legacy.d.ts +5 -2
  16. package/dist/lib/daemon/legacy.js +6 -4
  17. package/dist/lib/daemon/migrate.d.ts +10 -1
  18. package/dist/lib/daemon/migrate.js +22 -7
  19. package/dist/lib/daemon/run.js +19 -7
  20. package/dist/lib/daemon/supervisor.js +9 -1
  21. package/dist/lib/lanes/quota.d.ts +19 -0
  22. package/dist/lib/lanes/quota.js +106 -29
  23. package/dist/lib/lanes/reconcile.js +34 -5
  24. package/dist/lib/lanes/store.d.ts +2 -0
  25. package/dist/lib/lanes/store.js +4 -0
  26. package/dist/lib/lanes/story-switch.d.ts +21 -0
  27. package/dist/lib/lanes/story-switch.js +75 -0
  28. package/dist/lib/lanes/types.d.ts +25 -0
  29. package/dist/lib/lanes/types.js +1 -1
  30. package/dist/lib/lanes/watcher.js +25 -5
  31. package/dist/lib/mesh/live/daemon/install.d.ts +3 -0
  32. package/dist/lib/mesh/live/daemon/install.js +13 -2
  33. package/dist/utils/self-update.js +249 -2
  34. package/dist/utils/staged-install.js +71 -21
  35. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.300.0] — 2026-09-30
6
+ - With the default-off hq-flags gate `files.share-empty-prefix-warning`, `hq files share` warns when a granted prefix has no files. A failed or timed-out list check does not block the grant.
7
+
8
+ - Lane messages and resumes that switch project or story attribution now tell the provider which story is active. Reused completion artifacts no longer close the new story.
9
+ - Make signed-out `hq sync` commands fail fast with `hq login` guidance when stdin or stdout is not a terminal, while keeping browser login in a terminal.
10
+
11
+ ## [5.299.0] — 2026-09-30
12
+ - Add `hq lanes accounts clear-quota` and clear future quota marks after successful lane turns.
13
+ - On macOS, `hq daemon` retires a loaded legacy mesh LaunchAgent even when its plist is missing, and daemon uninstall boots the job out before deleting its plist. Mesh-owner wait logs now repeat at most every ten minutes.
14
+ - Restore npm command links after a failed hq-cli self-update and repair or report missing links after successful installs.
15
+
5
16
  ## [5.298.0] — 2026-09-30
6
17
  - [feedback_d4a080e9-de79-4290-81c3-06ba26990a38] Preserve raw substring matches while normalizing company path searches.
7
18
  - `hq agents` now prints the Slack app settings link and the console paste link whenever an agent is waiting on its Slack app-level token, and follows the pending actions and paste URL the server returns.
@@ -8509,10 +8509,10 @@ export declare const COMMAND_CATALOG: readonly [{
8509
8509
  readonly description: "Message text";
8510
8510
  }, {
8511
8511
  readonly flags: "--project <slug>";
8512
- readonly description: "Re-point project attribution from this message";
8512
+ readonly description: "Re-point project attribution; a changed story is named in the resumed prompt";
8513
8513
  }, {
8514
8514
  readonly flags: "--story <id>";
8515
- readonly description: "Re-point story attribution from this message";
8515
+ readonly description: "Re-point story attribution; a changed story is named in the resumed prompt";
8516
8516
  }, {
8517
8517
  readonly flags: "--account <id>";
8518
8518
  readonly description: "Move the lane's session to this account of its provider before the message resumes it";
@@ -8602,10 +8602,10 @@ export declare const COMMAND_CATALOG: readonly [{
8602
8602
  readonly description: "Required explanation for resuming from failed";
8603
8603
  }, {
8604
8604
  readonly flags: "--project <slug>";
8605
- readonly description: "Re-point project attribution from this resume";
8605
+ readonly description: "Re-point project attribution; a changed story is named in the resumed prompt";
8606
8606
  }, {
8607
8607
  readonly flags: "--story <id>";
8608
- readonly description: "Re-point story attribution from this resume";
8608
+ readonly description: "Re-point story attribution; a changed story is named in the resumed prompt";
8609
8609
  }, {
8610
8610
  readonly flags: "--account <id>";
8611
8611
  readonly description: "Move the lane's session to this account of its provider before resuming";
@@ -8990,6 +8990,29 @@ export declare const COMMAND_CATALOG: readonly [{
8990
8990
  readonly description: "JSON on stdout (always)";
8991
8991
  }];
8992
8992
  readonly subcommands: readonly [];
8993
+ }, {
8994
+ readonly name: "clear-quota";
8995
+ readonly description: "Clear quota reset marks for a provider account";
8996
+ readonly aliases: readonly [];
8997
+ readonly hidden: false;
8998
+ readonly usage: "[options] <provider> <account-id>";
8999
+ readonly arguments: readonly [{
9000
+ readonly name: "provider";
9001
+ readonly required: true;
9002
+ readonly variadic: false;
9003
+ }, {
9004
+ readonly name: "account-id";
9005
+ readonly required: true;
9006
+ readonly variadic: false;
9007
+ }];
9008
+ readonly options: readonly [{
9009
+ readonly flags: "--model <model>";
9010
+ readonly description: "Clear only this model's reset";
9011
+ }, {
9012
+ readonly flags: "--json";
9013
+ readonly description: "JSON on stdout (always)";
9014
+ }];
9015
+ readonly subcommands: readonly [];
8993
9016
  }, {
8994
9017
  readonly name: "enable";
8995
9018
  readonly description: "Enable an account without touching credentials";
@@ -10972,11 +10972,11 @@ export const COMMAND_CATALOG = [
10972
10972
  },
10973
10973
  {
10974
10974
  "flags": "--project <slug>",
10975
- "description": "Re-point project attribution from this message"
10975
+ "description": "Re-point project attribution; a changed story is named in the resumed prompt"
10976
10976
  },
10977
10977
  {
10978
10978
  "flags": "--story <id>",
10979
- "description": "Re-point story attribution from this message"
10979
+ "description": "Re-point story attribution; a changed story is named in the resumed prompt"
10980
10980
  },
10981
10981
  {
10982
10982
  "flags": "--account <id>",
@@ -11094,11 +11094,11 @@ export const COMMAND_CATALOG = [
11094
11094
  },
11095
11095
  {
11096
11096
  "flags": "--project <slug>",
11097
- "description": "Re-point project attribution from this resume"
11097
+ "description": "Re-point project attribution; a changed story is named in the resumed prompt"
11098
11098
  },
11099
11099
  {
11100
11100
  "flags": "--story <id>",
11101
- "description": "Re-point story attribution from this resume"
11101
+ "description": "Re-point story attribution; a changed story is named in the resumed prompt"
11102
11102
  },
11103
11103
  {
11104
11104
  "flags": "--account <id>",
@@ -11601,6 +11601,36 @@ export const COMMAND_CATALOG = [
11601
11601
  ],
11602
11602
  "subcommands": []
11603
11603
  },
11604
+ {
11605
+ "name": "clear-quota",
11606
+ "description": "Clear quota reset marks for a provider account",
11607
+ "aliases": [],
11608
+ "hidden": false,
11609
+ "usage": "[options] <provider> <account-id>",
11610
+ "arguments": [
11611
+ {
11612
+ "name": "provider",
11613
+ "required": true,
11614
+ "variadic": false
11615
+ },
11616
+ {
11617
+ "name": "account-id",
11618
+ "required": true,
11619
+ "variadic": false
11620
+ }
11621
+ ],
11622
+ "options": [
11623
+ {
11624
+ "flags": "--model <model>",
11625
+ "description": "Clear only this model's reset"
11626
+ },
11627
+ {
11628
+ "flags": "--json",
11629
+ "description": "JSON on stdout (always)"
11630
+ }
11631
+ ],
11632
+ "subcommands": []
11633
+ },
11604
11634
  {
11605
11635
  "name": "enable",
11606
11636
  "description": "Enable an account without touching credentials",
@@ -53,6 +53,11 @@ export class SyncExitError extends Error {
53
53
  this.health = opts.health ?? "failed";
54
54
  }
55
55
  }
56
+ async function ensureSyncCognitoToken() {
57
+ return ensureCognitoToken({
58
+ interactive: process.stdin.isTTY === true && process.stdout.isTTY === true,
59
+ });
60
+ }
56
61
  /**
57
62
  * Preserve the vault's typed write-scope refusal at the CLI boundary. This is
58
63
  * deliberately duck-typed so hq-cli remains compatible while hq-cloud and the
@@ -845,11 +850,11 @@ export function registerCloudCommands(program) {
845
850
  }
846
851
  // Upload creds are pre-vended in entityContext, but prefix filtering
847
852
  // still needs vault-service membership/grant resolution.
848
- const accessToken = await ensureCognitoToken();
853
+ const accessToken = await ensureSyncCognitoToken();
849
854
  scopeVaultConfig = buildVaultConfig(accessToken);
850
855
  }
851
856
  else {
852
- const accessToken = await ensureCognitoToken();
857
+ const accessToken = await ensureSyncCognitoToken();
853
858
  vaultConfig = buildVaultConfig(accessToken);
854
859
  scopeVaultConfig = vaultConfig;
855
860
  }
@@ -1064,7 +1069,7 @@ export function registerCloudCommands(program) {
1064
1069
  console.log(chalk.bold("\nHQ Sync — Pull"));
1065
1070
  console.log(` HQ root: ${options.hqRoot}`);
1066
1071
  console.log(` Company: ${options.company ?? "(from .hq/config.json)"}\n`);
1067
- const accessToken = await ensureCognitoToken();
1072
+ const accessToken = await ensureSyncCognitoToken();
1068
1073
  const vaultConfig = buildVaultConfig(accessToken);
1069
1074
  // US-011 (2026-05-21 fix): resolve the caller's sync-config for
1070
1075
  // the targeted membership BEFORE the pull, so we can (a) emit the
@@ -1368,7 +1373,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
1368
1373
  console.log("");
1369
1374
  let result;
1370
1375
  try {
1371
- const accessToken = await ensureCognitoToken();
1376
+ const accessToken = await ensureSyncCognitoToken();
1372
1377
  const vaultConfig = buildVaultConfig(accessToken);
1373
1378
  const realClient = new VaultClient(vaultConfig);
1374
1379
  const adapter = {
@@ -1461,7 +1466,7 @@ export async function runPullPersonal(hqRoot, onConflict) {
1461
1466
  console.log(` HQ root: ${hqRoot}`);
1462
1467
  console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
1463
1468
  try {
1464
- const accessToken = await ensureCognitoToken();
1469
+ const accessToken = await ensureSyncCognitoToken();
1465
1470
  const vaultConfig = buildVaultConfig(accessToken);
1466
1471
  const client = new VaultClient(vaultConfig);
1467
1472
  const personalUid = await resolveCanonicalPersonUid({
@@ -1509,7 +1514,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
1509
1514
  console.log("");
1510
1515
  let result;
1511
1516
  try {
1512
- const accessToken = await ensureCognitoToken();
1517
+ const accessToken = await ensureSyncCognitoToken();
1513
1518
  const vaultConfig = buildVaultConfig(accessToken);
1514
1519
  const realClient = new VaultClient(vaultConfig);
1515
1520
  const author = resolveUploadAuthor();
@@ -1637,7 +1642,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1637
1642
  console.log(` Target: ${personal ? "(personal)" : (company ?? "(active company)")}`);
1638
1643
  console.log(` Strategy: ${onConflict ?? "(interactive)"}\n`);
1639
1644
  try {
1640
- const accessToken = await ensureCognitoToken();
1645
+ const accessToken = await ensureSyncCognitoToken();
1641
1646
  const vaultConfig = buildVaultConfig(accessToken);
1642
1647
  const author = resolveUploadAuthor();
1643
1648
  // Resolve the target. For --personal, look up the canonical person and
@@ -91,6 +91,7 @@ async function loadDefaults(over) {
91
91
  home: dir ?? home,
92
92
  existsSync: fs.existsSync,
93
93
  listDir: (dir) => fs.readdirSync(dir),
94
+ launchdJobLoaded: (label) => run("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${label}`]).status === 0,
94
95
  windowsTaskExists: (task) => run("schtasks.exe", ["/Query", "/TN", task]).status === 0,
95
96
  })),
96
97
  resolveHqRoot: over.resolveHqRoot ?? ((flag) => resolveDaemonHqRoot(flag, { env, cwd: process.cwd(), home })),
@@ -1,4 +1,5 @@
1
1
  import { Command } from "commander";
2
+ export declare const FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG = "files.share-empty-prefix-warning";
2
3
  /**
3
4
  * Parse a human-friendly duration string ("15m", "1h", "24h", "2d") into
4
5
  * milliseconds. Returns null on parse failure. Mirrors the parser used in
@@ -12,7 +12,10 @@ import { fetchChannels, matchChannelsByName } from "./dm.js";
12
12
  import { resolveCompanySlug, resolvePersonWithRosterFallback, } from "./people.js";
13
13
  import { findHqRoot } from "../utils/manifest.js";
14
14
  import { maxLength } from "../utils/table-width.js";
15
+ import { resolveCompanyFlagGate } from "../lib/flag-registry.js";
15
16
  const PERSON_UID_PATTERN = /^(prs|agt)_[A-Za-z0-9_-]+$/;
17
+ export const FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG = "files.share-empty-prefix-warning";
18
+ const FILES_SHARE_EMPTY_PREFIX_CHECK_TIMEOUT_MS = 5_000;
16
19
  function collectMentions(value, previous) {
17
20
  return [...previous, value];
18
21
  }
@@ -589,6 +592,56 @@ export function registerFilesCommand(program) {
589
592
  // files-browse.ts) onto the same group without re-creating it.
590
593
  return files;
591
594
  }
595
+ /** Best-effort, default-off warning after the ACL grant has succeeded. */
596
+ async function warnIfEmptySharePrefix(params) {
597
+ if (isPrivateFolderPattern(params.prefix))
598
+ return;
599
+ let enabled;
600
+ try {
601
+ enabled = await resolveCompanyFlagGate(params.companyUid, params.token, FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG, { fallback: false }, () => false);
602
+ }
603
+ catch (error) {
604
+ const errorClass = error instanceof Error ? error.name : "unknown error";
605
+ console.error(`hq: Could not resolve ${FILES_SHARE_EMPTY_PREFIX_WARNING_FLAG} (${errorClass}); using its fallback.`);
606
+ return;
607
+ }
608
+ if (!enabled)
609
+ return;
610
+ const listPrefix = params.fullVault
611
+ ? ""
612
+ : params.prefix.endsWith("*")
613
+ ? params.prefix.slice(0, -1)
614
+ : params.prefix;
615
+ try {
616
+ const query = {
617
+ company: params.companyUid,
618
+ };
619
+ if (listPrefix)
620
+ query.prefix = listPrefix;
621
+ const listed = await vaultApiFetch({
622
+ token: params.token,
623
+ path: "/v1/files/list",
624
+ query,
625
+ signal: AbortSignal.timeout(FILES_SHARE_EMPTY_PREFIX_CHECK_TIMEOUT_MS),
626
+ });
627
+ if (!listed.ok)
628
+ throw new Error(`files list failed (${listed.status})`);
629
+ const body = (await listed.json());
630
+ const objects = Array.isArray(body.objects) ? body.objects : [];
631
+ const hasMatchingObject = params.fullVault || params.prefix.endsWith("*")
632
+ ? objects.length > 0
633
+ : objects.some((object) => object.key === listPrefix);
634
+ if (!hasMatchingObject) {
635
+ console.warn(chalk.yellow(`Warning: no files exist under '${displayAclPattern(params.prefix)}'. ` +
636
+ `The recipient will see nothing until files sync there.`));
637
+ }
638
+ }
639
+ catch (error) {
640
+ const errorClass = error instanceof Error ? error.name : "unknown error";
641
+ console.warn(chalk.yellow(`Could not check whether '${displayAclPattern(params.prefix)}' has files; ` +
642
+ `the share succeeded (${errorClass}).`));
643
+ }
644
+ }
592
645
  async function resolveShareChannelTarget(params) {
593
646
  const token = (await resolveVaultCredential()).token;
594
647
  const channels = await fetchChannels(token);
@@ -757,6 +810,12 @@ async function runDirectGrant(params) {
757
810
  }
758
811
  const data = (await res.json());
759
812
  const printedPrefix = data.acl?.path ?? data.acl?.prefix ?? canonicalPrefix;
813
+ await warnIfEmptySharePrefix({
814
+ token,
815
+ companyUid,
816
+ prefix: canonicalPrefix,
817
+ fullVault: params.fullVault === true,
818
+ });
760
819
  const verb = autoCreated ? "Created ACL and granted" : "Granted";
761
820
  if (params.fullVault) {
762
821
  console.log(chalk.green(`${verb} ${params.permission} on the ENTIRE vault to ${principalLabel}`));
@@ -17,6 +17,9 @@ export declare function createLaneAccountsHandlers(deps: LaneAccountsHandlerDeps
17
17
  host?: string;
18
18
  company?: string;
19
19
  }) => Promise<void>;
20
+ runAccountsClearQuota: (ctx: Ctx, providerRaw: string, accountIdRaw: string, opts: {
21
+ model?: string;
22
+ }) => Promise<void>;
20
23
  runAccountsEnable: (ctx: Ctx, providerRaw: string, accountId: string) => Promise<void>;
21
24
  runAccountsDisable: (ctx: Ctx, providerRaw: string, accountId: string, opts: {
22
25
  reason?: string;
@@ -8,7 +8,7 @@ import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, PROVIDER
8
8
  import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
9
9
  import { resolveLaneEffort } from "../lib/lanes/effort.js";
10
10
  import { LanesError } from "../lib/lanes/errors.js";
11
- import { accountQuotaView, loadQuotaState } from "../lib/lanes/quota.js";
11
+ import { accountQuotaView, clearAccountQuota, loadQuotaState } from "../lib/lanes/quota.js";
12
12
  import { resolveLaneModel } from "../lib/lanes/spawn.js";
13
13
  export function createLaneAccountsHandlers(deps) {
14
14
  const { automaticBalanceMoveHistoryReason, ensureDir, laneWithRecoveredProviderSession, loadClaudeBalanceHostSnapshots, parseRemoteJson, prepareAccountMove, printJson, reconcileHostFromCtx, resolveCallerPath } = deps;
@@ -489,6 +489,29 @@ export function createLaneAccountsHandlers(deps) {
489
489
  home_source: home === account.home ? "default" : "host",
490
490
  });
491
491
  }
492
+ async function runAccountsClearQuota(ctx, providerRaw, accountIdRaw, opts) {
493
+ const provider = parseLaneProvider(providerRaw);
494
+ const account = accountIdRaw.trim();
495
+ if (!account) {
496
+ throw new AccountResolutionError("unknown_account", "account-id must be non-empty", { provider, account: accountIdRaw, enabled: [] });
497
+ }
498
+ const model = opts.model?.trim();
499
+ if (opts.model !== undefined && !model) {
500
+ throw new LanesError("invalid_model", "--model must be non-empty", {
501
+ provider,
502
+ account,
503
+ });
504
+ }
505
+ const cleared = clearAccountQuota(ctx.hqRoot(), provider, account, model === undefined ? {} : { model });
506
+ printJson({
507
+ ok: true,
508
+ action: "accounts.clear-quota",
509
+ provider,
510
+ account,
511
+ all_models_quota_reset_at: cleared.all_models_quota_reset_at,
512
+ model_quota_resets: cleared.model_quota_resets,
513
+ });
514
+ }
492
515
  async function runAccountsEnable(ctx, providerRaw, accountId) {
493
516
  const hqRoot = ctx.hqRoot();
494
517
  const provider = parseLaneProvider(providerRaw);
@@ -562,6 +585,7 @@ export function createLaneAccountsHandlers(deps) {
562
585
  runAccountsBalance,
563
586
  runAccountsLogin,
564
587
  runAccountsSetHome,
588
+ runAccountsClearQuota,
565
589
  runAccountsEnable,
566
590
  runAccountsDisable
567
591
  };
@@ -8,6 +8,7 @@ import { dispatchLaneDelivery } from "../lib/lanes/delivery-core.js";
8
8
  import { dropboxInboxHasMessages, drainDropbox, formatDropboxMessages, formatMonitorEvent, requeueDropboxMessages } from "../lib/lanes/dropbox.js";
9
9
  import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, writeWatcherDecision } from "../lib/lanes/watcher.js";
10
10
  import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
11
+ import { artifactIdentityKeys, rollbackStorySwitchAttribution, storySwitchNotice } from "../lib/lanes/story-switch.js";
11
12
  import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLaneWorker } from "../lib/lanes/spawn.js";
12
13
  import { keepAliveExclusion, keepAliveSupersededBy, LANE_RUNNING_SUPERSEDED_MESSAGE_DELIVERY_FLAG, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
13
14
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
@@ -212,8 +213,11 @@ export function createLaneMessageHandlers(deps) {
212
213
  })();
213
214
  let accepted = false;
214
215
  let claimedMessages = [];
216
+ let storySwitchRollback;
217
+ let storySwitchDeliveryStarted = false;
215
218
  const markAccepted = () => {
216
219
  accepted = true;
220
+ storySwitchDeliveryStarted = true;
217
221
  opts.onAccepted?.();
218
222
  };
219
223
  let text = opts.text;
@@ -392,19 +396,21 @@ export function createLaneMessageHandlers(deps) {
392
396
  if ((opts.project !== undefined || opts.story !== undefined) && ctx.storyLedgerEnabled()) {
393
397
  validateProjectStories(hqRoot, projectId, [storyId], lane.company?.slug);
394
398
  }
399
+ const notice = storySwitchNotice(lane.project_id, lane.story_id, projectId, storyId);
400
+ const promptedText = notice === undefined ? text : `${notice}\n\n${text}`;
395
401
  const framedMonitorEvent = opts.monitorEvent === undefined
396
402
  ? undefined
397
403
  : formatMonitorEvent({
398
404
  monitorId: opts.monitorEvent.monitor_id,
399
405
  description: opts.monitorEvent.description,
400
406
  sequence: opts.monitorEvent.sequence,
401
- text,
407
+ text: promptedText,
402
408
  });
403
- const deliveryText = framedMonitorEvent ?? text;
409
+ const deliveryText = framedMonitorEvent ?? promptedText;
404
410
  const dropboxMessage = {
405
411
  at,
406
412
  message_id: messageId,
407
- text,
413
+ text: promptedText,
408
414
  project_id: projectId,
409
415
  story_id: storyId,
410
416
  ...(opts.monitorEvent === undefined ? {} : {
@@ -414,6 +420,52 @@ export function createLaneMessageHandlers(deps) {
414
420
  sequence: opts.monitorEvent.sequence,
415
421
  }),
416
422
  };
423
+ if (projectId !== lane.project_id || storyId !== lane.story_id) {
424
+ lane = casUpdateLane(hqRoot, laneId, (current) => {
425
+ const already = current.attribution_history.some((entry) => entry.message_id === messageId);
426
+ if (already)
427
+ return current;
428
+ const existingStory = laneStory(current, projectId, storyId);
429
+ storySwitchRollback = {
430
+ message_id: messageId,
431
+ project_id: current.project_id,
432
+ story_id: current.story_id,
433
+ no_story_reason: current.no_story_reason,
434
+ ...(!existingStory ? {
435
+ added_story: { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
436
+ } : {}),
437
+ story_switch_unacknowledged: current.story_switch_unacknowledged,
438
+ };
439
+ return {
440
+ ...current,
441
+ project_id: projectId,
442
+ story_id: storyId,
443
+ no_story_reason: storyId ? undefined : current.no_story_reason,
444
+ stories: laneStory(current, projectId, storyId)
445
+ ? laneStories(current)
446
+ : [
447
+ ...laneStories(current),
448
+ { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at },
449
+ ],
450
+ attribution_history: appendCappedHistory(current.attribution_history, {
451
+ project_id: projectId,
452
+ story_id: storyId,
453
+ message_id: messageId,
454
+ at,
455
+ }),
456
+ story_switch_pending: {
457
+ previous_project_id: current.project_id,
458
+ previous_story_id: current.story_id,
459
+ project_id: projectId,
460
+ story_id: storyId,
461
+ message_id: messageId,
462
+ at,
463
+ previous_artifact_keys: artifactIdentityKeys(current.last_envelope?.artifacts),
464
+ },
465
+ story_switch_unacknowledged: undefined,
466
+ };
467
+ });
468
+ }
417
469
  let resumed = null;
418
470
  let move = null;
419
471
  let queuedOnAdmission = false;
@@ -491,6 +543,7 @@ export function createLaneMessageHandlers(deps) {
491
543
  throw new LanesError("lane_not_accepting_messages", operatorMessageRefusalReason(lane.state), { lane_id: laneId, state: lane.state });
492
544
  }
493
545
  appendDropboxMessage(hqRoot, laneId, dropboxMessage);
546
+ storySwitchDeliveryStarted = true;
494
547
  return null;
495
548
  },
496
549
  resume: async () => {
@@ -565,6 +618,8 @@ export function createLaneMessageHandlers(deps) {
565
618
  compact: opts.compact !== false,
566
619
  effort: resumeEffort ?? recordedResumeEffort(lane),
567
620
  });
621
+ storySwitchDeliveryStarted = true;
622
+ deps.onLaneWorkerStarted?.(laneId, "message");
568
623
  archiveResumeMessage(hqRoot, laneId, dropboxMessage);
569
624
  return started;
570
625
  }, hireGrantContext);
@@ -576,41 +631,10 @@ export function createLaneMessageHandlers(deps) {
576
631
  markAccepted();
577
632
  }
578
633
  // Every accepted message moves the compaction reference forward.
579
- let next = casUpdateLane(hqRoot, laneId, (current) => ({
634
+ const next = casUpdateLane(hqRoot, laneId, (current) => ({
580
635
  ...resetKeepAliveProgress(current),
581
636
  last_message_at: at,
582
637
  }));
583
- if (projectId !== lane.project_id || storyId !== lane.story_id) {
584
- next = casUpdateLane(hqRoot, laneId, (current) => {
585
- const already = current.attribution_history.some((entry) => entry.message_id === messageId);
586
- if (already)
587
- return current;
588
- return {
589
- ...current,
590
- project_id: projectId,
591
- story_id: storyId,
592
- no_story_reason: storyId ? undefined : current.no_story_reason,
593
- stories: laneStory(current, projectId, storyId)
594
- ? laneStories(current)
595
- : [
596
- ...laneStories(current),
597
- {
598
- project_id: projectId,
599
- story_id: storyId,
600
- status: "in_progress",
601
- added_at: at,
602
- updated_at: at,
603
- },
604
- ],
605
- attribution_history: appendCappedHistory(current.attribution_history, {
606
- project_id: projectId,
607
- story_id: storyId,
608
- message_id: messageId,
609
- at,
610
- }),
611
- };
612
- });
613
- }
614
638
  // What this command can claim when it returns: the message is durably
615
639
  // written (accepted). Delivery to the model is asynchronous in both
616
640
  // mechanisms. `dropbox` delivers on the worker's next tool call, and only
@@ -660,6 +684,15 @@ export function createLaneMessageHandlers(deps) {
660
684
  });
661
685
  }
662
686
  catch (err) {
687
+ if (storySwitchRollback !== undefined && !storySwitchDeliveryStarted) {
688
+ try {
689
+ casUpdateLane(hqRoot, laneId, (current) => rollbackStorySwitchAttribution(current, storySwitchRollback));
690
+ }
691
+ catch (rollbackErr) {
692
+ const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
693
+ process.stderr.write(`Could not roll back story attribution after message failure: ${rollbackMessage}\n`);
694
+ }
695
+ }
663
696
  if (claimedMessages.length > 0 && !accepted) {
664
697
  const error = err instanceof Error ? err.message : String(err);
665
698
  writeWatcherDecision(watcherOutLogPath(hqRoot, laneId), {