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.
- package/README.md +2 -1
- package/dist/assets/{abnfDiagram-O67JEVCF-BTiuL79F.js → abnfDiagram-O67JEVCF-BwaMaZEm.js} +1 -1
- package/dist/assets/{arc-C88Blc2v.js → arc-Be-8GKxd.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-BZsJIZrb.js → architectureDiagram-NJMV4G6O-Dp_U2Kv1.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CVeMe9Ah.js → blockDiagram-BEXU5L5S-DQjUjYUk.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-Cedk3Bhx.js → c4Diagram-YGBWAQC7-Bd3aE4Kc.js} +1 -1
- package/dist/assets/channel-DqY_gMLg.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-D-VRqzTB.js → chunk-2Q5K7J3B-BijjTBeJ.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-BChov01Y.js → chunk-3FUC2YCW-CMGdwvzW.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-C-TfwvHy.js → chunk-5VM5RSS4-0G09BfsZ.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-Db7CGj5Y.js → chunk-GTNCS2PH-FOp3vSHe.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-CNvIfqKT.js → chunk-GWA4HPMP-Dv-20VIn.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-C_bEp5tq.js → chunk-JWPE2WC7-CRgrdFcz.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-Coi4BOls.js → chunk-NETBCI7D-3hP4f7QB.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-CzylEuL3.js → chunk-WEXAMYUT-CG98IjlG.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-m7p7ztaD.js → chunk-XXDRQBXY-BrSTC9xG.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BMy6o9x2.js → classDiagram-v2-NBCMYWYE-rpQQQDFX.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-BDQ__0_J.js → cose-bilkent-JH36ORCC-G6RSafHA.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-Djv4ghUb.js → cynefin-OW5HDTMX-CXjv-IM2.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-BULrlGYH.js → cynefinDiagram-VND7K2PF-Dw07MR9f.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-D-jrT_hU.js → dagre-6A5THRUB-BwPnlTg2.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-p2UhQmfL.js → diagram-22UHCM2B-Cm6xcMiz.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-DHvGWZ7q.js → diagram-3UASUU5V-CDpogPbE.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-ChhKDot-.js → diagram-ATOU4E4O-BV8z3Pnc.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-B-T7A43R.js → diagram-CDSNMT55-CKCQhOOC.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-CJm8ENNY.js → diagram-MLGK6HIB-FGvJg69C.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-510tlza8.js → diagram-MPIPVDR6-CwATvgnS.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-PkvRPHel.js → ebnfDiagram-ZINNZB2B-CH_c3SSC.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-BVBVPoX3.js → elk-276RUBZZ-3guZi71R.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BgYMVm7L.js → erDiagram-OPXOYQCR-BMHphR2n.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-BGqsnc_3.js → flowDiagram-KWPJA3E3-Vm5DkW5x.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-D_-UeReH.js → ganttDiagram-FUAMR5RP-DO-1ct07.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-D0uooONx.js → gitGraphDiagram-X574FWY7-nxeksEIN.js} +1 -1
- package/dist/assets/index-BIMdIbaw.css +1 -0
- package/dist/assets/index-BVPdbq2r.js +583 -0
- package/dist/assets/{index-zRsJERGJ.js → index-Oxk9x45i.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-B9e1z77f.js → infoDiagram-VRGFBTTK-Dkcvr0p7.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-BB4vNwvD.js → ishikawaDiagram-OU5B5YK6-BwVibkoO.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DpkosOTB.js → journeyDiagram-ZHPQQLJL-ByiBZqgG.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-DbDP4kUu.js → kanban-definition-PNTS6WVX-qqjgXBF3.js} +1 -1
- package/dist/assets/{linear-DBJGPmjT.js → linear-CtJnKe6A.js} +1 -1
- package/dist/assets/{mermaid.core-DWPRi-ue.js → mermaid.core-BEiDklrY.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-nszBbJmx.js → mindmap-definition-NLK3R4M7-DfIYHia9.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-CWykJWu_.js → pegDiagram-GJSIUBJH-CYyRqa1a.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-g3Da-Vnv.js → pieDiagram-5QR66LMP-hOToy_zH.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-Wzrlpzgh.js → quadrantDiagram-O4NWA36T-DKAsNN9I.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-moHTFMC0.js → railroadDiagram-XR7U4H2S-BSA-d8JU.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-CwfXdFH8.js → requirementDiagram-PLB6GJNP-CSmlvb-p.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-3ZROYvnq.js → sankeyDiagram-IPEJSGJF-CD5F6fnG.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-E1TBebP8.js → sequenceDiagram-PO4LG4MO-JUq5sx6d.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-DHKEU5pu.js → sizeCapture-INFHLROL-BjIS-Stk.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-BguEj09D.js → stateDiagram-v2-GCMORJYK-DklkUxOg.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-C7IDYnhd.js → swimlanes-2SLR337P-CsjIU6be.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-DCW-F5PW.js → timeline-definition-EJHVYXUP-BkFqK7AB.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-xBMFtWYV.js → usecaseDiagram-POWQR4AR-70gJB1EU.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-B5nIkuH8.js → vennDiagram-UO4OBE2U-83Tm62dN.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-DYFAhdbr.js → wardleyDiagram-VNRHLVJA-tTmseoX9.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-DeXwX6Cz.js → xychartDiagram-PMCCYNJV-R1LBA55a.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +8 -1
- package/dist-server/connector-proxy.js +105 -1
- package/dist-server/container-mcp.js +11 -1
- package/dist-server/drivers/agents-proxy.js +277 -22
- package/dist-server/index.js +23722 -20272
- package/dist-server/local-computer-proxy.js +2 -0
- package/dist-server/mcp-gate.js +2 -0
- package/dist-server/mcp-server.js +5 -3
- package/dist-server/message-search.worker.js +52 -0
- package/dist-server/openmausbot.js +755 -404
- package/dist-server/pair-cli.js +755 -404
- package/dist-server/server/admission.js +81 -0
- package/dist-server/server/aside-queue.js +294 -0
- package/dist-server/server/attachments.js +43 -1
- package/dist-server/server/automatic-recovery.js +15 -0
- package/dist-server/server/bot-attachment.js +107 -0
- package/dist-server/server/bot-overview.js +19 -0
- package/dist-server/server/composio.js +297 -1
- package/dist-server/server/config.js +34 -3
- package/dist-server/server/connector-advertisement.js +165 -0
- package/dist-server/server/connector-proxy.js +14 -2
- package/dist-server/server/connector-verdict.js +31 -4
- package/dist-server/server/container-computer.js +70 -0
- package/dist-server/server/contracts.js +32 -0
- package/dist-server/server/drivers/acp/core.js +148 -23
- package/dist-server/server/drivers/acp/cursor.js +3 -7
- package/dist-server/server/drivers/acp/droid.js +4 -7
- package/dist-server/server/drivers/acp/gemini.js +4 -4
- package/dist-server/server/drivers/acp/grok.js +4 -9
- package/dist-server/server/drivers/acp/hermes.js +3 -5
- package/dist-server/server/drivers/acp/opencode-go.js +2 -5
- package/dist-server/server/drivers/acp/qwen.js +12 -15
- package/dist-server/server/drivers/agents-call.js +137 -10
- package/dist-server/server/drivers/agents-catalog.js +100 -6
- package/dist-server/server/drivers/boxagent.js +3 -17
- package/dist-server/server/drivers/claude.js +31 -17
- package/dist-server/server/drivers/codex-catalog.js +14 -6
- package/dist-server/server/drivers/codex-identity.js +2 -1
- package/dist-server/server/drivers/codex-release.js +48 -0
- package/dist-server/server/drivers/codex.js +7 -25
- package/dist-server/server/drivers/openai-chat.js +31 -3
- package/dist-server/server/drivers/pi.js +237 -30
- package/dist-server/server/drivers/prompt-split.js +32 -9
- package/dist-server/server/drivers/session-idle.js +29 -0
- package/dist-server/server/env-path.js +15 -0
- package/dist-server/server/guarded-requests.js +2 -2
- package/dist-server/server/harness-capabilities.js +61 -0
- package/dist-server/server/index.js +1173 -262
- package/dist-server/server/message-db.js +22 -42
- package/dist-server/server/message-file.js +9 -1
- package/dist-server/server/message-search-query.js +43 -0
- package/dist-server/server/message-search-worker.js +80 -0
- package/dist-server/server/message-search.worker.js +21 -0
- package/dist-server/server/model-requests.js +248 -0
- package/dist-server/server/options-card.js +21 -0
- package/dist-server/server/org-library.js +15 -1
- package/dist-server/server/peer-delivery.js +60 -0
- package/dist-server/server/peer-roster.js +57 -0
- package/dist-server/server/presets.js +32 -32
- package/dist-server/server/profile-requests.js +66 -39
- package/dist-server/server/profile-revision.js +9 -1
- package/dist-server/server/profile-versions.js +6 -3
- package/dist-server/server/routine-requests.js +29 -7
- package/dist-server/server/skill-fetch.js +72 -14
- package/dist-server/server/skills.js +1 -0
- package/dist-server/server/store.js +32 -2
- package/dist-server/server/system-prompt.js +37 -2
- package/dist-server/server/team-setup-requests.js +124 -23
- package/dist-server/server/thread-events.js +3 -1
- package/dist-server/server/tightening-requests.js +405 -0
- package/dist-server/server/tts/chatterbox.js +27 -5
- package/dist-server/server/usage-ledger.js +7 -1
- package/dist-server/server/webhooks.js +21 -3
- package/dist-server/server/workspace-backup.js +40 -3
- package/dist-server/server/workspace-backup.worker.js +10 -0
- package/dist-server/shared/ask-question.js +19 -0
- package/dist-server/shared/credential-request.js +5 -1
- package/dist-server/shared/line-diff.js +23 -0
- package/dist-server/shared/model-request.js +1 -0
- package/dist-server/shared/options-card.js +51 -0
- package/dist-server/shared/profile-request.js +5 -4
- package/dist-server/shared/tightening-request.js +87 -0
- package/dist-server/src/lib/composer-attachments.js +42 -3
- package/dist-server/vps-container-mcp.js +11 -1
- package/dist-server/workspace-backup.worker.js +19482 -0
- package/package.json +1 -1
- package/dist/assets/channel-taCNRlUU.js +0 -1
- package/dist/assets/index-BwdMAQ6p.css +0 -1
- package/dist/assets/index-ByJennr7.js +0 -581
- 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,15 @@
|
|
|
1
|
+
import { supportsWorkspaceFiles } from "./workspace.js";
|
|
2
|
+
/** An automatic switch must not strand the task without its tools or move
|
|
3
|
+
* its filesystem to another machine. Approval and organisation policy are
|
|
4
|
+
* checked by the ordinary model-switch and turn-admission gates. */
|
|
5
|
+
export function recoveryCapabilityError(from, to) {
|
|
6
|
+
if (from.capabilities.remoteAgent || to.capabilities.remoteAgent ||
|
|
7
|
+
supportsWorkspaceFiles(from.driverKind) !== supportsWorkspaceFiles(to.driverKind)) {
|
|
8
|
+
return "The backup cannot use the same workspace.";
|
|
9
|
+
}
|
|
10
|
+
const tools = ["agentsMcp", "computerMcp", "cloudComputerMcp", "composioMcp",
|
|
11
|
+
"phoneMcp", "browserMcp", "localComputerMcp", "customMcp", "images"];
|
|
12
|
+
if (tools.some(key => from.capabilities[key] === true && to.capabilities[key] !== true)) {
|
|
13
|
+
return "The backup does not support all of this engine's tools and attachments.";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -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
|
}
|