openmausbot 0.1.91 → 0.1.92
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/DesktopViewer-Do6lBa4C.js +5 -0
- package/dist/assets/{abnfDiagram-O67JEVCF-yP-w8pRq.js → abnfDiagram-O67JEVCF-Cd3X7Hxc.js} +1 -1
- package/dist/assets/{arc-DsivfZEh.js → arc-DIYc7Ymx.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CILw7lzT.js → architectureDiagram-NJMV4G6O-DBmRGVyP.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-DEQUV3HF.js → blockDiagram-BEXU5L5S-CPFePSEE.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-lSXCZoKA.js → c4Diagram-YGBWAQC7-DD_DO1qy.js} +1 -1
- package/dist/assets/channel-Dtk5Mtic.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-QCD5bgWP.js → chunk-2Q5K7J3B-BNEeNSjX.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-C5-iZJuL.js → chunk-3FUC2YCW-BbinLaqH.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-cxsawMpY.js → chunk-5VM5RSS4-BBgYrgDR.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-DV8tV8H7.js → chunk-GTNCS2PH-ezXj-kA-.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-CJFZyfbW.js → chunk-GWA4HPMP-9FxZ3diV.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-BigbMXco.js → chunk-JWPE2WC7-CO-40JpI.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-oVxTfpDy.js → chunk-NETBCI7D-BEhjslYE.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-CH4BiroW.js → chunk-WEXAMYUT-CNeq4n7_.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-KEDa6gax.js → chunk-XXDRQBXY-OsCY7KSi.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BiPjfqVv.js → classDiagram-v2-NBCMYWYE-CloRUEtk.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-Caxew3OY.js → cose-bilkent-JH36ORCC-D1BGZhp4.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-B_Pui369.js → cynefin-OW5HDTMX-CUf4T2CN.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-X64ydIhP.js → cynefinDiagram-VND7K2PF-DPEZrFGA.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-TqYCI1E1.js → dagre-6A5THRUB-bDe6GIv5.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-TeOt3rAQ.js → diagram-22UHCM2B-CdcUQz55.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-D70BLhDA.js → diagram-3UASUU5V-BZpqGg7c.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BbQDLwUC.js → diagram-ATOU4E4O-DvGM6ugp.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-ChNJZ1u-.js → diagram-CDSNMT55-DSTmMm0s.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-DMBl2APa.js → diagram-MLGK6HIB-15cucnMn.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-CEqDchOp.js → diagram-MPIPVDR6-tMbuaE6n.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-BVDKRp_-.js → ebnfDiagram-ZINNZB2B-BYxYzjHI.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-CRkofO9B.js → elk-276RUBZZ-v6bj852W.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-DruOMh5e.js → erDiagram-OPXOYQCR-5lBBlnd2.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-8GMQeB7t.js → flowDiagram-KWPJA3E3-CZyl1zIj.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-ORE9jcsM.js → ganttDiagram-FUAMR5RP-BiAI-ltZ.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-BmsedsWh.js → gitGraphDiagram-X574FWY7-BAZISkj3.js} +1 -1
- package/dist/assets/index-Cdmjrl28.css +1 -0
- package/dist/assets/{index-DKNEM8Q6.js → index-DGBaEXrM.js} +1 -1
- package/dist/assets/index-DR7YPMkW.js +597 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-E_p7AooJ.js → infoDiagram-VRGFBTTK-B6ooOy-Z.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-Y-B2mKda.js → ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-eA8RELcK.js → journeyDiagram-ZHPQQLJL-DWqlzIEe.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-DqIL2e8e.js → kanban-definition-PNTS6WVX-JX6piDwL.js} +1 -1
- package/dist/assets/{linear-yC_29R6e.js → linear-CmOWzX0P.js} +1 -1
- package/dist/assets/{mermaid.core-VhEMkSND.js → mermaid.core-CF1zwoQ-.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-oJ7kyQZy.js → mindmap-definition-NLK3R4M7-CGh2K4fW.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-BmfYcY0O.js → pegDiagram-GJSIUBJH-CDwYN0s7.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-DKvgP7w9.js → pieDiagram-5QR66LMP-1rfjJocB.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-BEKL3W0L.js → quadrantDiagram-O4NWA36T-tKPvPiok.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-BEsiqR5s.js → railroadDiagram-XR7U4H2S-BHLm5vXR.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DWBcWvAP.js → requirementDiagram-PLB6GJNP-CB45STp7.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-rpZ2_O4s.js → sankeyDiagram-IPEJSGJF-55QA_huC.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-B99HQdJR.js → sequenceDiagram-PO4LG4MO-BckFmQHn.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-BjN0qT5T.js → sizeCapture-INFHLROL-BFCOJF11.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-BbsVgGsC.js → stateDiagram-v2-GCMORJYK-CZvmiVVN.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DG3gKkeR.js → swimlanes-2SLR337P-BpDUCEKy.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BTHOdtF_.js → timeline-definition-EJHVYXUP-BpxHq6IK.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-acLSLFwc.js → usecaseDiagram-POWQR4AR-Cj66vm02.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-C5JzNX9G.js → vennDiagram-UO4OBE2U-C9nRkWE_.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-DL1-PrR_.js → wardleyDiagram-VNRHLVJA-D-me_nWj.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-Bvr1477p.js → xychartDiagram-PMCCYNJV-Dc_fL4mk.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/novnc-NOTICE.txt +118 -0
- package/dist-server/cloud-home-start.js +112 -14
- package/dist-server/container-mcp.js +195 -15
- package/dist-server/drivers/agents-proxy.js +5 -4
- package/dist-server/enterprise/server/index.js +5 -1
- package/dist-server/index.js +9300 -4155
- package/dist-server/local-computer-proxy.js +128 -7
- package/dist-server/mcp-gate.js +11 -5
- package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
- package/dist-server/openmausbot.js +3910 -1182
- package/dist-server/pair-cli.js +3910 -1182
- package/dist-server/server/boat.js +2 -2
- package/dist-server/server/browser-engine.js +10 -1
- package/dist-server/server/browser-live.js +46 -2
- package/dist-server/server/browser-runtime.js +74 -8
- package/dist-server/server/browser-tool-shape.js +6 -4
- package/dist-server/server/chief-of-staff.js +1 -1
- package/dist-server/server/cloud-home-start.js +118 -26
- package/dist-server/server/cloud-home.js +39 -3
- package/dist-server/server/cloud-lending.js +74 -10
- package/dist-server/server/cloud-owner.js +196 -0
- package/dist-server/server/cloud-secrets-boot.js +6 -0
- package/dist-server/server/cloud-secrets.js +65 -0
- package/dist-server/server/config.js +174 -14
- package/dist-server/server/container-computer.js +3 -2
- package/dist-server/server/desktop-viewer-targets.js +26 -0
- package/dist-server/server/drivers/acp/core.js +167 -21
- package/dist-server/server/drivers/acp/opencode-go.js +614 -85
- package/dist-server/server/drivers/agents-catalog.js +6 -6
- package/dist-server/server/drivers/antigravity-acp.js +69 -11
- package/dist-server/server/drivers/antigravity-temp.js +360 -0
- package/dist-server/server/drivers/antigravity.js +21 -0
- package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
- package/dist-server/server/drivers/claude.js +312 -34
- package/dist-server/server/drivers/codex.js +228 -18
- package/dist-server/server/drivers/grok.js +2 -2
- package/dist-server/server/drivers/mistral.js +4 -3
- package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
- package/dist-server/server/drivers/openai-chat.js +41 -3
- package/dist-server/server/drivers/openai-compat.js +73 -23
- package/dist-server/server/engine-install.js +46 -12
- package/dist-server/server/env-path.js +15 -3
- package/dist-server/server/harness/registry.js +2 -2
- package/dist-server/server/index.js +1338 -184
- package/dist-server/server/lending-memory.js +479 -0
- package/dist-server/server/local-vm-idle.js +24 -6
- package/dist-server/server/managed-desktop.js +3 -0
- package/dist-server/server/mcp-bridge.js +5 -3
- package/dist-server/server/mcp-http.js +6 -3
- package/dist-server/server/mcp-oauth-discovery.js +133 -0
- package/dist-server/server/mcp-oauth-store.js +70 -0
- package/dist-server/server/mcp-oauth.js +550 -0
- package/dist-server/server/mcp-probe.js +14 -1
- package/dist-server/server/mcp-registry.js +43 -0
- package/dist-server/server/memory-upkeep.js +94 -84
- package/dist-server/server/model-catalog/catalog.js +374 -0
- package/dist-server/server/model-catalog/presets.js +176 -0
- package/dist-server/server/model-catalog/trim.js +219 -0
- package/dist-server/server/provider-key-check.js +4 -2
- package/dist-server/server/recent-work.js +1 -1
- package/dist-server/server/request-auth.js +23 -0
- package/dist-server/server/routes/antigravity-leftovers.js +45 -0
- package/dist-server/server/routes/bot-memory.js +31 -6
- package/dist-server/server/routes/desktop-viewer.js +194 -0
- package/dist-server/server/routine-requests.js +1 -1
- package/dist-server/server/routines.js +1 -1
- package/dist-server/server/sessions.js +56 -9
- package/dist-server/server/skills.js +25 -10
- package/dist-server/server/store.js +62 -122
- package/dist-server/server/thread-events.js +2 -0
- package/dist-server/server/thread-starters.js +33 -0
- package/dist-server/server/thread-title.js +46 -0
- package/dist-server/server/tts/fish.js +4 -5
- package/dist-server/server/tts/index.js +5 -3
- package/dist-server/server/vps-computer.js +79 -12
- package/dist-server/server/webhooks.js +18 -1
- package/dist-server/server/workspace-backup-policy.js +3 -0
- package/dist-server/server/workspace-backup.js +3 -0
- package/dist-server/server/workspace.js +65 -15
- package/dist-server/shared/workspace-backup-client.js +1 -0
- package/dist-server/src/lib/citations.js +177 -0
- package/dist-server/src/lib/composer-attachments.js +7 -1
- package/dist-server/vps-container-mcp.js +196 -15
- package/dist-server/workspace-backup.worker.js +73 -9
- package/enterprise/server/index.js +5 -1
- package/package.json +1 -1
- package/dist/assets/channel-DrfGMu7X.js +0 -1
- package/dist/assets/index-BlnCEcU6.js +0 -588
- package/dist/assets/index-DVxjOvID.css +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +0 -8
|
@@ -442,6 +442,10 @@ export function roomResponders(text, members, defaultResponder) {
|
|
|
442
442
|
}
|
|
443
443
|
return [];
|
|
444
444
|
}
|
|
445
|
+
/** A hidden routine execution is not a conversation the person can open. */
|
|
446
|
+
function taskCountsAsBotUnread(task) {
|
|
447
|
+
return Boolean(task.unread) && !task.routineRunId;
|
|
448
|
+
}
|
|
445
449
|
export class Store {
|
|
446
450
|
bots = [];
|
|
447
451
|
groups = [];
|
|
@@ -721,6 +725,13 @@ export class Store {
|
|
|
721
725
|
task.unread = task === active && b.unread;
|
|
722
726
|
botsMigrated = true;
|
|
723
727
|
}
|
|
728
|
+
// Older builds left a failed hidden run unread, which kept the bot
|
|
729
|
+
// dot on after every visible chat was read. Clear it before the
|
|
730
|
+
// recompute below so the saved roster heals.
|
|
731
|
+
if (task.routineRunId && task.unread) {
|
|
732
|
+
task.unread = false;
|
|
733
|
+
botsMigrated = true;
|
|
734
|
+
}
|
|
724
735
|
if (task === active) {
|
|
725
736
|
if (task.rewound === undefined && b.rewound !== undefined) {
|
|
726
737
|
task.rewound = b.rewound;
|
|
@@ -742,7 +753,7 @@ export class Store {
|
|
|
742
753
|
task.turnStartedAt = undefined;
|
|
743
754
|
}
|
|
744
755
|
this.mirrorActiveTask(b, active);
|
|
745
|
-
b.unread = b.tasks.some(
|
|
756
|
+
b.unread = b.tasks.some(taskCountsAsBotUnread);
|
|
746
757
|
}
|
|
747
758
|
if (botsMigrated)
|
|
748
759
|
this.saveBots();
|
|
@@ -819,13 +830,20 @@ export class Store {
|
|
|
819
830
|
if (groupsDirty)
|
|
820
831
|
this.saveGroups();
|
|
821
832
|
}
|
|
833
|
+
/** Resolve the identity of a historical pair row, including a sender
|
|
834
|
+
* recreated with the same unique name. */
|
|
835
|
+
openerIdentity(openedBy) {
|
|
836
|
+
if (this.bot(openedBy.botId))
|
|
837
|
+
return openedBy.botId;
|
|
838
|
+
const named = this.bots.filter((bot) => bot.name === openedBy.name);
|
|
839
|
+
return named.length === 1 ? named[0].id : openedBy.botId;
|
|
840
|
+
}
|
|
822
841
|
/** At most one live pair row per (recipient, sender identity). Servers
|
|
823
842
|
* before identity-stable matching minted a second live pair row for a
|
|
824
843
|
* deleted-and-recreated sender while the predecessor's row dangled live
|
|
825
|
-
* forever; collapse those on load by keeping the row the
|
|
826
|
-
*
|
|
827
|
-
* the rest to closed plain threads. History is kept, never deleted
|
|
828
|
-
* a demoted row is never re-adopted: adoption skips closed rows. Runs on
|
|
844
|
+
* forever; collapse those on load by keeping the first row in the task
|
|
845
|
+
* list (the newest, actively used one) and demoting
|
|
846
|
+
* the rest to closed plain threads. History is kept, never deleted. Runs on
|
|
829
847
|
* every load and touches nothing in a store that already holds the
|
|
830
848
|
* invariant. */
|
|
831
849
|
repairDuplicatePairConversations() {
|
|
@@ -840,8 +858,7 @@ export class Store {
|
|
|
840
858
|
pairs.set(identity, [...(pairs.get(identity) ?? []), task]);
|
|
841
859
|
}
|
|
842
860
|
for (const rows of pairs.values()) {
|
|
843
|
-
//
|
|
844
|
-
// find() favors: the first is the one it keeps using.
|
|
861
|
+
// Rows are in task-list order; keep the newest.
|
|
845
862
|
for (const duplicate of rows.slice(1)) {
|
|
846
863
|
const by = duplicate.openedBy;
|
|
847
864
|
const identity = this.openerIdentity(by);
|
|
@@ -856,8 +873,7 @@ export class Store {
|
|
|
856
873
|
}
|
|
857
874
|
}
|
|
858
875
|
};
|
|
859
|
-
//
|
|
860
|
-
// recipient, and group tasks carry no peer stamps.
|
|
876
|
+
// Historical pair rows lived only on bots; group tasks have no peer stamps.
|
|
861
877
|
for (const bot of this.bots)
|
|
862
878
|
sweep(bot.tasks, () => { botsDirty = true; });
|
|
863
879
|
if (botsDirty)
|
|
@@ -1796,7 +1812,7 @@ export class Store {
|
|
|
1796
1812
|
Object.assign(task, { [key]: structuredClone(patch[key]) });
|
|
1797
1813
|
}
|
|
1798
1814
|
}
|
|
1799
|
-
bot.unread = bot.tasks.some(
|
|
1815
|
+
bot.unread = bot.tasks.some(taskCountsAsBotUnread);
|
|
1800
1816
|
}
|
|
1801
1817
|
this.saveBots();
|
|
1802
1818
|
this.emit({ type: "bot", botId: id });
|
|
@@ -2146,11 +2162,34 @@ export class Store {
|
|
|
2146
2162
|
* current folder — unless the task already has a session (a thread from
|
|
2147
2163
|
* before folders existed), which pins to the default so the folder can't
|
|
2148
2164
|
* move under it. Returns the pinned value: a path, or null for default. */
|
|
2165
|
+
/** Forget where a conversation works: its next turn pins it again
|
|
2166
|
+
* (pinTaskCwd). For a Cloud home's conversation a revoked session opened,
|
|
2167
|
+
* whose next turn must work in its own folder (server/cloud-owner.ts). */
|
|
2168
|
+
unpinTaskCwd(threadId) {
|
|
2169
|
+
const bot = this.botByThread(threadId);
|
|
2170
|
+
const task = bot ? this.taskByThread(bot.id, threadId) : undefined;
|
|
2171
|
+
if (!bot || !task || task.cwd === undefined)
|
|
2172
|
+
return false;
|
|
2173
|
+
delete task.cwd;
|
|
2174
|
+
this.saveBots();
|
|
2175
|
+
this.emit({ type: "bot", botId: bot.id });
|
|
2176
|
+
return true;
|
|
2177
|
+
}
|
|
2149
2178
|
pinTaskCwd(botId, threadId, fallbackCwd, opts = {}) {
|
|
2150
2179
|
const bot = this.bot(botId);
|
|
2151
2180
|
const task = bot ? this.taskByThread(botId, threadId) : undefined;
|
|
2152
2181
|
if (!bot || !task)
|
|
2153
2182
|
return null;
|
|
2183
|
+
// Its own folder, never the bot's project folder (a Cloud home's guest
|
|
2184
|
+
// conversation), pinned when it first runs. A conversation that already
|
|
2185
|
+
// ran elsewhere keeps its pin: it was not opened that way, and moving it
|
|
2186
|
+
// would lose its work and its session.
|
|
2187
|
+
if (opts.privateOnly && task.cwd === undefined) {
|
|
2188
|
+
task.cwd = fallbackCwd ?? null;
|
|
2189
|
+
this.saveBots();
|
|
2190
|
+
this.emit({ type: "bot", botId });
|
|
2191
|
+
return task.cwd;
|
|
2192
|
+
}
|
|
2154
2193
|
if (opts.none) {
|
|
2155
2194
|
if (task.cwd !== null) {
|
|
2156
2195
|
task.cwd = null;
|
|
@@ -2318,9 +2357,13 @@ export class Store {
|
|
|
2318
2357
|
task.title = patch.title.trim().slice(0, 80) || UNTITLED_THREAD;
|
|
2319
2358
|
if (Object.prototype.hasOwnProperty.call(patch, "pinned") && task.pinned !== true)
|
|
2320
2359
|
delete task.pinned;
|
|
2360
|
+
// Still hidden: attention belongs on the source conversation. A same-call
|
|
2361
|
+
// clear of routineRunId (no-source promotion) may keep unread.
|
|
2362
|
+
if (task.routineRunId)
|
|
2363
|
+
task.unread = false;
|
|
2321
2364
|
if (bot.threadId === threadId)
|
|
2322
2365
|
this.mirrorActiveTask(bot, task);
|
|
2323
|
-
bot.unread = bot.tasks.some(
|
|
2366
|
+
bot.unread = bot.tasks.some(taskCountsAsBotUnread);
|
|
2324
2367
|
this.saveBots();
|
|
2325
2368
|
this.emit({ type: "bot", botId });
|
|
2326
2369
|
return task;
|
|
@@ -2399,8 +2442,10 @@ export class Store {
|
|
|
2399
2442
|
bot.pinnedMessageId = task.pinnedMessageId;
|
|
2400
2443
|
}
|
|
2401
2444
|
/** A fresh context on the same bot: new thread, new session, same
|
|
2402
|
-
* persona/tools/computer. Becomes the active task.
|
|
2403
|
-
|
|
2445
|
+
* persona/tools/computer. Becomes the active task. It runs on the bot's
|
|
2446
|
+
* default model unless the caller hands it one (a thread opened from
|
|
2447
|
+
* another of this bot's threads keeps that thread's model). */
|
|
2448
|
+
createTask(botId, title, activate = true, projectId, openedBy, approvalMode, modelSelection) {
|
|
2404
2449
|
const bot = this.bot(botId);
|
|
2405
2450
|
if (!bot)
|
|
2406
2451
|
return null;
|
|
@@ -2415,7 +2460,7 @@ export class Store {
|
|
|
2415
2460
|
...(projectId ? { projectId } : {}),
|
|
2416
2461
|
...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
|
|
2417
2462
|
resumeCursors: {},
|
|
2418
|
-
modelSelection: structuredClone(bot.modelSelection),
|
|
2463
|
+
modelSelection: structuredClone(modelSelection ?? bot.modelSelection),
|
|
2419
2464
|
approvalMode: approvalMode ?? approvalModeFor(bot),
|
|
2420
2465
|
autoApprove: approvalMode ? false : Boolean(bot.autoApprove),
|
|
2421
2466
|
alwaysAllow: approvalMode ? [] : [...(bot.alwaysAllow ?? [])],
|
|
@@ -2463,112 +2508,6 @@ export class Store {
|
|
|
2463
2508
|
this.emit({ type: "bot", botId });
|
|
2464
2509
|
return task;
|
|
2465
2510
|
}
|
|
2466
|
-
/** Where a bot-to-bot send outside a room lands: the PAIR CONVERSATION
|
|
2467
|
-
* for (sender, recipient) — the recipient's task stamped `openedBy` this
|
|
2468
|
-
* sender with kind "pair".
|
|
2469
|
-
*
|
|
2470
|
-
* Its scope is global for those two bots: deliberately not per source
|
|
2471
|
-
* thread and not per assignment, so a teammate you work with all day is
|
|
2472
|
-
* one readable row in the recipient's sidebar that remembers what was
|
|
2473
|
-
* asked last time, instead of one row per message. Nothing about the
|
|
2474
|
-
* caller's current turn takes part in choosing it — no dispatch
|
|
2475
|
-
* generation, no request key — and never the recipient's selected
|
|
2476
|
-
* thread, which belongs to the person.
|
|
2477
|
-
*
|
|
2478
|
-
* Two things bend that rule, both deliberately:
|
|
2479
|
-
*
|
|
2480
|
-
* adoption — a recipient still carrying threads this sender opened
|
|
2481
|
-
* before pair conversations existed (one per assignment, each titled
|
|
2482
|
-
* with a sliced brief) has its most recently active one stamped as the
|
|
2483
|
-
* pair conversation instead of gaining yet another row, so the sprawl
|
|
2484
|
-
* stops on upgrade day. Nothing is deleted or closed. A start_thread
|
|
2485
|
-
* handoff is left alone: the sender named that job itself and tracks
|
|
2486
|
-
* it by its own delegation id.
|
|
2487
|
-
*
|
|
2488
|
-
* concurrency — a second assignment arriving while the pair
|
|
2489
|
-
* conversation is still working (`working`, which the caller answers
|
|
2490
|
-
* from live turn state) gets its own work thread, so two jobs never
|
|
2491
|
-
* interleave in one transcript. `label` names that thread; the caller
|
|
2492
|
-
* closes it once its result has been reported. A pair conversation
|
|
2493
|
-
* never auto-closes. */
|
|
2494
|
-
/** The identity a peer-opened row belongs to: its opener's id while that
|
|
2495
|
-
* bot lives, else the one live bot the stamp's name still points at —
|
|
2496
|
-
* what a deleted-and-recreated same-name bot inherits — else the dead id
|
|
2497
|
-
* itself. A name two live bots share resolves to nobody's twin:
|
|
2498
|
-
* ambiguous means unmatched, never a wrong merge. */
|
|
2499
|
-
openerIdentity(openedBy) {
|
|
2500
|
-
if (this.bot(openedBy.botId))
|
|
2501
|
-
return openedBy.botId;
|
|
2502
|
-
const named = this.bots.filter((bot) => bot.name === openedBy.name);
|
|
2503
|
-
return named.length === 1 ? named[0].id : openedBy.botId;
|
|
2504
|
-
}
|
|
2505
|
-
resolvePairConversation(sender, recipientId, options) {
|
|
2506
|
-
if (!this.bot(recipientId))
|
|
2507
|
-
return null;
|
|
2508
|
-
const title = `@${sender.name}`;
|
|
2509
|
-
const opener = (kind, at = Date.now()) => ({ botId: sender.id, name: sender.name, kind, at });
|
|
2510
|
-
// Identity-stable: the sender's own rows, plus ones a deleted
|
|
2511
|
-
// predecessor opened when the stamp's name still points at exactly
|
|
2512
|
-
// this bot — a same-name recreation inherits the conversation instead
|
|
2513
|
-
// of minting a twin while the old row dangles live. A name two live
|
|
2514
|
-
// bots share matches nobody's inheritance: refusing the fallback can
|
|
2515
|
-
// cost a new row, never merge two bots' histories.
|
|
2516
|
-
const fromSender = this.tasks(recipientId).filter((task) => task.openedBy && this.openerIdentity(task.openedBy) === sender.id);
|
|
2517
|
-
let pair = fromSender.find((task) => task.openedBy?.kind === "pair");
|
|
2518
|
-
if (!pair) {
|
|
2519
|
-
const lastActivity = (task) => this.messagesTail(task.threadId, 1).messages.at(-1)?.at ?? task.openedBy?.at ?? task.createdAt;
|
|
2520
|
-
const adopted = fromSender
|
|
2521
|
-
.filter((task) => !task.openedBy?.kind && !task.openedBy?.delegationId && !task.closedBy)
|
|
2522
|
-
.sort((a, b) => lastActivity(b) - lastActivity(a))[0];
|
|
2523
|
-
if (adopted) {
|
|
2524
|
-
// Keep the hour it was really opened: list_threads and the sidebar
|
|
2525
|
-
// order by it, and adoption is not a new conversation.
|
|
2526
|
-
this.setTaskOpenedBy(recipientId, adopted.threadId, opener("pair", adopted.openedBy?.at ?? adopted.createdAt));
|
|
2527
|
-
// The title changes only when nobody typed it. The rule: rename it
|
|
2528
|
-
// when it still equals what createTask made of the assignment that
|
|
2529
|
-
// opened the thread — and that assignment is still the thread's
|
|
2530
|
-
// first message, "@Recipient <brief>" — so the comparison is
|
|
2531
|
-
// threadTitleFrom(that brief). Anything else is a name a person
|
|
2532
|
-
// chose, and a thread with no request to read (its handoff never
|
|
2533
|
-
// ran) cannot be checked, so both keep the title they have.
|
|
2534
|
-
if (adopted.title === this.openingRequestTitle(recipientId, adopted.threadId)) {
|
|
2535
|
-
this.renameTask(recipientId, adopted.threadId, title);
|
|
2536
|
-
}
|
|
2537
|
-
pair = adopted;
|
|
2538
|
-
}
|
|
2539
|
-
}
|
|
2540
|
-
if (pair && !options.working(pair.threadId)) {
|
|
2541
|
-
// A conversation the sender closed after reading a result is picked
|
|
2542
|
-
// back up, never replaced: closing is only the sidebar's idle state.
|
|
2543
|
-
if (pair.closedBy)
|
|
2544
|
-
this.setTaskClosedBy(recipientId, pair.threadId, null);
|
|
2545
|
-
// An inherited row carries the predecessor's id; rebind it to the
|
|
2546
|
-
// live bot so the name fallback is needed only once — a namesake
|
|
2547
|
-
// appearing later cannot claim the row. The hour it was opened
|
|
2548
|
-
// stays: inheritance is not a new conversation.
|
|
2549
|
-
const inherited = pair.openedBy;
|
|
2550
|
-
if (inherited && inherited.botId !== sender.id) {
|
|
2551
|
-
this.setTaskOpenedBy(recipientId, pair.threadId, { ...inherited, botId: sender.id, name: sender.name });
|
|
2552
|
-
}
|
|
2553
|
-
return { task: pair, created: false };
|
|
2554
|
-
}
|
|
2555
|
-
// The brief is never a title. An 80-character slice of an assignment
|
|
2556
|
-
// is the row nobody can read, and a durable conversation outlives the
|
|
2557
|
-
// one brief that opened it.
|
|
2558
|
-
const task = this.createTask(recipientId, pair ? `${title} · ${options.label || "parallel work"}` : title, false, undefined, opener(pair ? "work" : "pair"));
|
|
2559
|
-
return task ? { task, created: true } : null;
|
|
2560
|
-
}
|
|
2561
|
-
/** The title a peer-opened thread was born with: what createTask made of
|
|
2562
|
-
* the request that opened it, which is still the first message in it,
|
|
2563
|
-
* addressed "@Recipient <brief>". null when there is no such message to
|
|
2564
|
-
* read — an unrun handoff proves nothing about who named the row. */
|
|
2565
|
-
openingRequestTitle(recipientId, threadId) {
|
|
2566
|
-
const first = this.messagesFor(threadId)[0]?.text?.trim();
|
|
2567
|
-
if (!first)
|
|
2568
|
-
return null;
|
|
2569
|
-
const addressed = `@${this.bot(recipientId)?.name ?? ""} `;
|
|
2570
|
-
return threadTitleFrom(first.startsWith(addressed) ? first.slice(addressed.length) : first);
|
|
2571
|
-
}
|
|
2572
2511
|
switchTask(botId, threadId) {
|
|
2573
2512
|
const bot = this.bot(botId);
|
|
2574
2513
|
const task = bot?.tasks?.find((t) => t.threadId === threadId);
|
|
@@ -2598,7 +2537,8 @@ export class Store {
|
|
|
2598
2537
|
/** Swap a machine-made first-message title for a generated one, once.
|
|
2599
2538
|
* Equality against the snippet is the whole contract: a rename by the
|
|
2600
2539
|
* person, by pair adoption, or by an earlier generated title each break
|
|
2601
|
-
* it, so this never overwrites a name anyone chose.
|
|
2540
|
+
* it, so this never overwrites a name anyone chose. Regenerate title passes
|
|
2541
|
+
* the title it started from, so a rename made while it ran stands. */
|
|
2602
2542
|
retitleTask(botId, threadId, machineTitle, title) {
|
|
2603
2543
|
const task = this.taskByThread(botId, threadId);
|
|
2604
2544
|
if (!task || task.title !== machineTitle)
|
|
@@ -2620,7 +2560,7 @@ export class Store {
|
|
|
2620
2560
|
this.mirrorActiveTask(bot, visible);
|
|
2621
2561
|
}
|
|
2622
2562
|
this.deleteThreadRecord(threadId);
|
|
2623
|
-
bot.unread = bot.tasks.some(
|
|
2563
|
+
bot.unread = bot.tasks.some(taskCountsAsBotUnread);
|
|
2624
2564
|
this.refreshBotActivity(bot);
|
|
2625
2565
|
this.saveBots();
|
|
2626
2566
|
this.emit({ type: "bot", botId });
|
|
@@ -215,6 +215,8 @@ function isRuntimeEvent(value) {
|
|
|
215
215
|
value.source === "peer"));
|
|
216
216
|
case "thread.token-usage.updated":
|
|
217
217
|
return typeof value.input === "number" && typeof value.output === "number";
|
|
218
|
+
case "runtime.notice":
|
|
219
|
+
return typeof value.message === "string";
|
|
218
220
|
case "runtime.error":
|
|
219
221
|
return typeof value.message === "string" &&
|
|
220
222
|
(value.setup === undefined || typeof value.setup === "boolean") &&
|
|
@@ -35,6 +35,39 @@ export class ThreadStarters {
|
|
|
35
35
|
get(threadId) {
|
|
36
36
|
return this.starters.get(threadId);
|
|
37
37
|
}
|
|
38
|
+
/** The threads opened for one person, newest first, at most `limit`. */
|
|
39
|
+
threadsOf(person, limit) {
|
|
40
|
+
const threads = [];
|
|
41
|
+
for (const [threadId, starter] of [...this.starters].reverse()) {
|
|
42
|
+
if (starter !== person)
|
|
43
|
+
continue;
|
|
44
|
+
threads.push(threadId);
|
|
45
|
+
if (threads.length >= limit)
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
return threads;
|
|
49
|
+
}
|
|
50
|
+
/** Everyone a thread here was opened for. */
|
|
51
|
+
people() {
|
|
52
|
+
return new Set(this.starters.values());
|
|
53
|
+
}
|
|
54
|
+
/** Name `to` for every thread whose person `move` accepts: a one-time
|
|
55
|
+
* migration (server/cloud-owner.ts), not an ordinary record. The threads
|
|
56
|
+
* that changed. */
|
|
57
|
+
reassign(move, to) {
|
|
58
|
+
if (!KEY.test(to))
|
|
59
|
+
return [];
|
|
60
|
+
const moved = [];
|
|
61
|
+
for (const [threadId, person] of this.starters) {
|
|
62
|
+
if (person === to || !move(person))
|
|
63
|
+
continue;
|
|
64
|
+
this.starters.set(threadId, to);
|
|
65
|
+
moved.push(threadId);
|
|
66
|
+
}
|
|
67
|
+
if (moved.length)
|
|
68
|
+
writeFileAtomic(this.file, JSON.stringify(Object.fromEntries(this.starters)) + "\n", { mode: 0o600 });
|
|
69
|
+
return moved;
|
|
70
|
+
}
|
|
38
71
|
/** Record once; a thread keeps the person it was first opened for. */
|
|
39
72
|
set(threadId, person) {
|
|
40
73
|
if (!person || !KEY.test(person) || !THREAD.test(threadId) || this.starters.has(threadId))
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// What a generated thread title is asked from.
|
|
2
|
+
//
|
|
3
|
+
// A fresh thread is named from its first message. "Regenerate title" (#1858)
|
|
4
|
+
// names it again from the conversation as it is now: the newest user and bot
|
|
5
|
+
// lines, never tool output, cards or screen frames, and bounded so the
|
|
6
|
+
// one-shot stays as cheap as the first-message one.
|
|
7
|
+
import { redactSecretsInText } from "./redact.js";
|
|
8
|
+
/** The most text either kind of title one-shot ever sees. */
|
|
9
|
+
export const TITLE_INPUT_MAX_CHARS = 1_500;
|
|
10
|
+
const EXCERPT_MAX_MESSAGES = 12;
|
|
11
|
+
const EXCERPT_LINE_MAX_CHARS = 300;
|
|
12
|
+
const ATTACHMENT_TAG = /<attached-(?:image|file)\b[^>]*\/>/g;
|
|
13
|
+
export function threadTitlePrompt(text, source = "first-message") {
|
|
14
|
+
return [
|
|
15
|
+
source === "conversation"
|
|
16
|
+
? "Name the conversation below by what it is about now."
|
|
17
|
+
: "Name the conversation that begins with the message below.",
|
|
18
|
+
"Reply with only a short title: 3 to 6 words, plain text, no quotes, no trailing period.",
|
|
19
|
+
source === "conversation" ? "Conversation:" : "Message:",
|
|
20
|
+
text.trim().slice(0, TITLE_INPUT_MAX_CHARS),
|
|
21
|
+
].join("\n");
|
|
22
|
+
}
|
|
23
|
+
/** The newest user and bot text lines, oldest first, one per line and each
|
|
24
|
+
* clipped, until the next line would pass the input cap. Attachment markup
|
|
25
|
+
* and secrets are scrubbed before anything leaves this machine. Empty when
|
|
26
|
+
* the thread has nothing a title could be drawn from. */
|
|
27
|
+
export function titleConversationExcerpt(messages) {
|
|
28
|
+
const lines = [];
|
|
29
|
+
let length = 0;
|
|
30
|
+
for (let index = messages.length - 1; index >= 0 && lines.length < EXCERPT_MAX_MESSAGES; index--) {
|
|
31
|
+
const message = messages[index];
|
|
32
|
+
// peer asides and still-queued lines are not the conversation yet
|
|
33
|
+
if (message.kind !== "text" || !message.text || message.aside || message.queued)
|
|
34
|
+
continue;
|
|
35
|
+
const text = redactSecretsInText(message.text.replace(ATTACHMENT_TAG, " ")).replace(/\s+/g, " ").trim();
|
|
36
|
+
if (!text)
|
|
37
|
+
continue;
|
|
38
|
+
const clipped = text.length > EXCERPT_LINE_MAX_CHARS ? `${text.slice(0, EXCERPT_LINE_MAX_CHARS - 1)}…` : text;
|
|
39
|
+
const line = `${message.role === "user" ? "User" : "Bot"}: ${clipped}`;
|
|
40
|
+
if (length + line.length + 1 > TITLE_INPUT_MAX_CHARS)
|
|
41
|
+
break;
|
|
42
|
+
lines.push(line);
|
|
43
|
+
length += line.length + 1;
|
|
44
|
+
}
|
|
45
|
+
return lines.reverse().join("\n");
|
|
46
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// Fish Audio text to speech. The API key stays on the harness: the renderer
|
|
2
|
-
// receives only voice metadata and synthesized audio bytes.
|
|
3
1
|
const API = (process.env.OMB_FISH_AUDIO_API || "https://api.fish.audio").replace(/\/+$/, "");
|
|
4
|
-
|
|
2
|
+
/** Used when no model is saved, so existing setups keep their behavior. */
|
|
3
|
+
export const DEFAULT_FISH_MODEL = "s2.1-pro";
|
|
5
4
|
const MAX_ERROR_LENGTH = 240;
|
|
6
5
|
const PAGE_SIZE = 100;
|
|
7
6
|
const MAX_OWNED_PAGES = 100;
|
|
@@ -124,7 +123,7 @@ export async function listVoices(key) {
|
|
|
124
123
|
});
|
|
125
124
|
return [...new Map(voices.map((voice) => [voice.id, voice])).values()];
|
|
126
125
|
}
|
|
127
|
-
export async function synthesize(text, voiceId, key) {
|
|
126
|
+
export async function synthesize(text, voiceId, key, model = DEFAULT_FISH_MODEL) {
|
|
128
127
|
let res;
|
|
129
128
|
try {
|
|
130
129
|
res = await fetch(`${API}/v1/tts`, {
|
|
@@ -133,7 +132,7 @@ export async function synthesize(text, voiceId, key) {
|
|
|
133
132
|
authorization: `Bearer ${key}`,
|
|
134
133
|
"content-type": "application/json",
|
|
135
134
|
accept: "audio/mpeg",
|
|
136
|
-
model
|
|
135
|
+
model,
|
|
137
136
|
},
|
|
138
137
|
body: JSON.stringify({
|
|
139
138
|
text,
|
|
@@ -69,8 +69,9 @@ export function voiceReady(cfg, voiceId) {
|
|
|
69
69
|
}
|
|
70
70
|
/** What the settings panel needs. Never includes the key — same write-only
|
|
71
71
|
* rule as every other credential. baseUrl and model are Chatterbox
|
|
72
|
-
* settings, not credentials, so they come back in full
|
|
73
|
-
*
|
|
72
|
+
* settings, not credentials, so they come back in full; `fishModel` is the
|
|
73
|
+
* Fish Audio speech model, resolved to its default when unset. `included`
|
|
74
|
+
* says the voice is Cloud Pro's, not a saved key. */
|
|
74
75
|
export function describeVoice(cfg) {
|
|
75
76
|
const provider = voiceProvider(cfg);
|
|
76
77
|
const included = provider === "elevenlabs" && elevenLabs(cfg)?.included === true;
|
|
@@ -81,6 +82,7 @@ export function describeVoice(cfg) {
|
|
|
81
82
|
provider,
|
|
82
83
|
baseUrl: provider === "chatterbox" ? (cfg.tts?.baseUrl ?? "") : "",
|
|
83
84
|
model: provider === "chatterbox" ? (cfg.tts?.model ?? "") : "",
|
|
85
|
+
...(provider === "fish" ? { fishModel: cfg.tts?.fishModel ?? fish.DEFAULT_FISH_MODEL } : {}),
|
|
84
86
|
...(included ? { included: true } : {}),
|
|
85
87
|
};
|
|
86
88
|
}
|
|
@@ -147,7 +149,7 @@ export function speak(cfg, text, voiceId, run) {
|
|
|
147
149
|
const voice = voiceId || cfg.tts?.voice;
|
|
148
150
|
if (!voice)
|
|
149
151
|
throw new NoVoiceConfigured("voice");
|
|
150
|
-
return fish.synthesize(text, voice, key);
|
|
152
|
+
return fish.synthesize(text, voice, key, cfg.tts?.fishModel);
|
|
151
153
|
}
|
|
152
154
|
const credential = elevenLabs(cfg);
|
|
153
155
|
if (!credential)
|
|
@@ -85,6 +85,8 @@ const SCREENSHOT_CLEANUP_BUDGET_MS = 10_000;
|
|
|
85
85
|
const PREPARATION_LOCK_TIMEOUT_MS = SCREENSHOT_BUDGET_MS + LOCK_ACQUIRE_TIMEOUT_MS;
|
|
86
86
|
const pendingScreenshots = new Map();
|
|
87
87
|
const viewerConnections = new Map();
|
|
88
|
+
const REMOTE_VIEWER_GRACE_MS = 30_000;
|
|
89
|
+
const NATIVE_VIEWER_LIFETIME_MS = 8 * 60 * 60_000;
|
|
88
90
|
const desktopTunnels = new Map();
|
|
89
91
|
function invalidateVpsStatus(key) {
|
|
90
92
|
statusCache.delete(key);
|
|
@@ -198,12 +200,53 @@ function stopDesktopTunnel(botId) {
|
|
|
198
200
|
return true;
|
|
199
201
|
}
|
|
200
202
|
export function closeVpsDesktopTunnel(botId) {
|
|
203
|
+
const tunnel = desktopTunnels.get(botId);
|
|
204
|
+
// Remote tabs still hold the tunnel; drop only the native claim and let
|
|
205
|
+
// the last tab's release start the grace period.
|
|
206
|
+
if (tunnel?.viewers) {
|
|
207
|
+
tunnel.localViewer = false;
|
|
208
|
+
clearTimeout(tunnel.expiry);
|
|
209
|
+
return { closed: false };
|
|
210
|
+
}
|
|
201
211
|
return { closed: stopDesktopTunnel(botId) };
|
|
202
212
|
}
|
|
203
213
|
export function closeAllVpsDesktopTunnels() {
|
|
204
214
|
for (const botId of desktopTunnels.keys())
|
|
205
215
|
stopDesktopTunnel(botId);
|
|
206
216
|
}
|
|
217
|
+
/** Resolve only a tunnel opened by the authenticated join route. Multiple
|
|
218
|
+
* remote tabs share it; the final tab releases it unless a native viewer owns it. */
|
|
219
|
+
export function vpsDesktopConnection(botId) {
|
|
220
|
+
const tunnel = desktopTunnels.get(botId);
|
|
221
|
+
if (!tunnel || tunnel.child.exitCode !== null || tunnel.child.killed)
|
|
222
|
+
return;
|
|
223
|
+
const url = new URL(tunnel.joinUrl);
|
|
224
|
+
return {
|
|
225
|
+
port: Number(url.port),
|
|
226
|
+
password: new URLSearchParams(url.hash.slice(1)).get("password"),
|
|
227
|
+
live: () => desktopTunnels.get(botId) === tunnel && tunnel.child.exitCode === null && !tunnel.child.killed,
|
|
228
|
+
retain: () => {
|
|
229
|
+
tunnel.viewers++;
|
|
230
|
+
if (!tunnel.localViewer)
|
|
231
|
+
clearTimeout(tunnel.expiry);
|
|
232
|
+
let released = false;
|
|
233
|
+
return () => {
|
|
234
|
+
if (released)
|
|
235
|
+
return;
|
|
236
|
+
released = true;
|
|
237
|
+
tunnel.viewers--;
|
|
238
|
+
if (!tunnel.viewers && !tunnel.localViewer && desktopTunnels.get(botId) === tunnel) {
|
|
239
|
+
// A short grace period lets a reload/reconnect reuse the tunnel.
|
|
240
|
+
tunnel.expiry = setTimeout(() => {
|
|
241
|
+
if (desktopTunnels.get(botId) === tunnel)
|
|
242
|
+
stopDesktopTunnel(botId);
|
|
243
|
+
}, REMOTE_VIEWER_GRACE_MS);
|
|
244
|
+
tunnel.expiry.unref();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
207
250
|
const STREAM_CAP_CHARS = 16 * 1024 * 1024;
|
|
208
251
|
/** Keeps the LAST 16MB of a stream without rebuilding one giant string per
|
|
209
252
|
* chunk (a 10-minute `docker build` stream made that rebuild quadratic).
|
|
@@ -317,7 +360,7 @@ function emptyStatus(botId, alias) {
|
|
|
317
360
|
desktopReady: false,
|
|
318
361
|
desktop_error: null,
|
|
319
362
|
ready: false,
|
|
320
|
-
problem: alias ? "Docker over SSH is not reachable" : "Configure a VPS SSH alias in
|
|
363
|
+
problem: alias ? "Docker over SSH is not reachable" : "Configure a VPS SSH alias in Settings → API keys",
|
|
321
364
|
image_ref: VPS_IMAGE,
|
|
322
365
|
base_image_ref: BASE_IMAGE,
|
|
323
366
|
driver_version: CUA_DRIVER_VERSION,
|
|
@@ -335,6 +378,9 @@ function isMissingObjectMessage(message) {
|
|
|
335
378
|
return /no such (object|image|container)/i.test(message);
|
|
336
379
|
}
|
|
337
380
|
function transportFailure(message) {
|
|
381
|
+
if (/timed out/i.test(message)) {
|
|
382
|
+
return "Docker over SSH failed while checking the VPS: the connection timed out. Check that the VPS is online, SSH is reachable from this computer, and Docker is running on the VPS, then retry. No computer was reset.";
|
|
383
|
+
}
|
|
338
384
|
return `Docker over SSH failed while checking the VPS: ${message.trim().slice(0, 200) || "unknown transport error"}`;
|
|
339
385
|
}
|
|
340
386
|
function privateDockerIpv4(value) {
|
|
@@ -371,7 +417,7 @@ function hasNoPublishedPorts(config, networks) {
|
|
|
371
417
|
}
|
|
372
418
|
function statusProblem(status) {
|
|
373
419
|
if (!status.configured)
|
|
374
|
-
return "Configure a VPS SSH alias in
|
|
420
|
+
return "Configure a VPS SSH alias in Settings → API keys";
|
|
375
421
|
if (!status.daemonUp)
|
|
376
422
|
return "Docker over SSH could not reach the VPS; check the SSH alias and Docker on the VPS";
|
|
377
423
|
if (!status.image)
|
|
@@ -412,9 +458,23 @@ async function computeVpsComputerStatus(cfg, botId, runner) {
|
|
|
412
458
|
return status;
|
|
413
459
|
viewerConnections.delete(`${alias}:${status.container_name}`);
|
|
414
460
|
const run = (args, timeoutMs = 10_000, input) => runner(vpsDockerArgs(alias, args), { timeoutMs, input });
|
|
461
|
+
// Only repeat read-only Docker inspections, never an exec or lifecycle
|
|
462
|
+
// mutation whose outcome is unknown after a lost connection. Awaiting the
|
|
463
|
+
// runner also lets its owned SSH process finish cleanup before retrying.
|
|
464
|
+
const inspect = async (args) => {
|
|
465
|
+
try {
|
|
466
|
+
return await run(args);
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
470
|
+
if (!/timed out|connection reset by peer|broken pipe/i.test(message))
|
|
471
|
+
throw error;
|
|
472
|
+
return run(args);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
415
475
|
let inspectedImageId = null;
|
|
416
476
|
try {
|
|
417
|
-
const inspected = JSON.parse((await
|
|
477
|
+
const inspected = JSON.parse((await inspect(["image", "inspect", VPS_IMAGE])).stdout);
|
|
418
478
|
status.daemonUp = true;
|
|
419
479
|
const image = inspected[0];
|
|
420
480
|
const labels = image?.Config?.Labels ?? image?.config?.Labels ?? image?.config?.labels;
|
|
@@ -434,7 +494,7 @@ async function computeVpsComputerStatus(cfg, botId, runner) {
|
|
|
434
494
|
status.image = false;
|
|
435
495
|
}
|
|
436
496
|
try {
|
|
437
|
-
const inspected = JSON.parse((await
|
|
497
|
+
const inspected = JSON.parse((await inspect(["inspect", status.container_name])).stdout);
|
|
438
498
|
const detail = inspected[0];
|
|
439
499
|
const labels = detail?.Config?.Labels;
|
|
440
500
|
const containerId = detail?.Id ?? detail?.id;
|
|
@@ -897,7 +957,7 @@ export async function removeManagedVpsComputer(cfg, owners, containerName, confi
|
|
|
897
957
|
cfg = snapshotVpsConfig(cfg);
|
|
898
958
|
const alias = vpsSshAlias(cfg);
|
|
899
959
|
if (!alias) {
|
|
900
|
-
throw Object.assign(new Error("VPS is not configured — add an SSH config alias in
|
|
960
|
+
throw Object.assign(new Error("VPS is not configured — add an SSH config alias in Settings → API keys"), { status: 409 });
|
|
901
961
|
}
|
|
902
962
|
if (!MANAGED_VPS_CONTAINER_NAME.test(containerName)) {
|
|
903
963
|
throw Object.assign(new Error("invalid managed VPS computer name"), { status: 400 });
|
|
@@ -948,7 +1008,7 @@ export async function vpsComputerAction(action, cfg, botId, runner = defaultRunn
|
|
|
948
1008
|
cfg = snapshotVpsConfig(cfg);
|
|
949
1009
|
const alias = vpsSshAlias(cfg);
|
|
950
1010
|
if (!alias)
|
|
951
|
-
throw Object.assign(new Error("VPS is not configured — add an SSH config alias in
|
|
1011
|
+
throw Object.assign(new Error("VPS is not configured — add an SSH config alias in Settings → API keys"), { status: 409 });
|
|
952
1012
|
const key = `${alias}:${vpsContainerName(botId)}`;
|
|
953
1013
|
const pending = action === "provision" ? pendingProvisions.get(key) : undefined;
|
|
954
1014
|
if (pending)
|
|
@@ -1054,13 +1114,20 @@ export async function inspectVpsForAuto(cfg, botId, runner = defaultRunner) {
|
|
|
1054
1114
|
* returned URL is loopback-only and contains the per-container password in
|
|
1055
1115
|
* its fragment (never sent in HTTP). Closing the app viewer calls the paired
|
|
1056
1116
|
* close endpoint, and process shutdown closes every remaining child. */
|
|
1057
|
-
export async function vpsComputerJoin(cfg, botId, runner = defaultRunner) {
|
|
1117
|
+
export async function vpsComputerJoin(cfg, botId, runner = defaultRunner, remoteViewer = false) {
|
|
1058
1118
|
cfg = snapshotVpsConfig(cfg);
|
|
1059
1119
|
const alias = vpsSshAlias(cfg);
|
|
1060
1120
|
if (!alias)
|
|
1061
1121
|
throw Object.assign(new Error("VPS is not configured"), { status: 409 });
|
|
1062
1122
|
const existing = desktopTunnels.get(botId);
|
|
1063
1123
|
if (existing && existing.child.exitCode === null && !existing.child.killed) {
|
|
1124
|
+
if (!remoteViewer)
|
|
1125
|
+
existing.localViewer = true;
|
|
1126
|
+
if (!existing.viewers || existing.localViewer) {
|
|
1127
|
+
clearTimeout(existing.expiry);
|
|
1128
|
+
existing.expiry = setTimeout(() => stopDesktopTunnel(botId), existing.localViewer ? NATIVE_VIEWER_LIFETIME_MS : REMOTE_VIEWER_GRACE_MS);
|
|
1129
|
+
existing.expiry.unref();
|
|
1130
|
+
}
|
|
1064
1131
|
return { joinUrl: existing.joinUrl, state: "running" };
|
|
1065
1132
|
}
|
|
1066
1133
|
stopDesktopTunnel(botId);
|
|
@@ -1114,11 +1181,11 @@ export async function vpsComputerJoin(cfg, botId, runner = defaultRunner) {
|
|
|
1114
1181
|
throw Object.assign(new Error(`Could not open the VPS live desktop: ${detail}`), { status: 502 });
|
|
1115
1182
|
}
|
|
1116
1183
|
const joinUrl = `http://127.0.0.1:${localPort}/vnc.html#autoconnect=true&resize=scale&password=${encodeURIComponent(connection.password)}`;
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
const expiry = setTimeout(() => stopDesktopTunnel(botId),
|
|
1184
|
+
// Remote tabs retain the tunnel when their WebSocket opens. Reclaim an
|
|
1185
|
+
// abandoned join promptly; native windows retain the existing close/ceiling.
|
|
1186
|
+
const expiry = setTimeout(() => stopDesktopTunnel(botId), remoteViewer ? REMOTE_VIEWER_GRACE_MS : NATIVE_VIEWER_LIFETIME_MS);
|
|
1120
1187
|
expiry.unref?.();
|
|
1121
|
-
desktopTunnels.set(botId, { child, joinUrl, expiry });
|
|
1188
|
+
desktopTunnels.set(botId, { child, joinUrl, expiry, viewers: 0, localViewer: !remoteViewer });
|
|
1122
1189
|
return { joinUrl, state: "running" };
|
|
1123
1190
|
}
|
|
1124
1191
|
export function vpsContainerMcpArgs(alias, containerName) {
|
|
@@ -1142,7 +1209,7 @@ export function vpsDriverError(driverKind, computerMcp) {
|
|
|
1142
1209
|
return "The Computer engine runs its agent on Boat and cannot use a self-hosted VPS — choose Claude or an ACP engine";
|
|
1143
1210
|
}
|
|
1144
1211
|
if (!computerMcp) {
|
|
1145
|
-
return "This model
|
|
1212
|
+
return "This model cannot mount a self-hosted VPS computer — choose Claude or an ACP model provider";
|
|
1146
1213
|
}
|
|
1147
1214
|
return null;
|
|
1148
1215
|
}
|