openmausbot 0.1.97 → 0.1.98
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/dist/assets/ActivityPanel-H6D3SD39.js +1 -0
- package/dist/assets/BotSettingsDialog-Dqz_AiBZ.js +7 -0
- package/dist/assets/CloudBackendPicker-iezpJFpF.js +1 -0
- package/dist/assets/ComputerPanel-DZPj-IWu.js +1 -0
- package/dist/assets/DesktopViewer-utONms1p.js +5 -0
- package/dist/assets/InspectorPanel-OVaBVrzC.js +5 -0
- package/dist/assets/KeyboardShortcutsModal-gk9mvHj_.js +1 -0
- package/dist/assets/LocalVmWorkspace-DLIvd2kC.js +1 -0
- package/dist/assets/MacLocalControl-bJv8yiPB.js +1 -0
- package/dist/assets/NewBotDialog-1qGOAdUM.js +2 -0
- package/dist/assets/RemoteAgentSettingsPanel-DqKr0HkO.js +1 -0
- package/dist/assets/RoutinesPage-BM2D4qGi.js +1 -0
- package/dist/assets/RoutinesSection-Dny9ra-c.js +1 -0
- package/dist/assets/SettingsModal-gC6XJJmt.js +11 -0
- package/dist/assets/TeamMapPage-B6fuu4r_.js +8 -0
- package/dist/assets/TriggersPanel-j0qTP2CO.js +1 -0
- package/dist/assets/VisibilitySection-DT0FqPvu.js +12 -0
- package/dist/assets/WebhooksPanel--YQyCjKO.js +3 -0
- package/dist/assets/{abnfDiagram-O67JEVCF-k1UCNXb7.js → abnfDiagram-O67JEVCF-BsjcFX0-.js} +1 -1
- package/dist/assets/activity-BxOkayvD.js +1 -0
- package/dist/assets/{arc-BUWGa9UU.js → arc-CJNA94c6.js} +1 -1
- package/dist/assets/architectureDiagram-NJMV4G6O-dz2ud7lP.js +36 -0
- package/dist/assets/{blockDiagram-BEXU5L5S-Ag9nLW4H.js → blockDiagram-BEXU5L5S-ChBEALoR.js} +1 -1
- package/dist/assets/brain-CFZ7PEXc.js +1 -0
- package/dist/assets/{c4Diagram-YGBWAQC7-Bh0sjgUa.js → c4Diagram-YGBWAQC7-KZNRKDQe.js} +1 -1
- package/dist/assets/channel-WhFyujhZ.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-BFBcIIqu.js → chunk-2Q5K7J3B-BLLwmCET.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-Dvfpex-f.js → chunk-3FUC2YCW-CQxyreBZ.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-CMjP2YZW.js → chunk-5VM5RSS4-D6sFMW0u.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-BryoeTc7.js → chunk-GTNCS2PH-BaZ1YgLF.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-BhfvcchO.js → chunk-GWA4HPMP-CcalphmC.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-bcCVp8rp.js → chunk-JWPE2WC7-CSk8R3ve.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-yKIjIlkQ.js → chunk-NETBCI7D-CTmLjA57.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-D3Zqbmup.js → chunk-WEXAMYUT-CM9Z77Ne.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-CjVoIMG6.js → chunk-XXDRQBXY-B1fxpMS2.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-mUCWdVyH.js → classDiagram-v2-NBCMYWYE-DCCyoLpj.js} +1 -1
- package/dist/assets/computer-control-NLov352p.js +1 -0
- package/dist/assets/cose-bilkent-JH36ORCC-KyImBKvE.js +1 -0
- package/dist/assets/{cynefin-OW5HDTMX-BpRrxoO4.js → cynefin-OW5HDTMX-fsZc6MGa.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-D3VBWCPK.js → cynefinDiagram-VND7K2PF-CGmHsShB.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-DxEIACOO.js → dagre-6A5THRUB-Dah1h-CH.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-BANgZEOS.js → diagram-22UHCM2B-BIu71OyV.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-Cnq7nJir.js → diagram-3UASUU5V-gxhUB_Mu.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-4TBDIWnz.js → diagram-ATOU4E4O-CBhusUBn.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-CD_B39D4.js → diagram-CDSNMT55-BdPrc86x.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-CzvnFhEC.js → diagram-MLGK6HIB-Clp110R_.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-C4w2SNml.js → diagram-MPIPVDR6-DJKfbugw.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-gkUEBm2s.js → ebnfDiagram-ZINNZB2B-DnsltoBu.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-B_56X6mw.js → elk-276RUBZZ-jWdq6gqH.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-C_oByoDS.js → erDiagram-OPXOYQCR-DArPVf8u.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-CR9xXJkC.js → flowDiagram-KWPJA3E3-D2HZ-2f7.js} +1 -1
- package/dist/assets/ganttDiagram-FUAMR5RP--o0KIxEo.js +292 -0
- package/dist/assets/{gitGraphDiagram-X574FWY7-VJ6-Tp_p.js → gitGraphDiagram-X574FWY7-CsoXr2D7.js} +1 -1
- package/dist/assets/image-plus-BbVjGHin.js +1 -0
- package/dist/assets/index-BrPW5HDO.css +1 -0
- package/dist/assets/index-C3NqfdVU.js +585 -0
- package/dist/assets/{index-Cp4S4NOL.js → index-CJTp9JEp.js} +1 -1
- package/dist/assets/{infoDiagram-VRGFBTTK-2Lu9te4D.js → infoDiagram-VRGFBTTK-g-6lNp7r.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-DR49XY-e.js → ishikawaDiagram-OU5B5YK6-B1d6qVTV.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-ZoR0M305.js → journeyDiagram-ZHPQQLJL-BTPuiU6T.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-DLKHxrO-.js → kanban-definition-PNTS6WVX-bDDuRJvP.js} +1 -1
- package/dist/assets/{linear-BASsfWly.js → linear-BVVKbEMy.js} +1 -1
- package/dist/assets/{mermaid.core-BVidiPkk.js → mermaid.core-JJdoKyKB.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-g_Qx0U5C.js → mindmap-definition-NLK3R4M7-B4aE3T0q.js} +1 -1
- package/dist/assets/module-DCUsy2BQ.js +3 -0
- package/dist/assets/page-visible-BBo_Zm8g.js +1 -0
- package/dist/assets/{pegDiagram-GJSIUBJH-DI9Cjs8w.js → pegDiagram-GJSIUBJH-DFKQWpR7.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-sRI0Uhr5.js → pieDiagram-5QR66LMP-Iv7OKVJi.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-CamKTby3.js → quadrantDiagram-O4NWA36T-U2YnfwBk.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-DOLlkbf3.js → railroadDiagram-XR7U4H2S-CuPy6RqM.js} +1 -1
- package/dist/assets/remote-desktop-panel-BNEj7tUx.js +1 -0
- package/dist/assets/{requirementDiagram-PLB6GJNP-DrRQ5r97.js → requirementDiagram-PLB6GJNP-bMI5AK7T.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-DGS_lXcX.js → sankeyDiagram-IPEJSGJF-BKcFE2WQ.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-CkjWqwtf.js → sequenceDiagram-PO4LG4MO-Bn9q88kl.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-CepRgful.js → sizeCapture-INFHLROL-Cvmdq9HD.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-hykr-9RS.js → stateDiagram-v2-GCMORJYK-JJQYw3eQ.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-CKOHEZbI.js → swimlanes-2SLR337P-BkSCBEwp.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-CmTirbVT.js +8 -0
- package/dist/assets/table-file.worker-KkMx-SNx.js +10 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-CB8pJ6AC.js → timeline-definition-EJHVYXUP-DsaM4GHr.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-DrclJHdl.js → usecaseDiagram-POWQR4AR-3FOwLq7B.js} +1 -1
- package/dist/assets/user-DcZBnAuv.js +1 -0
- package/dist/assets/{vennDiagram-UO4OBE2U-zS23qjv8.js → vennDiagram-UO4OBE2U-DyNjJsKt.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-52jgeKxz.js → wardleyDiagram-VNRHLVJA-Y4ZMcnBC.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-DSdl9rQ8.js → xychartDiagram-PMCCYNJV-BXZpg955.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +19 -8
- package/dist-server/container-mcp.js +8 -0
- package/dist-server/desktop-entry.mjs +54 -0
- package/dist-server/drivers/agents-proxy.js +21 -4
- package/dist-server/index.js +1412 -821
- package/dist-server/openmausbot.js +837 -571
- package/dist-server/pair-cli.js +837 -571
- package/dist-server/permission-proxy.js +2 -0
- package/dist-server/server/atomic.js +70 -2
- package/dist-server/server/cloud-home-start.js +3 -3
- package/dist-server/server/config.js +22 -1
- package/dist-server/server/connector-card-text.js +15 -0
- package/dist-server/server/default-model-selection.js +7 -4
- package/dist-server/server/drivers/acp/core.js +10 -0
- package/dist-server/server/drivers/acp/grok.js +47 -5
- package/dist-server/server/drivers/agents-catalog.js +24 -4
- package/dist-server/server/drivers/agents-proxy.js +1 -0
- package/dist-server/server/drivers/chat-mcp-tools.js +5 -3
- package/dist-server/server/drivers/claude-login-auth.js +1 -1
- package/dist-server/server/drivers/claude.js +35 -11
- package/dist-server/server/drivers/codex-device-auth.js +27 -319
- package/dist-server/server/drivers/device-auth.js +342 -0
- package/dist-server/server/drivers/openai-chat.js +12 -4
- package/dist-server/server/fleet-agent.js +18 -5
- package/dist-server/server/harness/http.js +5 -2
- package/dist-server/server/harness/registry.js +19 -0
- package/dist-server/server/harness-capabilities.js +7 -4
- package/dist-server/server/hosted-models.js +3 -1
- package/dist-server/server/index.js +268 -54
- package/dist-server/server/key-rejections.js +57 -0
- package/dist-server/server/message-file.js +7 -4
- package/dist-server/server/permission-proxy.js +4 -0
- package/dist-server/server/provider-key-check.js +5 -1
- package/dist-server/server/request-auth.js +1 -1
- package/dist-server/server/room-turn-timeout.js +28 -0
- package/dist-server/server/routes/thread-models.js +36 -0
- package/dist-server/server/routines.js +2 -2
- package/dist-server/server/skills.js +3 -2
- package/dist-server/server/store.js +148 -60
- package/dist-server/server/team-backup.js +19 -4
- package/dist-server/server/thread-model.js +25 -0
- package/dist-server/server/turn-continuation.js +8 -2
- package/dist-server/server/webhook-ingress.js +8 -3
- package/dist-server/server/webhooks.js +40 -5
- package/dist-server/shared/device-sign-in.js +57 -0
- package/dist-server/shared/team-backup.js +2 -0
- package/dist-server/shared/thread-model.js +13 -0
- package/dist-server/vps-container-mcp.js +8 -0
- package/dist-server/workspace-backup.worker.js +41 -22
- package/package.json +1 -1
- package/dist/assets/DesktopViewer-CPidMCtt.js +0 -5
- package/dist/assets/architectureDiagram-NJMV4G6O-DiPor3UT.js +0 -36
- package/dist/assets/channel-DR8eD6QW.js +0 -1
- package/dist/assets/cose-bilkent-JH36ORCC-BBmR1bbX.js +0 -1
- package/dist/assets/ganttDiagram-FUAMR5RP-gtYBTu0B.js +0 -292
- package/dist/assets/index-CpaIXRLp.js +0 -614
- package/dist/assets/index-Z81SMsgR.css +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-D-8vqZ4-.js +0 -8
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { chmodSync, existsSync, readFileSync, mkdirSync, rmSync, statSync, unlinkSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { writeFileAtomic } from "./atomic.js";
|
|
8
|
+
import { writeFileAtomic, writeFileAtomicIfChanged } from "./atomic.js";
|
|
9
9
|
import { parseToolScope, toolScopeWidens } from "../shared/tool-scope.js";
|
|
10
10
|
import { ensureSections, readSections, changeEmptySection } from "./section-context.js";
|
|
11
11
|
import { removeBotFolder, soulFile, soulHash, writeSoulMirror } from "./bot-folder.js";
|
|
@@ -22,6 +22,7 @@ import { approvalModeFor, isApprovalMode, modelSwitchNeedsAsk } from "../shared/
|
|
|
22
22
|
import { REMOVED_COMPUTER_DRIVER } from "./computer-engine-removal.js";
|
|
23
23
|
import { isMentionBoundary, isMentionNameContinuation } from "../shared/mention-boundary.js";
|
|
24
24
|
import { CONNECTOR_SLUG_PATTERN, CONNECTOR_TOOL_NAME_PATTERN } from "../shared/wire.js";
|
|
25
|
+
import { sameModelSelection } from "../shared/thread-model.js";
|
|
25
26
|
export const groupWireProjectionIsExact = true;
|
|
26
27
|
// Unicode's complete emoji sequences include flags, skin tones and ZWJ
|
|
27
28
|
// combinations. Also allow unqualified single symbols (e.g. ♥), but not
|
|
@@ -32,13 +33,19 @@ export function isProjectEmoji(value) {
|
|
|
32
33
|
}
|
|
33
34
|
export const taskWireProjectionIsExact = true;
|
|
34
35
|
/** The typed wire projection for one task. Pairs with the assertion above:
|
|
35
|
-
* returning WireTask means an undeclared server field cannot ride silently.
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
* returning WireTask means an undeclared server field cannot ride silently.
|
|
37
|
+
* A thread with no model of its own follows its bot: the wire carries the
|
|
38
|
+
* model it runs on either way, so every client reads one effective model. */
|
|
39
|
+
export function toWireTask(task, botModel) {
|
|
40
|
+
const { resumeCursors: _resumeCursors, lastInstanceId: _lastInstanceId, handedMessages: _handedMessages, appliedCompactionId: _appliedCompactionId, contextFloor: _contextFloor, lastContextModel: _lastContextModel, surfaceSource, ...stored } = task;
|
|
41
|
+
const wire = { ...stored, modelSelection: structuredClone(task.modelSelection ?? botModel), followsBotModel: task.modelSelection === undefined };
|
|
38
42
|
// Who pinned stays private. A client learns only whether the pin is the
|
|
39
43
|
// machine's own record, so it never presents one as the person's choice.
|
|
40
44
|
return surfaceSource === "auto" && wire.surface !== undefined ? { ...wire, surfaceAuto: true } : wire;
|
|
41
45
|
}
|
|
46
|
+
/** The level a thread drops to when its engine changes under a level the new
|
|
47
|
+
* engine would have to confirm (modelSwitchNeedsAsk), as on every switch. */
|
|
48
|
+
const ASK_NOW = { approvalMode: "ask", autoApprove: false, alwaysAllow: [] };
|
|
42
49
|
const TASK_PATCH_FIELDS = [
|
|
43
50
|
"title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
|
|
44
51
|
"unread", "rewound", "archivedAt", "pinned", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
|
|
@@ -326,6 +333,9 @@ export function parseConnectorTools(value) {
|
|
|
326
333
|
}
|
|
327
334
|
const BOTS_FILE = join(DATA_DIR, "bots.json");
|
|
328
335
|
const GROUPS_FILE = join(DATA_DIR, "groups.json");
|
|
336
|
+
/** Written once the copies older builds made of a bot's model on its threads
|
|
337
|
+
* are cleared (Store load), so that cleanup runs once per data directory. */
|
|
338
|
+
const THREAD_MODEL_COPIES_CLEARED = join(DATA_DIR, "thread-model-copies-cleared");
|
|
329
339
|
/** The registries carry souls, project paths and per-bot settings, so they are
|
|
330
340
|
* owner-only like the other data-dir stores. A file written by an older
|
|
331
341
|
* release (or loosened by hand) is tightened on load. Best effort: failing to
|
|
@@ -456,6 +466,7 @@ export class Store {
|
|
|
456
466
|
threads = new Map();
|
|
457
467
|
defaultSelection;
|
|
458
468
|
completeNewBotSelection;
|
|
469
|
+
driverKindOf;
|
|
459
470
|
listeners = new Set();
|
|
460
471
|
/** A broken team registry must not prevent loading independent chat data. */
|
|
461
472
|
registeringInitialSections = true;
|
|
@@ -465,9 +476,14 @@ export class Store {
|
|
|
465
476
|
constructor(defaultSelection,
|
|
466
477
|
/** Workspace-wide new-bot defaults (config newBots), applied to every
|
|
467
478
|
* new bot's selection whichever path created it. */
|
|
468
|
-
completeNewBotSelection = (selection) => selection
|
|
479
|
+
completeNewBotSelection = (selection) => selection,
|
|
480
|
+
/** The driver an engine instance runs, for the level a thread keeps when
|
|
481
|
+
* its engine changes (modelSwitchNeedsAsk). Unknown counts as another
|
|
482
|
+
* driver, so Full and Custom fall back to Ask. */
|
|
483
|
+
driverKindOf = () => undefined) {
|
|
469
484
|
this.defaultSelection = defaultSelection;
|
|
470
485
|
this.completeNewBotSelection = completeNewBotSelection;
|
|
486
|
+
this.driverKindOf = driverKindOf;
|
|
471
487
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
472
488
|
for (const file of [BOTS_FILE, GROUPS_FILE])
|
|
473
489
|
tightenRegistryFile(file);
|
|
@@ -696,11 +712,17 @@ export class Store {
|
|
|
696
712
|
}
|
|
697
713
|
if (groupsMigrated)
|
|
698
714
|
this.saveGroups();
|
|
715
|
+
// Older builds copied a bot's model onto every thread it made, so a
|
|
716
|
+
// thread on exactly its bot's model is a copy, not a person's pick: once
|
|
717
|
+
// per data directory it is cleared and the thread follows its bot. A
|
|
718
|
+
// different model can't be told apart from a pick and stays. Only over a
|
|
719
|
+
// bot list this load could read, so an unreadable file is not skipped.
|
|
720
|
+
const clearModelCopies = !existsSync(THREAD_MODEL_COPIES_CLEARED) && (botsLoaded || !existsSync(BOTS_FILE));
|
|
699
721
|
// bots saved before tasks existed have one endless thread; adopt it as
|
|
700
722
|
// their first task so nothing is lost and nothing special-cases it
|
|
701
723
|
for (const b of this.bots) {
|
|
702
|
-
// Folders are organizational only. Preserve
|
|
703
|
-
//
|
|
724
|
+
// Folders are organizational only. Preserve threads' own models while
|
|
725
|
+
// discarding the unshipped folder-default setting.
|
|
704
726
|
if (b.projects?.some((project) => "modelSelection" in project)) {
|
|
705
727
|
b.projects = b.projects.map(({ id, name, emoji }) => ({ id, name, ...(isProjectEmoji(emoji) ? { emoji } : {}) }));
|
|
706
728
|
botsMigrated = true;
|
|
@@ -730,8 +752,8 @@ export class Store {
|
|
|
730
752
|
botsMigrated = true;
|
|
731
753
|
}
|
|
732
754
|
for (const task of b.tasks) {
|
|
733
|
-
if (task.modelSelection
|
|
734
|
-
task.modelSelection
|
|
755
|
+
if (clearModelCopies && task.modelSelection !== undefined && sameModelSelection(task.modelSelection, b.modelSelection)) {
|
|
756
|
+
delete task.modelSelection;
|
|
735
757
|
botsMigrated = true;
|
|
736
758
|
}
|
|
737
759
|
if (!task.resumeCursors) {
|
|
@@ -774,6 +796,8 @@ export class Store {
|
|
|
774
796
|
}
|
|
775
797
|
if (botsMigrated)
|
|
776
798
|
this.saveBots();
|
|
799
|
+
if (clearModelCopies)
|
|
800
|
+
writeFileAtomic(THREAD_MODEL_COPIES_CLEARED, "Threads follow their bot's model; the copies older builds made are cleared.\n", { mode: 0o600 });
|
|
777
801
|
// Search reads SQLite directly, so migrate every known legacy transcript
|
|
778
802
|
// at startup rather than waiting until the user happens to open it. Only
|
|
779
803
|
// pending JSON files are touched; already-migrated threads stay lazy.
|
|
@@ -896,10 +920,13 @@ export class Store {
|
|
|
896
920
|
if (botsDirty)
|
|
897
921
|
this.saveBots();
|
|
898
922
|
}
|
|
923
|
+
// Many saves repeat what is already on disk (the same resume cursor, an
|
|
924
|
+
// unchanged task field), so both registries skip a byte-identical write.
|
|
925
|
+
// Events are still emitted by the callers either way.
|
|
899
926
|
saveBots(bots = this.bots, registerSections = true) {
|
|
900
927
|
if (registerSections)
|
|
901
928
|
this.rememberSections([...this.bots, ...bots].map((bot) => bot.section));
|
|
902
|
-
|
|
929
|
+
writeFileAtomicIfChanged(BOTS_FILE, JSON.stringify(bots.map(({ busy: _busy, activity: _activity, ...bot }) => ({
|
|
903
930
|
...bot,
|
|
904
931
|
tasks: bot.tasks?.map(({ busy: _taskBusy, activity: _taskActivity, turnStartedAt: _taskTurnStarted, ...task }) => persistedPin(task)),
|
|
905
932
|
})), null, 2), { mode: 0o600 });
|
|
@@ -907,7 +934,7 @@ export class Store {
|
|
|
907
934
|
saveGroups(groups = this.groups, registerSections = true) {
|
|
908
935
|
if (registerSections)
|
|
909
936
|
this.rememberSections(groups.map((group) => group.section));
|
|
910
|
-
|
|
937
|
+
writeFileAtomicIfChanged(GROUPS_FILE, JSON.stringify(groups.map(({ busyBotId: _busyBotId, turnStartedAt: _turnStartedAt, ...g }) => ({
|
|
911
938
|
...g,
|
|
912
939
|
...(g.tasks ? { tasks: g.tasks.map((task) => persistedPin(task)) } : {}),
|
|
913
940
|
})), null, 2), { mode: 0o600 });
|
|
@@ -1120,6 +1147,9 @@ export class Store {
|
|
|
1120
1147
|
else if (!patch.busyBotId)
|
|
1121
1148
|
delete group.turnStartedAt;
|
|
1122
1149
|
}
|
|
1150
|
+
if (Object.prototype.hasOwnProperty.call(patch, "turnTimeoutMinutes") && patch.turnTimeoutMinutes == null) {
|
|
1151
|
+
delete group.turnTimeoutMinutes;
|
|
1152
|
+
}
|
|
1123
1153
|
if (!group.dm && Object.prototype.hasOwnProperty.call(patch, "pinnedMessageId")) {
|
|
1124
1154
|
const active = this.activeGroupTask(group.id);
|
|
1125
1155
|
if (active)
|
|
@@ -1260,6 +1290,20 @@ export class Store {
|
|
|
1260
1290
|
this.emit({ type: "group", groupId });
|
|
1261
1291
|
return task;
|
|
1262
1292
|
}
|
|
1293
|
+
/** Null removes the conversation's own ceiling so the next turn uses the
|
|
1294
|
+
* global group limit. A sibling conversation is left alone. */
|
|
1295
|
+
setGroupTaskTurnTimeout(groupId, threadId, minutes) {
|
|
1296
|
+
const task = this.groupTaskByThread(groupId, threadId);
|
|
1297
|
+
if (!task)
|
|
1298
|
+
return null;
|
|
1299
|
+
if (minutes == null)
|
|
1300
|
+
delete task.turnTimeoutMinutes;
|
|
1301
|
+
else
|
|
1302
|
+
task.turnTimeoutMinutes = minutes;
|
|
1303
|
+
this.saveGroups();
|
|
1304
|
+
this.emit({ type: "group", groupId });
|
|
1305
|
+
return task;
|
|
1306
|
+
}
|
|
1263
1307
|
renameGroupTask(groupId, threadId, title) {
|
|
1264
1308
|
const task = this.groupTaskByThread(groupId, threadId);
|
|
1265
1309
|
if (!task)
|
|
@@ -1626,7 +1670,6 @@ export class Store {
|
|
|
1626
1670
|
createdAt: bot.createdAt,
|
|
1627
1671
|
updatedAt: bot.createdAt,
|
|
1628
1672
|
resumeCursors: {},
|
|
1629
|
-
modelSelection: structuredClone(bot.modelSelection),
|
|
1630
1673
|
unread: false,
|
|
1631
1674
|
activity: "idle",
|
|
1632
1675
|
busy: false,
|
|
@@ -1658,7 +1701,8 @@ export class Store {
|
|
|
1658
1701
|
return bot;
|
|
1659
1702
|
}
|
|
1660
1703
|
/** All setup fields and the Chief's receipt commit before publishing any
|
|
1661
|
-
* mutation.
|
|
1704
|
+
* mutation. A new model moves the threads that follow the bot, never a
|
|
1705
|
+
* thread's own model. */
|
|
1662
1706
|
applyTeamSetup(request) {
|
|
1663
1707
|
const chief = this.bot(request.botId);
|
|
1664
1708
|
if (!chief)
|
|
@@ -1687,7 +1731,7 @@ export class Store {
|
|
|
1687
1731
|
// a restricted Chief never creates a bot everyone sees.
|
|
1688
1732
|
...(chief.visibility && chief.visibility !== "everyone" ? { visibility: structuredClone(chief.visibility) } : {}),
|
|
1689
1733
|
tasks: [{ threadId: operation.threadId, title: UNTITLED_THREAD, createdAt, updatedAt: createdAt, resumeCursors: {},
|
|
1690
|
-
|
|
1734
|
+
approvalMode: "ask", autoApprove: false,
|
|
1691
1735
|
unread: false, activity: "idle", busy: false }],
|
|
1692
1736
|
};
|
|
1693
1737
|
// "" is the private-workspace spelling on proposal; the record
|
|
@@ -1702,13 +1746,7 @@ export class Store {
|
|
|
1702
1746
|
const previous = nextBots[at];
|
|
1703
1747
|
next = { ...previous, ...operation.fields };
|
|
1704
1748
|
if (operation.fields.modelSelection)
|
|
1705
|
-
next.tasks = previous.
|
|
1706
|
-
...task,
|
|
1707
|
-
modelSelection: structuredClone(task.modelSelection ?? previous.modelSelection),
|
|
1708
|
-
approvalMode: approvalModeFor(this.projectBotForTask(previous.id, task.threadId)),
|
|
1709
|
-
autoApprove: task.autoApprove ?? previous.autoApprove,
|
|
1710
|
-
alwaysAllow: structuredClone(task.alwaysAllow ?? previous.alwaysAllow ?? []),
|
|
1711
|
-
}));
|
|
1749
|
+
next.tasks = this.withDefaultModel(previous, operation.fields.modelSelection);
|
|
1712
1750
|
nextBots[at] = next;
|
|
1713
1751
|
}
|
|
1714
1752
|
if (operation.fields.chiefOfStaff === false)
|
|
@@ -1746,23 +1784,38 @@ export class Store {
|
|
|
1746
1784
|
this.emit({ type: "bot", botId: chief.id });
|
|
1747
1785
|
return result;
|
|
1748
1786
|
}
|
|
1749
|
-
/**
|
|
1750
|
-
*
|
|
1751
|
-
*
|
|
1752
|
-
|
|
1753
|
-
|
|
1787
|
+
/** Moving this thread from one model to another takes its level back to
|
|
1788
|
+
* Ask where the new engine would have to confirm it (modelSwitchNeedsAsk),
|
|
1789
|
+
* as on every engine switch. Read before anything changes. */
|
|
1790
|
+
switchNeedsAsk(botId, threadId, from, to) {
|
|
1791
|
+
if (from.instanceId === to.instanceId)
|
|
1792
|
+
return false;
|
|
1793
|
+
const thread = this.projectBotForTask(botId, threadId);
|
|
1794
|
+
return Boolean(thread) && modelSwitchNeedsAsk(approvalModeFor(thread), this.driverKindOf(from.instanceId), this.driverKindOf(to.instanceId));
|
|
1795
|
+
}
|
|
1796
|
+
/** A bot's threads as a change of its model to `next` leaves them. Each
|
|
1797
|
+
* keeps the level it runs at now (older threads may still inherit the
|
|
1798
|
+
* bot's, which may change in the same write); one that follows the bot
|
|
1799
|
+
* moves with it, back to Ask where the new engine would have to confirm
|
|
1800
|
+
* its level; a thread's own model stays. */
|
|
1801
|
+
withDefaultModel(bot, next) {
|
|
1802
|
+
return bot.tasks?.map((task) => ({
|
|
1803
|
+
...task,
|
|
1804
|
+
approvalMode: approvalModeFor(this.projectBotForTask(bot.id, task.threadId)),
|
|
1805
|
+
autoApprove: task.autoApprove ?? bot.autoApprove,
|
|
1806
|
+
alwaysAllow: structuredClone(task.alwaysAllow ?? bot.alwaysAllow ?? []),
|
|
1807
|
+
...(task.modelSelection === undefined && this.switchNeedsAsk(bot.id, task.threadId, bot.modelSelection, next) ? structuredClone(ASK_NOW) : {}),
|
|
1808
|
+
}));
|
|
1809
|
+
}
|
|
1810
|
+
/** One reviewed default-model change (propose_model), with
|
|
1811
|
+
* applyTeamSetup's task stamping (withDefaultModel): every thread that
|
|
1812
|
+
* follows the bot moves to the new model, and a thread's own model, which a
|
|
1813
|
+
* person picked there, stays. */
|
|
1754
1814
|
applyModelDefault(id, modelSelection) {
|
|
1755
1815
|
const previous = this.bot(id);
|
|
1756
1816
|
if (!previous)
|
|
1757
1817
|
return null;
|
|
1758
|
-
const next = { ...previous, modelSelection: structuredClone(modelSelection) };
|
|
1759
|
-
next.tasks = previous.tasks?.map((task) => ({
|
|
1760
|
-
...task,
|
|
1761
|
-
modelSelection: structuredClone(task.modelSelection ?? previous.modelSelection),
|
|
1762
|
-
approvalMode: approvalModeFor(this.projectBotForTask(previous.id, task.threadId)),
|
|
1763
|
-
autoApprove: task.autoApprove ?? previous.autoApprove,
|
|
1764
|
-
alwaysAllow: structuredClone(task.alwaysAllow ?? previous.alwaysAllow ?? []),
|
|
1765
|
-
}));
|
|
1818
|
+
const next = { ...previous, modelSelection: structuredClone(modelSelection), tasks: this.withDefaultModel(previous, modelSelection) };
|
|
1766
1819
|
this.saveBots(this.bots.map((candidate) => candidate.id === id ? next : candidate));
|
|
1767
1820
|
this.bots = this.bots.map((candidate) => candidate.id === id ? next : candidate);
|
|
1768
1821
|
this.emit({ type: "bot", botId: id });
|
|
@@ -1860,9 +1913,16 @@ export class Store {
|
|
|
1860
1913
|
patch = { ...patch };
|
|
1861
1914
|
delete patch.toolScope;
|
|
1862
1915
|
}
|
|
1916
|
+
// A new model moves the threads that follow the bot; one whose level the
|
|
1917
|
+
// new engine would have to confirm goes back to Ask. That is a
|
|
1918
|
+
// revocation, so it holds in memory like the others below.
|
|
1919
|
+
const toAsk = patch.modelSelection ? (bot.tasks ?? []).filter((task) => task.modelSelection === undefined &&
|
|
1920
|
+
this.switchNeedsAsk(id, task.threadId, bot.modelSelection, patch.modelSelection)) : [];
|
|
1863
1921
|
// Runtime revocations must become effective in memory even when disk is
|
|
1864
1922
|
// unavailable. Profile edits use the separate atomic path below.
|
|
1865
1923
|
Object.assign(bot, patch);
|
|
1924
|
+
for (const task of toAsk)
|
|
1925
|
+
Object.assign(task, structuredClone(ASK_NOW));
|
|
1866
1926
|
const task = this.activeTask(id);
|
|
1867
1927
|
if (task) {
|
|
1868
1928
|
for (const key of ["resumeCursors", "rewound", "pinnedMessageId", "unread"]) {
|
|
@@ -2586,37 +2646,61 @@ export class Store {
|
|
|
2586
2646
|
this.emit({ type: "bot", botId });
|
|
2587
2647
|
return cleared;
|
|
2588
2648
|
}
|
|
2589
|
-
/**
|
|
2590
|
-
*
|
|
2591
|
-
*
|
|
2649
|
+
/** A person picks a model in one thread, and with updateBotDefault makes it
|
|
2650
|
+
* the bot's model too. A thread on its bot's model follows the bot: it
|
|
2651
|
+
* keeps no model of its own. Model/provider changes are one configuration
|
|
2652
|
+
* transaction: never publish a new provider before its confirmed approval
|
|
2653
|
+
* downgrade, or change the default while leaving the selected thread
|
|
2654
|
+
* behind after a write failure. */
|
|
2592
2655
|
switchTaskModel(botId, threadId, selection, updateBotDefault, resetApprovalToAsk, taskPatch = {}) {
|
|
2593
2656
|
const bot = this.bot(botId);
|
|
2594
2657
|
const task = this.taskByThread(botId, threadId);
|
|
2595
2658
|
if (!bot || !task)
|
|
2596
2659
|
return null;
|
|
2597
|
-
const
|
|
2598
|
-
|
|
2599
|
-
const nextTask = persistedPin({ ...task, ...taskPatch, ...
|
|
2660
|
+
const approval = resetApprovalToAsk ? structuredClone(ASK_NOW) : {};
|
|
2661
|
+
const own = sameModelSelection(selection, updateBotDefault ? selection : bot.modelSelection) ? undefined : structuredClone(selection);
|
|
2662
|
+
const nextTask = persistedPin({ ...task, ...taskPatch, modelSelection: own, ...approval,
|
|
2600
2663
|
...(typeof taskPatch.title === "string" ? { title: taskPatch.title.trim().slice(0, 80) || UNTITLED_THREAD } : {}) });
|
|
2601
|
-
//
|
|
2602
|
-
//
|
|
2603
|
-
|
|
2604
|
-
const nextTasks = bot.tasks.map((candidate) => candidate === task ? nextTask :
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
approvalMode: approvalModeFor(this.projectBotForTask(botId, candidate.threadId)),
|
|
2608
|
-
autoApprove: candidate.autoApprove ?? bot.autoApprove,
|
|
2609
|
-
alwaysAllow: structuredClone(candidate.alwaysAllow ?? bot.alwaysAllow ?? []),
|
|
2610
|
-
});
|
|
2611
|
-
const next = { ...bot, ...(updateBotDefault ? patch : {}),
|
|
2612
|
-
tasks: nextTasks };
|
|
2613
|
-
this.saveBots(this.bots.map((candidate) => candidate === bot ? next : candidate));
|
|
2664
|
+
// A new bot model moves the threads that follow it (withDefaultModel);
|
|
2665
|
+
// every other thread keeps its model and the level it runs at now.
|
|
2666
|
+
const others = updateBotDefault ? this.withDefaultModel(bot, selection) : bot.tasks;
|
|
2667
|
+
const nextTasks = bot.tasks.map((candidate, index) => candidate === task ? nextTask : others[index]);
|
|
2668
|
+
const botPatch = updateBotDefault ? { modelSelection: structuredClone(selection), ...approval } : {};
|
|
2669
|
+
this.saveBots(this.bots.map((candidate) => candidate === bot ? { ...bot, ...botPatch, tasks: nextTasks } : candidate));
|
|
2614
2670
|
bot.tasks.forEach((candidate, index) => Object.assign(candidate, nextTasks[index]));
|
|
2615
|
-
|
|
2616
|
-
Object.assign(bot, patch);
|
|
2671
|
+
Object.assign(bot, botPatch);
|
|
2617
2672
|
this.emit({ type: "bot", botId });
|
|
2618
2673
|
return task;
|
|
2619
2674
|
}
|
|
2675
|
+
/** These threads follow their bot's model again: each one's own model is
|
|
2676
|
+
* cleared ("Switch them too", "Use the bot's model", or a model that can't
|
|
2677
|
+
* run here). A level the bot's engine would have to confirm goes back to
|
|
2678
|
+
* Ask, as on every engine switch, and `reset` adds what a changed model
|
|
2679
|
+
* needs (a hosted workspace's fresh session). One write. Returns the
|
|
2680
|
+
* threads that changed; a listed thread that already follows stays as it is. */
|
|
2681
|
+
followBotModel(botId, threadIds, reset = () => ({})) {
|
|
2682
|
+
const bot = this.bot(botId);
|
|
2683
|
+
if (!bot?.tasks)
|
|
2684
|
+
return [];
|
|
2685
|
+
const listed = new Set(threadIds);
|
|
2686
|
+
const patches = bot.tasks.map((task) => !listed.has(task.threadId) || task.modelSelection === undefined ? null : {
|
|
2687
|
+
...reset(task.modelSelection, bot.modelSelection),
|
|
2688
|
+
modelSelection: undefined,
|
|
2689
|
+
...(this.switchNeedsAsk(botId, task.threadId, task.modelSelection, bot.modelSelection) ? structuredClone(ASK_NOW) : {}),
|
|
2690
|
+
});
|
|
2691
|
+
const moved = bot.tasks.filter((_, index) => patches[index]).map((task) => task.threadId);
|
|
2692
|
+
if (!moved.length)
|
|
2693
|
+
return [];
|
|
2694
|
+
const nextTasks = bot.tasks.map((task, index) => patches[index] ? { ...task, ...patches[index] } : task);
|
|
2695
|
+
this.saveBots(this.bots.map((candidate) => candidate === bot ? { ...bot, tasks: nextTasks } : candidate));
|
|
2696
|
+
bot.tasks.forEach((task, index) => { if (patches[index])
|
|
2697
|
+
Object.assign(task, patches[index]); });
|
|
2698
|
+
const active = bot.tasks.find((task) => task.threadId === bot.threadId);
|
|
2699
|
+
if (active)
|
|
2700
|
+
this.mirrorActiveTask(bot, active);
|
|
2701
|
+
this.emit({ type: "bot", botId });
|
|
2702
|
+
return moved;
|
|
2703
|
+
}
|
|
2620
2704
|
/** One durable write: never leave only part of a bot's threads updated. */
|
|
2621
2705
|
setAllThreadApprovalMode(botId, mode) {
|
|
2622
2706
|
const bot = this.bot(botId);
|
|
@@ -2656,9 +2740,9 @@ export class Store {
|
|
|
2656
2740
|
bot.pinnedMessageId = task.pinnedMessageId;
|
|
2657
2741
|
}
|
|
2658
2742
|
/** A fresh context on the same bot: new thread, new session, same
|
|
2659
|
-
* persona/tools/computer. Becomes the active task. It
|
|
2660
|
-
*
|
|
2661
|
-
* another of this bot's threads keeps
|
|
2743
|
+
* persona/tools/computer. Becomes the active task. It follows the bot's
|
|
2744
|
+
* model unless the caller hands it another one: a thread opened from
|
|
2745
|
+
* another of this bot's threads keeps the model a person picked there. */
|
|
2662
2746
|
createTask(botId, title, activate = true, projectId, openedBy, approvalMode, modelSelection) {
|
|
2663
2747
|
const bot = this.bot(botId);
|
|
2664
2748
|
if (!bot)
|
|
@@ -2674,7 +2758,7 @@ export class Store {
|
|
|
2674
2758
|
...(projectId ? { projectId } : {}),
|
|
2675
2759
|
...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
|
|
2676
2760
|
resumeCursors: {},
|
|
2677
|
-
modelSelection
|
|
2761
|
+
...(modelSelection && !sameModelSelection(modelSelection, bot.modelSelection) ? { modelSelection: structuredClone(modelSelection) } : {}),
|
|
2678
2762
|
approvalMode: approvalMode ?? approvalModeFor(bot),
|
|
2679
2763
|
autoApprove: approvalMode ? false : Boolean(bot.autoApprove),
|
|
2680
2764
|
alwaysAllow: approvalMode ? [] : [...(bot.alwaysAllow ?? [])],
|
|
@@ -2780,10 +2864,14 @@ export class Store {
|
|
|
2780
2864
|
this.emit({ type: "bot", botId });
|
|
2781
2865
|
return bot;
|
|
2782
2866
|
}
|
|
2867
|
+
/** A first run: no bot yet, so seedIfEmpty makes one. */
|
|
2868
|
+
needsSeed() {
|
|
2869
|
+
return this.bots.length === 0;
|
|
2870
|
+
}
|
|
2783
2871
|
/** First-run seed: one bot so the app never opens empty — it gets a
|
|
2784
2872
|
* random friendly name like every other bot. */
|
|
2785
2873
|
seedIfEmpty() {
|
|
2786
|
-
if (this.
|
|
2874
|
+
if (!this.needsSeed())
|
|
2787
2875
|
return;
|
|
2788
2876
|
this.createBot();
|
|
2789
2877
|
}
|
|
@@ -78,6 +78,9 @@ export function createTeamBackup(store, routines, name) {
|
|
|
78
78
|
? { botId: task.closedBy.botId, name: task.closedBy.name, at: task.closedBy.at }
|
|
79
79
|
: undefined,
|
|
80
80
|
pinned: task.pinned === true ? true : undefined,
|
|
81
|
+
turnTimeoutMinutes: "turnTimeoutMinutes" in task && typeof task.turnTimeoutMinutes === "number"
|
|
82
|
+
? task.turnTimeoutMinutes
|
|
83
|
+
: undefined,
|
|
81
84
|
activeLeafId: store.activeLeaf(task.threadId),
|
|
82
85
|
messages: store.messagesFor(task.threadId).map((message) => ({
|
|
83
86
|
id: message.id, role: message.role, text: messageText(message), at: message.at,
|
|
@@ -97,7 +100,10 @@ export function createTeamBackup(store, routines, name) {
|
|
|
97
100
|
? { kind: "mentions" }
|
|
98
101
|
: group.defaultResponder.kind === "auto" && group.defaultResponder.fallbackBotId && !memberIds.includes(group.defaultResponder.fallbackBotId)
|
|
99
102
|
? { kind: "auto" } : group.defaultResponder,
|
|
100
|
-
activeTask: group.threadId,
|
|
103
|
+
activeTask: group.threadId,
|
|
104
|
+
tasks: history(group).map((task, index) => group.dm && index === 0 && typeof group.turnTimeoutMinutes === "number"
|
|
105
|
+
? { ...task, turnTimeoutMinutes: group.turnTimeoutMinutes }
|
|
106
|
+
: task),
|
|
101
107
|
};
|
|
102
108
|
});
|
|
103
109
|
const validRoutines = routines.filter((routine) => {
|
|
@@ -197,8 +203,9 @@ export function importTeamBackup(store, routines, input, selection, options = {}
|
|
|
197
203
|
const bot = store.bot(botIds.get(source.key));
|
|
198
204
|
const tasks = source.tasks.map((task, i) => {
|
|
199
205
|
const record = {
|
|
206
|
+
// Each imported thread follows the imported bot's model.
|
|
200
207
|
threadId: i === 0 ? bot.threadId : newId(), title: task.title, createdAt: task.createdAt, resumeCursors: {},
|
|
201
|
-
|
|
208
|
+
activity: "idle", busy: false, unread: false,
|
|
202
209
|
...(task.titleFromFirstMessage ? { titleFromFirstMessage: true } : {}),
|
|
203
210
|
};
|
|
204
211
|
// Same rule as a message's `from`: the opener is remapped to its
|
|
@@ -229,9 +236,16 @@ export function importTeamBackup(store, routines, input, selection, options = {}
|
|
|
229
236
|
groups.push(group);
|
|
230
237
|
groupIds.set(source.key, group.id);
|
|
231
238
|
const responder = source.defaultResponder;
|
|
232
|
-
store.patchGroup(group.id, {
|
|
239
|
+
store.patchGroup(group.id, {
|
|
240
|
+
bulletin: source.bulletin,
|
|
241
|
+
setupCompletedAt: Date.now(),
|
|
242
|
+
...(source.dm && source.tasks[0]?.turnTimeoutMinutes != null
|
|
243
|
+
? { turnTimeoutMinutes: source.tasks[0].turnTimeoutMinutes }
|
|
244
|
+
: {}),
|
|
245
|
+
defaultResponder: responder.kind === "member" ? { kind: "member", botId: botIds.get(responder.botId) }
|
|
233
246
|
: responder.kind === "auto" ? { kind: "auto", ...(responder.fallbackBotId ? { fallbackBotId: botIds.get(responder.fallbackBotId) } : {}) }
|
|
234
|
-
: responder
|
|
247
|
+
: responder
|
|
248
|
+
});
|
|
235
249
|
// Use the existing task APIs for rooms; direct-message rooms have one.
|
|
236
250
|
const threads = source.tasks.map((task, i) => {
|
|
237
251
|
if (i === 0) {
|
|
@@ -248,6 +262,7 @@ export function importTeamBackup(store, routines, input, selection, options = {}
|
|
|
248
262
|
updatedAt: task.messages.reduce((max, message) => Math.max(max, message.at), task.createdAt),
|
|
249
263
|
...(task.pinned === true ? { pinned: true } : {}),
|
|
250
264
|
...(task.titleFromFirstMessage ? { titleFromFirstMessage: true } : {}),
|
|
265
|
+
...(task.turnTimeoutMinutes != null ? { turnTimeoutMinutes: task.turnTimeoutMinutes } : {}),
|
|
251
266
|
}));
|
|
252
267
|
store.switchGroupTask(group.id, threads[source.tasks.findIndex((task) => task.key === source.activeTask)]);
|
|
253
268
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readyToRun, signedOut } from "./default-model-selection.js";
|
|
2
|
+
import { sameModelSelection } from "../shared/thread-model.js";
|
|
3
|
+
function canRunOn(engine, selection, context) {
|
|
4
|
+
return Boolean(engine?.enabled) && engine.snapshot.state === "available" && context.refusal?.(engine) === undefined &&
|
|
5
|
+
context.allows?.(selection) !== false;
|
|
6
|
+
}
|
|
7
|
+
/** Why this thread's own model gives way to its bot's, or null when the turn
|
|
8
|
+
* runs on it as picked:
|
|
9
|
+
* - "unavailable": its engine is gone, disabled, unavailable or not allowed
|
|
10
|
+
* (by the organisation, or a hosted workspace's assigned models);
|
|
11
|
+
* - "signed-out": its engine is signed out and the bot's model is on another
|
|
12
|
+
* engine that can run. On the same engine the turn runs as picked, so the
|
|
13
|
+
* engine's own sign-in prompt shows; a fallback never hides it. */
|
|
14
|
+
export function threadModelFallback(own, botModel, engine, context = {}) {
|
|
15
|
+
if (sameModelSelection(own, botModel))
|
|
16
|
+
return null;
|
|
17
|
+
const mine = engine(own.instanceId);
|
|
18
|
+
if (!canRunOn(mine, own, context))
|
|
19
|
+
return "unavailable";
|
|
20
|
+
if (!signedOut(mine))
|
|
21
|
+
return null;
|
|
22
|
+
// On the same engine the bot's model is signed out too, so it never stands in.
|
|
23
|
+
const theirs = engine(botModel.instanceId);
|
|
24
|
+
return canRunOn(theirs, botModel, context) && readyToRun(theirs, context) ? "signed-out" : null;
|
|
25
|
+
}
|
|
@@ -96,7 +96,10 @@ export function classifyContinuable(_stopReason, failure) {
|
|
|
96
96
|
return null;
|
|
97
97
|
if (NOT_RESUMABLE.test(failure))
|
|
98
98
|
return null;
|
|
99
|
-
|
|
99
|
+
// Only OpenMausBot's own step cap. A provider's "Rate limit reached" or
|
|
100
|
+
// "usage limit reached" is not a budget a new thread can continue past:
|
|
101
|
+
// continuing would go straight back to the provider that said slow down.
|
|
102
|
+
if (/stopped after \d+ steps without a final answer|model-call limit reached/i.test(failure))
|
|
100
103
|
return "cap";
|
|
101
104
|
return null;
|
|
102
105
|
}
|
|
@@ -131,7 +134,10 @@ export function makeCapContinuationSubscriber(deps) {
|
|
|
131
134
|
if (CONTINUE_TITLE.test(deadTask.title ?? ""))
|
|
132
135
|
return;
|
|
133
136
|
const approvalMode = deadTask.approvalMode === "ask" || deadTask.approvalMode === "full" ? deadTask.approvalMode : undefined;
|
|
134
|
-
const contTask = deps.store.createTask(
|
|
137
|
+
const contTask = deps.store.createTask(
|
|
138
|
+
// Not activated: the person's open thread stays theirs, and their next
|
|
139
|
+
// message goes where they were, not into the continuation.
|
|
140
|
+
bot.id, `Continue: ${(deadTask.title ?? "task").slice(0, 90)}`, false, deadTask.projectId, undefined, approvalMode, deadTask.modelSelection);
|
|
135
141
|
if (!contTask)
|
|
136
142
|
return;
|
|
137
143
|
rec.count += 1;
|
|
@@ -84,11 +84,16 @@ export function createWebhookIngressHandler(manager, claimRequest) {
|
|
|
84
84
|
let release;
|
|
85
85
|
try {
|
|
86
86
|
release = claimRequest?.();
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
let secret = "";
|
|
88
|
+
try {
|
|
89
|
+
secret = (match[2] ? decodeURIComponent(match[2]) : "") || bearerSecret(req);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// A malformed escape is not a secret this server issued.
|
|
93
|
+
}
|
|
89
94
|
// Reject bad capability URLs before buffering or parsing attacker input.
|
|
90
95
|
if (!manager.authorize(match[1], secret)) {
|
|
91
|
-
manager.
|
|
96
|
+
manager.recordUnauthorized(match[1], {
|
|
92
97
|
contentType: header(req, "content-type"),
|
|
93
98
|
eventName: eventName(req),
|
|
94
99
|
deliveryId: deliveryId(req),
|
|
@@ -10,6 +10,7 @@ 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 UNAUTHORIZED_REASON = "Invalid webhook URL or secret";
|
|
13
14
|
/** Unfinished (queued, running or waiting) runs one webhook may hold before
|
|
14
15
|
* new deliveries are refused with 429. A webhook can set its own limit; one
|
|
15
16
|
* fanning out a project manager's events needs more than a CI hook does. */
|
|
@@ -223,6 +224,8 @@ export class WebhookManager {
|
|
|
223
224
|
deliveries = [];
|
|
224
225
|
attempts = [];
|
|
225
226
|
rate = new Map();
|
|
227
|
+
/** Bad-secret requests folded into each webhook's rolling record. */
|
|
228
|
+
unauthorized = new Map();
|
|
226
229
|
constructor(options) {
|
|
227
230
|
this.options = options;
|
|
228
231
|
this.file = options.file ?? join(DATA_DIR, "webhooks.json");
|
|
@@ -305,6 +308,7 @@ export class WebhookManager {
|
|
|
305
308
|
this.deliveries = this.deliveries.filter((delivery) => !delivery.key.startsWith(`${trigger.endpointId}:`));
|
|
306
309
|
this.attempts = this.attempts.filter((attempt) => attempt.webhookId !== trigger.id);
|
|
307
310
|
this.rate.delete(trigger.endpointId);
|
|
311
|
+
this.unauthorized.delete(trigger.id);
|
|
308
312
|
this.options.cancelQueued?.(trigger.id, "The webhook was deleted before this delivery started");
|
|
309
313
|
this.save();
|
|
310
314
|
this.options.emit?.({ kind: "webhook.deleted", webhookId: id });
|
|
@@ -374,6 +378,33 @@ export class WebhookManager {
|
|
|
374
378
|
return null;
|
|
375
379
|
return this.recordRejectedForTrigger(trigger, statusCode, reason, event);
|
|
376
380
|
}
|
|
381
|
+
/** A request with a wrong secret can come from anyone who reaches the
|
|
382
|
+
* receiver, before any rate limit applies. Each webhook keeps one rolling
|
|
383
|
+
* record of them, updated in place and saved with the next real change, so
|
|
384
|
+
* a flood neither pushes real deliveries out of the shared history nor
|
|
385
|
+
* rewrites the file on every request. */
|
|
386
|
+
recordUnauthorized(endpointId, event = {}) {
|
|
387
|
+
const trigger = this.webhooks.find((candidate) => candidate.endpointId === endpointId);
|
|
388
|
+
if (!trigger)
|
|
389
|
+
return null;
|
|
390
|
+
const at = this.attempts.findLastIndex((attempt) => attempt.webhookId === trigger.id && attempt.outcome === "rejected" && attempt.statusCode === 401);
|
|
391
|
+
if (at === -1) {
|
|
392
|
+
this.unauthorized.set(trigger.id, 1);
|
|
393
|
+
return this.appendAttempt(trigger, event, { outcome: "rejected", statusCode: 401, reason: UNAUTHORIZED_REASON, deliveryId: event.deliveryId });
|
|
394
|
+
}
|
|
395
|
+
const count = (this.unauthorized.get(trigger.id) ?? 1) + 1;
|
|
396
|
+
this.unauthorized.set(trigger.id, count);
|
|
397
|
+
// Same id: clients replace the record they hold instead of adding one.
|
|
398
|
+
const attempt = this.buildAttempt(trigger, event, {
|
|
399
|
+
outcome: "rejected",
|
|
400
|
+
statusCode: 401,
|
|
401
|
+
reason: `${UNAUTHORIZED_REASON} (${count} requests)`,
|
|
402
|
+
deliveryId: event.deliveryId,
|
|
403
|
+
}, this.attempts[at].id);
|
|
404
|
+
this.attempts[at] = attempt;
|
|
405
|
+
this.options.emit?.({ kind: "webhook.attempt", attempt: { ...attempt } });
|
|
406
|
+
return attempt;
|
|
407
|
+
}
|
|
377
408
|
dispatch(trigger, event) {
|
|
378
409
|
if (!trigger.enabled)
|
|
379
410
|
fail(409, "This webhook is paused");
|
|
@@ -524,8 +555,16 @@ export class WebhookManager {
|
|
|
524
555
|
return attempt;
|
|
525
556
|
}
|
|
526
557
|
appendAttempt(trigger, event, details) {
|
|
558
|
+
const attempt = this.buildAttempt(trigger, event, details);
|
|
559
|
+
this.attempts.push(attempt);
|
|
560
|
+
if (this.attempts.length > MAX_ATTEMPTS)
|
|
561
|
+
this.attempts.splice(0, this.attempts.length - MAX_ATTEMPTS);
|
|
562
|
+
this.options.emit?.({ kind: "webhook.attempt", attempt: { ...attempt } });
|
|
563
|
+
return attempt;
|
|
564
|
+
}
|
|
565
|
+
buildAttempt(trigger, event, details, id = randomUUID()) {
|
|
527
566
|
const attempt = {
|
|
528
|
-
id
|
|
567
|
+
id,
|
|
529
568
|
webhookId: trigger.id,
|
|
530
569
|
receivedAt: this.now(),
|
|
531
570
|
outcome: details.outcome,
|
|
@@ -541,10 +580,6 @@ export class WebhookManager {
|
|
|
541
580
|
attempt.runId = details.runId;
|
|
542
581
|
if (details.reason)
|
|
543
582
|
attempt.reason = details.reason;
|
|
544
|
-
this.attempts.push(attempt);
|
|
545
|
-
if (this.attempts.length > MAX_ATTEMPTS)
|
|
546
|
-
this.attempts.splice(0, this.attempts.length - MAX_ATTEMPTS);
|
|
547
|
-
this.options.emit?.({ kind: "webhook.attempt", attempt: { ...attempt } });
|
|
548
583
|
return attempt;
|
|
549
584
|
}
|
|
550
585
|
emit(trigger) {
|