@songsid/agend 2.3.2-beta.10 → 2.3.2-beta.11

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 (128) hide show
  1. package/dist/agent-cli.js +48 -0
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.js +6 -0
  4. package/dist/agent-endpoint.js.map +1 -1
  5. package/dist/agent-whats-new.js +3 -0
  6. package/dist/agent-whats-new.js.map +1 -1
  7. package/dist/backend/kiro-classic-archive.d.ts +52 -0
  8. package/dist/backend/kiro-classic-archive.js +444 -0
  9. package/dist/backend/kiro-classic-archive.js.map +1 -0
  10. package/dist/backend/kiro-identity.d.ts +19 -0
  11. package/dist/backend/kiro-identity.js +43 -0
  12. package/dist/backend/kiro-identity.js.map +1 -1
  13. package/dist/backend/kiro-migration-verify.d.ts +10 -0
  14. package/dist/backend/kiro-migration-verify.js +52 -0
  15. package/dist/backend/kiro-migration-verify.js.map +1 -0
  16. package/dist/backend/kiro-v2-store-worker.js +21 -1
  17. package/dist/backend/kiro-v2-store-worker.js.map +1 -1
  18. package/dist/backend/kiro-v2-store.d.ts +8 -0
  19. package/dist/backend/kiro-v2-store.js.map +1 -1
  20. package/dist/backend/kiro.d.ts +99 -1
  21. package/dist/backend/kiro.js +352 -18
  22. package/dist/backend/kiro.js.map +1 -1
  23. package/dist/backend/types.d.ts +49 -0
  24. package/dist/backend/types.js +9 -0
  25. package/dist/backend/types.js.map +1 -1
  26. package/dist/channel/ipc-timeouts.js +3 -0
  27. package/dist/channel/ipc-timeouts.js.map +1 -1
  28. package/dist/channel/mcp-tools.js +12 -0
  29. package/dist/channel/mcp-tools.js.map +1 -1
  30. package/dist/cli.js +33 -0
  31. package/dist/cli.js.map +1 -1
  32. package/dist/config-validator.js +2 -0
  33. package/dist/config-validator.js.map +1 -1
  34. package/dist/config.js +2 -0
  35. package/dist/config.js.map +1 -1
  36. package/dist/daemon.d.ts +45 -0
  37. package/dist/daemon.js +342 -19
  38. package/dist/daemon.js.map +1 -1
  39. package/dist/dashboard-permissions.d.ts +15 -4
  40. package/dist/dashboard-permissions.js +49 -23
  41. package/dist/dashboard-permissions.js.map +1 -1
  42. package/dist/fleet-control-audit.d.ts +1 -1
  43. package/dist/fleet-control-audit.js +3 -0
  44. package/dist/fleet-control-audit.js.map +1 -1
  45. package/dist/fleet-manager.d.ts +61 -2
  46. package/dist/fleet-manager.js +421 -28
  47. package/dist/fleet-manager.js.map +1 -1
  48. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +10 -0
  49. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +25 -0
  50. package/dist/general-knowledge/skills/web-appearance/SKILL.md +25 -0
  51. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +12 -2
  52. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +12 -2
  53. package/dist/instance-config-impact.js +2 -2
  54. package/dist/instance-config-impact.js.map +1 -1
  55. package/dist/instance-lifecycle.d.ts +4 -0
  56. package/dist/instance-lifecycle.js +7 -0
  57. package/dist/instance-lifecycle.js.map +1 -1
  58. package/dist/kiro-migrate.d.ts +11 -0
  59. package/dist/kiro-migrate.js +86 -31
  60. package/dist/kiro-migrate.js.map +1 -1
  61. package/dist/kiro-session-proof.d.ts +41 -0
  62. package/dist/kiro-session-proof.js +89 -0
  63. package/dist/kiro-session-proof.js.map +1 -0
  64. package/dist/kiro-tui-forensic.d.ts +5 -0
  65. package/dist/kiro-tui-forensic.js +3 -3
  66. package/dist/kiro-tui-forensic.js.map +1 -1
  67. package/dist/locale.js +142 -0
  68. package/dist/locale.js.map +1 -1
  69. package/dist/log-service.js +1 -1
  70. package/dist/log-service.js.map +1 -1
  71. package/dist/needs-you.d.ts +3 -1
  72. package/dist/needs-you.js +4 -2
  73. package/dist/needs-you.js.map +1 -1
  74. package/dist/office-share.d.ts +12 -2
  75. package/dist/office-share.js +27 -2
  76. package/dist/office-share.js.map +1 -1
  77. package/dist/outbound-schemas.d.ts +39 -0
  78. package/dist/outbound-schemas.js +29 -0
  79. package/dist/outbound-schemas.js.map +1 -1
  80. package/dist/remote-card-prompt.d.ts +38 -0
  81. package/dist/remote-card-prompt.js +86 -0
  82. package/dist/remote-card-prompt.js.map +1 -0
  83. package/dist/settings-change.js +8 -5
  84. package/dist/settings-change.js.map +1 -1
  85. package/dist/settings-instance-patch.js +1 -1
  86. package/dist/settings-instance-patch.js.map +1 -1
  87. package/dist/tool-permissions.js +17 -0
  88. package/dist/tool-permissions.js.map +1 -1
  89. package/dist/transcript-sources.d.ts +1 -0
  90. package/dist/transcript-sources.js +2 -1
  91. package/dist/transcript-sources.js.map +1 -1
  92. package/dist/types.d.ts +5 -0
  93. package/dist/ui/chat-store.js +3 -2
  94. package/dist/ui/chat-strings.js +2 -2
  95. package/dist/ui/fleet-office-editor.js +54 -2
  96. package/dist/ui/fleet-office-layout.js +44 -7
  97. package/dist/ui/fleet-office-pack.d.ts +6 -1
  98. package/dist/ui/fleet-office-pack.js +101 -15
  99. package/dist/ui/fleet-office-store.js +43 -0
  100. package/dist/ui/fleet-office-zones.js +64 -13
  101. package/dist/ui/fleet-office.js +5 -3
  102. package/dist/ui/panel-chat.js +14 -2
  103. package/dist/ui/panel-log.js +2 -2
  104. package/dist/ui/panel-needs.js +1 -1
  105. package/dist/ui/panel-settings.js +5 -5
  106. package/dist/ui/settings-model.js +17 -9
  107. package/dist/ui/settings-strings.js +2 -0
  108. package/dist/ui/shared/app-i18n.js +2 -2
  109. package/dist/ui/shared/app-stream.js +1 -1
  110. package/dist/ui/shared/app.css +18 -2
  111. package/dist/web-api.d.ts +5 -1
  112. package/dist/web-api.js +91 -3
  113. package/dist/web-api.js.map +1 -1
  114. package/dist/web-office-acts.js +2 -2
  115. package/dist/web-office-acts.js.map +1 -1
  116. package/dist/web-office-pack-read.d.ts +35 -0
  117. package/dist/web-office-pack-read.js +252 -0
  118. package/dist/web-office-pack-read.js.map +1 -0
  119. package/dist/web-office-pack-tools.d.ts +232 -0
  120. package/dist/web-office-pack-tools.js +754 -0
  121. package/dist/web-office-pack-tools.js.map +1 -0
  122. package/dist/web-office-skins.d.ts +68 -4
  123. package/dist/web-office-skins.js +318 -33
  124. package/dist/web-office-skins.js.map +1 -1
  125. package/dist/web-office-store.d.ts +5 -2
  126. package/dist/web-office-store.js +41 -15
  127. package/dist/web-office-store.js.map +1 -1
  128. package/package.json +1 -1
@@ -1,4 +1,7 @@
1
- import { hasLegacyOffice, normalizeOfficeStorage } from "./dashboard-permissions.js";
1
+ import { getCachedKiroCliCompatibility, kiroV1DbPath, kiroV2SessionsDir } from "./backend/kiro.js";
2
+ import { readKiroV1SavedModel } from "./backend/kiro-retired-model.js";
3
+ import { sharedKiroV2StoreLane } from "./backend/kiro-v2-store.js";
4
+ import { SEPARATE_FEATURE_KEYS, hasLegacyOffice, normalizeOfficeStorage } from "./dashboard-permissions.js";
2
5
  import { loginNoticeMode, normalizeNoticeDelivery, setNoticeMode } from "./notice-delivery.js";
3
6
  import { DEFAULT_REPLY_TRIGGERS } from "./reply-triggers.js";
4
7
  import { WorkingRecoveryCoordinator, WORKING_RECOVERY_KIND, buildWorkingRecoveryPrompt } from "./working-recovery.js";
@@ -83,7 +86,7 @@ import { createBackend } from "./backend/factory.js";
83
86
  import { readEffortMetadata } from "./backend/effort-metadata.js";
84
87
  import { codexEffort, flagEffort } from "./backend/launch-effort.js";
85
88
  import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
86
- import { CLI_ENV_TTL_MS, isModelCompatible, noteResolvedBinary, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
89
+ import { CLI_ENV_TTL_MS, isModelCompatible, noteResolvedBinary, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError, ResumeBlockedError } from "./backend/types.js";
87
90
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
88
91
  import { processAttachments } from "./channel/attachment-handler.js";
89
92
  import { routeToolCall } from "./channel/tool-router.js";
@@ -170,6 +173,7 @@ import { ThemeStore } from "./web-theme-store.js";
170
173
  import { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
171
174
  import { OfficeSkinStore } from "./web-office-skins.js";
172
175
  import { OfficeActs } from "./web-office-acts.js";
176
+ import { OFFICE_PACK_TOOLS, OfficePackTools } from "./web-office-pack-tools.js";
173
177
  import { WebAppearance } from "./web-appearance.js";
174
178
  import { WebEffectLibrary } from "./web-effect-library.js";
175
179
  import { WebEffectTrials } from "./web-effect-trials.js";
@@ -225,6 +229,7 @@ import { ONBOARDING_BACKENDS, readPendingBackendSignIns } from "./backend-signin
225
229
  import { structuredSignInSupported } from "./structured-signin.js";
226
230
  import { handleSettingsRequest } from "./settings-api.js";
227
231
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
232
+ import { remoteCardAction, remoteCardChoiceLabel, remoteCardOutcomeText, remoteCardPrompt, REMOTE_CARD_TEXT_MAX } from "./remote-card-prompt.js";
228
233
  import { recentChatContext } from "./log-tail.js";
229
234
  import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
230
235
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
@@ -478,6 +483,13 @@ const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
478
483
  const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
479
484
  const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
480
485
  const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
486
+ /** #2675: a card a CLI shows in place of its composer (kiro V3's Question / MCP input), posted with its answers. */
487
+ const REMOTE_CARD_CALLBACK_PREFIX = "card-answer:";
488
+ const REMOTE_CARD_CALLBACK_RE = /^card-answer:([0-9a-f]{32}):(opt-\d{1,2}|sub-all|sub-\d{1,2}|back|skip|decline|cancel|text)$/;
489
+ /** A posted card's buttons stay this long; the card itself waits in the pane for as long as it takes. */
490
+ const REMOTE_CARD_TIMEOUT_MS = 12 * 60 * 60_000;
491
+ /** A reply already taken as a card's answer is remembered this long, so another bot's copy of it is dropped (#2675). */
492
+ const REMOTE_CARD_TAKEN_TTL_MS = 10 * 60_000;
481
493
  const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
482
494
  const OOM_RETRY_CALLBACK_PREFIX = "oom-retry:";
483
495
  /** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
@@ -486,6 +498,10 @@ const HANG_CALLBACK_PREFIX = "hang:";
486
498
  const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
487
499
  /** #2598 B2: a kiro classic → TUI move's Confirm in General. "kiro-migrate:" (13) + 32 hex + ":confirm" (8) = 53 bytes. */
488
500
  const KIRO_MIGRATE_CALLBACK_PREFIX = "kiro-migrate:";
501
+ /** #2692: a fleet admin's Approve / Reject on an agent's pending skin-pack change, in General. */
502
+ const OFFICE_PACK_CALLBACK_PREFIX = "office-pack:";
503
+ /** #2692: the skin-pack tools' IPC types (answered as web theme tools). */
504
+ const OFFICE_PACK_IPC = new Set(OFFICE_PACK_TOOLS.map(tool => `fleet_${tool}`));
489
505
  /** Long enough for an admin to read the prompt; the move is judged again on the click. */
490
506
  const KIRO_MIGRATE_TIMEOUT_MS = 10 * 60_000;
491
507
  /**
@@ -496,6 +512,7 @@ const KIRO_MIGRATE_TIMEOUT_MS = 10 * 60_000;
496
512
  */
497
513
  const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
498
514
  HANG_CALLBACK_PREFIX, EXIT_RESTART_CALLBACK_PREFIX, OOM_RETRY_CALLBACK_PREFIX, INTERACTIVE_ASSIST_CALLBACK_PREFIX,
515
+ REMOTE_CARD_CALLBACK_PREFIX,
499
516
  ]);
500
517
  /**
501
518
  * Where a reply goes on a fleet with no chat platform (web track C4): it is "sent" by being shown in the
@@ -913,6 +930,7 @@ export class FleetManager {
913
930
  pendingNonceButtons = new Map();
914
931
  /** #2598 B2: the kiro moves waiting on a fleet admin's Confirm in General, by prompt nonce. */
915
932
  pendingKiroMigrations = new Map();
933
+ kiroMigrationRuns = new Map();
916
934
  /** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
917
935
  needsYou = null;
918
936
  /** When the fleet first saw an instance crashed, for its "Needs you" age; cleared when it is not. */
@@ -925,6 +943,12 @@ export class FleetManager {
925
943
  * adapter emits — whatever fields its payload carries — can claim a dashboard click's authority.
926
944
  */
927
945
  webPromptClicks = new WeakSet();
946
+ /** #2675: a dashboard click's typed answer, for the click it came with. */
947
+ webPromptTexts = new WeakMap();
948
+ /** #2675: the nonce of each instance's posted card. */
949
+ remoteCardPosts = new Map();
950
+ /** #2675: replies taken as a card's answer (`<chatId>|<messageId>` → monotonic expiry), so another bot's copy is dropped. */
951
+ takenCardReplies = new Map();
928
952
  /** The web clicks consumeNonceCallback actually claimed (the others were refused or lost a race). */
929
953
  webPromptClaims = new WeakSet();
930
954
  // Model failover state
@@ -1235,6 +1259,98 @@ export class FleetManager {
1235
1259
  changed: acts => this.emitSseEvent("office_acts", { acts, now: Date.now() }),
1236
1260
  }));
1237
1261
  }
1262
+ officePackToolsInstance;
1263
+ /** #2692: the agents' skin-pack tools — their log, the admins' approval mode and the pending changes (memory only). */
1264
+ get officePackTools() {
1265
+ return (this.officePackToolsInstance ??= new OfficePackTools({
1266
+ skins: this.officeSkins, offices: this.officeStore,
1267
+ allowedAgents: () => this.officePackAgents(),
1268
+ notice: (text, pending) => { void this.officePackNotice(text, pending); },
1269
+ settled: (id, text) => this.settleOfficePackPrompt(id, text),
1270
+ changed: what => {
1271
+ if (what.skins !== undefined)
1272
+ this.emitSseEvent("office_skins", { rev: what.skins });
1273
+ if (what.offices !== undefined)
1274
+ this.emitSseEvent("offices", { rev: what.offices });
1275
+ if (what.changes)
1276
+ this.emitSseEvent("office_pack_changes", {});
1277
+ },
1278
+ logger: this.logger,
1279
+ }));
1280
+ }
1281
+ /** #2692: the instances whose `office_packs` feature is on (their own switch, else the defaults'): the Office page lists them. */
1282
+ officePackAgents() {
1283
+ const names = new Set([...Object.keys(this.fleetConfig?.instances ?? {}), ...(this.classicChannels?.getAll() ?? []).map(ch => ch.instanceName)]);
1284
+ return [...names].filter(name => dashboardRefusalIn(this.fleetConfig, name, "office_pack_upload") === null);
1285
+ }
1286
+ /** #2692: the nonce of each pending pack change's prompt in General (its buttons are retired when it is decided). */
1287
+ officePackPrompts = new Map();
1288
+ /**
1289
+ * #2692: tell the fleet's admins of an agent's pack change — one short message in General (notifyFleetError, never a
1290
+ * sign-in notice). A pending one comes with Approve / Reject (a fleet admin's, as a kiro move); where no General can
1291
+ * carry buttons it is the plain message, and the Office page decides it.
1292
+ */
1293
+ async officePackNotice(text, pending) {
1294
+ if (!pending) {
1295
+ this.notifyFleetError(text, { throttle: false });
1296
+ return;
1297
+ }
1298
+ const general = this.findGeneralInstance();
1299
+ const adapterId = general ? this.getInstanceAdapterId(general) : undefined;
1300
+ const adapter = general ? this.getAdapterForInstance(general) : undefined;
1301
+ const chatId = general ? this.getGroupIdForInstance(general) : undefined;
1302
+ const topic = general ? String(this.fleetConfig?.instances[general]?.topic_id ?? "") : "";
1303
+ if (general && adapterId && adapter && chatId) {
1304
+ try {
1305
+ const nonce = await this.postNonceButtonPromptOrThrow({
1306
+ prefix: OFFICE_PACK_CALLBACK_PREFIX, alertType: "clear_confirm", instanceName: pending.instance, adapter, adapterId, chatId,
1307
+ ...(topic ? { threadId: topic } : {}), message: text,
1308
+ choices: [{ action: "approve", label: t("office_packs.approve_button") }, { action: "reject", label: t("office_packs.reject_button") }],
1309
+ expiredText: t("office_packs.expired", ""), timeoutMs: Math.max(1_000, pending.expires_at - Date.now()),
1310
+ });
1311
+ if (this.officePackTools.pendingViews().some(v => v.id === pending.id))
1312
+ this.officePackPrompts.set(pending.id, nonce);
1313
+ else
1314
+ this.retireOfficePackNonce(nonce, t("office_packs.gone"));
1315
+ return;
1316
+ }
1317
+ catch (err) {
1318
+ this.logger.info({ err: err.message }, "Office pack approval prompt unavailable; the plain notice and the Office page remain");
1319
+ }
1320
+ }
1321
+ this.notifyFleetError(text, { throttle: false });
1322
+ }
1323
+ retireOfficePackNonce(nonce, text) {
1324
+ const entry = this.pendingNonceButtons.get(nonce);
1325
+ if (!entry || entry.prefix !== OFFICE_PACK_CALLBACK_PREFIX)
1326
+ return;
1327
+ this.pendingNonceButtons.delete(nonce);
1328
+ clearTimeout(entry.timer);
1329
+ void this.retireNonceButtons(entry, entry.messageId ?? "", text);
1330
+ }
1331
+ /** #2692: a pending change was decided (here or on the page) or expired: its prompt says so and loses its buttons. */
1332
+ settleOfficePackPrompt(id, text) {
1333
+ const nonce = this.officePackPrompts.get(id);
1334
+ this.officePackPrompts.delete(id);
1335
+ if (nonce)
1336
+ this.retireOfficePackNonce(nonce, text);
1337
+ }
1338
+ /** #2692: Approve / Reject on a pending pack change. Any fleet admin (the default nonce authorization), in General. */
1339
+ async handleOfficePackCallback(data, adapterId, adapter) {
1340
+ const claimed = this.consumeNonceCallback(OFFICE_PACK_CALLBACK_PREFIX, /^office-pack:([0-9a-f]{32}):(approve|reject)$/, data, adapterId, adapter);
1341
+ if (claimed === null)
1342
+ return false;
1343
+ if (claimed === "consumed")
1344
+ return true;
1345
+ const { entry, action } = claimed;
1346
+ const nonce = data.callbackData.slice(OFFICE_PACK_CALLBACK_PREFIX.length, OFFICE_PACK_CALLBACK_PREFIX.length + 32);
1347
+ const id = [...this.officePackPrompts].find(([, n]) => n === nonce)?.[0];
1348
+ if (id)
1349
+ this.officePackPrompts.delete(id);
1350
+ const decided = id ? this.officePackTools.decide(id, action, t("settings_confirm.admin_label", data.userId ?? "")) : null;
1351
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, decided?.text ?? t("office_packs.gone"));
1352
+ return true;
1353
+ }
1238
1354
  /**
1239
1355
  * #2578: each instance's state as the org chart shows it (fleet-org.js orgState, from the same fields the status
1240
1356
  * frame and the Needs-you list carry) and its display name (resolveInstanceIdentity, as getUiStatus) — read once.
@@ -1797,12 +1913,93 @@ export class FleetManager {
1797
1913
  this.logger.info({ id: view.id }, "Settings change requires local `agend settings confirm <id>`");
1798
1914
  }
1799
1915
  kiroMigrateDeps() {
1916
+ let token;
1917
+ const current = (name) => {
1918
+ const run = this.kiroMigrationRuns.get(name);
1919
+ return !!token && run?.token === token && !this.shuttingDown
1920
+ && (this.explicitStopGeneration.get(name) ?? 0) === run.stopGeneration;
1921
+ };
1800
1922
  return {
1801
- agendHome: getAgendHome(),
1802
- fleetConfig: () => this.fleetConfig,
1803
- saveFleetConfig: () => this.saveFleetConfig(),
1923
+ agendHome: getAgendHome(), fleetConfig: () => this.fleetConfig, saveFleetConfig: () => this.saveFleetConfig(),
1804
1924
  status: name => this.getInstanceStatus(name),
1805
- restart: name => this.restartSingleInstance(name, { explicit: true }),
1925
+ reserve: name => {
1926
+ if (this.kiroMigrationRuns.has(name) || this.restartsInFlight.has(name) || this.stopsInFlight.has(name))
1927
+ throw new Error(t("kiro_migrate.in_progress"));
1928
+ token = Symbol(name);
1929
+ this.kiroMigrationRuns.set(name, { token, stopGeneration: this.explicitStopGeneration.get(name) ?? 0 });
1930
+ return { current: () => current(name), release: () => {
1931
+ if (this.kiroMigrationRuns.get(name)?.token === token)
1932
+ this.kiroMigrationRuns.delete(name);
1933
+ this.scheduleDeliveryOutboxPump();
1934
+ } };
1935
+ },
1936
+ preflight: async (name, plan) => {
1937
+ const compat = getCachedKiroCliCompatibility(resolveBinary("kiro-cli"));
1938
+ if (!compat.supportsTui || !compat.supportsInstanceAgent || !compat.agentEngines?.includes("v2")
1939
+ || !/^2\.(?:3[4-9]|[4-9]\d|\d{3,})\./.test(compat.version ?? ""))
1940
+ throw new Error(t("kiro_migrate.version_required"));
1941
+ const daemon = this.daemons.get(name);
1942
+ if (this.getInstanceStatus(name) === "running" && (!daemon || !await daemon.kiroMigrationIdle()))
1943
+ throw new Error(t("kiro_migrate.idle_required"));
1944
+ if (!current(name) || daemon && this.daemons.get(name) !== daemon)
1945
+ throw new Error(t("kiro_migrate.superseded"));
1946
+ const inst = this.fleetConfig?.instances[name];
1947
+ const model = daemon?.getModelOverride() ?? inst?.model ?? (plan.classicId ? readKiroV1SavedModel({ id: plan.classicId,
1948
+ workingDirectory: plan.workingDirectory, dbPath: kiroV1DbPath(plan.credentialProfile) }) : undefined);
1949
+ if (!model && plan.classicId)
1950
+ throw new Error(t("kiro_migrate.model_unknown"));
1951
+ return model ?? "";
1952
+ },
1953
+ restart: async (name) => {
1954
+ if (!current(name))
1955
+ throw new Error(t("kiro_migrate.superseded"));
1956
+ const run = this.kiroMigrationRuns.get(name);
1957
+ const expected = run.stopGeneration + 1;
1958
+ // This restart owns exactly one stop. A person's concurrent stop must not be hidden by the workflow.
1959
+ try {
1960
+ await this.restartSingleInstance(name, { explicit: true, kiroMigration: token });
1961
+ }
1962
+ finally {
1963
+ // Even a failed replacement owns its completed stop; retain authority to clean up/pause it.
1964
+ if (this.kiroMigrationRuns.get(name) === run && this.explicitStopGeneration.get(name) === expected)
1965
+ run.stopGeneration = expected;
1966
+ }
1967
+ if (this.explicitStopGeneration.get(name) !== expected)
1968
+ throw new Error(t("kiro_migrate.superseded"));
1969
+ },
1970
+ verify: async (name, id, model, archived) => {
1971
+ const daemon = this.daemons.get(name), deadline = performance.now() + 60_000;
1972
+ if (!daemon)
1973
+ throw new Error(t("kiro_migrate.verification_failed"));
1974
+ while (performance.now() < deadline) {
1975
+ if (!current(name) || this.daemons.get(name) !== daemon)
1976
+ throw new Error(t("kiro_migrate.superseded"));
1977
+ const observed = await daemon.kiroMigrationObservation();
1978
+ if (observed) {
1979
+ if (observed.sessionId !== id || observed.model !== model || resolvePath(observed.transcript) !== resolvePath(kiroV2SessionsDir(), id + ".jsonl"))
1980
+ throw new Error(t("kiro_migrate.verification_failed"));
1981
+ const inst = this.fleetConfig.instances[name];
1982
+ const reply = await sharedKiroV2StoreLane.read({ keys: [], sessionsDir: kiroV2SessionsDir(), migration: {
1983
+ agendHome: getAgendHome(), instance: name, workingDirectory: inst.working_directory, credentialProfile: instanceCredentialProfile(inst, this.fleetConfig?.defaults, "kiro-cli"),
1984
+ sessionId: id, sessionsDir: kiroV2SessionsDir(), dbPath: kiroV1DbPath(instanceCredentialProfile(inst, this.fleetConfig?.defaults, "kiro-cli")),
1985
+ model, archived, outboxDbPath: join(this.dataDir, "delivery-outbox.db"), knownInstances: Object.keys(this.fleetConfig.instances),
1986
+ } });
1987
+ if (!current(name) || this.daemons.get(name) !== daemon)
1988
+ throw new Error(t("kiro_migrate.superseded"));
1989
+ if (reply.kind !== "ok" || !reply.verification)
1990
+ throw new Error(reply.kind === "unreadable" ? reply.detail : t("kiro_migrate.verification_failed"));
1991
+ return reply.verification;
1992
+ }
1993
+ await new Promise(r => setTimeout(r, 500));
1994
+ }
1995
+ throw new Error(t("kiro_migrate.verification_failed"));
1996
+ },
1997
+ stop: async (name) => { try {
1998
+ await this.stopInstance(name);
1999
+ }
2000
+ finally {
2001
+ writePausedMarker(this.getInstanceDir(name), Date.now(), "error");
2002
+ } },
1806
2003
  };
1807
2004
  }
1808
2005
  /**
@@ -1860,8 +2057,7 @@ export class FleetManager {
1860
2057
  const outcome = await applyKiroMigration(this.kiroMigrateDeps(), plan);
1861
2058
  this.logger.info({ name: plan.name, ok: outcome.ok, classicId: plan.classicId }, "kiro classic → TUI move");
1862
2059
  this.notifyFleetError(outcome.text);
1863
- if (outcome.ok)
1864
- this.notifyInstanceTopic(plan.name, outcome.text);
2060
+ this.notifyInstanceTopic(plan.name, outcome.text);
1865
2061
  return true;
1866
2062
  }
1867
2063
  async handleSettingsChangeCallback(data, adapterId, adapter) {
@@ -5937,6 +6133,11 @@ export class FleetManager {
5937
6133
  stopOnUnsupportedCli(name, err, what) {
5938
6134
  if (!(err instanceof UnsupportedCliError))
5939
6135
  return false;
6136
+ if (err instanceof ResumeBlockedError) {
6137
+ this.logger.error({ name, reason: err.message }, `Not starting ${what}: retained session needs store repair`);
6138
+ void this.notifyFleetError(t("fleet.resume_blocked", name, err.message, err.remedy));
6139
+ return true;
6140
+ }
5940
6141
  this.logger.error({ name, reason: err.message }, `Not starting ${what}: the installed CLI cannot run it as configured`);
5941
6142
  this.queueUnsupportedCliNotice(name, err.message);
5942
6143
  this.queueKiroIncompatNotice(name, err.message);
@@ -6136,6 +6337,9 @@ export class FleetManager {
6136
6337
  }
6137
6338
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
6138
6339
  async restartSingleInstance(name, opts) {
6340
+ const migration = this.kiroMigrationRuns?.get(name);
6341
+ if (migration && migration.token !== opts?.kiroMigration)
6342
+ throw new Error(t("kiro_migrate.in_progress"));
6139
6343
  if (opts?.recoveryCurrent && !opts.recoveryCurrent())
6140
6344
  throw new Error(t("exit.expired", name));
6141
6345
  const retry = opts?.explicit ? opts.sessionOomRetry ?? captureSessionOomRetry(this.getInstanceDir(name)) : undefined;
@@ -6196,7 +6400,7 @@ export class FleetManager {
6196
6400
  return run;
6197
6401
  }
6198
6402
  isInstanceRestarting(name) {
6199
- return this.restartsInFlight.has(name);
6403
+ return this.restartsInFlight.has(name) || this.kiroMigrationRuns?.has(name) === true;
6200
6404
  }
6201
6405
  /** InstanceLifecycleContext: an explicit wake joins the restart in flight. */
6202
6406
  restartInFlight(name) {
@@ -9948,6 +10152,10 @@ export class FleetManager {
9948
10152
  }))
9949
10153
  return;
9950
10154
  }
10155
+ // #2675: a reply to a posted question card is its typed answer (judged by who may talk to that instance), not a
10156
+ // message for the instance — which could not take one while its card is up anyway.
10157
+ if (await this.takeRemoteCardReply(msg))
10158
+ return;
9951
10159
  // Access control — classic channels are open to all, others require an allowed
9952
10160
  // user. This runs before the dedup claim, and is judged by the adapter that
9953
10161
  // owns the topic rather than the one that happened to receive the message:
@@ -11339,7 +11547,7 @@ export class FleetManager {
11339
11547
  if (!ipc || !fleetRequestId)
11340
11548
  return;
11341
11549
  const type = String(msg.type);
11342
- const responseType = isCanvasWorkerTool(type.replace(/^fleet_/, "")) || type === "fleet_office_view" || type === "fleet_office_set" ? "fleet_web_theme_response"
11550
+ const responseType = isCanvasWorkerTool(type.replace(/^fleet_/, "")) || type === "fleet_office_view" || type === "fleet_office_set" || OFFICE_PACK_IPC.has(type) ? "fleet_web_theme_response"
11343
11551
  : type.startsWith("fleet_schedule_") ? "fleet_schedule_response"
11344
11552
  : type.startsWith("fleet_decision_") ? "fleet_decision_response"
11345
11553
  : type === "fleet_task" ? "fleet_task_response"
@@ -11394,7 +11602,10 @@ export class FleetManager {
11394
11602
  || type === "fleet_set_web_widget" || type === "fleet_remove_web_widget"
11395
11603
  || type === "fleet_set_web_ambient" || type === "fleet_clear_web_ambient"
11396
11604
  || type === "fleet_export_web_mod" || type === "fleet_propose_web_mod"
11397
- || type === "fleet_office_view" || type === "fleet_office_set") {
11605
+ || type === "fleet_office_view" || type === "fleet_office_set"
11606
+ // #2692: the skin packs (OFFICE_PACK_IPC), answered as web theme tools.
11607
+ || type === "fleet_office_pack_check" || type === "fleet_office_pack_upload" || type === "fleet_office_pack_delete"
11608
+ || type === "fleet_office_pack_restore" || type === "fleet_office_pack_list" || type === "fleet_office_set_style") {
11398
11609
  this.handleWebTheme(name, msg);
11399
11610
  return;
11400
11611
  }
@@ -11416,7 +11627,8 @@ export class FleetManager {
11416
11627
  ? this.appearanceTools.handle(instanceName, tool, args)
11417
11628
  : tool === "export_web_mod" || tool === "propose_web_mod" ? this.agentMods.handle(instanceName, tool, args)
11418
11629
  : tool === "office_view" || tool === "office_set" ? this.officeActs.handle(instanceName, tool, args)
11419
- : this.webAppearance.handleTool(instanceName, tool, args);
11630
+ : OFFICE_PACK_IPC.has(`fleet_${tool}`) ? this.officePackTools.handle(instanceName, tool, args)
11631
+ : this.webAppearance.handleTool(instanceName, tool, args);
11420
11632
  ipc.send({ type: "fleet_web_theme_response", fleetRequestId, result });
11421
11633
  }
11422
11634
  catch (err) {
@@ -11854,6 +12066,8 @@ export class FleetManager {
11854
12066
  return this.agentMods.handle(instance, tool, args);
11855
12067
  if (tool === "office_view" || tool === "office_set")
11856
12068
  return this.officeActs.handle(instance, tool, args); // #2578
12069
+ if (OFFICE_PACK_IPC.has(`fleet_${tool}`))
12070
+ return this.officePackTools.handle(instance, tool, args); // #2692
11857
12071
  return this.webAppearance.handleTool(instance, tool, args);
11858
12072
  }
11859
12073
  async handlePreviewEmojisHttp(instance, args) {
@@ -12292,12 +12506,21 @@ export class FleetManager {
12292
12506
  normalizeOfficeStorage(this.fleetConfig);
12293
12507
  explicitPatches = explicitPatches.flatMap(patch => {
12294
12508
  const path = patch.path;
12295
- if (path.at(-2) === "dashboard" && path.at(-1) === "office" && !patch.remove) {
12296
- return [{ ...patch, path: [...path.slice(0, -2), "office_access"] }, { path, remove: true }];
12297
- }
12298
- if (path.at(-1) === "dashboard" && patch.value && typeof patch.value === "object" && Object.hasOwn(patch.value, "office")) {
12299
- const { office, ...dashboard } = patch.value;
12300
- return [{ ...patch, value: dashboard }, { path: [...path.slice(0, -1), "office_access"], value: office, remove: office === null }];
12509
+ // #2601 (and #2692's office_packs): a separately kept feature is written under its own key, never in `dashboard`.
12510
+ const separate = path.at(-2) === "dashboard" ? SEPARATE_FEATURE_KEYS[path.at(-1)] : undefined;
12511
+ if (separate && !patch.remove) {
12512
+ return [{ ...patch, path: [...path.slice(0, -2), separate] }, { path, remove: true }];
12513
+ }
12514
+ if (path.at(-1) === "dashboard" && patch.value && typeof patch.value === "object" && Object.keys(SEPARATE_FEATURE_KEYS).some(f => Object.hasOwn(patch.value, f))) {
12515
+ const dashboard = { ...patch.value }, moved = [];
12516
+ for (const [feature, key] of Object.entries(SEPARATE_FEATURE_KEYS)) {
12517
+ if (!Object.hasOwn(dashboard, feature))
12518
+ continue;
12519
+ const value = dashboard[feature];
12520
+ delete dashboard[feature];
12521
+ moved.push({ path: [...path.slice(0, -1), key], value, remove: value === null });
12522
+ }
12523
+ return [{ ...patch, value: dashboard }, ...moved];
12301
12524
  }
12302
12525
  // #2581's DM choice was withdrawn: an old string is just "on", posted in the topic, and its route is dropped.
12303
12526
  if (patch.path.join(".") === "web.notify_login" && patch.value === "dm")
@@ -12411,11 +12634,11 @@ export class FleetManager {
12411
12634
  const previous = daemon.getConfigSnapshot?.();
12412
12635
  if (!next || !previous)
12413
12636
  continue;
12414
- if (isDeepStrictEqual(previous.dashboard, next.dashboard) && previous.office_access === next.office_access)
12637
+ if (isDeepStrictEqual(previous.dashboard, next.dashboard) && previous.office_access === next.office_access && previous.office_pack_access === next.office_pack_access)
12415
12638
  continue;
12416
12639
  // Only permissions are adopted here. Cold fields retain the live daemon's old snapshot, so reconcile still
12417
12640
  // detects that they require a restart. MCP menus also watch the raw file; this updates the next spawn's env.
12418
- daemon.applyConfigUpdate({ dashboard: next.dashboard ?? null, office_access: next.office_access ?? null });
12641
+ daemon.applyConfigUpdate({ dashboard: next.dashboard ?? null, office_access: next.office_access ?? null, office_pack_access: next.office_pack_access ?? null });
12419
12642
  if (taskBoardSwitchedOn(previous.dashboard, next.dashboard)) {
12420
12643
  this.admitTaskBoardNotice(name, TASK_BOARD_ON_NOTICE, `task-board-on:${name}:${Date.now()}`);
12421
12644
  }
@@ -12480,11 +12703,13 @@ export class FleetManager {
12480
12703
  const patches = [];
12481
12704
  const raw = this.rawFleetConfig;
12482
12705
  for (const [path, item] of [[["defaults"], raw.defaults], ...Object.entries(raw.instances ?? {}).map(([name, value]) => [["instances", name], value])]) {
12483
- if (!item?.dashboard || !Object.hasOwn(item.dashboard, "office"))
12484
- continue;
12485
- patches.push({ path: [...path, "dashboard", "office"], remove: true });
12486
- if (!Object.hasOwn(item, "office_access") && typeof item.dashboard.office === "boolean")
12487
- patches.push({ path: [...path, "office_access"], value: item.dashboard.office });
12706
+ for (const [feature, key] of Object.entries(SEPARATE_FEATURE_KEYS)) {
12707
+ if (!item?.dashboard || !Object.hasOwn(item.dashboard, feature))
12708
+ continue;
12709
+ patches.push({ path: [...path, "dashboard", feature], remove: true });
12710
+ if (!Object.hasOwn(item, key) && typeof item.dashboard[feature] === "boolean")
12711
+ patches.push({ path: [...path, key], value: item.dashboard[feature] });
12712
+ }
12488
12713
  }
12489
12714
  this.saveFleetConfig(patches);
12490
12715
  this.logger.info("Stored Office access outside dashboard for downgrade compatibility");
@@ -12719,6 +12944,7 @@ export class FleetManager {
12719
12944
  this.mods.forgetInstance(name);
12720
12945
  this.agentMods.forget(name);
12721
12946
  this.officeActsInstance?.forget(name);
12947
+ this.officePackToolsInstance?.forget(name);
12722
12948
  }
12723
12949
  catch (err) {
12724
12950
  this.logger.warn({ err: err.message, name }, "Dropping a removed instance's web theme failed");
@@ -14350,7 +14576,7 @@ export class FleetManager {
14350
14576
  entry.web = {
14351
14577
  text: web.text,
14352
14578
  ...(links ? { links } : {}),
14353
- actions: opts.choices.map(c => ({ id: c.action, label: c.label })),
14579
+ actions: [...opts.choices.map(c => ({ id: c.action, label: c.label })), ...(opts.webInput ? [{ id: opts.webInput.action, label: opts.webInput.label, input: true }] : [])],
14354
14580
  expiresAt: Date.now() + (opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS),
14355
14581
  };
14356
14582
  this.emitSseEvent("prompt", { instance: entry.instanceName, nonce, ...entry.web });
@@ -14527,6 +14753,8 @@ export class FleetManager {
14527
14753
  return true;
14528
14754
  if (await this.handleKiroMigrateCallback(data, adapterId, adapter))
14529
14755
  return true;
14756
+ if (await this.handleOfficePackCallback(data, adapterId, adapter))
14757
+ return true;
14530
14758
  if (await this.handleDashboardCallback(data, adapterId, adapter))
14531
14759
  return true;
14532
14760
  if (await this.handleClassicApproval(data, adapterId, adapter))
@@ -14543,6 +14771,8 @@ export class FleetManager {
14543
14771
  return true;
14544
14772
  if (await this.handleInteractivePromptAssist(data, adapterId, adapter))
14545
14773
  return true;
14774
+ if (await this.handleRemoteCardClick(data, adapterId, adapter))
14775
+ return true;
14546
14776
  if (await this.handleClassicBackendSelection(data))
14547
14777
  return true;
14548
14778
  if (await this.handleModelSelection(data, adapterId))
@@ -15016,8 +15246,12 @@ export class FleetManager {
15016
15246
  const buttons = this.replyButtons();
15017
15247
  return buttons ? buttons.clickWeb(instance, id, index, voter) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
15018
15248
  }
15019
- async clickWebPrompt(instance, nonce, action) {
15020
- if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
15249
+ async clickWebPrompt(instance, nonce, action, text) {
15250
+ if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z0-9-]{0,23}$/.test(action))
15251
+ return { status: 400, error: "Malformed prompt answer" };
15252
+ // #2675: only an action the page shows a text box for takes text, and it must say something.
15253
+ const input = this.pendingNonceButtons.get(nonce)?.web?.actions.find(a => a.id === action)?.input === true;
15254
+ if (input !== (text !== undefined) || (input && (!text.trim() || text.length > REMOTE_CARD_TEXT_MAX)))
15021
15255
  return { status: 400, error: "Malformed prompt answer" };
15022
15256
  const entry = this.pendingNonceButtons.get(nonce);
15023
15257
  // Unknown is the same answer as answered or expired: the page drops the buttons either way.
@@ -15039,6 +15273,8 @@ export class FleetManager {
15039
15273
  notice = n; },
15040
15274
  };
15041
15275
  this.webPromptClicks.add(data);
15276
+ if (text !== undefined)
15277
+ this.webPromptTexts.set(data, text);
15042
15278
  await this.dispatchAdapterCallback(data, entry.adapterId, entry.adapter);
15043
15279
  if (this.webPromptClaims.has(data))
15044
15280
  return { status: 200 };
@@ -16096,6 +16332,163 @@ export class FleetManager {
16096
16332
  }
16097
16333
  return true;
16098
16334
  }
16335
+ // ── #2675: a card a CLI shows in place of its composer, answered from the chat ─────────────────────────────────
16336
+ /**
16337
+ * Where an instance's card is put to people: its own place — its topic, or its ClassicBot room — or, on a fleet with
16338
+ * no chat platform, the dashboard. Never General: the answer is the instance's conversation.
16339
+ */
16340
+ remoteCardPlace(instanceName) {
16341
+ const web = this.webOnlyPromptPlace();
16342
+ if (web)
16343
+ return web;
16344
+ const room = this.classicChannels?.getChannelIdByInstance(instanceName);
16345
+ if (room) {
16346
+ const adapterId = this.classicChannels?.getAdapterIdByInstance(instanceName) ?? this.getPrimaryAdapterId();
16347
+ const adapter = adapterId ? this.worlds.get(adapterId)?.adapter : undefined;
16348
+ return adapter && adapterId ? { adapter, adapterId, chatId: room } : null;
16349
+ }
16350
+ const adapterId = this.getInstanceAdapterId(instanceName);
16351
+ const adapter = adapterId ? this.worlds.get(adapterId)?.adapter : undefined;
16352
+ const chatId = this.getGroupIdForInstance(instanceName);
16353
+ const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
16354
+ if (!adapter || !adapterId || !chatId || topicId == null)
16355
+ return null;
16356
+ return { adapter, adapterId, chatId, threadId: String(topicId) };
16357
+ }
16358
+ /**
16359
+ * The daemon saw a card held still on `instanceName`'s screen (remote_card): put it to the people who talk to that
16360
+ * instance, with a button per answer. A card already posted for the instance is retired first (the daemon said it
16361
+ * was gone; this is the fallback).
16362
+ */
16363
+ async postRemoteCard(instanceName, card) {
16364
+ this.retireRemoteCard(instanceName, null, t("card.answered_in_pane"));
16365
+ const place = this.remoteCardPlace(instanceName);
16366
+ if (!place) {
16367
+ this.logger.warn({ instanceName }, "A card is waiting on screen but the instance has no place to post it — the parked notice stands");
16368
+ return;
16369
+ }
16370
+ const prompt = remoteCardPrompt(instanceName, card);
16371
+ const nonce = await this.postNonceButtonPrompt({
16372
+ prefix: REMOTE_CARD_CALLBACK_PREFIX, alertType: "interactive_prompt", instanceName, ...place,
16373
+ message: prompt.message, choices: prompt.choices, webInput: prompt.webInput,
16374
+ expiredText: t("card.expired", instanceName), timeoutMs: REMOTE_CARD_TIMEOUT_MS,
16375
+ extra: { remoteCard: { identity: card.identity, kind: card.kind, options: card.kind === "question" ? card.options.map(o => ({ n: o.n, title: o.title })) : [] } },
16376
+ });
16377
+ if (nonce)
16378
+ this.remoteCardPosts.set(instanceName, nonce);
16379
+ }
16380
+ /**
16381
+ * The card is gone from the screen (remote_card_gone, `identity`; null: whichever is posted): its buttons collapse to
16382
+ * `text` if nobody answered them here.
16383
+ */
16384
+ retireRemoteCard(instanceName, identity, text = t("card.answered_in_pane")) {
16385
+ const nonce = this.remoteCardPosts.get(instanceName);
16386
+ if (!nonce)
16387
+ return;
16388
+ const entry = this.pendingNonceButtons.get(nonce);
16389
+ if (entry && identity !== null && entry.remoteCard?.identity !== identity)
16390
+ return;
16391
+ this.remoteCardPosts.delete(instanceName);
16392
+ if (!entry)
16393
+ return;
16394
+ this.pendingNonceButtons.delete(nonce);
16395
+ if (entry.timer)
16396
+ clearTimeout(entry.timer);
16397
+ void this.retireNonceButtons(entry, entry.messageId ?? "", text);
16398
+ }
16399
+ /** Who may answer an instance's card: whoever may already talk to it there (a ClassicBot room is open to all). */
16400
+ mayAnswerRemoteCard(userId, entry) {
16401
+ if (this.classicChannels?.getChannelIdByInstance(entry.instanceName) === entry.chatId)
16402
+ return true;
16403
+ const am = this.worlds.get(entry.adapterId)?.accessManager ?? this.accessManager;
16404
+ return !am || am.isAllowed(userId);
16405
+ }
16406
+ /** A click on a card's button (platform or dashboard). */
16407
+ async handleRemoteCardClick(data, callbackAdapterId, receivingAdapter) {
16408
+ if (!data.callbackData.startsWith(REMOTE_CARD_CALLBACK_PREFIX))
16409
+ return false;
16410
+ // A typed answer from the dashboard must come with its text; refused before the claim, so the buttons stay.
16411
+ const match = REMOTE_CARD_CALLBACK_RE.exec(data.callbackData);
16412
+ if (match?.[2] === "text" && remoteCardAction("text", this.webPromptTexts.get(data)) === null) {
16413
+ data.ack?.(t("buttons.stale_notice"));
16414
+ return true;
16415
+ }
16416
+ const claimed = this.consumeNonceCallback(REMOTE_CARD_CALLBACK_PREFIX, REMOTE_CARD_CALLBACK_RE, data, callbackAdapterId, receivingAdapter, undefined, (userId, entry) => this.mayAnswerRemoteCard(userId, entry));
16417
+ if (claimed === null)
16418
+ return false;
16419
+ if (claimed === "consumed")
16420
+ return true;
16421
+ const action = remoteCardAction(claimed.action, this.webPromptTexts.get(data));
16422
+ const who = this.webPromptClicks.has(data) ? t("card.someone_on_dashboard") : (data.username ?? data.userId ?? "?");
16423
+ await this.answerRemoteCardFor(claimed.entry, action, who, data.messageId, data.editClicked);
16424
+ return true;
16425
+ }
16426
+ /**
16427
+ * An inbound message that replies to a posted question card, by someone who may answer it: its text is the typed
16428
+ * answer, and the message goes no further (true). Another bot's copy of a reply already taken is dropped (true).
16429
+ */
16430
+ async takeRemoteCardReply(msg) {
16431
+ if (msg.isBotMessage || !msg.replyTo)
16432
+ return false;
16433
+ const now = performance.now();
16434
+ for (const [key, until] of this.takenCardReplies)
16435
+ if (until <= now)
16436
+ this.takenCardReplies.delete(key);
16437
+ const key = `${msg.chatId}|${msg.messageId}`;
16438
+ if (this.takenCardReplies.has(key))
16439
+ return true;
16440
+ for (const [nonce, entry] of this.pendingNonceButtons) {
16441
+ if (entry.prefix !== REMOTE_CARD_CALLBACK_PREFIX || entry.remoteCard?.kind !== "question")
16442
+ continue;
16443
+ // By place, whichever of the fleet's bots received the reply (a sibling's copy can arrive first, #2680 review), on
16444
+ // the post's own platform; who may answer is judged by the post's world (mayAnswerRemoteCard), as Needs you does.
16445
+ if (entry.chatId !== msg.chatId || entry.messageId !== msg.replyTo || entry.adapter.type !== msg.source)
16446
+ continue;
16447
+ if (!msg.userId || !this.mayAnswerRemoteCard(msg.userId, entry))
16448
+ return false; // not theirs to answer: it is just a message
16449
+ const action = remoteCardAction("text", msg.text ?? "");
16450
+ if (!action)
16451
+ return false;
16452
+ this.pendingNonceButtons.delete(nonce);
16453
+ if (entry.timer)
16454
+ clearTimeout(entry.timer);
16455
+ this.webPromptGone(entry);
16456
+ this.takenCardReplies.set(key, now + REMOTE_CARD_TAKEN_TTL_MS);
16457
+ await this.answerRemoteCardFor(entry, action, msg.username ?? msg.userId, entry.messageId ?? msg.replyTo);
16458
+ return true;
16459
+ }
16460
+ return false;
16461
+ }
16462
+ /**
16463
+ * Key a claimed answer into the instance's card (the daemon checks it is still that card and walks a verified
16464
+ * cursor), then collapse the post to what happened. When the card is still waiting and was not answered — refused
16465
+ * or stopped part-way — it is posted again, so it can still be answered from here.
16466
+ */
16467
+ async answerRemoteCardFor(entry, action, who, messageId, editClicked) {
16468
+ if (this.remoteCardPosts.get(entry.instanceName) === entry.nonce)
16469
+ this.remoteCardPosts.delete(entry.instanceName);
16470
+ const daemon = this.daemons.get(entry.instanceName);
16471
+ const card = entry.remoteCard;
16472
+ let outcome;
16473
+ if (!card || !action)
16474
+ outcome = { outcome: "refused", reason: "malformed" };
16475
+ else if (!daemon?.answerRemoteCard)
16476
+ outcome = { outcome: "stale" };
16477
+ else {
16478
+ try {
16479
+ outcome = await daemon.answerRemoteCard(card.identity, action);
16480
+ }
16481
+ catch (err) {
16482
+ this.logger.warn({ err: err?.message, instanceName: entry.instanceName }, "Answering a card from the chat failed");
16483
+ outcome = { outcome: "stopped", step: "error" };
16484
+ }
16485
+ }
16486
+ this.eventLog?.insert(entry.instanceName, "remote_card_answer", { action: action?.kind ?? null, outcome: outcome.outcome });
16487
+ const choice = card && action ? remoteCardChoiceLabel(card, action) : "?";
16488
+ await this.retireNonceButtons(entry, messageId, remoteCardOutcomeText(entry.instanceName, who, choice, outcome), editClicked);
16489
+ if (outcome.outcome === "refused" || outcome.outcome === "stopped")
16490
+ daemon?.repostRemoteCard?.();
16491
+ }
16099
16492
  // ── Cancel button ────────────────────────────────────────────────────
16100
16493
  // Sent after delivering a user message to an instance; clicking it (or
16101
16494
  // /cancel) sends Escape to the instance's pane to interrupt generation.