openmausbot 0.1.87 → 0.1.88

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 (149) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-BTiuL79F.js → abnfDiagram-O67JEVCF-qd9VD3GV.js} +1 -1
  3. package/dist/assets/{arc-C88Blc2v.js → arc-3RwkUjRe.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-BZsJIZrb.js → architectureDiagram-NJMV4G6O-CFKSJS7a.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CVeMe9Ah.js → blockDiagram-BEXU5L5S-CG1rEfWD.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-Cedk3Bhx.js → c4Diagram-YGBWAQC7-CBW9N664.js} +1 -1
  7. package/dist/assets/channel-CnRnO3tF.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-D-VRqzTB.js → chunk-2Q5K7J3B-2mRfTksB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BChov01Y.js → chunk-3FUC2YCW-Cv1dpErj.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-C-TfwvHy.js → chunk-5VM5RSS4-B7IACJp-.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Db7CGj5Y.js → chunk-GTNCS2PH-An0hZtYa.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CNvIfqKT.js → chunk-GWA4HPMP-BcJB2nEm.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-C_bEp5tq.js → chunk-JWPE2WC7-CV_W7TN7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-Coi4BOls.js → chunk-NETBCI7D-Bcs_kwnA.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CzylEuL3.js → chunk-WEXAMYUT-lrx_DxFP.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-m7p7ztaD.js → chunk-XXDRQBXY-DabHlLGM.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BMy6o9x2.js → classDiagram-v2-NBCMYWYE-BnHj6P7d.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BDQ__0_J.js → cose-bilkent-JH36ORCC-CfLp6UDi.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-Djv4ghUb.js → cynefin-OW5HDTMX-Dm4dbVA1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BULrlGYH.js → cynefinDiagram-VND7K2PF-B9mH-PID.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-D-jrT_hU.js → dagre-6A5THRUB-DDc_NVQJ.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-p2UhQmfL.js → diagram-22UHCM2B-lwr8Ip8-.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-DHvGWZ7q.js → diagram-3UASUU5V-BLLe6dmI.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-ChhKDot-.js → diagram-ATOU4E4O-6eUIq4lz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-B-T7A43R.js → diagram-CDSNMT55-O-pRN7VQ.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CJm8ENNY.js → diagram-MLGK6HIB-BietvGAI.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-510tlza8.js → diagram-MPIPVDR6-Bd2Cl62X.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-PkvRPHel.js → ebnfDiagram-ZINNZB2B-B6iDy69W.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BVBVPoX3.js → elk-276RUBZZ-DQVHfKe0.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BgYMVm7L.js → erDiagram-OPXOYQCR-BWdVp2tQ.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-BGqsnc_3.js → flowDiagram-KWPJA3E3-WLE6rKB4.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-D_-UeReH.js → ganttDiagram-FUAMR5RP-BbjS-4gn.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-D0uooONx.js → gitGraphDiagram-X574FWY7-Dpy2QSGq.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-CHnODCr2.js +583 -0
  36. package/dist/assets/{index-zRsJERGJ.js → index-CIaHn5NS.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B9e1z77f.js → infoDiagram-VRGFBTTK-D_jKWlKe.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BB4vNwvD.js → ishikawaDiagram-OU5B5YK6-DLJohSuK.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DpkosOTB.js → journeyDiagram-ZHPQQLJL-DPOFj0qN.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DbDP4kUu.js → kanban-definition-PNTS6WVX-BI7v-xpK.js} +1 -1
  41. package/dist/assets/{linear-DBJGPmjT.js → linear-C2pWPSxO.js} +1 -1
  42. package/dist/assets/{mermaid.core-DWPRi-ue.js → mermaid.core-CR-j5WQU.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-nszBbJmx.js → mindmap-definition-NLK3R4M7-lz9GvXRh.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CWykJWu_.js → pegDiagram-GJSIUBJH-Dl4_ia61.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-g3Da-Vnv.js → pieDiagram-5QR66LMP-DWuT3n88.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Wzrlpzgh.js → quadrantDiagram-O4NWA36T-DXWe9Jx7.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-moHTFMC0.js → railroadDiagram-XR7U4H2S-COY1IBVY.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CwfXdFH8.js → requirementDiagram-PLB6GJNP-DnyVuhsQ.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-3ZROYvnq.js → sankeyDiagram-IPEJSGJF-BSc4NdGK.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-E1TBebP8.js → sequenceDiagram-PO4LG4MO-kqEU2EGq.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-DHKEU5pu.js → sizeCapture-INFHLROL-BvB4bkWD.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BguEj09D.js → stateDiagram-v2-GCMORJYK-BIrqNv7y.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-C7IDYnhd.js → swimlanes-2SLR337P-CFnalQUE.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C4a8qNGj.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-DCW-F5PW.js → timeline-definition-EJHVYXUP-CsSWJnSQ.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-xBMFtWYV.js → usecaseDiagram-POWQR4AR-DkAmJ1EE.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-B5nIkuH8.js → vennDiagram-UO4OBE2U-C3vd9drL.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DYFAhdbr.js → wardleyDiagram-VNRHLVJA-j26CM-pi.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DeXwX6Cz.js → xychartDiagram-PMCCYNJV-BYu1Q_at.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 +3 -1
  64. package/dist-server/drivers/agents-proxy.js +277 -22
  65. package/dist-server/index.js +23150 -19947
  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 +540 -298
  71. package/dist-server/pair-cli.js +540 -298
  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/bot-attachment.js +107 -0
  76. package/dist-server/server/bot-overview.js +19 -0
  77. package/dist-server/server/cli-api-setup.js +1 -0
  78. package/dist-server/server/composio.js +297 -1
  79. package/dist-server/server/config.js +25 -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 +60 -0
  84. package/dist-server/server/contracts.js +22 -0
  85. package/dist-server/server/drivers/acp/core.js +24 -6
  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 +9 -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 +2 -16
  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 +26 -2
  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 +46 -0
  108. package/dist-server/server/index.js +1037 -209
  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 +26 -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/workspace-backup.js +40 -3
  134. package/dist-server/server/workspace-backup.worker.js +10 -0
  135. package/dist-server/shared/ask-question.js +19 -0
  136. package/dist-server/shared/credential-request.js +5 -1
  137. package/dist-server/shared/line-diff.js +23 -0
  138. package/dist-server/shared/model-request.js +1 -0
  139. package/dist-server/shared/options-card.js +51 -0
  140. package/dist-server/shared/profile-request.js +5 -4
  141. package/dist-server/shared/tightening-request.js +87 -0
  142. package/dist-server/src/lib/composer-attachments.js +25 -3
  143. package/dist-server/vps-container-mcp.js +3 -1
  144. package/dist-server/workspace-backup.worker.js +19474 -0
  145. package/package.json +1 -1
  146. package/dist/assets/channel-taCNRlUU.js +0 -1
  147. package/dist/assets/index-BwdMAQ6p.css +0 -1
  148. package/dist/assets/index-ByJennr7.js +0 -581
  149. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +0 -8
@@ -0,0 +1,81 @@
1
+ // One admission decision for every surface that starts, steers, or parks
2
+ // work. Each seam used to hand-roll its own busy policy; they agreed by
3
+ // convention and drifted by accident. This module is the frozen record of
4
+ // what each surface does today (M1): same inputs, same outputs, one place.
5
+ //
6
+ // Purity is the contract: no IO, no store reads, no adapter calls. Callers
7
+ // gather state, call admit(), and apply the returned decision with their own
8
+ // side effects. Anything that can settle a turn (an awaited steer, a slow
9
+ // disk) belongs to the caller, which re-reads state and asks again.
10
+ /** The shared admission decision. Pure: same surface, message, and state
11
+ * always return the same decision, with no side effects. */
12
+ export function admit(surface, message = {}, state = {}) {
13
+ switch (surface) {
14
+ case "direct-busy": {
15
+ // Steer-first for plain text into a capable engine; every mechanical
16
+ // clamp (attachments, a pending surface choice, an incapable engine)
17
+ // parks the words for the next ordinary turn instead.
18
+ const steerable = !message.carriesImages && !message.pendingComputerSelection && message.engineCanSteer === true;
19
+ return steerable ? { action: "steer" } : { action: "queue" };
20
+ }
21
+ case "direct": {
22
+ if (state.atCapacity || state.threadBusy || state.groupTurn || state.parksBehindCoordination) {
23
+ // Capacity outranks a room turn in the receipt because the person
24
+ // can fix it (finish a thread); coordination parking is reasonless.
25
+ const reason = state.atCapacity ? "capacity" : state.groupTurn ? "group-turn" : undefined;
26
+ return { action: "queue", reason };
27
+ }
28
+ return { action: "start" };
29
+ }
30
+ case "room":
31
+ // Room sends never auto-steer; the queue is the policy. Human override
32
+ // is the head-only manual steer below.
33
+ return state.roomWorking ? { action: "queue" } : { action: "start" };
34
+ case "room-steer": {
35
+ if (!message.speakerPresent || message.engineCanSteer !== true) {
36
+ // An incapable room keeps its queue, exactly like an incapable 1:1
37
+ // engine; the request succeeds without delivering.
38
+ return { action: "queue" };
39
+ }
40
+ if (!message.isQueueHead) {
41
+ return { action: "refuse", code: "queue-head-only" };
42
+ }
43
+ return { action: "steer" };
44
+ }
45
+ case "guarded": {
46
+ // A guarded send is an external contract: never queued, never steered,
47
+ // never started under stale permissions. Any busy shape refuses.
48
+ if (state.botBusy || state.threadBusy || state.atCapacity || state.parksBehindCoordination || state.groupTurn) {
49
+ return { action: "refuse", code: "guarded_busy" };
50
+ }
51
+ return { action: "start" };
52
+ }
53
+ case "unattended": {
54
+ // Routines and webhooks share startTurn's admission; busy means a
55
+ // missed or failed run, not a queue — their receipts are the
56
+ // monitoring feature.
57
+ if (state.botPresent === false)
58
+ return { action: "refuse", code: "missing" };
59
+ if (state.atCapacity || state.groupTurn)
60
+ return { action: "refuse", code: "busy" };
61
+ return { action: "start" };
62
+ }
63
+ case "peer": {
64
+ // Exactly what startTurn admits for a direct thread: the landing
65
+ // thread free, a free slot, no live room turn. Never the whole-bot
66
+ // busy flag.
67
+ if (state.threadBusy || state.atCapacity || state.groupTurn)
68
+ return { action: "refuse", code: "busy" };
69
+ return { action: "start" };
70
+ }
71
+ case "opened-thread": {
72
+ // A self-opened thread is brand new: its own busy flag is impossible,
73
+ // so only a full slot list or a live room turn can park it. Provenance
74
+ // (peerAsk, unattended) survives the wait in the queue payload.
75
+ if (state.atCapacity || state.groupTurn) {
76
+ return { action: "queue", reason: state.atCapacity ? "capacity" : "group-turn" };
77
+ }
78
+ return { action: "start" };
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,294 @@
1
+ // The aside lane: peer context handed to a busy thread's RUNNING turn.
2
+ //
3
+ // A peer send that arrives mid-turn used to degrade straight to the
4
+ // delegation queue — full work, later turn, no reply until then. When the
5
+ // engine running that turn has a mid-turn seam (Adapter.steer), the same
6
+ // words can instead be folded into the live turn at its next step boundary
7
+ // as an ASIDE: context the model reads and usually ignores. The person's
8
+ // lane keeps real steering (parent authority changes the turn's course);
9
+ // an aside never claims that authority, and the envelope says so.
10
+ //
11
+ // Durability follows steer-queue's shape (chat_followups rows, restore
12
+ // only what never dispatched) with one aside-specific rule: the row id is
13
+ // the messageId dedupe key, it is written BEFORE the seam call, and the
14
+ // injected line lands on the transcript with queueId = row id BEFORE the
15
+ // row is deleted. A crash in either window is then self-healing: restore
16
+ // skips any row whose transcript already shows its marker (the words ran;
17
+ // never replay them) and requeues only the rest. "indeterminate" seam
18
+ // outcomes count as injected for the same reason — the words may already
19
+ // be running, and running them twice is the one mistake this lane must
20
+ // never make.
21
+ import { newId } from "./contracts.js";
22
+ import { chatFollowups, saveChatFollowup, settleChatFollowups } from "./message-db.js";
23
+ import { peerName } from "./peer-roster.js";
24
+ const asides = new Map(); // threadId → waiting asides
25
+ /** Threads with an injection or drain in flight: seam calls are awaited
26
+ * work, so two boundaries firing close together must not double-process
27
+ * the same words. */
28
+ const inFlight = new Set();
29
+ /** The mandatory non-steering envelope (audit §5.2). Every mid-turn channel
30
+ * OMB can ride is steering-branded, so the words themselves must say what
31
+ * they are: peer context from a named sender, not a course change. */
32
+ export function asideEnvelope(fromName, text) {
33
+ // Peer words sit between fixed markers, and a peer cannot be allowed to
34
+ // forge the closing marker inside its own text: that would end the aside
35
+ // early and make the batched context ambiguous downstream.
36
+ const body = text.replace(/\[\s*end\s+aside\s*\]/gi, "(end aside)");
37
+ return [
38
+ `[aside from @${peerName(fromName)} — peer context, not steering; continue your current plan unless this directly changes a fact you are using]`,
39
+ body,
40
+ "[end aside]",
41
+ ].join("\n");
42
+ }
43
+ /** Rebuild the in-memory lane after a restart. Only rows whose dispatch
44
+ * never began come back; a row whose transcript already shows its queueId
45
+ * marker was injected before the process died, and those words must never
46
+ * run twice, so the row is retired instead of requeued. */
47
+ export function restoreAsideMessages(store) {
48
+ asides.clear();
49
+ for (const row of chatFollowups("aside")) {
50
+ if (row.status !== "pending")
51
+ continue;
52
+ if (store.messagesFor(row.threadId).some((message) => message.queueId === row.id)) {
53
+ settleChatFollowups([row.id], null);
54
+ continue;
55
+ }
56
+ const entry = asides.get(row.threadId) ?? { botId: row.ownerId, items: [] };
57
+ if (entry.botId !== row.ownerId)
58
+ throw new Error("queued aside belongs to another bot");
59
+ entry.items.push({
60
+ messageId: row.id,
61
+ text: row.payload.text,
62
+ prompt: row.payload.prompt ?? row.payload.text,
63
+ aside: row.payload.aside ?? { fromBotId: row.ownerId, fromBotName: "teammate", commsDepth: 0 },
64
+ trigger: row.payload.trigger,
65
+ });
66
+ asides.set(row.threadId, entry);
67
+ }
68
+ }
69
+ /** Persist one aside and park it for injection. The row exists before any
70
+ * seam call so a crash can only ever re-queue words that never ran. */
71
+ export function queueAsideMessage(botId, threadId, text, options) {
72
+ const id = newId();
73
+ const item = {
74
+ messageId: id,
75
+ text,
76
+ prompt: asideEnvelope(options.fromBotName, text),
77
+ aside: {
78
+ fromBotId: options.fromBotId,
79
+ fromBotName: options.fromBotName,
80
+ ...(options.unattended ? { unattended: true } : {}),
81
+ commsDepth: options.commsDepth,
82
+ },
83
+ trigger: options.trigger,
84
+ };
85
+ const entry = asides.get(threadId) ?? { botId, items: [] };
86
+ if (entry.botId !== botId)
87
+ throw new Error("queued aside belongs to another bot");
88
+ saveChatFollowup({
89
+ id,
90
+ kind: "aside",
91
+ ownerId: botId,
92
+ threadId,
93
+ payload: { text: item.text, prompt: item.prompt, aside: item.aside, ...(item.trigger ? { trigger: item.trigger } : {}) },
94
+ });
95
+ entry.items.push(item);
96
+ asides.set(threadId, entry);
97
+ return { id };
98
+ }
99
+ /** Everything waiting for this thread, in arrival order. */
100
+ export function pendingAsides(botId, threadId) {
101
+ const entry = asides.get(threadId);
102
+ return entry?.botId === botId ? [...entry.items] : [];
103
+ }
104
+ /** Record delivered asides: one transcript line per item (enveloped text,
105
+ * queueId marker, peer provenance), then the durable rows are deleted. The
106
+ * marker write precedes the delete, so the crash window between them heals
107
+ * as "already injected" on restore rather than as a replay. */
108
+ export function recordInjectedAsides(store, threadId, items) {
109
+ const appended = [];
110
+ for (const item of items) {
111
+ appended.push(store.appendMessage(threadId, {
112
+ role: "user",
113
+ kind: "text",
114
+ text: item.prompt,
115
+ queueId: item.messageId,
116
+ aside: true,
117
+ peerAsk: {
118
+ botId: item.aside.fromBotId,
119
+ name: item.aside.fromBotName,
120
+ ...(item.aside.unattended ? { unattended: true } : {}),
121
+ },
122
+ }));
123
+ }
124
+ settleChatFollowups(items.map((item) => item.messageId), null);
125
+ // The words are delivered and their rows retired; the in-memory lane must
126
+ // agree, or a later boundary would batch them into another injection.
127
+ const entry = asides.get(threadId);
128
+ if (entry) {
129
+ const delivered = new Set(items.map((item) => item.messageId));
130
+ const remaining = entry.items.filter((item) => !delivered.has(item.messageId));
131
+ if (remaining.length)
132
+ asides.set(threadId, { botId: entry.botId, items: remaining });
133
+ else
134
+ asides.delete(threadId);
135
+ }
136
+ return appended;
137
+ }
138
+ /** Drop a thread's waiting asides: the bot or task is gone, so no turn will
139
+ * ever read them. Tombstoned like a cancelled follow-up. */
140
+ export function cancelAsides(threadId) {
141
+ const entry = asides.get(threadId);
142
+ if (!entry)
143
+ return;
144
+ settleChatFollowups(entry.items.map((item) => item.messageId), "cancelled");
145
+ asides.delete(threadId);
146
+ }
147
+ /** Fold everything waiting on this thread into its running turn.
148
+ *
149
+ * The rows are marked "dispatching" BEFORE the seam call: a crash at any
150
+ * point then leaves words the restart recovery path treats as may-have-run
151
+ * (recovered to the transcript with a review notice, never re-executed as
152
+ * a turn). "refused" — provably not delivered — returns them to "pending"
153
+ * so the next boundary can try again. "steered" and "indeterminate" both
154
+ * record delivery: the words may already be running, and running them
155
+ * twice is the one mistake this lane must never make.
156
+ *
157
+ * The batch leaves the shared queue BEFORE the seam call and stays out
158
+ * for the whole await: while the words may already be running inside the
159
+ * turn, no later boundary may be able to see them again, and a cancel
160
+ * landing mid-seam must not resurrect them. Only a "refused" outcome on a
161
+ * lane that is still ours puts the words back — ahead of anything that
162
+ * arrived during the call.
163
+ *
164
+ * Returns null when nothing is waiting or the thread is not this bot's;
165
+ * otherwise whether the batch was delivered and how many items it held. */
166
+ export async function attemptAsideInjection(store, botId, threadId, inject) {
167
+ const entry = asides.get(threadId);
168
+ if (!entry || entry.botId !== botId || entry.items.length === 0)
169
+ return null;
170
+ if (inFlight.has(threadId))
171
+ return { delivered: false, count: entry.items.length };
172
+ const batch = entry.items;
173
+ entry.items = [];
174
+ const ids = batch.map((item) => item.messageId);
175
+ settleChatFollowups(ids, "dispatching");
176
+ inFlight.add(threadId);
177
+ let outcome;
178
+ try {
179
+ outcome = await inject(botId, threadId, batch.map((item) => item.prompt).join("\n\n"));
180
+ }
181
+ catch {
182
+ outcome = "indeterminate";
183
+ }
184
+ finally {
185
+ inFlight.delete(threadId);
186
+ }
187
+ if (outcome === "refused") {
188
+ if (asides.get(threadId) === entry) {
189
+ // Still our lane: back on the queue, ahead of anything that
190
+ // arrived during the seam call.
191
+ entry.items = [...batch, ...entry.items];
192
+ settleChatFollowups(ids, "pending");
193
+ }
194
+ else {
195
+ // cancelAsides took the lane while the seam call was awaited: the
196
+ // words never ran, but their lane is gone, so they retire with it
197
+ // instead of coming back as phantom rows after a restart.
198
+ settleChatFollowups(ids, "cancelled");
199
+ }
200
+ return { delivered: false, count: batch.length };
201
+ }
202
+ try {
203
+ recordInjectedAsides(store, threadId, batch);
204
+ }
205
+ catch (error) {
206
+ // The seam says the words may already be running; a failed transcript
207
+ // write must never hand them to a later boundary. Retire the rows
208
+ // loudly instead of replaying them.
209
+ console.error("aside-queue: could not record injected asides", error);
210
+ settleChatFollowups(ids, null);
211
+ if (asides.get(threadId) === entry && entry.items.length === 0)
212
+ asides.delete(threadId);
213
+ }
214
+ return { delivered: true, count: batch.length };
215
+ }
216
+ /**
217
+ * One boundary pass over the aside lane.
218
+ *
219
+ * Injection first: a thread already running a seam-capable turn gets every
220
+ * waiting aside folded in as ONE steer call (envelopes joined by a blank
221
+ * line — one boundary, one batch). Otherwise, when the thread is idle and
222
+ * unblocked, the asides degrade to a single enveloped follow-up turn, the
223
+ * same shape the steer-queue gives queued person messages. Callers run the
224
+ * steer drain before this one, so at a shared boundary the person's queued
225
+ * correction lands ahead of peer context.
226
+ */
227
+ export async function drainAsideMessages(options) {
228
+ const { store, inject, run, isBlocked } = options;
229
+ for (const [threadId, entry] of asides) {
230
+ if (inFlight.has(threadId))
231
+ continue;
232
+ const bot = store.projectBotForTask
233
+ ? store.projectBotForTask(entry.botId, threadId)
234
+ : store.bot(entry.botId);
235
+ if (!bot) {
236
+ // the bot or task was deleted while the aside waited
237
+ cancelAsides(threadId);
238
+ continue;
239
+ }
240
+ if (bot.busy) {
241
+ // Seam attempt: one batched call for everything waiting here.
242
+ await attemptAsideInjection(store, entry.botId, threadId, inject);
243
+ continue;
244
+ }
245
+ if (isBlocked?.(entry.botId, threadId))
246
+ continue;
247
+ // Degrade to one follow-up turn. The entry leaves the map before
248
+ // anything runs, so a settle racing another settle can never fire the
249
+ // same asides twice.
250
+ const items = entry.items;
251
+ const ids = items.map((item) => item.messageId);
252
+ settleChatFollowups(ids, "dispatching");
253
+ asides.delete(threadId);
254
+ const appended = recordDegradedAsides(store, threadId, items);
255
+ const last = appended.at(-1);
256
+ if (!last) {
257
+ settleChatFollowups(ids, null);
258
+ continue;
259
+ }
260
+ const running = run(entry.botId, threadId, items.map((item) => item.prompt).join("\n\n"), last, appended.map((message) => message.id), { trigger: items[0].trigger, aside: items[0].aside });
261
+ void Promise.resolve(running).then(() => settleChatFollowups(ids, null), () => settleChatFollowups(ids, "interrupted")).catch((error) => console.warn("aside-queue: could not settle degraded aside", error));
262
+ }
263
+ }
264
+ /** Append the waiting asides as transcript lines for the degraded turn.
265
+ * Same record shape as injection — enveloped text, queueId marker, peer
266
+ * provenance — so both deliveries read identically downstream. */
267
+ function recordDegradedAsides(store, threadId, items) {
268
+ const appended = [];
269
+ for (const item of items) {
270
+ appended.push(store.appendMessage(threadId, {
271
+ role: "user",
272
+ kind: "text",
273
+ text: item.prompt,
274
+ queueId: item.messageId,
275
+ aside: true,
276
+ peerAsk: {
277
+ botId: item.aside.fromBotId,
278
+ name: item.aside.fromBotName,
279
+ ...(item.aside.unattended ? { unattended: true } : {}),
280
+ },
281
+ }));
282
+ }
283
+ return appended;
284
+ }
285
+ /** Whether a thread currently has an injection or drain in flight. The
286
+ * arrival path uses this to stay honest ("queued") instead of racing a
287
+ * boundary pass that may be folding the same lane right now. */
288
+ export function asideAttemptInFlight(threadId) {
289
+ return inFlight.has(threadId);
290
+ }
291
+ /** Test helper: how many asides remain queued for a thread. */
292
+ export function _asideCount(threadId) {
293
+ return asides.get(threadId)?.items.length ?? 0;
294
+ }
@@ -61,6 +61,9 @@ const IMAGE_MIMES = {
61
61
  * despite using ZIP internally. The claimed mime determines the extension;
62
62
  * an attacker-controlled filename never does. */
63
63
  const FILE_MIMES = {
64
+ "video/mp4": ".mp4",
65
+ "video/webm": ".webm",
66
+ "video/quicktime": ".mov",
64
67
  "audio/opus": ".opus",
65
68
  "audio/ogg": ".ogg",
66
69
  "audio/mpeg": ".mp3",
@@ -622,7 +625,7 @@ export function attachmentExists(name) {
622
625
  * filename (no separators, no dotfiles) inside ATTACHMENTS_DIR resolve —
623
626
  * the route must never become a general file server for the data dir. */
624
627
  export function readAttachment(name) {
625
- if (!/^[A-Za-z0-9-]+\.(png|jpg|jpeg|gif|webp)$/.test(name))
628
+ if (!/^[A-Za-z0-9-]+\.(png|jpg|jpeg|gif|webp|mp3)$/.test(name))
626
629
  return null;
627
630
  const path = join(ATTACHMENTS_DIR, name);
628
631
  if (extname(path) === ".jpeg")
@@ -644,7 +647,46 @@ function mimeForExt(ext) {
644
647
  return "image/gif";
645
648
  case ".webp":
646
649
  return "image/webp";
650
+ case ".mp3":
651
+ return "audio/mpeg";
647
652
  default:
648
653
  return "application/octet-stream";
649
654
  }
650
655
  }
656
+ /** Parse a single `bytes=` range against a stored audio file. Only the
657
+ * canonical shapes a player sends are honored: `a-b`, `a-`, and `-n`
658
+ * (the last-n-bytes suffix). Anything else — commas, letters, reversed
659
+ * bounds, internal spaces — reads as "not understood" and the caller sends
660
+ * the whole file, which is always a correct answer to a GET. */
661
+ export function parseAudioRange(header, size) {
662
+ const raw = header?.trim();
663
+ if (!raw)
664
+ return { kind: "none" };
665
+ const m = raw.match(/^bytes=(\d*)-(\d*)$/);
666
+ if (!m)
667
+ return { kind: "none" };
668
+ const [, rawStart, rawEnd] = m;
669
+ if (rawStart === "") {
670
+ // `bytes=-` carries no bounds at all: malformed input, not a suffix
671
+ // range, so the caller answers with the whole file. A real zero-length
672
+ // suffix (`bytes=-0`) stays unsatisfiable below, per RFC 9110.
673
+ if (rawEnd === "")
674
+ return { kind: "none" };
675
+ // suffix range: the final n bytes; zero is unsatisfiable by definition
676
+ const suffix = Number(rawEnd);
677
+ if (!suffix)
678
+ return { kind: "unsatisfiable" };
679
+ if (size === 0)
680
+ return { kind: "unsatisfiable" };
681
+ return { kind: "range", start: Math.max(0, size - suffix), end: size - 1 };
682
+ }
683
+ const start = Number(rawStart);
684
+ if (start >= size)
685
+ return { kind: "unsatisfiable" };
686
+ if (rawEnd === "")
687
+ return { kind: "range", start, end: size - 1 };
688
+ const end = Number(rawEnd);
689
+ if (end < start)
690
+ return { kind: "none" };
691
+ return { kind: "range", start, end: Math.min(end, size - 1) };
692
+ }
@@ -0,0 +1,107 @@
1
+ // Lets a bot hand a finished file to the user as a real chat attachment.
2
+ // The source is opened with the same hardened resolver the message-file route
3
+ // uses (realpath containment, O_NOFOLLOW, size cap), then copied into the
4
+ // private attachment store, so the chat never needs to reach into a bot's
5
+ // workspace or VM home afterwards.
6
+ import { basename, join, posix } from "node:path";
7
+ import { extensionForFileMime, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, saveFile, saveImage, } from "./attachments.js";
8
+ import { mimeFor, openMessageFile } from "./message-file.js";
9
+ export const ATTACH_SUPPORTED_TYPES = "images (png, jpg, gif, webp), video (mp4, webm, mov), audio (mp3, m4a, aac, wav, ogg, opus, flac), " +
10
+ "pdf, Word/Excel/PowerPoint and OpenDocument files, and csv, tsv, txt, md, json, rtf";
11
+ function statusError(status, message) {
12
+ return Object.assign(new Error(message), { status });
13
+ }
14
+ /** Map a path inside a VM's durable workspace (for example
15
+ * /home/cua/workspace/report.pdf, optionally as a file:// URL) onto the host
16
+ * directory that is bind-mounted there. Anything outside the guest workspace,
17
+ * including `..` escapes, is not a VM path and returns null. */
18
+ export function guestWorkspaceToHost(input, guestRoot, hostRoot) {
19
+ let value = input.trim();
20
+ if (/^file:\/\//i.test(value)) {
21
+ try {
22
+ value = decodeURIComponent(new URL(value).pathname);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ if (!value.startsWith("/"))
29
+ return null;
30
+ const normalized = posix.normalize(value);
31
+ const relative = posix.relative(guestRoot, normalized);
32
+ if (!relative || relative === ".." || relative.startsWith("../") || posix.isAbsolute(relative))
33
+ return null;
34
+ return join(hostRoot, ...relative.split("/"));
35
+ }
36
+ /** A display name for the chat: the model's suggestion if it gave one, else
37
+ * the file's own name. Directory parts are dropped, never trusted. */
38
+ function displayNameFor(requested, fallback) {
39
+ const cleaned = requested?.trim().replace(/\\/g, "/");
40
+ const name = cleaned ? posix.basename(cleaned) : "";
41
+ return name || fallback;
42
+ }
43
+ export async function saveBotAttachment(input) {
44
+ const requested = input.path.trim();
45
+ if (!requested)
46
+ throw statusError(400, "path is required");
47
+ const guestHost = input.guest ? guestWorkspaceToHost(requested, input.guest.root, input.guest.host) : null;
48
+ // A bot with a VM works in it: a relative path means its VM workspace first.
49
+ const roots = input.guest ? [input.guest.host, ...input.roots] : input.roots;
50
+ const file = await openMessageFile(guestHost ?? requested, roots);
51
+ try {
52
+ const mime = mimeFor(file.name);
53
+ const name = displayNameFor(input.name, file.name);
54
+ if (extensionForMime(mime)) {
55
+ if (file.bytes > IMAGE_MAX_BYTES)
56
+ throw statusError(413, `image exceeds ${IMAGE_MAX_BYTES} bytes`);
57
+ const saved = saveImage(await file.handle.readFile(), mime);
58
+ return { attachment: { kind: "image", path: saved.path, mime: saved.mime }, bytes: saved.bytes };
59
+ }
60
+ if (!extensionForFileMime(mime)) {
61
+ throw statusError(415, `${file.name} is not a supported attachment type. Supported: ${ATTACH_SUPPORTED_TYPES}.`);
62
+ }
63
+ if (file.bytes > FILE_MAX_BYTES)
64
+ throw statusError(413, `file exceeds ${FILE_MAX_BYTES} bytes`);
65
+ const saved = await saveFile(file.handle.createReadStream({ start: 0, autoClose: false }), name || basename(file.name), mime, {
66
+ expectedBytes: file.bytes,
67
+ });
68
+ return { attachment: { kind: "file", path: saved.path, mime: saved.mime, name: saved.name }, bytes: saved.bytes };
69
+ }
70
+ finally {
71
+ await file.handle.close().catch(() => undefined);
72
+ }
73
+ }
74
+ /**
75
+ * Attach one file for one provider turn. Two things must hold while the copy
76
+ * is awaited, so both are handled here rather than in the route:
77
+ *
78
+ * - The per-turn count is exact under parallel calls: the slot is reserved
79
+ * before the await, so simultaneous calls cannot all pass the same check.
80
+ * A call that fails or is refused gives its slot back.
81
+ * - Nothing is published for a turn that ended (stopped, replaced, deleted, or
82
+ * moved) while the copy ran. `stillLive` is asked again after the await,
83
+ * just before `publish`, and the copy is discarded when it says no.
84
+ */
85
+ export async function attachForTurn(turn, max, hooks) {
86
+ const used = turn.attachedFiles ?? 0;
87
+ if (used >= max)
88
+ return { status: "limit" };
89
+ turn.attachedFiles = used + 1;
90
+ let saved;
91
+ let published = false;
92
+ try {
93
+ saved = await hooks.save();
94
+ if (!hooks.stillLive())
95
+ return { status: "ended" };
96
+ hooks.publish(saved);
97
+ published = true;
98
+ return { status: "attached", saved };
99
+ }
100
+ finally {
101
+ if (!published) {
102
+ turn.attachedFiles = Math.max(0, (turn.attachedFiles ?? 1) - 1);
103
+ if (saved !== undefined)
104
+ hooks.discard(saved);
105
+ }
106
+ }
107
+ }
@@ -126,6 +126,24 @@ export async function connectedAppsFacts(configured, availability, read) {
126
126
  function couldUseApps(facts) {
127
127
  return facts.bot.composio !== false && facts.connectedApps.configured && Boolean(facts.engine?.composioMcp);
128
128
  }
129
+ /** Summarize a bot's connectorTools record for the Overview. Undefined in,
130
+ * undefined out: a bot with no record keeps legacy all-tools behavior and
131
+ * the field stays off the wire. An explicit record — even an empty one —
132
+ * becomes a (possibly empty) list, sorted by slug so both phones and the
133
+ * web dialog render it in one deterministic order. */
134
+ export function grantsSummary(connectorTools) {
135
+ if (connectorTools === undefined)
136
+ return undefined;
137
+ return Object.entries(connectorTools)
138
+ .map(([slug, grant]) => {
139
+ if (grant.tools === "*")
140
+ return { slug, level: "all", toolCount: 0 };
141
+ if (grant.tools.length > 0)
142
+ return { slug, level: "partial", toolCount: grant.tools.length };
143
+ return { slug, level: "none", toolCount: 0 };
144
+ })
145
+ .sort((a, b) => (a.slug < b.slug ? -1 : 1));
146
+ }
129
147
  function computerReach(computer) {
130
148
  switch (computer) {
131
149
  case "cloud":
@@ -210,6 +228,7 @@ export function buildBotOverview(facts) {
210
228
  reaches: reachesLines(facts),
211
229
  wont: wontLines(facts),
212
230
  recent: facts.recent,
231
+ grants: grantsSummary(facts.bot.connectorTools),
213
232
  setup: setupSteps(facts),
214
233
  };
215
234
  }
@@ -4,6 +4,7 @@ export const API_ENDPOINTS = [
4
4
  { id: "openai", label: "OpenAI API", url: "https://api.openai.com/v1", keyUrl: "https://platform.openai.com/api-keys" },
5
5
  { id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/api/v1", keyUrl: "https://openrouter.ai/settings/keys" },
6
6
  { id: "groq", label: "Groq", url: "https://api.groq.com/openai/v1", keyUrl: "https://console.groq.com/keys" },
7
+ { id: "requesty", label: "Requesty", url: "https://router.requesty.ai/v1", keyUrl: "https://app.requesty.ai/api-keys" },
7
8
  ];
8
9
  export function normalizeApiUrl(value) {
9
10
  const input = value.trim();