openmausbot 0.1.87 → 0.1.89

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 (150) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-BTiuL79F.js → abnfDiagram-O67JEVCF-BwaMaZEm.js} +1 -1
  3. package/dist/assets/{arc-C88Blc2v.js → arc-Be-8GKxd.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-BZsJIZrb.js → architectureDiagram-NJMV4G6O-Dp_U2Kv1.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CVeMe9Ah.js → blockDiagram-BEXU5L5S-DQjUjYUk.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-Cedk3Bhx.js → c4Diagram-YGBWAQC7-Bd3aE4Kc.js} +1 -1
  7. package/dist/assets/channel-DqY_gMLg.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-D-VRqzTB.js → chunk-2Q5K7J3B-BijjTBeJ.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BChov01Y.js → chunk-3FUC2YCW-CMGdwvzW.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-C-TfwvHy.js → chunk-5VM5RSS4-0G09BfsZ.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Db7CGj5Y.js → chunk-GTNCS2PH-FOp3vSHe.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CNvIfqKT.js → chunk-GWA4HPMP-Dv-20VIn.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-C_bEp5tq.js → chunk-JWPE2WC7-CRgrdFcz.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-Coi4BOls.js → chunk-NETBCI7D-3hP4f7QB.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CzylEuL3.js → chunk-WEXAMYUT-CG98IjlG.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-m7p7ztaD.js → chunk-XXDRQBXY-BrSTC9xG.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BMy6o9x2.js → classDiagram-v2-NBCMYWYE-rpQQQDFX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BDQ__0_J.js → cose-bilkent-JH36ORCC-G6RSafHA.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-Djv4ghUb.js → cynefin-OW5HDTMX-CXjv-IM2.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BULrlGYH.js → cynefinDiagram-VND7K2PF-Dw07MR9f.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-D-jrT_hU.js → dagre-6A5THRUB-BwPnlTg2.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-p2UhQmfL.js → diagram-22UHCM2B-Cm6xcMiz.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-DHvGWZ7q.js → diagram-3UASUU5V-CDpogPbE.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-ChhKDot-.js → diagram-ATOU4E4O-BV8z3Pnc.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-B-T7A43R.js → diagram-CDSNMT55-CKCQhOOC.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CJm8ENNY.js → diagram-MLGK6HIB-FGvJg69C.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-510tlza8.js → diagram-MPIPVDR6-CwATvgnS.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-PkvRPHel.js → ebnfDiagram-ZINNZB2B-CH_c3SSC.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BVBVPoX3.js → elk-276RUBZZ-3guZi71R.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BgYMVm7L.js → erDiagram-OPXOYQCR-BMHphR2n.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-BGqsnc_3.js → flowDiagram-KWPJA3E3-Vm5DkW5x.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-D_-UeReH.js → ganttDiagram-FUAMR5RP-DO-1ct07.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-D0uooONx.js → gitGraphDiagram-X574FWY7-nxeksEIN.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-BVPdbq2r.js +583 -0
  36. package/dist/assets/{index-zRsJERGJ.js → index-Oxk9x45i.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B9e1z77f.js → infoDiagram-VRGFBTTK-Dkcvr0p7.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BB4vNwvD.js → ishikawaDiagram-OU5B5YK6-BwVibkoO.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DpkosOTB.js → journeyDiagram-ZHPQQLJL-ByiBZqgG.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DbDP4kUu.js → kanban-definition-PNTS6WVX-qqjgXBF3.js} +1 -1
  41. package/dist/assets/{linear-DBJGPmjT.js → linear-CtJnKe6A.js} +1 -1
  42. package/dist/assets/{mermaid.core-DWPRi-ue.js → mermaid.core-BEiDklrY.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-nszBbJmx.js → mindmap-definition-NLK3R4M7-DfIYHia9.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CWykJWu_.js → pegDiagram-GJSIUBJH-CYyRqa1a.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-g3Da-Vnv.js → pieDiagram-5QR66LMP-hOToy_zH.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Wzrlpzgh.js → quadrantDiagram-O4NWA36T-DKAsNN9I.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-moHTFMC0.js → railroadDiagram-XR7U4H2S-BSA-d8JU.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CwfXdFH8.js → requirementDiagram-PLB6GJNP-CSmlvb-p.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-3ZROYvnq.js → sankeyDiagram-IPEJSGJF-CD5F6fnG.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-E1TBebP8.js → sequenceDiagram-PO4LG4MO-JUq5sx6d.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-DHKEU5pu.js → sizeCapture-INFHLROL-BjIS-Stk.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BguEj09D.js → stateDiagram-v2-GCMORJYK-DklkUxOg.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-C7IDYnhd.js → swimlanes-2SLR337P-CsjIU6be.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-DCW-F5PW.js → timeline-definition-EJHVYXUP-BkFqK7AB.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-xBMFtWYV.js → usecaseDiagram-POWQR4AR-70gJB1EU.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-B5nIkuH8.js → vennDiagram-UO4OBE2U-83Tm62dN.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DYFAhdbr.js → wardleyDiagram-VNRHLVJA-tTmseoX9.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DeXwX6Cz.js → xychartDiagram-PMCCYNJV-R1LBA55a.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/companion/src/routes.js +8 -1
  62. package/dist-server/connector-proxy.js +105 -1
  63. package/dist-server/container-mcp.js +11 -1
  64. package/dist-server/drivers/agents-proxy.js +277 -22
  65. package/dist-server/index.js +23722 -20272
  66. package/dist-server/local-computer-proxy.js +2 -0
  67. package/dist-server/mcp-gate.js +2 -0
  68. package/dist-server/mcp-server.js +5 -3
  69. package/dist-server/message-search.worker.js +52 -0
  70. package/dist-server/openmausbot.js +755 -404
  71. package/dist-server/pair-cli.js +755 -404
  72. package/dist-server/server/admission.js +81 -0
  73. package/dist-server/server/aside-queue.js +294 -0
  74. package/dist-server/server/attachments.js +43 -1
  75. package/dist-server/server/automatic-recovery.js +15 -0
  76. package/dist-server/server/bot-attachment.js +107 -0
  77. package/dist-server/server/bot-overview.js +19 -0
  78. package/dist-server/server/composio.js +297 -1
  79. package/dist-server/server/config.js +34 -3
  80. package/dist-server/server/connector-advertisement.js +165 -0
  81. package/dist-server/server/connector-proxy.js +14 -2
  82. package/dist-server/server/connector-verdict.js +31 -4
  83. package/dist-server/server/container-computer.js +70 -0
  84. package/dist-server/server/contracts.js +32 -0
  85. package/dist-server/server/drivers/acp/core.js +148 -23
  86. package/dist-server/server/drivers/acp/cursor.js +3 -7
  87. package/dist-server/server/drivers/acp/droid.js +4 -7
  88. package/dist-server/server/drivers/acp/gemini.js +4 -4
  89. package/dist-server/server/drivers/acp/grok.js +4 -9
  90. package/dist-server/server/drivers/acp/hermes.js +3 -5
  91. package/dist-server/server/drivers/acp/opencode-go.js +2 -5
  92. package/dist-server/server/drivers/acp/qwen.js +12 -15
  93. package/dist-server/server/drivers/agents-call.js +137 -10
  94. package/dist-server/server/drivers/agents-catalog.js +100 -6
  95. package/dist-server/server/drivers/boxagent.js +3 -17
  96. package/dist-server/server/drivers/claude.js +31 -17
  97. package/dist-server/server/drivers/codex-catalog.js +14 -6
  98. package/dist-server/server/drivers/codex-identity.js +2 -1
  99. package/dist-server/server/drivers/codex-release.js +48 -0
  100. package/dist-server/server/drivers/codex.js +7 -25
  101. package/dist-server/server/drivers/openai-chat.js +31 -3
  102. package/dist-server/server/drivers/pi.js +237 -30
  103. package/dist-server/server/drivers/prompt-split.js +32 -9
  104. package/dist-server/server/drivers/session-idle.js +29 -0
  105. package/dist-server/server/env-path.js +15 -0
  106. package/dist-server/server/guarded-requests.js +2 -2
  107. package/dist-server/server/harness-capabilities.js +61 -0
  108. package/dist-server/server/index.js +1173 -262
  109. package/dist-server/server/message-db.js +22 -42
  110. package/dist-server/server/message-file.js +9 -1
  111. package/dist-server/server/message-search-query.js +43 -0
  112. package/dist-server/server/message-search-worker.js +80 -0
  113. package/dist-server/server/message-search.worker.js +21 -0
  114. package/dist-server/server/model-requests.js +248 -0
  115. package/dist-server/server/options-card.js +21 -0
  116. package/dist-server/server/org-library.js +15 -1
  117. package/dist-server/server/peer-delivery.js +60 -0
  118. package/dist-server/server/peer-roster.js +57 -0
  119. package/dist-server/server/presets.js +32 -32
  120. package/dist-server/server/profile-requests.js +66 -39
  121. package/dist-server/server/profile-revision.js +9 -1
  122. package/dist-server/server/profile-versions.js +6 -3
  123. package/dist-server/server/routine-requests.js +29 -7
  124. package/dist-server/server/skill-fetch.js +72 -14
  125. package/dist-server/server/skills.js +1 -0
  126. package/dist-server/server/store.js +32 -2
  127. package/dist-server/server/system-prompt.js +37 -2
  128. package/dist-server/server/team-setup-requests.js +124 -23
  129. package/dist-server/server/thread-events.js +3 -1
  130. package/dist-server/server/tightening-requests.js +405 -0
  131. package/dist-server/server/tts/chatterbox.js +27 -5
  132. package/dist-server/server/usage-ledger.js +7 -1
  133. package/dist-server/server/webhooks.js +21 -3
  134. package/dist-server/server/workspace-backup.js +40 -3
  135. package/dist-server/server/workspace-backup.worker.js +10 -0
  136. package/dist-server/shared/ask-question.js +19 -0
  137. package/dist-server/shared/credential-request.js +5 -1
  138. package/dist-server/shared/line-diff.js +23 -0
  139. package/dist-server/shared/model-request.js +1 -0
  140. package/dist-server/shared/options-card.js +51 -0
  141. package/dist-server/shared/profile-request.js +5 -4
  142. package/dist-server/shared/tightening-request.js +87 -0
  143. package/dist-server/src/lib/composer-attachments.js +42 -3
  144. package/dist-server/vps-container-mcp.js +11 -1
  145. package/dist-server/workspace-backup.worker.js +19482 -0
  146. package/package.json +1 -1
  147. package/dist/assets/channel-taCNRlUU.js +0 -1
  148. package/dist/assets/index-BwdMAQ6p.css +0 -1
  149. package/dist/assets/index-ByJennr7.js +0 -581
  150. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +0 -8
@@ -0,0 +1,405 @@
1
+ // propose_tightening: a bot (or a Chief for a section peer) proposes an
2
+ // authority REDUCTION through a human-confirmed card. Same shape as
3
+ // profile-requests.ts: everything is re-validated at confirm time — a card
4
+ // can sit open for days, so the direction is checked against live state
5
+ // again and the revision (a hash of the touched authority surface) must
6
+ // still match. Escalation is inexpressible in the payload and rejected by
7
+ // the validator; restoring authority stays owner-only in Edit Profile.
8
+ import { createHash } from "node:crypto";
9
+ import { z } from "zod";
10
+ import { approvalModeFor } from "../shared/approval-mode.js";
11
+ import { TIGHTENING_MODE_TARGETS, validateTightening, } from "../shared/tightening-request.js";
12
+ import { newId } from "./contracts.js";
13
+ import { redactSecretsInText } from "./redact.js";
14
+ const MAX_REASON = 500;
15
+ const STALE = "This bot's authority changed after this card was prepared. Ask the bot to review it and propose again.";
16
+ const NO_SUCH_BOT = "That bot no longer exists";
17
+ const CHOOSE_ONE = "Choose at least one tightening intent: approvalMode, composio, browser, approvePeerComms, alwaysAllow, mcpServers, skills";
18
+ const INTENT_KEYS = [
19
+ "approvalMode",
20
+ "composio",
21
+ "browser",
22
+ "approvePeerComms",
23
+ "alwaysAllow",
24
+ "mcpServers",
25
+ "skills",
26
+ ];
27
+ /** Direction is enforced by shape first: the payload cannot name "full" or
28
+ * "custom" as a target, cannot ask to switch composio/browser back on, and
29
+ * the list fields only carry removals. The state-relative remainder (an
30
+ * upward move inside ask/edits/auto, a grant not currently held) is checked
31
+ * by validateTightening against live state at propose and confirm. */
32
+ const intentsSchema = z.object({
33
+ approvalMode: z.enum(TIGHTENING_MODE_TARGETS, {
34
+ error: 'approvalMode may only be proposed as "ask", "edits", or "auto"; full and custom stay owner-only',
35
+ }).optional(),
36
+ composio: z.literal(false, { error: "composio may only be turned off" }).optional(),
37
+ browser: z.literal(false, { error: "browser may only be turned off" }).optional(),
38
+ approvePeerComms: z.literal(true, { error: "approvePeerComms may only be turned on" }).optional(),
39
+ alwaysAllow: z.array(z.string().min(1).max(200)).max(200).optional(),
40
+ mcpServers: z.array(z.string().min(1).max(200)).max(100).optional(),
41
+ skills: z.array(z.string().min(1).max(100)).max(100).optional(),
42
+ });
43
+ export class TighteningRequestError extends Error {
44
+ status;
45
+ constructor(message, status = 400) {
46
+ super(message);
47
+ this.name = "TighteningRequestError";
48
+ this.status = status;
49
+ }
50
+ }
51
+ function reasonText(value) {
52
+ if (typeof value !== "string")
53
+ throw new TighteningRequestError("reason is required");
54
+ const trimmed = value.trim();
55
+ if (!trimmed)
56
+ throw new TighteningRequestError("reason is required");
57
+ if (trimmed.length > MAX_REASON) {
58
+ throw new TighteningRequestError(`reason must be ${MAX_REASON} characters or fewer`);
59
+ }
60
+ return redactSecretsInText(trimmed);
61
+ }
62
+ function parseIntents(input) {
63
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
64
+ throw new TighteningRequestError(CHOOSE_ONE);
65
+ }
66
+ const record = input;
67
+ for (const key of Object.keys(record)) {
68
+ if (!INTENT_KEYS.includes(key)) {
69
+ throw new TighteningRequestError(`unsupported tightening field: ${key}`);
70
+ }
71
+ }
72
+ const parsed = intentsSchema.safeParse(input);
73
+ if (!parsed.success) {
74
+ throw new TighteningRequestError(parsed.error.issues[0]?.message ?? CHOOSE_ONE);
75
+ }
76
+ const intents = parsed.data;
77
+ // Copy through only the keys the caller sent, so "did this card touch
78
+ // approvalMode" stays answerable from the payload alone.
79
+ const present = {};
80
+ if (record.approvalMode !== undefined)
81
+ present.approvalMode = intents.approvalMode;
82
+ if (record.composio !== undefined)
83
+ present.composio = intents.composio;
84
+ if (record.browser !== undefined)
85
+ present.browser = intents.browser;
86
+ if (record.approvePeerComms !== undefined)
87
+ present.approvePeerComms = intents.approvePeerComms;
88
+ if (record.alwaysAllow !== undefined)
89
+ present.alwaysAllow = intents.alwaysAllow;
90
+ if (record.mcpServers !== undefined)
91
+ present.mcpServers = intents.mcpServers;
92
+ if (record.skills !== undefined)
93
+ present.skills = intents.skills;
94
+ if (Object.keys(present).length === 0)
95
+ throw new TighteningRequestError(CHOOSE_ONE);
96
+ return present;
97
+ }
98
+ /** The card revision: a hash of exactly the authority surface a tightening
99
+ * can touch, plus the private receipt (an older, interrupted card cannot
100
+ * apply twice). Sorted lists so an unrelated reorder is not a false stale. */
101
+ export function tighteningRevision(state, lastTighteningRequestId = "") {
102
+ return createHash("sha256")
103
+ .update(JSON.stringify({
104
+ approvalMode: state.approvalMode,
105
+ composio: state.composio,
106
+ browser: state.browser,
107
+ approvePeerComms: state.approvePeerComms,
108
+ alwaysAllow: [...state.alwaysAllow].sort(),
109
+ mcpServers: [...state.mcpServers].sort(),
110
+ skills: [...state.skills].sort(),
111
+ lastTighteningRequestId,
112
+ }), "utf8")
113
+ .digest("hex");
114
+ }
115
+ export function tighteningSnapshot(bot, extras) {
116
+ return {
117
+ approvalMode: approvalModeFor(bot),
118
+ composio: bot.composio !== false,
119
+ browser: bot.browser !== false,
120
+ approvePeerComms: bot.approvePeerComms === true,
121
+ alwaysAllow: bot.alwaysAllow ?? [],
122
+ mcpServers: extras.mcpServers ?? [...(bot.mcpServers ?? [])],
123
+ skills: extras.skills ?? [],
124
+ };
125
+ }
126
+ /** Minimal card copy: the judged facts (field, before, after, names) plus
127
+ * the standing rule that only the owner can grant authority back. */
128
+ export function tighteningCardCopy(target, before, intents, reason) {
129
+ const title = target.crossBot ? `Tighten @${target.name}'s permissions?` : `Tighten ${target.name}'s permissions?`;
130
+ const lines = target.crossBot ? [`Whose permissions: @${target.name}`, `Why: ${reason}`] : [`Why: ${reason}`];
131
+ if (intents.approvalMode !== undefined)
132
+ lines.push(`Approvals: ${before.approvalMode} → ${intents.approvalMode}`);
133
+ if (intents.composio !== undefined)
134
+ lines.push("Connected apps: on → off");
135
+ if (intents.browser !== undefined)
136
+ lines.push("Browser: on → off");
137
+ if (intents.approvePeerComms !== undefined)
138
+ lines.push("Peer contact: no approval → ask first");
139
+ if (intents.alwaysAllow?.length)
140
+ lines.push(`Always-allow grants: remove ${intents.alwaysAllow.join(", ")}`);
141
+ if (intents.mcpServers?.length)
142
+ lines.push(`MCP servers: unmount ${intents.mcpServers.join(", ")}`);
143
+ if (intents.skills?.length)
144
+ lines.push(`Skills: disable ${intents.skills.join(", ")}`);
145
+ lines.push(`This reduces ${target.name}'s authority. Only the owner can grant it back.`);
146
+ const fields = INTENT_KEYS.filter((key) => intents[key] !== undefined);
147
+ return { title, summary: `${title} · ${fields.join(", ")}`, detail: lines.join("\n") };
148
+ }
149
+ export class TighteningRequestService {
150
+ store;
151
+ now;
152
+ canPersist;
153
+ autoApply;
154
+ mountedMcpServers;
155
+ enabledSkills;
156
+ disableSkill;
157
+ targetBusy;
158
+ /** Public: a caller's section membership can change between propose and
159
+ * confirm, and tests flip this mid-scenario to model that. */
160
+ validateTarget;
161
+ constructor(options) {
162
+ this.store = options.store;
163
+ this.now = options.now ?? Date.now;
164
+ this.canPersist = options.canPersist;
165
+ this.autoApply = options.autoApply;
166
+ this.mountedMcpServers = options.mountedMcpServers;
167
+ this.enabledSkills = options.enabledSkills;
168
+ this.disableSkill = options.disableSkill;
169
+ this.targetBusy = options.targetBusy;
170
+ this.validateTarget = options.validateTarget;
171
+ }
172
+ snapshotOf(bot) {
173
+ return tighteningSnapshot(bot, {
174
+ mcpServers: this.mountedMcpServers?.(bot),
175
+ skills: this.enabledSkills?.(bot.id) ?? [],
176
+ });
177
+ }
178
+ /** Disable every named skill, or report the first failure together with
179
+ * the names still outstanding. Shared by the fresh apply and the
180
+ * receipt-backed retry so both leave the same trail. */
181
+ applySkillDisables(botId, names) {
182
+ for (let index = 0; index < names.length; index += 1) {
183
+ const disabled = this.disableSkill(botId, names[index]);
184
+ if (!disabled.ok)
185
+ return { ok: false, error: disabled.error, outstanding: names.slice(index) };
186
+ }
187
+ return { ok: true };
188
+ }
189
+ propose(args) {
190
+ return this.prepare(args);
191
+ }
192
+ submit(args) {
193
+ const proposal = this.prepare(args, true);
194
+ return { ...proposal, state: proposal.result ? "applied" : "pending" };
195
+ }
196
+ prepare(args, submitted = false) {
197
+ const reason = reasonText(args.reason);
198
+ const intents = parseIntents(args.intents);
199
+ const targetBotId = args.targetBotId ?? args.botId;
200
+ const target = this.store.bot(targetBotId);
201
+ if (!target)
202
+ throw new TighteningRequestError(NO_SUCH_BOT, 404);
203
+ const crossBot = targetBotId !== args.botId;
204
+ if (crossBot && this.validateTarget) {
205
+ const refusal = this.validateTarget(args.botId, targetBotId);
206
+ if (refusal)
207
+ throw new TighteningRequestError(refusal, 403);
208
+ }
209
+ const before = this.snapshotOf(target);
210
+ const checked = validateTightening(before, intents);
211
+ if (!checked.ok)
212
+ throw new TighteningRequestError(checked.error, 400);
213
+ const requestId = newId();
214
+ const targetName = redactSecretsInText(target.name);
215
+ const payload = {
216
+ version: 1,
217
+ requestId,
218
+ botId: args.botId,
219
+ threadId: args.threadId,
220
+ targetBotId,
221
+ targetName,
222
+ createdAt: this.now(),
223
+ reason,
224
+ intents,
225
+ before,
226
+ expectedRevision: tighteningRevision(before, target.lastTighteningRequestId),
227
+ };
228
+ const copy = tighteningCardCopy({ name: targetName, crossBot }, before, intents, reason);
229
+ const persistence = this.canPersist?.(args.botId, args.threadId);
230
+ if (persistence && !persistence.ok) {
231
+ throw new TighteningRequestError(persistence.error, persistence.status);
232
+ }
233
+ const automatic = submitted && this.autoApply?.(args.botId, args.threadId) === true;
234
+ const messageInput = {
235
+ role: "bot",
236
+ kind: "options",
237
+ card: {
238
+ title: copy.title,
239
+ subtitle: copy.detail,
240
+ options: automatic ? [] : ["Confirm", "Cancel"],
241
+ ...(automatic ? { dismissed: true } : {}),
242
+ requestId,
243
+ tool: "tighten_permissions",
244
+ tighteningRequest: payload,
245
+ },
246
+ };
247
+ if (args.from)
248
+ messageInput.from = args.from;
249
+ const message = this.store.appendMessage(args.threadId, messageInput);
250
+ const proposal = { requestId, messageId: message.id, title: copy.title, summary: copy.summary, detail: copy.detail };
251
+ if (!automatic)
252
+ return proposal;
253
+ // The hidden receipt is persisted before the authority changes. The same
254
+ // validation and durable commit marker serve both automatic and human decisions.
255
+ const result = this.resolve({ botId: args.botId, threadId: args.threadId, requestId, behavior: "allow" });
256
+ if (result.state === "applied")
257
+ return { ...proposal, result };
258
+ throw new TighteningRequestError(result.state === "invalid" ? result.error : "The tightening could not be applied", result.state === "invalid" ? result.status : 409);
259
+ }
260
+ /** Claims a tightening card even after it was settled, so a duplicate
261
+ * click never re-applies an already-applied reduction. */
262
+ resolve(args) {
263
+ const message = this.store
264
+ .messagesFor(args.threadId)
265
+ .find((candidate) => candidate.card?.requestId === args.requestId && candidate.card.tighteningRequest);
266
+ const card = message?.card;
267
+ const payload = card?.tighteningRequest;
268
+ if (!message || !card || !payload)
269
+ return { claimed: false, state: "not_found" };
270
+ if (payload.requestId !== card.requestId) {
271
+ return { claimed: true, state: "invalid", error: "This tightening request does not match its card", status: 409 };
272
+ }
273
+ if (args.behavior !== "allow" && args.behavior !== "deny") {
274
+ return { claimed: true, state: "invalid", error: "Tightening confirmations must be confirmed or cancelled", status: 400 };
275
+ }
276
+ if (payload.botId !== args.botId || payload.threadId !== args.threadId) {
277
+ return { claimed: true, state: "invalid", error: "This tightening request belongs to another conversation", status: 403 };
278
+ }
279
+ if (card.answered)
280
+ return { claimed: true, state: "already_settled", behavior: card.answered };
281
+ let emergency = false;
282
+ let outstandingSkills = [];
283
+ try {
284
+ const target = this.store.bot(payload.targetBotId);
285
+ // The authority change and receipt share one durable write. If saving
286
+ // the card failed afterward, a retry only settles it; it never
287
+ // reapplies authority — but it does finish any skills a failed
288
+ // attempt left enabled, so a partial disable can never strand the card.
289
+ if (target?.lastTighteningRequestId === payload.requestId) {
290
+ const enabled = new Set(this.enabledSkills?.(payload.targetBotId) ?? []);
291
+ const remaining = (payload.intents.skills ?? []).filter((name) => enabled.has(name));
292
+ if (remaining.length && !this.disableSkill) {
293
+ throw new TighteningRequestError("Skill changes are not available in this workspace", 400);
294
+ }
295
+ const finished = this.applySkillDisables(payload.targetBotId, remaining);
296
+ if (!finished.ok) {
297
+ outstandingSkills = finished.outstanding;
298
+ throw new TighteningRequestError(finished.error, 409);
299
+ }
300
+ const settled = this.store.patchMessage(args.threadId, message.id, {
301
+ card: { ...card, answered: "allow", held: undefined, tighteningRequest: { ...payload, appliedAt: payload.appliedAt ?? this.now() } },
302
+ });
303
+ if (!settled)
304
+ throw new TighteningRequestError("This tightening confirmation card is no longer available", 409);
305
+ return { claimed: true, state: "already_settled", behavior: "allow" };
306
+ }
307
+ if (args.behavior === "deny") {
308
+ this.store.patchMessage(args.threadId, message.id, { card: { ...card, answered: "deny", held: undefined } });
309
+ return { claimed: true, state: "denied" };
310
+ }
311
+ if (!target)
312
+ throw new TighteningRequestError(NO_SUCH_BOT, 404);
313
+ const crossBot = payload.targetBotId !== payload.botId;
314
+ if (crossBot && this.validateTarget) {
315
+ const refusal = this.validateTarget(payload.botId, payload.targetBotId);
316
+ if (refusal)
317
+ throw new TighteningRequestError(refusal, 404);
318
+ }
319
+ const current = this.snapshotOf(target);
320
+ if (tighteningRevision(current, target.lastTighteningRequestId) !== payload.expectedRevision) {
321
+ throw new TighteningRequestError(STALE, 409);
322
+ }
323
+ // Direction is re-validated against live state, not trusted from the
324
+ // card: a card that sat open can only ever apply a reduction.
325
+ const checked = validateTightening(current, payload.intents);
326
+ if (!checked.ok)
327
+ throw new TighteningRequestError(checked.error, 409);
328
+ emergency = payload.intents.approvalMode !== undefined &&
329
+ (current.approvalMode === "full" || current.approvalMode === "custom") &&
330
+ payload.intents.approvalMode === "ask";
331
+ if (this.targetBusy?.(target.id) && !emergency) {
332
+ if (payload.intents.approvalMode !== undefined) {
333
+ throw new TighteningRequestError("Stop this bot's turn before changing its approval level", 409);
334
+ }
335
+ if (payload.intents.mcpServers !== undefined) {
336
+ throw new TighteningRequestError("Stop this bot's turns before changing its MCP servers", 409);
337
+ }
338
+ }
339
+ // The capability check runs before any mutation: a workspace that
340
+ // cannot touch skills must not half-apply the card.
341
+ if (payload.intents.skills?.length && !this.disableSkill) {
342
+ throw new TighteningRequestError("Skill changes are not available in this workspace", 400);
343
+ }
344
+ const patch = { lastTighteningRequestId: payload.requestId };
345
+ if (payload.intents.approvalMode !== undefined) {
346
+ patch.approvalMode = payload.intents.approvalMode;
347
+ patch.autoApprove = payload.intents.approvalMode === "auto";
348
+ patch.approvalGrant = undefined;
349
+ }
350
+ if (payload.intents.composio !== undefined)
351
+ patch.composio = false;
352
+ if (payload.intents.browser !== undefined)
353
+ patch.browser = false;
354
+ if (payload.intents.approvePeerComms !== undefined)
355
+ patch.approvePeerComms = true;
356
+ if (payload.intents.alwaysAllow !== undefined)
357
+ patch.alwaysAllow = [...checked.after.alwaysAllow];
358
+ if (payload.intents.mcpServers !== undefined)
359
+ patch.mcpServers = [...checked.after.mcpServers];
360
+ if (!this.store.patchBot(target.id, patch))
361
+ throw new TighteningRequestError(NO_SUCH_BOT, 404);
362
+ // Skills disable AFTER the receipt write. Each disable persists on
363
+ // its own, so a failure midway can leave part of the list done — but
364
+ // the receipt is already durable, which turns every retry into the
365
+ // branch above: finish the remainder, never reapply authority, and
366
+ // never leave the card permanently stale.
367
+ const skills = this.applySkillDisables(target.id, payload.intents.skills ?? []);
368
+ if (!skills.ok) {
369
+ outstandingSkills = skills.outstanding;
370
+ throw new TighteningRequestError(skills.error, 409);
371
+ }
372
+ const appliedAt = this.now();
373
+ const settled = this.store.patchMessage(args.threadId, message.id, {
374
+ card: { ...card, answered: "allow", held: undefined, tighteningRequest: { ...payload, appliedAt } },
375
+ });
376
+ if (!settled)
377
+ throw new TighteningRequestError("This tightening confirmation card is no longer available", 409);
378
+ const fields = INTENT_KEYS.filter((key) => payload.intents[key] !== undefined);
379
+ return { claimed: true, state: "applied", targetBotId: target.id, fields, ...(emergency ? { emergencyStop: true } : {}) };
380
+ }
381
+ catch (error) {
382
+ const status = error instanceof TighteningRequestError ? error.status : 400;
383
+ const detail = error instanceof Error ? error.message : String(error);
384
+ const saved = this.store.bot(payload.targetBotId)?.lastTighteningRequestId === payload.requestId;
385
+ const notice = outstandingSkills.length
386
+ ? `Permissions tightened. Confirm again to finish disabling: ${redactSecretsInText(outstandingSkills.join(", "))}`
387
+ : card.dismissed && card.options.length === 0
388
+ ? "Permissions tightened. Recording the operation receipt could not finish; the changes will not be applied again."
389
+ : "Permissions tightened. Confirm again to finish recording this decision; the changes will not be applied again.";
390
+ try {
391
+ this.store.patchMessage(args.threadId, message.id, {
392
+ card: { ...card, held: saved ? notice : redactSecretsInText(detail).slice(0, 500) },
393
+ });
394
+ }
395
+ catch { /* The durable receipt still permits a safe retry. */ }
396
+ if (saved)
397
+ return {
398
+ claimed: true, state: "applied", targetBotId: payload.targetBotId,
399
+ fields: INTENT_KEYS.filter((key) => payload.intents[key] !== undefined),
400
+ ...(emergency ? { emergencyStop: true } : {}),
401
+ };
402
+ return { claimed: true, state: "invalid", error: detail, status };
403
+ }
404
+ }
405
+ }
@@ -44,11 +44,33 @@ async function request(url, init, what, baseUrl) {
44
44
  }
45
45
  export async function listChatterboxVoices(baseUrl) {
46
46
  try {
47
- const res = await request(`${apiRoot(baseUrl)}/models`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) }, "listing voices", baseUrl);
48
- if (res.ok) {
49
- const body = await safeJson(res);
50
- const voices = (body?.data ?? [])
51
- .map((m) => ({ id: String(m?.id ?? ""), label: String(m?.id ?? "") }))
47
+ try {
48
+ const res = await request(`${apiRoot(baseUrl)}/models`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) }, "listing voices", baseUrl);
49
+ if (res.ok) {
50
+ const body = await safeJson(res);
51
+ const voices = (body?.data ?? [])
52
+ .map((m) => ({ id: String(m?.id ?? ""), label: String(m?.id ?? "") }))
53
+ .filter((v) => v.id);
54
+ if (voices.length)
55
+ return voices;
56
+ }
57
+ }
58
+ catch {
59
+ // /v1/models missing or unreachable — try /v1/audio/voices next
60
+ }
61
+ // Some servers (e.g. Chatterbox-TTS-Server) list voices here instead.
62
+ const voicesRes = await request(`${apiRoot(baseUrl)}/audio/voices`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) }, "listing voices", baseUrl);
63
+ if (voicesRes.ok) {
64
+ const body = await safeJson(voicesRes);
65
+ const voices = (body?.voices ?? [])
66
+ .map((entry) => {
67
+ // Chatterbox-TTS-Server currently returns filename strings; docs (and
68
+ // /get_predefined_voices) describe { filename, display_name } objects.
69
+ if (typeof entry === "string")
70
+ return { id: entry, label: entry };
71
+ const id = String(entry?.filename ?? entry?.id ?? "");
72
+ return { id, label: String(entry?.display_name ?? entry?.name ?? id) };
73
+ })
52
74
  .filter((v) => v.id);
53
75
  if (voices.length)
54
76
  return voices;
@@ -11,7 +11,7 @@ import { readFileSync } from "node:fs";
11
11
  import { appendFile, mkdir } from "node:fs/promises";
12
12
  import { join } from "node:path";
13
13
  import { billableFor } from "./prices.js";
14
- export const USAGE_GROUPINGS = ["bot", "model", "user", "day", "engine"];
14
+ export const USAGE_GROUPINGS = ["bot", "model", "user", "day", "engine", "routine"];
15
15
  const DIR = "usage";
16
16
  const MAX_RANGE_DAYS = 366;
17
17
  const DAY_MS = 24 * 60 * 60_000;
@@ -184,6 +184,12 @@ function groupOf(row, groupBy) {
184
184
  return { key: triggerKey(row.trigger), label: triggerLabel(row.trigger) };
185
185
  case "day":
186
186
  return { key: `day:${row.at.slice(0, 10)}`, label: row.at.slice(0, 10) };
187
+ case "routine":
188
+ // Turns a routine started are grouped by routine; everything else
189
+ // (people, the owner, other bots) shares one "manual" bucket.
190
+ return row.trigger.kind === "routine"
191
+ ? { key: triggerKey(row.trigger), label: row.trigger.label ?? row.trigger.routineId ?? "unknown" }
192
+ : { key: "manual", label: "Not from a routine" };
187
193
  default:
188
194
  return { key: `engine:${row.driverKind}`, label: row.driverKind };
189
195
  }
@@ -10,7 +10,12 @@ const MAX_ATTEMPTS = 2_000;
10
10
  const MAX_EVENT_CHARS = 48_000;
11
11
  const RATE_WINDOW_MS = 60_000;
12
12
  const RATE_LIMIT = 10;
13
- const MAX_PENDING_RUNS = 3;
13
+ /** Unfinished (queued, running or waiting) runs one webhook may hold before
14
+ * new deliveries are refused with 429. A webhook can set its own limit; one
15
+ * fanning out a project manager's events needs more than a CI hook does. */
16
+ export const DEFAULT_MAX_PENDING_RUNS = 3;
17
+ export const MAX_PENDING_RUNS_LIMIT = 50;
18
+ const maxPendingRunsSchema = z.number().int().min(1).max(MAX_PENDING_RUNS_LIMIT);
14
19
  const runOnSchema = z.enum(["maus", "cloud"]);
15
20
  const deliverySchema = z.enum(["run", "post"]);
16
21
  const eventTypesSchema = z.array(z.string()).max(20).optional();
@@ -23,6 +28,8 @@ const triggerInputSchema = z.object({
23
28
  enabled: z.boolean().optional(),
24
29
  verificationPending: z.boolean().optional(),
25
30
  eventTypes: eventTypesSchema,
31
+ /** `null` goes back to the default. */
32
+ maxPendingRuns: maxPendingRunsSchema.nullable().optional(),
26
33
  });
27
34
  const triggerPatchSchema = triggerInputSchema.partial();
28
35
  const verificationSampleSchema = z.object({
@@ -49,6 +56,9 @@ const storedWebhookSchema = z.object({
49
56
  verifiedAt: z.number().finite().nonnegative().optional(),
50
57
  verificationSample: verificationSampleSchema.optional(),
51
58
  eventTypes: eventTypesSchema,
59
+ // A hand-edited value out of range falls back to the default instead of
60
+ // making the whole webhooks file unreadable.
61
+ maxPendingRuns: maxPendingRunsSchema.optional().catch(undefined),
52
62
  secretHash: z.string().regex(/^[a-f0-9]{64}$/),
53
63
  });
54
64
  const deliveryReceiptSchema = z.object({
@@ -125,6 +135,8 @@ function cleanInput(input) {
125
135
  clean.eventTypes = eventTypes;
126
136
  if (input.delivery)
127
137
  clean.delivery = input.delivery;
138
+ if (typeof input.maxPendingRuns === "number")
139
+ clean.maxPendingRuns = input.maxPendingRuns;
128
140
  return clean;
129
141
  }
130
142
  function parseTriggerInput(value) {
@@ -265,12 +277,15 @@ export class WebhookManager {
265
277
  enabled: patch.enabled ?? trigger.enabled,
266
278
  verificationPending: patch.verificationPending ?? trigger.verificationPending,
267
279
  eventTypes: patch.eventTypes ?? trigger.eventTypes,
280
+ maxPendingRuns: patch.maxPendingRuns === undefined ? trigger.maxPendingRuns : patch.maxPendingRuns,
268
281
  });
269
282
  if (this.options.botState(clean.botId) === "missing")
270
283
  fail(400, "That MAUS no longer exists");
271
284
  Object.assign(trigger, clean, { updatedAt: this.now() });
272
285
  if (!clean.eventTypes?.length)
273
286
  delete trigger.eventTypes;
287
+ if (clean.maxPendingRuns === undefined)
288
+ delete trigger.maxPendingRuns;
274
289
  if (patch.enabled === false) {
275
290
  this.options.cancelQueued?.(trigger.id, "The webhook was paused before this delivery started");
276
291
  }
@@ -394,8 +409,11 @@ export class WebhookManager {
394
409
  }
395
410
  // A sender retrying an already-accepted delivery must remain idempotent
396
411
  // even while this webhook's queue is full. Only new work consumes a slot.
397
- if ((this.options.pendingRuns?.(trigger.id) ?? 0) >= MAX_PENDING_RUNS) {
398
- fail(429, "This webhook already has too many unfinished tasks");
412
+ const maxPendingRuns = trigger.maxPendingRuns ?? DEFAULT_MAX_PENDING_RUNS;
413
+ const pendingRuns = this.options.pendingRuns?.(trigger.id) ?? 0;
414
+ if (pendingRuns >= maxPendingRuns) {
415
+ fail(429, `This webhook already has ${pendingRuns} unfinished ${pendingRuns === 1 ? "task" : "tasks"} (its limit is ${maxPendingRuns}). `
416
+ + "Retry after one finishes, or raise \"Unfinished tasks at once\" in the webhook's settings.");
399
417
  }
400
418
  const recent = (this.rate.get(trigger.endpointId) ?? []).filter((at) => now - at < RATE_WINDOW_MS);
401
419
  if (recent.length >= RATE_LIMIT)
@@ -6,6 +6,7 @@ import { isAbsolute, join, parse, posix, relative, resolve, win32 } from "node:p
6
6
  import { homedir } from "node:os";
7
7
  import { backup, DatabaseSync } from "node:sqlite";
8
8
  import { pipeline } from "node:stream/promises";
9
+ import { Worker } from "node:worker_threads";
9
10
  import * as tar from "tar";
10
11
  import { fromMarkdown } from "mdast-util-from-markdown";
11
12
  import { writeFileAtomic } from "./atomic.js";
@@ -77,8 +78,7 @@ function jobPath(dataDir, id) {
77
78
  }
78
79
  return path;
79
80
  }
80
- function newJob(dataDir) {
81
- const id = randomUUID();
81
+ function newJob(dataDir, id = randomUUID()) {
82
82
  const directory = jobPath(dataDir, id);
83
83
  mkdirSync(directory, { mode: 0o700 });
84
84
  return { id, directory };
@@ -265,13 +265,50 @@ function databaseCounts(path) {
265
265
  db.close();
266
266
  }
267
267
  }
268
+ /** The existing maintenance gate owns exclusivity until this worker has exited.
269
+ * Keep synchronous file validation/fsync intact, but off the request thread. */
268
270
  export async function createWorkspaceBackup(dataDir, options) {
271
+ const source = new URL("./workspace-backup.worker.ts", import.meta.url);
272
+ // Assign ownership before the worker can create staging, including crashes
273
+ // too early to send a job-created message back to this process.
274
+ const jobId = randomUUID();
275
+ let worker;
276
+ let completed = false;
277
+ try {
278
+ worker = new Worker(existsSync(source) ? source : new URL("./workspace-backup.worker.js", import.meta.url), {
279
+ workerData: { dataDir, options, jobId }, execArgv: [],
280
+ });
281
+ const result = await new Promise((resolveBackup, reject) => {
282
+ const failed = () => reject(new Error("The backup worker stopped before completing. Try again."));
283
+ worker.once("message", (reply) => {
284
+ if (reply.result)
285
+ resolveBackup(reply.result);
286
+ else if (reply.error)
287
+ reject(new Error(reply.error));
288
+ else
289
+ failed();
290
+ });
291
+ worker.once("error", failed);
292
+ worker.once("exit", failed);
293
+ });
294
+ completed = true;
295
+ return result;
296
+ }
297
+ finally {
298
+ await worker?.terminate();
299
+ // Reuse the guarded deletion path: never sweep other jobs or recovery.
300
+ if (!completed && entryExists(join(dataDir, ".backups", jobId)))
301
+ removeWorkspaceBackupJob(dataDir, jobId);
302
+ }
303
+ }
304
+ /** Worker implementation; application callers use createWorkspaceBackup. */
305
+ export async function createWorkspaceBackupSnapshot(dataDir, options, jobId) {
269
306
  if (Object.hasOwn(options, "credentials"))
270
307
  throw new Error("Workspace backups do not transfer credentials.");
271
308
  assertLocalAuthOutsideSnapshot(dataDir);
272
309
  const salt = randomBytes(16);
273
310
  const key = await passwordKey(options.password, salt);
274
- const job = newJob(dataDir);
311
+ const job = newJob(dataDir, jobId);
275
312
  const snapshot = join(job.directory, "snapshot");
276
313
  folder(join(snapshot, "data"));
277
314
  const entries = [];
@@ -0,0 +1,10 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { createWorkspaceBackupSnapshot } from "./workspace-backup.js";
3
+ const { dataDir, options, jobId } = workerData;
4
+ try {
5
+ // Same snapshot and cleanup implementation; never send password/options back.
6
+ parentPort.postMessage({ result: await createWorkspaceBackupSnapshot(dataDir, options, jobId) });
7
+ }
8
+ catch (error) {
9
+ parentPort.postMessage({ error: error instanceof Error ? error.message : "The workspace backup could not finish." });
10
+ }
@@ -124,6 +124,25 @@ export function stripOmbAskBlock(output) {
124
124
  return output;
125
125
  return output.replace(OMB_ASK_FENCE_GLOBAL, "").replace(/\n{3,}/g, "\n\n").trim();
126
126
  }
127
+ /** The prompt contract that teaches a model the turn-held ask transport: end
128
+ * the reply with a fenced `omb-ask` block carrying the questions, and the
129
+ * answers come back on the next prompt. A harness that cannot pause mid-run
130
+ * (the BoxAgent transport) appends this to every prompt; the caps in the text
131
+ * are the constants above, so the taught contract and the parser cannot
132
+ * drift apart. */
133
+ export function ombAskProtocolPrompt() {
134
+ return [
135
+ "",
136
+ "## Asking the person a question",
137
+ "When a decision belongs to the person, end your reply with a fenced block exactly like this:",
138
+ "",
139
+ "```omb-ask",
140
+ '{"questions":[{"question":"Ship the release now?","header":"Release","options":[{"label":"Ship now"},{"label":"Wait for the QA signoff"}]}]}',
141
+ "```",
142
+ "",
143
+ `The block must be the last thing in your reply. You may ask up to ${MAX_QUESTIONS} questions at once, each with up to ${MAX_OPTIONS} options; the person can always answer in their own words. Their answers arrive on your next prompt as \`Q:\`/\`A:\` lines — never invent them.`,
144
+ ].join("\n");
145
+ }
127
146
  /** The one line the card subtitle and a spoken prompt show. */
128
147
  export function askQuestionSummary(questions) {
129
148
  const first = questions[0]?.question ?? "";
@@ -76,11 +76,15 @@ export function credentialIsConfigured(config, id) {
76
76
  return Boolean(config.imageGen?.key);
77
77
  }
78
78
  }
79
- export function isReusableCredentialRequest(message, target, requestingBotId, roomThread) {
79
+ /** A still-pending credential card for this target — the set a newer
80
+ * request supersedes. Provided, dismissed, and already-superseded cards
81
+ * keep their settled state untouched. */
82
+ export function isPendingCredentialRequest(message, target, requestingBotId, roomThread) {
80
83
  return (message.kind === "secret" &&
81
84
  message.secret?.target === target &&
82
85
  message.secret.provided !== true &&
83
86
  message.secret.dismissed !== true &&
87
+ message.secret.superseded !== true &&
84
88
  (!roomThread || message.from?.botId === requestingBotId));
85
89
  }
86
90
  export function credentialResumeOutcome(state) {