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.
Files changed (140) hide show
  1. package/dist/assets/{DesktopViewer-BwWpc4RK.js → DesktopViewer-DsnA23KM.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-jsQkIqTn.js → abnfDiagram-O67JEVCF-Dkbd2hcj.js} +1 -1
  3. package/dist/assets/{arc-7kei4Hsu.js → arc-Duj5Coy2.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CRWDFDMq.js → architectureDiagram-NJMV4G6O-uYUsDawP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CTKW_IKs.js → blockDiagram-BEXU5L5S-CWDlTpXe.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BBTW7-6M.js → c4Diagram-YGBWAQC7-B0t15PDc.js} +1 -1
  7. package/dist/assets/channel-BZxwC-Gw.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-M_Ox-MkD.js → chunk-2Q5K7J3B-BJSCL-HV.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-D2YiN-Ud.js → chunk-3FUC2YCW-D3_ILccd.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BmVYJ0f8.js → chunk-5VM5RSS4-D3dmwT_S.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-BtiLVVnZ.js → chunk-GTNCS2PH-Br73HFPD.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-DFYSb6J6.js → chunk-GWA4HPMP-uSsjxubC.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-mknKBzgV.js → chunk-JWPE2WC7-C40VWFyr.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DgSYGiOu.js → chunk-NETBCI7D-D6WNNYwU.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-C7ZvuguH.js → chunk-WEXAMYUT-DOEhq64P.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-CPqxINpA.js → chunk-XXDRQBXY-BqJEi6Pi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-Cd6xfJaC.js → classDiagram-v2-NBCMYWYE-ByU_GnxT.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BSXY5qRh.js → cose-bilkent-JH36ORCC-D-Uglxqq.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-D_lhEMj1.js → cynefin-OW5HDTMX-CZWO7aYj.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Da9FrFwk.js → cynefinDiagram-VND7K2PF-C70-w0la.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-T77jMOXe.js → dagre-6A5THRUB-CnhjhExB.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-Dkj96X5E.js → diagram-22UHCM2B-bnjWCNb4.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CMi9zlD_.js → diagram-3UASUU5V-A4uTWoFV.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-CXK6YUfS.js → diagram-ATOU4E4O-Zi6m3NUl.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-BE7B1u9Q.js → diagram-CDSNMT55-DtCcmd5k.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CCMQphKr.js → diagram-MLGK6HIB-DofuETS3.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-iHJ_-tGH.js → diagram-MPIPVDR6-Dw2T9PuU.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-DONsCWQ7.js → ebnfDiagram-ZINNZB2B-DbrwPiem.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CU_WOaXk.js → elk-276RUBZZ-BvCt1dCb.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BeYwM04G.js → erDiagram-OPXOYQCR-Y-QKqPnU.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bdw9DqEl.js → flowDiagram-KWPJA3E3-CDkL1B5w.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-C9iwq3qm.js → ganttDiagram-FUAMR5RP-CKV5D2F6.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dx-VWzUg.js → gitGraphDiagram-X574FWY7-nL9iFTCv.js} +1 -1
  34. package/dist/assets/{index-KygHRE3E.js → index-DreDnjOu.js} +1 -1
  35. package/dist/assets/index-Du7o6Dsf.js +610 -0
  36. package/dist/assets/index-KxG4bWSr.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BG7uaEd4.js → infoDiagram-VRGFBTTK-C7aHjzsf.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-D72WAXnM.js → ishikawaDiagram-OU5B5YK6-KUZsNBTh.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DKH1gjYT.js → journeyDiagram-ZHPQQLJL-CGUuodFl.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-3upm9JUT.js → kanban-definition-PNTS6WVX-Cy6r868Z.js} +1 -1
  41. package/dist/assets/{linear-CcR4pc0-.js → linear-Bl669148.js} +1 -1
  42. package/dist/assets/{mermaid.core-C6yU5FSS.js → mermaid.core-86Xqz5Mr.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DOI2xEuv.js → mindmap-definition-NLK3R4M7-G4DzHLrd.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BVWJSgLd.js → pegDiagram-GJSIUBJH-K4eruzY0.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-jtbYezhq.js → pieDiagram-5QR66LMP-DYdR9PPq.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-g0u8mtPX.js → quadrantDiagram-O4NWA36T-BCAh0nEy.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-k8689Wny.js → railroadDiagram-XR7U4H2S-p0dGRc4g.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DsJ22PDt.js → requirementDiagram-PLB6GJNP-tLeBG0aL.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-CneWBRgG.js → sankeyDiagram-IPEJSGJF-QRuN16rV.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-DWsFCWap.js → sequenceDiagram-PO4LG4MO-ueT1nVoa.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-Bvf0YE3S.js → sizeCapture-INFHLROL-wRS2zWg2.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-Bxb3Iei8.js → stateDiagram-v2-GCMORJYK-CYzA_hE1.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DLkk7W4A.js → swimlanes-2SLR337P-kDnv6LMc.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C7voMDle.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-KR9bIHYC.js → timeline-definition-EJHVYXUP-D1y2UbbH.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-DfsOrj8t.js → usecaseDiagram-POWQR4AR-IqB4v9HX.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-DRSTzG3h.js → vennDiagram-UO4OBE2U-BEi7KYVF.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DtEdhPgT.js → wardleyDiagram-VNRHLVJA-Dkybo-Yf.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-B-XhTxjG.js → xychartDiagram-PMCCYNJV-BAPFJLpg.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +25 -12
  62. package/dist-server/companion/src/listener.js +39 -18
  63. package/dist-server/container-mcp.js +14 -27
  64. package/dist-server/drivers/agents-proxy.js +103 -44
  65. package/dist-server/harness-mcp-proxy.js +1 -2
  66. package/dist-server/index.js +2633 -3396
  67. package/dist-server/openmausbot.js +606 -463
  68. package/dist-server/pair-cli.js +606 -463
  69. package/dist-server/server/admin-activity.js +1 -4
  70. package/dist-server/server/auto-approve.js +19 -4
  71. package/dist-server/server/boat.js +4 -4
  72. package/dist-server/server/checkpoints.js +90 -148
  73. package/dist-server/server/chief-of-staff.js +7 -6
  74. package/dist-server/server/cli.js +33 -26
  75. package/dist-server/server/cloud-home-start.js +12 -15
  76. package/dist-server/server/cloud-home.js +0 -4
  77. package/dist-server/server/cloud-move-http.js +44 -30
  78. package/dist-server/server/cloud-move.js +20 -18
  79. package/dist-server/server/composio.js +10 -3
  80. package/dist-server/server/config.js +92 -64
  81. package/dist-server/server/container-computer.js +16 -5
  82. package/dist-server/server/contracts.js +0 -9
  83. package/dist-server/server/decider/jev.js +1 -4
  84. package/dist-server/server/decider/log.js +1 -3
  85. package/dist-server/server/decision-log.js +1 -4
  86. package/dist-server/server/delegations.js +3 -2
  87. package/dist-server/server/drivers/acp/core.js +4 -3
  88. package/dist-server/server/drivers/acp/opencode-go.js +17 -16
  89. package/dist-server/server/drivers/agents-call.js +46 -63
  90. package/dist-server/server/drivers/agents-catalog.js +66 -0
  91. package/dist-server/server/drivers/agents-proxy.js +2 -1
  92. package/dist-server/server/drivers/claude-login-auth.js +2 -11
  93. package/dist-server/server/drivers/codex-device-auth.js +3 -1
  94. package/dist-server/server/drivers/codex.js +5 -4
  95. package/dist-server/server/drivers/grok.js +1 -1
  96. package/dist-server/server/drivers/openai-chat-protocol.js +4 -2
  97. package/dist-server/server/drivers/openai-chat.js +15 -5
  98. package/dist-server/server/drivers/prompt-split.js +4 -4
  99. package/dist-server/server/harness/bus.js +49 -0
  100. package/dist-server/server/harness-mcp-proxy.js +1 -1
  101. package/dist-server/server/index.js +448 -762
  102. package/dist-server/server/mcp-registry.js +35 -0
  103. package/dist-server/server/message-file.js +4 -4
  104. package/dist-server/server/peer-delivery.js +4 -19
  105. package/dist-server/server/peer-roster.js +30 -4
  106. package/dist-server/server/profile-versions.js +0 -31
  107. package/dist-server/server/provider-key-check.js +2 -1
  108. package/dist-server/server/restart.js +40 -0
  109. package/dist-server/server/room-handoffs.js +12 -0
  110. package/dist-server/server/routes/bot-memory.js +0 -24
  111. package/dist-server/server/routine-requests.js +1 -4
  112. package/dist-server/server/routines.js +5 -4
  113. package/dist-server/server/server-launcher.js +53 -0
  114. package/dist-server/server/store.js +1 -1
  115. package/dist-server/server/surface.js +23 -27
  116. package/dist-server/server/system-prompt.js +13 -3
  117. package/dist-server/server/team-manifest.js +1 -6
  118. package/dist-server/server/tunnel.js +4 -1
  119. package/dist-server/server/turn-images.js +1 -21
  120. package/dist-server/server/turn-resources.js +19 -150
  121. package/dist-server/server/usage-ledger.js +3 -1
  122. package/dist-server/server/workspace.js +2 -3
  123. package/dist-server/server-launcher.js +72 -0
  124. package/dist-server/shared/ask-question.js +1 -1
  125. package/dist-server/shared/cloud-computer.js +12 -0
  126. package/dist-server/shared/failed-turn.js +25 -0
  127. package/dist-server/shared/live-approval.js +2 -2
  128. package/dist-server/shared/package-format.js +3 -3
  129. package/dist-server/shared/pairing-link.js +144 -0
  130. package/dist-server/vps-container-mcp.js +14 -27
  131. package/dist-server/workspace-backup.worker.js +14 -27
  132. package/package.json +1 -1
  133. package/dist/assets/channel-DS2NbT5x.js +0 -1
  134. package/dist/assets/index-NF7XCHAy.css +0 -1
  135. package/dist/assets/index-X_5ydXIE.js +0 -610
  136. package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +0 -8
  137. package/dist-server/server/claim-idle.js +0 -33
  138. package/dist-server/server/cloud-overflow.js +0 -167
  139. package/dist-server/server/tightening-requests.js +0 -435
  140. 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 only
7
- // grants the app applies are Full access and the person's exact saved commands.
8
- // Questions never come through here: a bot's question always reaches a human.
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.replace(/^mcp__[^_]+__/, "").toLowerCase())) {
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, canMount, state, }) {
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
- direct = await boatJson(cfg, `/boxes/${cachedId}`);
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
- // Restore is redo-friendly on purpose: it commits a safety point, then moves
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 and restore shape follow Gemini CLI's gitService, the sanitized
24
- // GIT_* env list and the exclude categories follow Roo-Code's checkpoint
25
- // service, and the snapshot-before-every-turn cadence follows Cline.
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. The API hands out full hashes; accepting anything
34
- * looser would let arbitrary revspecs ("HEAD~3", "main@{u}") reach git. */
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. Restores must never delete
37
- // these either: `git clean -fd` (without -x) leaves ignored files alone, so
38
- // everything listed here survives a rollback untouched. Categories follow
39
- // Roo-Code's checkpoint excludes: VCS internals, dependency trees, build
40
- // output, caches, logs, secrets, media, archives, databases, model weights.
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/restore, after
124
- // their source objects are no longer needed.
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, as turn-resources.ts does for workspace claims.
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/restore resolves relative to it. A hung git (index
207
- * lock, dead network filesystem) would otherwise jam the per-repo queue for
208
- * the whole session, so every call carries a hard timeout. */
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 restores against the
238
- // same folder queue behind each other (git's index lock would fail the loser
239
- // anyway — this turns a crash into a wait). The stored tail never rejects, so
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 — it is filtered from listings and refused as a restore
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. A partial add must never replace
296
- * the last usable checkpoint or authorize removal of its objects. */
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 { hash: previous, complete: false };
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 { hash: previous, complete: true };
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 { hash, complete: true };
337
+ return hash;
320
338
  }
321
339
  async function collectObsolete(cwd, env) {
322
- // No caller may still need the previous tree. In particular, restore runs
323
- // this only AFTER using its source; the safety checkpoint stays reachable.
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 restore point.
330
- console.warn("checkpoint cleanup deferred; the latest restore point is preserved");
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, or the folder is refused.
335
- * Never throws — this is called fire-and-forget on the turn path. */
336
- export async function snapshot(botId, cwd, label, signal) {
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 result = await commitAll(worktree, env, label, signal);
352
- if (!result.complete)
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 result.hash;
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
- /** Diff the settled workspace without replacing its pre-turn restore point.
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 — and keeps this
21
- // prompt byte-identical to what Chiefs have always been given. The
22
- // ordinary-bot roster drops it (peer-roster.ts); widening the Chief's
23
- // existing exposure was never in scope, and narrowing it here would
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
- // A server too old to mint a credential simply has no invite: the web link
498
- // still works, so an upgrade is never required to pair a browser.
499
- // The address the phone will dial. `--public-url` wins, exactly as it does
500
- // for the web link above: a server behind someone else's proxy often does
501
- // not know its own public name, which is what that flag is for. Gate on the
502
- // credential, never on the server's own invite — a server started without
503
- // OMB_PUBLIC_URL returns a credential and no invite, and gating on the
504
- // invite would throw away a secret the CLI has every part it needs to use.
505
- const address = options.publicUrl ?? (typeof body.url === "string" ? originOf(body.url) : null);
506
- // A server too old to mint a credential simply has no invite: the web link
507
- // still works, so an upgrade is never required to pair a browser.
508
- const invite = typeof body.credential === "string" && address
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 = runServe, flow = {}) {
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 runServe(options);
1245
+ return serveUntilStopped(options);
1239
1246
  case "pair":
1240
1247
  return runPair(options);
1241
1248
  case "sessions":