openmausbot 0.1.95 → 0.1.96
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-BwWpc4RK.js → DesktopViewer-DsnA23KM.js} +1 -1
- package/dist/assets/{abnfDiagram-O67JEVCF-jsQkIqTn.js → abnfDiagram-O67JEVCF-Dkbd2hcj.js} +1 -1
- package/dist/assets/{arc-7kei4Hsu.js → arc-Duj5Coy2.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CRWDFDMq.js → architectureDiagram-NJMV4G6O-uYUsDawP.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-CTKW_IKs.js → blockDiagram-BEXU5L5S-CWDlTpXe.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-BBTW7-6M.js → c4Diagram-YGBWAQC7-B0t15PDc.js} +1 -1
- package/dist/assets/channel-BZxwC-Gw.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-M_Ox-MkD.js → chunk-2Q5K7J3B-BJSCL-HV.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-D2YiN-Ud.js → chunk-3FUC2YCW-D3_ILccd.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-BmVYJ0f8.js → chunk-5VM5RSS4-D3dmwT_S.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-BtiLVVnZ.js → chunk-GTNCS2PH-Br73HFPD.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-DFYSb6J6.js → chunk-GWA4HPMP-uSsjxubC.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-mknKBzgV.js → chunk-JWPE2WC7-C40VWFyr.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-DgSYGiOu.js → chunk-NETBCI7D-D6WNNYwU.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-C7ZvuguH.js → chunk-WEXAMYUT-DOEhq64P.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-CPqxINpA.js → chunk-XXDRQBXY-BqJEi6Pi.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-Cd6xfJaC.js → classDiagram-v2-NBCMYWYE-ByU_GnxT.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-BSXY5qRh.js → cose-bilkent-JH36ORCC-D-Uglxqq.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-D_lhEMj1.js → cynefin-OW5HDTMX-CZWO7aYj.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-Da9FrFwk.js → cynefinDiagram-VND7K2PF-C70-w0la.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-T77jMOXe.js → dagre-6A5THRUB-CnhjhExB.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-Dkj96X5E.js → diagram-22UHCM2B-bnjWCNb4.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-CMi9zlD_.js → diagram-3UASUU5V-A4uTWoFV.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-CXK6YUfS.js → diagram-ATOU4E4O-Zi6m3NUl.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-BE7B1u9Q.js → diagram-CDSNMT55-DtCcmd5k.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-CCMQphKr.js → diagram-MLGK6HIB-DofuETS3.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-iHJ_-tGH.js → diagram-MPIPVDR6-Dw2T9PuU.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-DONsCWQ7.js → ebnfDiagram-ZINNZB2B-DbrwPiem.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-CU_WOaXk.js → elk-276RUBZZ-BvCt1dCb.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-BeYwM04G.js → erDiagram-OPXOYQCR-Y-QKqPnU.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-Bdw9DqEl.js → flowDiagram-KWPJA3E3-CDkL1B5w.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-C9iwq3qm.js → ganttDiagram-FUAMR5RP-CKV5D2F6.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-Dx-VWzUg.js → gitGraphDiagram-X574FWY7-nL9iFTCv.js} +1 -1
- package/dist/assets/{index-KygHRE3E.js → index-DreDnjOu.js} +1 -1
- package/dist/assets/index-Du7o6Dsf.js +610 -0
- package/dist/assets/index-KxG4bWSr.css +1 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-BG7uaEd4.js → infoDiagram-VRGFBTTK-C7aHjzsf.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-D72WAXnM.js → ishikawaDiagram-OU5B5YK6-KUZsNBTh.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-DKH1gjYT.js → journeyDiagram-ZHPQQLJL-CGUuodFl.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-3upm9JUT.js → kanban-definition-PNTS6WVX-Cy6r868Z.js} +1 -1
- package/dist/assets/{linear-CcR4pc0-.js → linear-Bl669148.js} +1 -1
- package/dist/assets/{mermaid.core-C6yU5FSS.js → mermaid.core-86Xqz5Mr.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-DOI2xEuv.js → mindmap-definition-NLK3R4M7-G4DzHLrd.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-BVWJSgLd.js → pegDiagram-GJSIUBJH-K4eruzY0.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-jtbYezhq.js → pieDiagram-5QR66LMP-DYdR9PPq.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-g0u8mtPX.js → quadrantDiagram-O4NWA36T-BCAh0nEy.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-k8689Wny.js → railroadDiagram-XR7U4H2S-p0dGRc4g.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DsJ22PDt.js → requirementDiagram-PLB6GJNP-tLeBG0aL.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-CneWBRgG.js → sankeyDiagram-IPEJSGJF-QRuN16rV.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-DWsFCWap.js → sequenceDiagram-PO4LG4MO-ueT1nVoa.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-Bvf0YE3S.js → sizeCapture-INFHLROL-wRS2zWg2.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-Bxb3Iei8.js → stateDiagram-v2-GCMORJYK-CYzA_hE1.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DLkk7W4A.js → swimlanes-2SLR337P-kDnv6LMc.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-C7voMDle.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-KR9bIHYC.js → timeline-definition-EJHVYXUP-D1y2UbbH.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-DfsOrj8t.js → usecaseDiagram-POWQR4AR-IqB4v9HX.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-DRSTzG3h.js → vennDiagram-UO4OBE2U-BEi7KYVF.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-DtEdhPgT.js → wardleyDiagram-VNRHLVJA-Dkybo-Yf.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-B-XhTxjG.js → xychartDiagram-PMCCYNJV-BAPFJLpg.js} +1 -1
- package/dist/index.html +2 -2
- package/dist-server/cloud-home-start.js +25 -12
- package/dist-server/companion/src/listener.js +39 -18
- package/dist-server/container-mcp.js +14 -27
- package/dist-server/drivers/agents-proxy.js +103 -44
- package/dist-server/harness-mcp-proxy.js +1 -2
- package/dist-server/index.js +2633 -3396
- package/dist-server/openmausbot.js +606 -463
- package/dist-server/pair-cli.js +606 -463
- package/dist-server/server/admin-activity.js +1 -4
- package/dist-server/server/auto-approve.js +19 -4
- package/dist-server/server/boat.js +4 -4
- package/dist-server/server/checkpoints.js +90 -148
- package/dist-server/server/chief-of-staff.js +7 -6
- package/dist-server/server/cli.js +33 -26
- package/dist-server/server/cloud-home-start.js +12 -15
- package/dist-server/server/cloud-home.js +0 -4
- package/dist-server/server/cloud-move-http.js +44 -30
- package/dist-server/server/cloud-move.js +20 -18
- package/dist-server/server/composio.js +10 -3
- package/dist-server/server/config.js +92 -64
- package/dist-server/server/container-computer.js +16 -5
- package/dist-server/server/contracts.js +0 -9
- package/dist-server/server/decider/jev.js +1 -4
- package/dist-server/server/decider/log.js +1 -3
- package/dist-server/server/decision-log.js +1 -4
- package/dist-server/server/delegations.js +3 -2
- package/dist-server/server/drivers/acp/core.js +4 -3
- package/dist-server/server/drivers/acp/opencode-go.js +17 -16
- package/dist-server/server/drivers/agents-call.js +46 -63
- package/dist-server/server/drivers/agents-catalog.js +66 -0
- package/dist-server/server/drivers/agents-proxy.js +2 -1
- package/dist-server/server/drivers/claude-login-auth.js +2 -11
- package/dist-server/server/drivers/codex-device-auth.js +3 -1
- package/dist-server/server/drivers/codex.js +5 -4
- package/dist-server/server/drivers/grok.js +1 -1
- package/dist-server/server/drivers/openai-chat-protocol.js +4 -2
- package/dist-server/server/drivers/openai-chat.js +15 -5
- package/dist-server/server/drivers/prompt-split.js +4 -4
- package/dist-server/server/harness/bus.js +49 -0
- package/dist-server/server/harness-mcp-proxy.js +1 -1
- package/dist-server/server/index.js +448 -762
- package/dist-server/server/mcp-registry.js +35 -0
- package/dist-server/server/message-file.js +4 -4
- package/dist-server/server/peer-delivery.js +4 -19
- package/dist-server/server/peer-roster.js +30 -4
- package/dist-server/server/profile-versions.js +0 -31
- package/dist-server/server/provider-key-check.js +2 -1
- package/dist-server/server/restart.js +40 -0
- package/dist-server/server/room-handoffs.js +12 -0
- package/dist-server/server/routes/bot-memory.js +0 -24
- package/dist-server/server/routine-requests.js +1 -4
- package/dist-server/server/routines.js +5 -4
- package/dist-server/server/server-launcher.js +53 -0
- package/dist-server/server/store.js +1 -1
- package/dist-server/server/surface.js +23 -27
- package/dist-server/server/system-prompt.js +13 -3
- package/dist-server/server/team-manifest.js +1 -6
- package/dist-server/server/tunnel.js +4 -1
- package/dist-server/server/turn-images.js +1 -21
- package/dist-server/server/turn-resources.js +19 -150
- package/dist-server/server/usage-ledger.js +3 -1
- package/dist-server/server/workspace.js +2 -3
- package/dist-server/server-launcher.js +72 -0
- package/dist-server/shared/ask-question.js +1 -1
- package/dist-server/shared/cloud-computer.js +12 -0
- package/dist-server/shared/failed-turn.js +25 -0
- package/dist-server/shared/live-approval.js +2 -2
- package/dist-server/shared/package-format.js +3 -3
- package/dist-server/shared/pairing-link.js +144 -0
- package/dist-server/vps-container-mcp.js +14 -27
- package/dist-server/workspace-backup.worker.js +14 -27
- package/package.json +1 -1
- package/dist/assets/channel-DS2NbT5x.js +0 -1
- package/dist/assets/index-NF7XCHAy.css +0 -1
- package/dist/assets/index-X_5ydXIE.js +0 -610
- package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +0 -8
- package/dist-server/server/claim-idle.js +0 -33
- package/dist-server/server/cloud-overflow.js +0 -167
- package/dist-server/server/tightening-requests.js +0 -435
- package/dist-server/shared/tightening-request.js +0 -87
|
@@ -20,16 +20,13 @@ import { appendFile, mkdir } from "node:fs/promises";
|
|
|
20
20
|
import { basename, join } from "node:path";
|
|
21
21
|
import { actorLabel, boundRetentionDays, pruneMonthFiles } from "./decision-log.js";
|
|
22
22
|
import { redactSecrets } from "./redact.js";
|
|
23
|
-
import { csvCell } from "./usage-ledger.js";
|
|
23
|
+
import { csvCell, monthKey } from "./usage-ledger.js";
|
|
24
24
|
export const ADMIN_ACTIVITY_CATEGORIES = ["config", "people", "session", "webhook", "mcp", "engine", "bot", "budget", "visibility"];
|
|
25
25
|
const DIR = "admin-activity";
|
|
26
26
|
const MONTH_FILE = /^(\d{4})-(\d{2})\.ndjson$/;
|
|
27
27
|
const PRUNE_EVERY_MS = 60 * 60_000;
|
|
28
28
|
const writeQueues = new Map();
|
|
29
29
|
const lastPrune = new Map();
|
|
30
|
-
function monthKey(at) {
|
|
31
|
-
return `${at.getUTCFullYear()}-${String(at.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
32
|
-
}
|
|
33
30
|
export function adminActivityFileFor(dataDir, at) {
|
|
34
31
|
return join(dataDir, DIR, `${monthKey(at)}.ndjson`);
|
|
35
32
|
}
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// Nothing here decides whether an action is safe. Each approval level is a
|
|
4
4
|
// provider's own permission mode passed straight through (Claude `auto`,
|
|
5
5
|
// Grok `--permission-mode`, Codex `approvalsReviewer`, …), and a request that
|
|
6
|
-
// reaches this process is one the provider left for a person. The
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// reaches this process is one the provider left for a person. The grants the
|
|
7
|
+
// app applies are Full access and the person's exact saved commands. Approve
|
|
8
|
+
// for me also allows a web search, and that is the one action the app itself
|
|
9
|
+
// grants. Questions never come through here: a bot's question always reaches
|
|
10
|
+
// a human.
|
|
9
11
|
import { supportsApprovalMode } from "../shared/approval-mode.js";
|
|
10
12
|
import { isOutboundTool } from "../shared/outbound.js";
|
|
11
13
|
/** A failed delivery is a runtime error, not another permission decision.
|
|
@@ -78,11 +80,19 @@ export function delegatedApprovalMode(input) {
|
|
|
78
80
|
// produce an answer: the CLI runs the tool with none and the model is told
|
|
79
81
|
// "The user did not answer the questions." — a question silently lost.
|
|
80
82
|
const ASKS_A_PERSON = new Set(["askuserquestion", "ask_user", "omb-ask"]);
|
|
83
|
+
// A web search is a low-level read. Approve for me allows these names and
|
|
84
|
+
// nothing wider: a bare search, a page fetch, and every other tool stay a
|
|
85
|
+
// prompt. The name is the same shape as a question — one mcp__<server>__
|
|
86
|
+
// prefix removed, then compared case-insensitively.
|
|
87
|
+
const WEB_SEARCH = new Set(["websearch", "web_search", "web-search", "search_web"]);
|
|
88
|
+
function bareToolName(tool) {
|
|
89
|
+
return tool.replace(/^mcp__[^_]+__/, "").toLowerCase();
|
|
90
|
+
}
|
|
81
91
|
export function autoVerdict(mode, tool, context) {
|
|
82
92
|
// A question is for a person, whatever channel it arrived on — and
|
|
83
93
|
// whatever the mode: even Full has no answer to give, only an approval
|
|
84
94
|
// that would run the tool with none.
|
|
85
|
-
if (ASKS_A_PERSON.has(tool
|
|
95
|
+
if (ASKS_A_PERSON.has(bareToolName(tool))) {
|
|
86
96
|
return { approve: null, source: "no-grant" };
|
|
87
97
|
}
|
|
88
98
|
// Full's promise is literal: even a sandbox widening is approved. Entering
|
|
@@ -96,6 +106,11 @@ export function autoVerdict(mode, tool, context) {
|
|
|
96
106
|
return { approve: null, source: "outbound-guard" };
|
|
97
107
|
if (context?.commandAllowed)
|
|
98
108
|
return { approve: `approved ${tool} (saved command)`, source: "command-allowlist" };
|
|
109
|
+
// The one action the app itself grants. A fetch of an arbitrary URL is not
|
|
110
|
+
// a search and stays a prompt, in this mode and every other.
|
|
111
|
+
if (mode === "auto" && WEB_SEARCH.has(bareToolName(tool))) {
|
|
112
|
+
return { approve: `approved ${tool} (web search)`, source: "web-search" };
|
|
113
|
+
}
|
|
99
114
|
if (mode === "auto" || mode === "custom")
|
|
100
115
|
return { approve: null, source: "native-approval" };
|
|
101
116
|
return { approve: null, source: "no-grant" };
|
|
@@ -88,9 +88,7 @@ function scopedBoatPrefix() {
|
|
|
88
88
|
}
|
|
89
89
|
/** Decide lifecycle work before a turn mounts Boat. Auto may observe and
|
|
90
90
|
* attach an already-ready Boat, but only explicit Cloud may create or wake. */
|
|
91
|
-
export function boatTurnLifecycleAction({ explicitCloud,
|
|
92
|
-
if (!canMount)
|
|
93
|
-
return "none";
|
|
91
|
+
export function boatTurnLifecycleAction({ explicitCloud, state, }) {
|
|
94
92
|
if (state && READY.has(state))
|
|
95
93
|
return "attach";
|
|
96
94
|
if (!explicitCloud)
|
|
@@ -828,7 +826,9 @@ export async function findBoat(cfg, botId) {
|
|
|
828
826
|
if (cachedId) {
|
|
829
827
|
let direct = null;
|
|
830
828
|
try {
|
|
831
|
-
|
|
829
|
+
// Bounded like every other Boat read: a relay that accepts the
|
|
830
|
+
// connection and stalls must not hold a turn's setup for minutes.
|
|
831
|
+
direct = await boatJson(cfg, `/boxes/${cachedId}`, { signal: AbortSignal.timeout(20_000) });
|
|
832
832
|
}
|
|
833
833
|
catch {
|
|
834
834
|
// A direct read can fail while the account listing still succeeds.
|
|
@@ -2,17 +2,15 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Before a turn's engine can touch files, the working folder is snapshotted
|
|
4
4
|
// into a shadow repo at DATA_DIR/checkpoints/<botId>/<sha256(cwd)[..16]>/.git.
|
|
5
|
+
// When the turn settles, its digest diffs the folder against that snapshot to
|
|
6
|
+
// list the files the turn changed, added and deleted.
|
|
5
7
|
// The user's own .git (if the folder is a repository) is never read, written,
|
|
6
8
|
// or locked: every git call runs with GIT_DIR pointing at the shadow repo and
|
|
7
9
|
// GIT_WORK_TREE pointing at the folder, so the shadow index is the only index
|
|
8
10
|
// involved — and git always skips directories named .git when walking a work
|
|
9
11
|
// tree, so the user's repository internals are invisible to the snapshot.
|
|
10
12
|
//
|
|
11
|
-
//
|
|
12
|
-
// the index and work tree back with `git restore --source` + `git clean -fd`
|
|
13
|
-
// while HEAD keeps the safety point — so the state just replaced is the one
|
|
14
|
-
// retained checkpoint, and "undo the undo" is one more restore. Ignored/excluded files
|
|
15
|
-
// (node_modules, .env, media) are never snapshotted and never removed by one.
|
|
13
|
+
// Ignored/excluded files (node_modules, .env, media) are never snapshotted.
|
|
16
14
|
//
|
|
17
15
|
// Failure policy: checkpointing is best-effort convenience, never load-bearing.
|
|
18
16
|
// Any git failure disables the feature for that bot for the rest of the
|
|
@@ -20,9 +18,9 @@
|
|
|
20
18
|
//
|
|
21
19
|
// Adapted from the checkpoint designs of Cline, Roo-Code, and Gemini CLI
|
|
22
20
|
// (all Apache-2.0): the per-call GIT_DIR/GIT_WORK_TREE/GIT_CONFIG_* env
|
|
23
|
-
// override
|
|
24
|
-
//
|
|
25
|
-
//
|
|
21
|
+
// override follows Gemini CLI's gitService, the sanitized GIT_* env list and
|
|
22
|
+
// the exclude categories follow Roo-Code's checkpoint service, and the
|
|
23
|
+
// snapshot-before-every-turn cadence follows Cline.
|
|
26
24
|
import { execFile } from "node:child_process";
|
|
27
25
|
import { createHash } from "node:crypto";
|
|
28
26
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -30,15 +28,14 @@ import { homedir } from "node:os";
|
|
|
30
28
|
import { isAbsolute, join, parse, resolve } from "node:path";
|
|
31
29
|
import { DATA_DIR } from "./config.js";
|
|
32
30
|
export const CHECKPOINTS_DIR = join(DATA_DIR, "checkpoints");
|
|
33
|
-
/** Full sha1 hex only
|
|
34
|
-
*
|
|
31
|
+
/** Full sha1 hex only: accepting anything looser would let arbitrary
|
|
32
|
+
* revspecs ("HEAD~3", "main@{u}") reach git. */
|
|
35
33
|
const COMMIT_HASH = /^[0-9a-f]{40}$/;
|
|
36
|
-
// What a checkpoint deliberately does not carry
|
|
37
|
-
// these either
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
const EXCLUDES = `# OpenMausBot checkpoint excludes — never snapshotted, never removed by restore
|
|
34
|
+
// What a checkpoint deliberately does not carry, so a digest never lists
|
|
35
|
+
// these either. Categories follow Roo-Code's checkpoint excludes: VCS
|
|
36
|
+
// internals, dependency trees, build output, caches, logs, secrets, media,
|
|
37
|
+
// archives, databases, model weights.
|
|
38
|
+
const EXCLUDES = `# OpenMausBot checkpoint excludes — never snapshotted
|
|
42
39
|
.git/
|
|
43
40
|
.svn/
|
|
44
41
|
.hg/
|
|
@@ -120,8 +117,8 @@ Thumbs.db
|
|
|
120
117
|
`;
|
|
121
118
|
// gpgsign off: the user's global config may demand signing, and a shadow
|
|
122
119
|
// commit must never block on a passphrase prompt. Automatic GC stays off:
|
|
123
|
-
// explicit GC runs inside the same serialization as snapshot
|
|
124
|
-
//
|
|
120
|
+
// explicit GC runs inside the same serialization as each snapshot, after the
|
|
121
|
+
// previous snapshot's objects are no longer needed.
|
|
125
122
|
const GITCONFIG = "[commit]\n\tgpgsign = false\n[core]\n\tautocrlf = false\n[gc]\n\tauto = 0\n";
|
|
126
123
|
/** One failed git call disables checkpoints for that bot until restart —
|
|
127
124
|
* a broken shadow repo must cost the user one log line, not a failed turn. */
|
|
@@ -140,8 +137,7 @@ function gitAvailable() {
|
|
|
140
137
|
}
|
|
141
138
|
/** Folders a checkpoint must never be taken in: missing paths, the sprawling
|
|
142
139
|
* personal folders (home, Desktop, Documents, Downloads), and the filesystem
|
|
143
|
-
* root — snapshotting those would trawl unbounded personal data into a repo
|
|
144
|
-
* and a restore's `git clean -fd` there would be an act of vandalism. */
|
|
140
|
+
* root — snapshotting those would trawl unbounded personal data into a repo. */
|
|
145
141
|
export function refusalReason(cwd) {
|
|
146
142
|
if (!isAbsolute(cwd))
|
|
147
143
|
return "the working folder must be an absolute path";
|
|
@@ -161,7 +157,7 @@ export function refusalReason(cwd) {
|
|
|
161
157
|
// the refusal while git still follows the symlink into the protected tree.
|
|
162
158
|
// The native realpath also settles Windows' spellings of one folder —
|
|
163
159
|
// c:\users\me, C:\Users\me\DOCUME~1 — and names compare case-insensitively
|
|
164
|
-
// there
|
|
160
|
+
// there.
|
|
165
161
|
const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
166
162
|
if (dir === parse(dir).root)
|
|
167
163
|
return "checkpoints are not taken at the filesystem root";
|
|
@@ -203,9 +199,9 @@ function gitEnv(shadow, cwd) {
|
|
|
203
199
|
return env;
|
|
204
200
|
}
|
|
205
201
|
/** Run one git command against the shadow repo. cwd is the WORK TREE — the
|
|
206
|
-
* "." pathspec in add
|
|
207
|
-
*
|
|
208
|
-
*
|
|
202
|
+
* "." pathspec in add resolves relative to it. A hung git (index lock, dead
|
|
203
|
+
* network filesystem) would otherwise jam the per-repo queue for the whole
|
|
204
|
+
* session, so every call carries a hard timeout. */
|
|
209
205
|
function runGit(args, cwd, env, signal) {
|
|
210
206
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
211
207
|
signal?.throwIfAborted();
|
|
@@ -234,10 +230,10 @@ function hasStagedChanges(cwd, env, signal) {
|
|
|
234
230
|
});
|
|
235
231
|
});
|
|
236
232
|
}
|
|
237
|
-
// One operation at a time per shadow repo: snapshots and
|
|
238
|
-
// same folder queue behind each other (git's index lock would
|
|
239
|
-
// anyway — this turns a crash into a wait). The stored tail
|
|
240
|
-
// one failed operation can't poison the queue.
|
|
233
|
+
// One operation at a time per shadow repo: snapshots, diffs and pin releases
|
|
234
|
+
// against the same folder queue behind each other (git's index lock would
|
|
235
|
+
// fail the loser anyway — this turns a crash into a wait). The stored tail
|
|
236
|
+
// never rejects, so one failed operation can't poison the queue.
|
|
241
237
|
const chains = new Map();
|
|
242
238
|
function serialize(key, fn) {
|
|
243
239
|
const tail = chains.get(key) ?? Promise.resolve();
|
|
@@ -245,12 +241,32 @@ function serialize(key, fn) {
|
|
|
245
241
|
chains.set(key, run.then(() => undefined, () => undefined));
|
|
246
242
|
return run;
|
|
247
243
|
}
|
|
244
|
+
// A ref per running turn. Every snapshot parents only the empty base and the
|
|
245
|
+
// cleanup after it prunes whatever HEAD no longer reaches — so when a bot's
|
|
246
|
+
// threads work side by side in one folder, a sibling's snapshot would discard
|
|
247
|
+
// the pre-turn commit a still-running turn diffs against at its end. A pin
|
|
248
|
+
// keeps that commit reachable until release(). The pin's name is hashed so
|
|
249
|
+
// any token is a legal ref name.
|
|
250
|
+
const LIVE_REFS = "refs/omb-live/";
|
|
251
|
+
function liveRef(pin) {
|
|
252
|
+
return LIVE_REFS + createHash("sha256").update(pin).digest("hex").slice(0, 16);
|
|
253
|
+
}
|
|
254
|
+
// Pins outlive the process that wrote them (a crash mid-turn), so the first
|
|
255
|
+
// use of each shadow in this process drops every pin left behind: no turn of
|
|
256
|
+
// ours can be live in a shadow this process has not touched yet.
|
|
257
|
+
const sweptShadows = new Set();
|
|
258
|
+
async function sweepLiveRefs(cwd, env, shadow, signal) {
|
|
259
|
+
if (sweptShadows.has(shadow))
|
|
260
|
+
return;
|
|
261
|
+
const stale = (await runGit(["for-each-ref", "--format=%(refname)", LIVE_REFS], cwd, env, signal)).split("\n").filter(Boolean);
|
|
262
|
+
for (const ref of stale)
|
|
263
|
+
await runGit(["update-ref", "-d", ref], cwd, env, signal);
|
|
264
|
+
sweptShadows.add(shadow);
|
|
265
|
+
}
|
|
248
266
|
/** Create the shadow repo on first use; self-heal its config files on every
|
|
249
267
|
* use (they are tiny, and rewriting them lets exclude-list updates reach
|
|
250
268
|
* shadows that already exist). The base commit is an EMPTY marker so HEAD
|
|
251
|
-
* always resolves
|
|
252
|
-
* target, because "restore to empty" on a user's own project folder would
|
|
253
|
-
* delete their files. */
|
|
269
|
+
* always resolves; every snapshot parents only it. */
|
|
254
270
|
async function ensureShadow(cwd, env, shadow, signal) {
|
|
255
271
|
signal?.throwIfAborted();
|
|
256
272
|
mkdirSync(shadow, { recursive: true, mode: 0o700 });
|
|
@@ -291,9 +307,11 @@ async function ensureShadow(cwd, env, shadow, signal) {
|
|
|
291
307
|
// brand-new repo (or a crash between init and first commit)
|
|
292
308
|
await runGit(["commit", "--no-verify", "--allow-empty", "-m", "checkpoint base"], cwd, env, signal);
|
|
293
309
|
}
|
|
310
|
+
await sweepLiveRefs(cwd, env, shadow, signal);
|
|
294
311
|
}
|
|
295
|
-
/** Capture fully before advancing HEAD
|
|
296
|
-
* the last usable checkpoint or
|
|
312
|
+
/** Capture fully before advancing HEAD: null when the add was partial, so a
|
|
313
|
+
* partial capture never replaces the last usable checkpoint or authorizes
|
|
314
|
+
* removal of its objects. */
|
|
297
315
|
async function commitAll(cwd, env, label, signal) {
|
|
298
316
|
const previous = (await runGit(["rev-parse", "HEAD"], cwd, env, signal)).trim();
|
|
299
317
|
try {
|
|
@@ -303,37 +321,40 @@ async function commitAll(cwd, env, label, signal) {
|
|
|
303
321
|
await runGit(["add", "-A", "--ignore-errors", "."], cwd, env, signal);
|
|
304
322
|
}
|
|
305
323
|
catch {
|
|
306
|
-
return
|
|
324
|
+
return null;
|
|
307
325
|
}
|
|
308
326
|
const changed = await hasStagedChanges(cwd, env, signal);
|
|
309
327
|
const base = (await runGit(["rev-list", "--max-parents=0", "HEAD"], cwd, env, signal)).trim();
|
|
310
328
|
const count = (await runGit(["rev-list", "--count", "HEAD"], cwd, env, signal)).trim();
|
|
311
329
|
if (!changed && Number(count) <= 2)
|
|
312
|
-
return
|
|
330
|
+
return previous;
|
|
313
331
|
const tree = (await runGit(["write-tree"], cwd, env, signal)).trim();
|
|
314
332
|
const message = changed ? label : (await runGit(["show", "-s", "--format=%B", "HEAD"], cwd, env, signal)).trim();
|
|
315
333
|
// Parent only the empty marker, never the previous snapshot: keeping the
|
|
316
334
|
// previous commit as an ancestor would retain every old tree indefinitely.
|
|
317
335
|
const hash = (await runGit(["commit-tree", tree, "-p", base, "-m", message], cwd, env, signal)).trim();
|
|
318
336
|
await runGit(["update-ref", "HEAD", hash, previous], cwd, env, signal);
|
|
319
|
-
return
|
|
337
|
+
return hash;
|
|
320
338
|
}
|
|
321
339
|
async function collectObsolete(cwd, env) {
|
|
322
|
-
// No caller may still need
|
|
323
|
-
//
|
|
340
|
+
// No caller may still need an unpinned previous tree: HEAD stays reachable,
|
|
341
|
+
// and so does every commit a live turn still pins.
|
|
324
342
|
try {
|
|
325
343
|
await runGit(["reflog", "expire", "--expire=now", "--all"], cwd, env);
|
|
326
344
|
await runGit(["gc", "--prune=now", "--quiet"], cwd, env);
|
|
327
345
|
}
|
|
328
346
|
catch {
|
|
329
|
-
// A busy/full disk must not hide an otherwise usable
|
|
330
|
-
console.warn("checkpoint cleanup deferred; the latest
|
|
347
|
+
// A busy/full disk must not hide an otherwise usable snapshot.
|
|
348
|
+
console.warn("checkpoint cleanup deferred; the latest snapshot is preserved");
|
|
331
349
|
}
|
|
332
350
|
}
|
|
333
351
|
/** Snapshot the folder. Returns the checkpoint hash, or null when the
|
|
334
|
-
* feature is off for this bot, git is missing,
|
|
335
|
-
*
|
|
336
|
-
|
|
352
|
+
* feature is off for this bot, git is missing, the folder is refused, or a
|
|
353
|
+
* file could not be read.
|
|
354
|
+
* With `pin`, the returned commit stays reachable through later snapshots of
|
|
355
|
+
* the same folder until release(pin) — for a turn that will diff against it
|
|
356
|
+
* when it ends. Never throws — this is called fire-and-forget on the turn path. */
|
|
357
|
+
export async function snapshot(botId, cwd, label, signal, opts) {
|
|
337
358
|
if (signal?.aborted)
|
|
338
359
|
return null;
|
|
339
360
|
if (disabledBots.has(botId))
|
|
@@ -348,11 +369,13 @@ export async function snapshot(botId, cwd, label, signal) {
|
|
|
348
369
|
return await serialize(shadow, async () => {
|
|
349
370
|
const env = gitEnv(shadow, worktree);
|
|
350
371
|
await ensureShadow(worktree, env, shadow, signal);
|
|
351
|
-
const
|
|
352
|
-
if (!
|
|
372
|
+
const hash = await commitAll(worktree, env, label, signal);
|
|
373
|
+
if (!hash)
|
|
353
374
|
return null;
|
|
375
|
+
if (opts?.pin)
|
|
376
|
+
await runGit(["update-ref", liveRef(opts.pin), hash], worktree, env, signal);
|
|
354
377
|
await collectObsolete(worktree, env);
|
|
355
|
-
return
|
|
378
|
+
return hash;
|
|
356
379
|
});
|
|
357
380
|
}
|
|
358
381
|
catch (e) {
|
|
@@ -361,7 +384,27 @@ export async function snapshot(botId, cwd, label, signal) {
|
|
|
361
384
|
return null;
|
|
362
385
|
}
|
|
363
386
|
}
|
|
364
|
-
/**
|
|
387
|
+
/** Let go of a pinned pre-turn commit: the next snapshot's cleanup may
|
|
388
|
+
* reclaim it. Queued behind the shadow's pending operations, so a digest's
|
|
389
|
+
* diff already in that queue still finds its commit. Best-effort like the
|
|
390
|
+
* rest of this module — a pin that cannot be dropped costs one log line and
|
|
391
|
+
* is swept on the next start. */
|
|
392
|
+
export async function release(botId, cwd, pin) {
|
|
393
|
+
if (!(await gitAvailable()))
|
|
394
|
+
return;
|
|
395
|
+
try {
|
|
396
|
+
const worktree = realpathSync(resolve(cwd));
|
|
397
|
+
const shadow = shadowDir(botId, worktree);
|
|
398
|
+
if (!existsSync(join(shadow, ".git", "HEAD")))
|
|
399
|
+
return;
|
|
400
|
+
const env = gitEnv(shadow, worktree);
|
|
401
|
+
await serialize(shadow, () => runGit(["update-ref", "-d", liveRef(pin)], worktree, env));
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
console.warn(`checkpoint pin not released for bot ${botId}: ${e instanceof Error ? e.message : String(e)}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/** Diff the settled workspace without replacing its pre-turn snapshot.
|
|
365
408
|
* Staging is confined to the shadow index; the user's files and Git are untouched. */
|
|
366
409
|
export async function diffWorkingTree(botId, cwd, fromHash, signal) {
|
|
367
410
|
if (signal?.aborted || !COMMIT_HASH.test(fromHash))
|
|
@@ -396,104 +439,3 @@ export async function diffWorkingTree(botId, cwd, fromHash, signal) {
|
|
|
396
439
|
return null;
|
|
397
440
|
}
|
|
398
441
|
}
|
|
399
|
-
/** The latest usable checkpoint (plus legacy history until the next snapshot). Empty when nothing
|
|
400
|
-
* was ever snapshotted (listing never creates the shadow repo). The empty
|
|
401
|
-
* base marker is omitted — it is not a state anyone should return to. */
|
|
402
|
-
export async function listCheckpoints(botId, cwd) {
|
|
403
|
-
if (disabledBots.has(botId))
|
|
404
|
-
return [];
|
|
405
|
-
if (!(await gitAvailable()))
|
|
406
|
-
return [];
|
|
407
|
-
if (refusalReason(cwd) !== null)
|
|
408
|
-
return [];
|
|
409
|
-
try {
|
|
410
|
-
const worktree = realpathSync(resolve(cwd));
|
|
411
|
-
const shadow = shadowDir(botId, worktree);
|
|
412
|
-
if (!existsSync(join(shadow, ".git", "HEAD")))
|
|
413
|
-
return [];
|
|
414
|
-
return await serialize(shadow, async () => {
|
|
415
|
-
const env = gitEnv(shadow, worktree);
|
|
416
|
-
const out = await runGit(["log", "--format=%H%x09%ct%x09%s"], worktree, env);
|
|
417
|
-
const lines = out.split("\n").filter((line) => line.trim() !== "");
|
|
418
|
-
lines.pop(); // the root of the log is always the empty base marker
|
|
419
|
-
return lines.flatMap((line) => {
|
|
420
|
-
const [hash, seconds, ...subject] = line.split("\t");
|
|
421
|
-
if (!hash || !COMMIT_HASH.test(hash))
|
|
422
|
-
return [];
|
|
423
|
-
return [{ hash, at: Number(seconds) * 1000, label: subject.join("\t") }];
|
|
424
|
-
});
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
catch (e) {
|
|
428
|
-
disable(botId, e instanceof Error ? e.message : String(e));
|
|
429
|
-
return [];
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
/** Can this bot take/restore checkpoints in this folder right now? */
|
|
433
|
-
export async function checkpointsEnabled(botId, cwd) {
|
|
434
|
-
return !disabledBots.has(botId) && (await gitAvailable()) && refusalReason(cwd) === null;
|
|
435
|
-
}
|
|
436
|
-
/** Move the folder's files back to a checkpoint. The current state is
|
|
437
|
-
* committed first ("before restore"), so the restore itself shows up as a
|
|
438
|
-
* checkpoint and can be undone; HEAD never moves backwards, only forward
|
|
439
|
-
* over the "restored" commit. Excluded and gitignored files are untouched. */
|
|
440
|
-
export async function restore(botId, cwd, hash) {
|
|
441
|
-
if (disabledBots.has(botId)) {
|
|
442
|
-
return { ok: false, error: "checkpoints are disabled for this bot until the app restarts (an earlier snapshot failed — see the server log)" };
|
|
443
|
-
}
|
|
444
|
-
if (!(await gitAvailable()))
|
|
445
|
-
return { ok: false, error: "git is not installed on this machine" };
|
|
446
|
-
const reason = refusalReason(cwd);
|
|
447
|
-
if (reason !== null)
|
|
448
|
-
return { ok: false, error: reason };
|
|
449
|
-
if (!COMMIT_HASH.test(hash))
|
|
450
|
-
return { ok: false, error: "hash must be a full 40-character checkpoint hash" };
|
|
451
|
-
try {
|
|
452
|
-
const worktree = realpathSync(resolve(cwd));
|
|
453
|
-
const shadow = shadowDir(botId, worktree);
|
|
454
|
-
if (!existsSync(join(shadow, ".git", "HEAD"))) {
|
|
455
|
-
return { ok: false, error: "no checkpoints exist for this folder" };
|
|
456
|
-
}
|
|
457
|
-
return await serialize(shadow, async () => {
|
|
458
|
-
const env = gitEnv(shadow, worktree);
|
|
459
|
-
try {
|
|
460
|
-
await runGit(["cat-file", "-e", `${hash}^{commit}`], worktree, env);
|
|
461
|
-
}
|
|
462
|
-
catch {
|
|
463
|
-
return { ok: false, error: "no such checkpoint" };
|
|
464
|
-
}
|
|
465
|
-
const base = (await runGit(["rev-list", "--max-parents=0", "HEAD"], worktree, env)).trim();
|
|
466
|
-
if (base === hash)
|
|
467
|
-
return { ok: false, error: "that is the empty base marker, not a checkpoint" };
|
|
468
|
-
// safety point: whatever is about to be overwritten becomes restorable
|
|
469
|
-
await ensureShadow(worktree, env, shadow);
|
|
470
|
-
const safety = await commitAll(worktree, env, "before restore");
|
|
471
|
-
if (!safety.complete) {
|
|
472
|
-
return {
|
|
473
|
-
ok: false,
|
|
474
|
-
error: "restore stopped because some current files could not be added to the safety checkpoint",
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
// Index AND work tree move to the source; HEAD stays put. --staged
|
|
478
|
-
// matters: with a work-tree-only restore, a file that exists in the
|
|
479
|
-
// source but not in the index (deleted in a later checkpoint, now
|
|
480
|
-
// resurrected) would be untracked the moment restore recreates it —
|
|
481
|
-
// and the clean below would delete it right back. Restoring the index
|
|
482
|
-
// too makes clean blind to everything the checkpoint owns; what clean
|
|
483
|
-
// then sweeps is exactly the strays the safety commit could not stage
|
|
484
|
-
// (unreadable files, add races) — never ignored/excluded files (no -x).
|
|
485
|
-
await runGit(["restore", "--source", hash, "--staged", "--worktree", "--", "."], worktree, env);
|
|
486
|
-
await runGit(["clean", "-fd"], worktree, env);
|
|
487
|
-
// Retain the pre-restore safety point, not another copy of the state
|
|
488
|
-
// now on disk. Reset only the shadow index so it cannot pin old blobs.
|
|
489
|
-
await runGit(["read-tree", "HEAD"], worktree, env);
|
|
490
|
-
await collectObsolete(worktree, env);
|
|
491
|
-
return { ok: true };
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
catch (e) {
|
|
495
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
496
|
-
disable(botId, message);
|
|
497
|
-
return { ok: false, error: `restore failed: ${message}` };
|
|
498
|
-
}
|
|
499
|
-
}
|
|
@@ -8,7 +8,9 @@ const ROSTER_MAX_BOTS = 40;
|
|
|
8
8
|
const sectionKey = (section) => section?.trim() || "";
|
|
9
9
|
/** Dynamic system context for a section's Chief of Staff.
|
|
10
10
|
* It names the current team on every turn, while list_bots remains the
|
|
11
|
-
* authoritative tool for IDs and live availability at delegation time.
|
|
11
|
+
* authoritative tool for IDs and live availability at delegation time.
|
|
12
|
+
* Who is busy right now is not part of it: that changes turn to turn, so it
|
|
13
|
+
* rides the volatile half (teammateAvailabilityPrompt in peer-roster.ts). */
|
|
12
14
|
export function chiefOfStaffSystemPrompt(chiefId, bots, canDelegate, boundedCoordination = false) {
|
|
13
15
|
const chief = bots.find((bot) => bot.id === chiefId);
|
|
14
16
|
const chiefSection = sectionKey(chief?.section);
|
|
@@ -17,11 +19,10 @@ export function chiefOfStaffSystemPrompt(chiefId, bots, canDelegate, boundedCoor
|
|
|
17
19
|
// the endpoints must agree, or the prompt names teammates the tools will
|
|
18
20
|
// then refuse to reach.
|
|
19
21
|
const team = reachablePeers(bots, chief ?? { id: chiefId, name: "" });
|
|
20
|
-
// `about: true` keeps the blurb the Chief staffs from
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// silently change how a Chief picks a specialist.
|
|
22
|
+
// `about: true` keeps the blurb the Chief staffs from. The ordinary-bot
|
|
23
|
+
// roster drops it (peer-roster.ts); widening the Chief's existing exposure
|
|
24
|
+
// was never in scope, and narrowing it here would silently change how a
|
|
25
|
+
// Chief picks a specialist.
|
|
25
26
|
const roster = renderRoster(team, {
|
|
26
27
|
max: ROSTER_MAX_BOTS,
|
|
27
28
|
empty: "- No other visible bots are available yet.",
|
|
@@ -30,11 +30,13 @@ import { dirname, join, resolve } from "node:path";
|
|
|
30
30
|
import { createInterface } from "node:readline/promises";
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
import qrcode from "qrcode-terminal";
|
|
33
|
+
import { phonePairingLink } from "../shared/pairing-link.js";
|
|
33
34
|
import { parseAllowList } from "./account-signin.js";
|
|
34
35
|
import { appendAdminAction, flushAdminActivity, sharedSignIn } from "./admin-activity.js";
|
|
35
36
|
import { bindDecisionRetention, decisionRetentionDays } from "./decision-log.js";
|
|
36
37
|
import { hostedWorkspaceConfigured } from "./enterprise.js";
|
|
37
38
|
import { resolveLoopbackTrust } from "./request-auth.js";
|
|
39
|
+
import { restartPolicy } from "./restart.js";
|
|
38
40
|
import { writeFileAtomic } from "./atomic.js";
|
|
39
41
|
import { ensureCaddy, normalizeDomainOption, startCaddy } from "./caddy.js";
|
|
40
42
|
import { runServiceCommand } from "./service-cli.js";
|
|
@@ -466,15 +468,6 @@ export function pairingBlock(input) {
|
|
|
466
468
|
}
|
|
467
469
|
return lines.join("\n");
|
|
468
470
|
}
|
|
469
|
-
/** The scheme and host of a link, or null if it is not one we can dial. */
|
|
470
|
-
function originOf(link) {
|
|
471
|
-
try {
|
|
472
|
-
return new URL(link).origin;
|
|
473
|
-
}
|
|
474
|
-
catch {
|
|
475
|
-
return null;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
471
|
export function qrToString(text) {
|
|
479
472
|
let out = "";
|
|
480
473
|
qrcode.generate(text, { small: true }, (rendered) => {
|
|
@@ -494,20 +487,18 @@ async function mintPairing(port, options) {
|
|
|
494
487
|
if (status !== 200)
|
|
495
488
|
throw new Error(`server refused to mint a pairing code: ${typeof body?.error === "string" ? body.error : status}`);
|
|
496
489
|
const url = options.publicUrl ? `${options.publicUrl}/pair#code=${body.code}` : typeof body.url === "string" ? body.url : null;
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
// credential, never on the server's
|
|
503
|
-
// OMB_PUBLIC_URL returns a credential and no invite
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
? `openmausbot://pair?address=${encodeURIComponent(address)}&token=${encodeURIComponent(body.credential)}${typeof body.serverName === "string" ? `&name=${encodeURIComponent(body.serverName)}` : ""}`
|
|
510
|
-
: typeof body.inviteUrl === "string" ? body.inviteUrl : null;
|
|
490
|
+
// The phone-app invite. This command asks over loopback, so the server
|
|
491
|
+
// already built it from its own public address with the same builder; it is
|
|
492
|
+
// printed as it came. `--public-url` wins, exactly as it does for the web
|
|
493
|
+
// link above: a server behind someone else's proxy often does not know its
|
|
494
|
+
// own public name, which is what that flag is for. That case gates on the
|
|
495
|
+
// credential, never on the server's invite — a server started without
|
|
496
|
+
// OMB_PUBLIC_URL returns a credential and no invite. A server too old to
|
|
497
|
+
// mint a credential simply has no invite: the web link still works, so an
|
|
498
|
+
// upgrade is never required to pair a browser.
|
|
499
|
+
const invite = (options.publicUrl && typeof body.credential === "string"
|
|
500
|
+
? phonePairingLink({ address: options.publicUrl, token: body.credential, name: typeof body.serverName === "string" ? body.serverName : undefined })
|
|
501
|
+
: null) ?? (typeof body.inviteUrl === "string" ? body.inviteUrl : null);
|
|
511
502
|
return pairingBlock({ code: body.code, url, inviteUrl: invite, expiresAt: body.expiresAt, hint: typeof body.hint === "string" ? body.hint : null, phone: options.phone });
|
|
512
503
|
}
|
|
513
504
|
// ── commands ───────────────────────────────────────────────────────────
|
|
@@ -853,7 +844,7 @@ async function planTunnel(options, log, recovery) {
|
|
|
853
844
|
}
|
|
854
845
|
}
|
|
855
846
|
else {
|
|
856
|
-
account = createTunnelAccount({ dataDir: options.dataDir, version: serverVersion(), recovery });
|
|
847
|
+
account = createTunnelAccount({ dataDir: options.dataDir, version: serverVersion(), recovery, log: (line) => log(`tunnel: ${line}`) });
|
|
857
848
|
if (account.credentials.status === "unavailable")
|
|
858
849
|
return fail(`${account.credentials.file} exists but could not be read; fix or remove it`);
|
|
859
850
|
if (!describeTunnelAccount(account.credentials.read()).email) {
|
|
@@ -1156,9 +1147,25 @@ export async function runServe(options, log = console.log) {
|
|
|
1156
1147
|
process.removeListener("SIGTERM", onSignal);
|
|
1157
1148
|
}
|
|
1158
1149
|
}
|
|
1150
|
+
/** `serve` until the server stops for good. A server that exits with
|
|
1151
|
+
* RESTART_EXIT_CODE (a copied workspace committed; docs/copy-workspace.md)
|
|
1152
|
+
* is started again in this process, with its tunnel, Tailscale or domain
|
|
1153
|
+
* address unchanged, and without a second pairing code or browser tab; at
|
|
1154
|
+
* most MAX_RESTARTS times in a row (server/restart.ts restartPolicy). */
|
|
1155
|
+
export async function serveUntilStopped(options, run = runServe, now = Date.now) {
|
|
1156
|
+
const policy = restartPolicy(now);
|
|
1157
|
+
let launch = options;
|
|
1158
|
+
for (;;) {
|
|
1159
|
+
const code = await run(launch);
|
|
1160
|
+
if (!policy.again(code))
|
|
1161
|
+
return code;
|
|
1162
|
+
console.log("\nOpenMausBot is starting again to finish installing a copy from the desktop app…");
|
|
1163
|
+
launch = { ...options, pair: false, open: false };
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1159
1166
|
/** Keep setup imports behind the data-dir override: config binds its paths
|
|
1160
1167
|
* when first imported. `serve` remains usable with stdin closed. */
|
|
1161
|
-
export async function runOnboardingCommand(options, io = defaultIo(), startServer =
|
|
1168
|
+
export async function runOnboardingCommand(options, io = defaultIo(), startServer = serveUntilStopped, flow = {}) {
|
|
1162
1169
|
const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
1163
1170
|
if (options.command === "setup" && !interactive) {
|
|
1164
1171
|
io.error("Setup needs an interactive terminal. Run `npx openmausbot setup` in a terminal, then use `npx openmausbot serve` for unattended starts.");
|
|
@@ -1235,7 +1242,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1235
1242
|
case "start":
|
|
1236
1243
|
return runOnboardingCommand(options);
|
|
1237
1244
|
case "serve":
|
|
1238
|
-
return
|
|
1245
|
+
return serveUntilStopped(options);
|
|
1239
1246
|
case "pair":
|
|
1240
1247
|
return runPair(options);
|
|
1241
1248
|
case "sessions":
|