openmausbot 0.1.87 → 0.1.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-BTiuL79F.js → abnfDiagram-O67JEVCF-BwaMaZEm.js} +1 -1
  3. package/dist/assets/{arc-C88Blc2v.js → arc-Be-8GKxd.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-BZsJIZrb.js → architectureDiagram-NJMV4G6O-Dp_U2Kv1.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CVeMe9Ah.js → blockDiagram-BEXU5L5S-DQjUjYUk.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-Cedk3Bhx.js → c4Diagram-YGBWAQC7-Bd3aE4Kc.js} +1 -1
  7. package/dist/assets/channel-DqY_gMLg.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-D-VRqzTB.js → chunk-2Q5K7J3B-BijjTBeJ.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BChov01Y.js → chunk-3FUC2YCW-CMGdwvzW.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-C-TfwvHy.js → chunk-5VM5RSS4-0G09BfsZ.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Db7CGj5Y.js → chunk-GTNCS2PH-FOp3vSHe.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CNvIfqKT.js → chunk-GWA4HPMP-Dv-20VIn.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-C_bEp5tq.js → chunk-JWPE2WC7-CRgrdFcz.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-Coi4BOls.js → chunk-NETBCI7D-3hP4f7QB.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CzylEuL3.js → chunk-WEXAMYUT-CG98IjlG.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-m7p7ztaD.js → chunk-XXDRQBXY-BrSTC9xG.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BMy6o9x2.js → classDiagram-v2-NBCMYWYE-rpQQQDFX.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BDQ__0_J.js → cose-bilkent-JH36ORCC-G6RSafHA.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-Djv4ghUb.js → cynefin-OW5HDTMX-CXjv-IM2.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BULrlGYH.js → cynefinDiagram-VND7K2PF-Dw07MR9f.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-D-jrT_hU.js → dagre-6A5THRUB-BwPnlTg2.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-p2UhQmfL.js → diagram-22UHCM2B-Cm6xcMiz.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-DHvGWZ7q.js → diagram-3UASUU5V-CDpogPbE.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-ChhKDot-.js → diagram-ATOU4E4O-BV8z3Pnc.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-B-T7A43R.js → diagram-CDSNMT55-CKCQhOOC.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CJm8ENNY.js → diagram-MLGK6HIB-FGvJg69C.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-510tlza8.js → diagram-MPIPVDR6-CwATvgnS.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-PkvRPHel.js → ebnfDiagram-ZINNZB2B-CH_c3SSC.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BVBVPoX3.js → elk-276RUBZZ-3guZi71R.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BgYMVm7L.js → erDiagram-OPXOYQCR-BMHphR2n.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-BGqsnc_3.js → flowDiagram-KWPJA3E3-Vm5DkW5x.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-D_-UeReH.js → ganttDiagram-FUAMR5RP-DO-1ct07.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-D0uooONx.js → gitGraphDiagram-X574FWY7-nxeksEIN.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-BVPdbq2r.js +583 -0
  36. package/dist/assets/{index-zRsJERGJ.js → index-Oxk9x45i.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B9e1z77f.js → infoDiagram-VRGFBTTK-Dkcvr0p7.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BB4vNwvD.js → ishikawaDiagram-OU5B5YK6-BwVibkoO.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DpkosOTB.js → journeyDiagram-ZHPQQLJL-ByiBZqgG.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DbDP4kUu.js → kanban-definition-PNTS6WVX-qqjgXBF3.js} +1 -1
  41. package/dist/assets/{linear-DBJGPmjT.js → linear-CtJnKe6A.js} +1 -1
  42. package/dist/assets/{mermaid.core-DWPRi-ue.js → mermaid.core-BEiDklrY.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-nszBbJmx.js → mindmap-definition-NLK3R4M7-DfIYHia9.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CWykJWu_.js → pegDiagram-GJSIUBJH-CYyRqa1a.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-g3Da-Vnv.js → pieDiagram-5QR66LMP-hOToy_zH.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Wzrlpzgh.js → quadrantDiagram-O4NWA36T-DKAsNN9I.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-moHTFMC0.js → railroadDiagram-XR7U4H2S-BSA-d8JU.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CwfXdFH8.js → requirementDiagram-PLB6GJNP-CSmlvb-p.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-3ZROYvnq.js → sankeyDiagram-IPEJSGJF-CD5F6fnG.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-E1TBebP8.js → sequenceDiagram-PO4LG4MO-JUq5sx6d.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-DHKEU5pu.js → sizeCapture-INFHLROL-BjIS-Stk.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BguEj09D.js → stateDiagram-v2-GCMORJYK-DklkUxOg.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-C7IDYnhd.js → swimlanes-2SLR337P-CsjIU6be.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-Bk14M0_B.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-DCW-F5PW.js → timeline-definition-EJHVYXUP-BkFqK7AB.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-xBMFtWYV.js → usecaseDiagram-POWQR4AR-70gJB1EU.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-B5nIkuH8.js → vennDiagram-UO4OBE2U-83Tm62dN.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DYFAhdbr.js → wardleyDiagram-VNRHLVJA-tTmseoX9.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DeXwX6Cz.js → xychartDiagram-PMCCYNJV-R1LBA55a.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/companion/src/routes.js +8 -1
  62. package/dist-server/connector-proxy.js +105 -1
  63. package/dist-server/container-mcp.js +11 -1
  64. package/dist-server/drivers/agents-proxy.js +277 -22
  65. package/dist-server/index.js +23722 -20272
  66. package/dist-server/local-computer-proxy.js +2 -0
  67. package/dist-server/mcp-gate.js +2 -0
  68. package/dist-server/mcp-server.js +5 -3
  69. package/dist-server/message-search.worker.js +52 -0
  70. package/dist-server/openmausbot.js +755 -404
  71. package/dist-server/pair-cli.js +755 -404
  72. package/dist-server/server/admission.js +81 -0
  73. package/dist-server/server/aside-queue.js +294 -0
  74. package/dist-server/server/attachments.js +43 -1
  75. package/dist-server/server/automatic-recovery.js +15 -0
  76. package/dist-server/server/bot-attachment.js +107 -0
  77. package/dist-server/server/bot-overview.js +19 -0
  78. package/dist-server/server/composio.js +297 -1
  79. package/dist-server/server/config.js +34 -3
  80. package/dist-server/server/connector-advertisement.js +165 -0
  81. package/dist-server/server/connector-proxy.js +14 -2
  82. package/dist-server/server/connector-verdict.js +31 -4
  83. package/dist-server/server/container-computer.js +70 -0
  84. package/dist-server/server/contracts.js +32 -0
  85. package/dist-server/server/drivers/acp/core.js +148 -23
  86. package/dist-server/server/drivers/acp/cursor.js +3 -7
  87. package/dist-server/server/drivers/acp/droid.js +4 -7
  88. package/dist-server/server/drivers/acp/gemini.js +4 -4
  89. package/dist-server/server/drivers/acp/grok.js +4 -9
  90. package/dist-server/server/drivers/acp/hermes.js +3 -5
  91. package/dist-server/server/drivers/acp/opencode-go.js +2 -5
  92. package/dist-server/server/drivers/acp/qwen.js +12 -15
  93. package/dist-server/server/drivers/agents-call.js +137 -10
  94. package/dist-server/server/drivers/agents-catalog.js +100 -6
  95. package/dist-server/server/drivers/boxagent.js +3 -17
  96. package/dist-server/server/drivers/claude.js +31 -17
  97. package/dist-server/server/drivers/codex-catalog.js +14 -6
  98. package/dist-server/server/drivers/codex-identity.js +2 -1
  99. package/dist-server/server/drivers/codex-release.js +48 -0
  100. package/dist-server/server/drivers/codex.js +7 -25
  101. package/dist-server/server/drivers/openai-chat.js +31 -3
  102. package/dist-server/server/drivers/pi.js +237 -30
  103. package/dist-server/server/drivers/prompt-split.js +32 -9
  104. package/dist-server/server/drivers/session-idle.js +29 -0
  105. package/dist-server/server/env-path.js +15 -0
  106. package/dist-server/server/guarded-requests.js +2 -2
  107. package/dist-server/server/harness-capabilities.js +61 -0
  108. package/dist-server/server/index.js +1173 -262
  109. package/dist-server/server/message-db.js +22 -42
  110. package/dist-server/server/message-file.js +9 -1
  111. package/dist-server/server/message-search-query.js +43 -0
  112. package/dist-server/server/message-search-worker.js +80 -0
  113. package/dist-server/server/message-search.worker.js +21 -0
  114. package/dist-server/server/model-requests.js +248 -0
  115. package/dist-server/server/options-card.js +21 -0
  116. package/dist-server/server/org-library.js +15 -1
  117. package/dist-server/server/peer-delivery.js +60 -0
  118. package/dist-server/server/peer-roster.js +57 -0
  119. package/dist-server/server/presets.js +32 -32
  120. package/dist-server/server/profile-requests.js +66 -39
  121. package/dist-server/server/profile-revision.js +9 -1
  122. package/dist-server/server/profile-versions.js +6 -3
  123. package/dist-server/server/routine-requests.js +29 -7
  124. package/dist-server/server/skill-fetch.js +72 -14
  125. package/dist-server/server/skills.js +1 -0
  126. package/dist-server/server/store.js +32 -2
  127. package/dist-server/server/system-prompt.js +37 -2
  128. package/dist-server/server/team-setup-requests.js +124 -23
  129. package/dist-server/server/thread-events.js +3 -1
  130. package/dist-server/server/tightening-requests.js +405 -0
  131. package/dist-server/server/tts/chatterbox.js +27 -5
  132. package/dist-server/server/usage-ledger.js +7 -1
  133. package/dist-server/server/webhooks.js +21 -3
  134. package/dist-server/server/workspace-backup.js +40 -3
  135. package/dist-server/server/workspace-backup.worker.js +10 -0
  136. package/dist-server/shared/ask-question.js +19 -0
  137. package/dist-server/shared/credential-request.js +5 -1
  138. package/dist-server/shared/line-diff.js +23 -0
  139. package/dist-server/shared/model-request.js +1 -0
  140. package/dist-server/shared/options-card.js +51 -0
  141. package/dist-server/shared/profile-request.js +5 -4
  142. package/dist-server/shared/tightening-request.js +87 -0
  143. package/dist-server/src/lib/composer-attachments.js +42 -3
  144. package/dist-server/vps-container-mcp.js +11 -1
  145. package/dist-server/workspace-backup.worker.js +19482 -0
  146. package/package.json +1 -1
  147. package/dist/assets/channel-taCNRlUU.js +0 -1
  148. package/dist/assets/index-BwdMAQ6p.css +0 -1
  149. package/dist/assets/index-ByJennr7.js +0 -581
  150. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +0 -8
@@ -33,6 +33,22 @@ export function serviceSlugFor(tool) {
33
33
  return null;
34
34
  return tool.slice(0, underscore).toLowerCase();
35
35
  }
36
+ /** The service a Composio tool name belongs to when the caller knows the
37
+ * real service slugs. Slugs may contain underscores (bland_ai), which the
38
+ * plain first-segment split above cannot see — BLAND_AI_MAKE_CALL splits
39
+ * as "bland". When a known candidate matches the name's prefix, the
40
+ * longest candidate wins (bland_ai over bland), so an underscored service
41
+ * keeps its own tools; with no matching candidate the caller falls back
42
+ * to serviceSlugFor. */
43
+ export function serviceSlugForCandidates(tool, candidates) {
44
+ let match = null;
45
+ for (const candidate of candidates) {
46
+ if (tool.startsWith(candidate.toUpperCase() + "_") && (match === null || candidate.length > match.length)) {
47
+ match = candidate;
48
+ }
49
+ }
50
+ return match;
51
+ }
36
52
  /** Classify one relayed JSON-RPC frame. Anything that is not a tools/call,
37
53
  * or is a discovery/connection/platform meta-tool, passes through. */
38
54
  export function connectorCallFromFrame(payload) {
@@ -99,16 +115,27 @@ function multiExecuteCall(invoked, args) {
99
115
  }
100
116
  /** Judge distinct target names against a bot's grants. grants undefined
101
117
  * is the legacy all-tools bot and passes everything; an explicit record —
102
- * including the empty one — allows only what it names. */
103
- export function evaluateConnectorTools(names, grants) {
118
+ * including the empty one — allows only what it names. Service keys are
119
+ * read as own properties only: a name like CONSTRUCTOR_X maps to the
120
+ * "constructor" service, and the inherited property must never pose as
121
+ * a grant.
122
+ * candidates are the caller's connected-service slugs: the real backend
123
+ * slugs, so an underscored service (bland_ai) keeps its own tools instead
124
+ * of a plain-prefix grant (bland) capturing them. An empty list means the
125
+ * connected-service catalog was unreachable, and the plain first-segment
126
+ * split stands as the fallback — call-time enforcement degrades open, the
127
+ * same way advertisement filtering does. */
128
+ export function evaluateConnectorTools(names, grants, candidates = []) {
104
129
  if (grants === undefined) {
105
130
  return { allowed: true, legacy: true, denials: [], rule: "composio" };
106
131
  }
107
132
  const denials = [];
108
133
  let rule = "";
109
134
  for (const tool of new Set(names)) {
110
- const service = serviceSlugFor(tool);
111
- const grant = service === null ? undefined : grants[service];
135
+ const service = serviceSlugForCandidates(tool, candidates) ?? serviceSlugFor(tool);
136
+ const grant = service !== null && Object.hasOwn(grants, service)
137
+ ? grants[service]
138
+ : undefined;
112
139
  if (grant && (grant.tools === "*" || grant.tools.includes(tool))) {
113
140
  if (!rule)
114
141
  rule = "connectorTools." + service;
@@ -7,6 +7,7 @@
7
7
  // typing, screenshots, accessibility, or window discovery.
8
8
  import { execFile } from "node:child_process";
9
9
  import { createHash, randomBytes } from "node:crypto";
10
+ import { lstatSync } from "node:fs";
10
11
  import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
11
12
  import { tmpdir } from "node:os";
12
13
  import { join, resolve } from "node:path";
@@ -68,6 +69,15 @@ export function perBotLocalVmTarget(botId) {
68
69
  label: digest,
69
70
  };
70
71
  }
72
+ /** Only provisioning creates this durable directory; idle removal keeps it. */
73
+ export function localVmWorkspaceExists(target) {
74
+ try {
75
+ return lstatSync(target.workspaceDir, { throwIfNoEntry: false })?.isDirectory() === true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
71
81
  const LINUX_WHEELS = {
72
82
  x86_64: {
73
83
  url: "https://files.pythonhosted.org/packages/fa/d7/a43008a328a40c85e7bc706fc20235b9abedc75e28b413817655153157ff/cua_driver-0.20.0-py3-none-manylinux_2_31_x86_64.whl",
@@ -808,6 +818,66 @@ export async function containerComputerScreenshot(runner = sh, platform = proces
808
818
  }
809
819
  const screenshotStatusCache = new Map();
810
820
  const containerMcpPath = SPAWNED_PROXIES.containerMcp;
821
+ const EXEC_DEFAULT_SECONDS = 60;
822
+ const EXEC_MAX_SECONDS = 300;
823
+ const EXEC_OUTPUT_LIMIT = 20_000;
824
+ /** Keep the start and the end: a failure message is nearly always at the end. */
825
+ export function clipExecOutput(text, limit = EXEC_OUTPUT_LIMIT) {
826
+ if (text.length <= limit)
827
+ return text;
828
+ const head = Math.floor(limit / 5);
829
+ return `${text.slice(0, head)}\n… ${text.length - limit} characters omitted …\n${text.slice(text.length - (limit - head))}`;
830
+ }
831
+ const defaultExecRunner = async (command, args, options) => {
832
+ const resolved = resolveCliSpawn(command, args);
833
+ try {
834
+ const { stdout, stderr } = await run(resolved.command, resolved.args, {
835
+ timeout: options.timeout,
836
+ encoding: "utf8",
837
+ maxBuffer: 64 * 1024 * 1024,
838
+ env: { ...process.env, PATH: augmentedPath() },
839
+ });
840
+ return { stdout, stderr, code: 0 };
841
+ }
842
+ catch (error) {
843
+ const failure = error;
844
+ // A non-zero exit still carries the output; anything else (runtime missing,
845
+ // client killed) is a real failure to run at all.
846
+ if (typeof failure.code === "number") {
847
+ return { stdout: failure.stdout ?? "", stderr: failure.stderr ?? "", code: failure.code };
848
+ }
849
+ throw Object.assign(new Error(failure.killed ? "the command client timed out" : failure.message ?? "could not run the command"), { status: 502 });
850
+ }
851
+ };
852
+ /** Run one shell command inside a Local VM as the desktop user, in its durable
853
+ * workspace, and return the exit code and text output. Bots use this instead of
854
+ * typing into a terminal window and reading screenshots. The limit is enforced
855
+ * inside the container so a runaway process is really stopped. */
856
+ export async function containerExec(target, command, options = {}) {
857
+ if (!command.trim())
858
+ throw Object.assign(new Error("command is required"), { status: 400 });
859
+ if (command.length > 20_000)
860
+ throw Object.assign(new Error("command is too long"), { status: 400 });
861
+ if (options.timeoutSeconds !== undefined && !Number.isFinite(options.timeoutSeconds)) {
862
+ throw Object.assign(new Error("timeout_seconds must be finite"), { status: 400 });
863
+ }
864
+ const runtime = options.runtime ?? (await containerRuntimeStatus()).runtime;
865
+ if (!runtime)
866
+ throw Object.assign(new Error("No container runtime is available for the Local VM"), { status: 409 });
867
+ const seconds = Math.min(Math.max(Math.floor(options.timeoutSeconds ?? EXEC_DEFAULT_SECONDS), 1), EXEC_MAX_SECONDS);
868
+ const result = await (options.exec ?? defaultExecRunner)(runtime, [
869
+ "exec",
870
+ "-u", "cua",
871
+ "-w", VM_WORKSPACE_GUEST,
872
+ "-e", "HOME=/home/cua",
873
+ "-e", `DISPLAY=${DISPLAY}`,
874
+ target.containerName,
875
+ "timeout", "-k", "5", String(seconds),
876
+ "sh", "-lc", command,
877
+ ], { timeout: (seconds + 20) * 1000 });
878
+ const timedOut = result.code === 124 || result.code === 137;
879
+ return { exitCode: result.code, stdout: clipExecOutput(result.stdout), stderr: clipExecOutput(result.stderr), timedOut };
880
+ }
811
881
  export function containerComputerMcp(runtime, control, target = SHARED_LOCAL_VM_TARGET) {
812
882
  return {
813
883
  command: process.execPath,
@@ -12,11 +12,43 @@ export class ProviderError extends Error {
12
12
  this.code = code;
13
13
  }
14
14
  }
15
+ /** The driver proved this attempt never reached a prompt or client-side action,
16
+ * and its owned process has stopped. The harness may safely recover it. */
17
+ export class TurnNotStartedError extends Error {
18
+ turnId;
19
+ constructor(turnId, message) {
20
+ super(message);
21
+ this.name = "TurnNotStartedError";
22
+ this.turnId = turnId;
23
+ }
24
+ }
15
25
  /** Variants are opaque provider IDs, not the cross-engine effort enum. */
16
26
  export function isModelVariant(value) {
17
27
  return typeof value === "string" && value.length > 0 && value.length <= 256 &&
18
28
  value.trim() === value && !/\p{Cc}/u.test(value);
19
29
  }
30
+ /** The picker label for a model id when the catalog row carries no display
31
+ * name: split on the word breaks the catalog treats as separators and
32
+ * capitalize each part ("gpt-5.4-mini" → "Gpt 5.4 Mini"). Each harness
33
+ * passes its own break class so existing labels stay byte-for-byte. */
34
+ export function titleCaseModelId(id, wordBreaks) {
35
+ return id
36
+ .split(wordBreaks)
37
+ .filter(Boolean)
38
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
39
+ .join(" ");
40
+ }
41
+ /** A picker label that carries a second facet — a display name, a provider
42
+ * host — beside the model id, unless the facet is already what the id says.
43
+ * `redundantWhen` decides what counts as "already said" and `decorate` how
44
+ * the facet is appended, so every harness's labels stay exactly what they
45
+ * were while the shape is written once. */
46
+ export function qualifiedModelLabel(id, qualifier, options = {}) {
47
+ const { redundantWhen = () => false, decorate = (facet) => ` — ${facet}` } = options;
48
+ if (!qualifier || redundantWhen(id, qualifier))
49
+ return id;
50
+ return `${id}${decorate(qualifier)}`;
51
+ }
20
52
  let eventCounter = 0;
21
53
  export const newEventId = () => `ev-${Date.now().toString(36)}-${(eventCounter++).toString(36)}`;
22
54
  export const newId = () => crypto.randomUUID();
@@ -40,7 +40,7 @@ import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../proc
40
40
  export function skipSubscriptionAuthForLocalInject(model) {
41
41
  return Boolean(decodeInjectId(model));
42
42
  }
43
- import { newEventId, newId } from "../../contracts.js";
43
+ import { newEventId, newId, TurnNotStartedError } from "../../contracts.js";
44
44
  import { augmentedPath } from "../../env-path.js";
45
45
  import { supportsApprovalMode } from "../../../shared/approval-mode.js";
46
46
  import { parseAskQuestions, parseChoices, questionAnswersByQuestion } from "../../../shared/ask-question.js";
@@ -50,6 +50,8 @@ import { commandSummary, toolDetailPreview } from "../../tool-summary.js";
50
50
  import { extractMcpImages } from "../../mcp-tool-images.js";
51
51
  import { redactSecretsInText } from "../../redact.js";
52
52
  import { recoveryPromptFor } from "../../resume-recovery.js";
53
+ import { sessionIdlePolicy } from "../session-idle.js";
54
+ import { classifyError } from "../retry.js";
53
55
  /** ACP vendors put the actionable cause in error.data while keeping the
54
56
  * JSON-RPC message generic. Only surface known text fields, never a response
55
57
  * body/config dump, and redact before bounding the displayed diagnostic. */
@@ -80,6 +82,9 @@ const INIT_TIMEOUT = envOr("OPENMAUS_ACP_INIT_TIMEOUT_MS", 300_000);
80
82
  const SESSION_CONFIG_TIMEOUT = envOr("OPENMAUS_ACP_SESSION_CONFIG_TIMEOUT_MS", 300_000); // configureSession's per-request default
81
83
  const NEW_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_NEW_SESSION_TIMEOUT_MS", 300_000);
82
84
  const LOAD_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_LOAD_SESSION_TIMEOUT_MS", 120_000); // history replay on a long thread is slow
85
+ /** ACP agents may compact their own history without telling the client;
86
+ * re-send the full prompt after this many bare turns as a backstop. */
87
+ const ACP_PROMPT_RE_ANCHOR_TURNS = 8;
83
88
  // Read lazily (not at import) so a test can shorten the window. Unlike the
84
89
  // setup calls above, session/prompt legitimately streams for minutes, so a
85
90
  // wall-clock deadline would false-positive: this guard only trips when the
@@ -94,6 +99,20 @@ const promptIdleTimeoutMs = () => {
94
99
  const ms = Number(raw);
95
100
  return Number.isFinite(ms) && ms > 0 ? ms : 0;
96
101
  };
102
+ /** Keep a turn's set of running tool calls in step with the agent's
103
+ * `tool_call` / `tool_call_update` notifications: a call is running from
104
+ * the first update that is not terminal until one that is. A `tool_call`
105
+ * without a status is pending (ACP's default); a `tool_call_update` without
106
+ * one leaves the call as it was. */
107
+ function trackRunningTool(current, update, defaultStatus) {
108
+ if (typeof update.toolCallId !== "string" || !update.toolCallId)
109
+ return;
110
+ const status = update.status ?? defaultStatus;
111
+ if (status === "completed" || status === "failed")
112
+ current.runningTools.delete(update.toolCallId);
113
+ else if (status === "pending" || status === "in_progress")
114
+ current.runningTools.add(update.toolCallId);
115
+ }
97
116
  const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024;
98
117
  function acpVariantOption(result) {
99
118
  const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.type === "select" && typeof entry.id === "string"
@@ -212,6 +231,24 @@ export function versionFromProbe(stdout, stderr) {
212
231
  const err = (stderr ?? "").trim().split(/\r\n|\n|\r/, 1)[0]?.trim() ?? "";
213
232
  return err || null;
214
233
  }
234
+ /** The first dotted numeric triple in a version banner, or null when there is
235
+ * none. A wrapper that prints its own banner first still parses: the triple
236
+ * is the version wherever it appears. */
237
+ export function parseVersionTriple(value) {
238
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(value);
239
+ if (!match)
240
+ return null;
241
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
242
+ }
243
+ /** Whether `installed` compares at or above `floor`, componentwise. Every
244
+ * version floor in the tree is a dotted triple. */
245
+ export function versionAtLeast(installed, floor) {
246
+ for (let i = 0; i < 3; i += 1) {
247
+ if (installed[i] !== floor[i])
248
+ return installed[i] > floor[i];
249
+ }
250
+ return true;
251
+ }
215
252
  export function createAcpDriver(support) {
216
253
  const DRIVER_KIND = support.driverKind;
217
254
  const SOURCE = support.nativeSource;
@@ -274,11 +311,24 @@ export function createAcpDriver(support) {
274
311
  // contract prompts the live session instead of paying the handshake
275
312
  // again. An idle session closes after SESSION_IDLE_MS of quiet.
276
313
  const sessions = new Map();
277
- const configuredIdleMinimum = Number(process.env.OMB_ACP_SESSION_IDLE_MIN_MS);
278
- const sessionIdleMinimum = Number.isFinite(configuredIdleMinimum) && configuredIdleMinimum > 0
279
- ? configuredIdleMinimum
280
- : 10_000;
281
- const SESSION_IDLE_MS = Math.max(sessionIdleMinimum, Number(process.env.OMB_ACP_SESSION_IDLE_MS) || 10 * 60_000);
314
+ // Closing removes a session from the pool, not its native writer lease.
315
+ // Retain every closing Qwen child until its process tree is gone, even
316
+ // when Stop or a failed turn lets another turn start during cleanup.
317
+ const retiring = new Map();
318
+ const retireChild = async (threadId, child) => {
319
+ let children = retiring.get(threadId);
320
+ if (!children)
321
+ retiring.set(threadId, children = new Set());
322
+ children.add(child);
323
+ const stopped = await killCliTree(child);
324
+ if (stopped) {
325
+ children.delete(child);
326
+ if (!children.size && retiring.get(threadId) === children)
327
+ retiring.delete(threadId);
328
+ }
329
+ return stopped;
330
+ };
331
+ const { idleMs: SESSION_IDLE_MS } = sessionIdlePolicy("ACP");
282
332
  const closeSession = (threadId, why) => {
283
333
  const session = sessions.get(threadId);
284
334
  if (!session || session.closing)
@@ -290,16 +340,20 @@ export function createAcpDriver(support) {
290
340
  // a new turn must never adopt a closing session
291
341
  sessions.delete(threadId);
292
342
  session.acp.close();
293
- // stdin EOF asks the agent to exit; EOF is not a guaranteed exit
294
- // signal for ACP agents, so insist after a grace period
343
+ // EOF is not a guaranteed exit signal. Qwen must be tracked before
344
+ // another turn can resume its history; other agents keep their grace.
295
345
  try {
296
346
  session.child.stdin.end();
297
347
  }
298
348
  catch { }
299
- const kill = setTimeout(() => {
300
- void killCliTree(session.child);
301
- }, 5_000);
302
- kill.unref?.();
349
+ if (support.restartOnMcpChange)
350
+ void retireChild(threadId, session.child);
351
+ else {
352
+ const kill = setTimeout(() => {
353
+ void killCliTree(session.child);
354
+ }, 5_000);
355
+ kill.unref?.();
356
+ }
303
357
  };
304
358
  const armIdle = (threadId) => {
305
359
  const session = sessions.get(threadId);
@@ -418,6 +472,7 @@ export function createAcpDriver(support) {
418
472
  if (!current || current.state.settled)
419
473
  return;
420
474
  current.state.settled = true;
475
+ current.acknowledge();
421
476
  if (current.interruptTimer)
422
477
  clearTimeout(current.interruptTimer);
423
478
  for (const finish of current.asks.values())
@@ -503,8 +558,9 @@ export function createAcpDriver(support) {
503
558
  if (idleTimer)
504
559
  clearTimeout(idleTimer);
505
560
  idleTimer = setTimeout(() => {
506
- // Waiting for a person is not an unresponsive agent.
507
- if (session.current?.asks.size) {
561
+ // Waiting for a person, or for a tool the agent is running,
562
+ // is not an unresponsive agent.
563
+ if (session.current?.asks.size || session.current?.runningTools.size) {
508
564
  armIdle();
509
565
  return;
510
566
  }
@@ -621,6 +677,7 @@ export function createAcpDriver(support) {
621
677
  // server→client permission request → canonical request.opened,
622
678
  // answered fail-closed for the running turn
623
679
  const handleServerRequest = (msg, current) => {
680
+ current.state.startupActivity = true;
624
681
  if (msg.method === "fs/read_text_file" || msg.method === "fs/write_text_file") {
625
682
  void handleClientFileRequest(msg);
626
683
  return;
@@ -760,6 +817,9 @@ export function createAcpDriver(support) {
760
817
  current.receiveModelVariants(p.update);
761
818
  return;
762
819
  }
820
+ if (current && ["agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update"].includes(p.update?.sessionUpdate)) {
821
+ current.state.startupActivity = true;
822
+ }
763
823
  if (!current || !current.state.promptSent)
764
824
  return;
765
825
  const u = p.update ?? {};
@@ -793,6 +853,7 @@ export function createAcpDriver(support) {
793
853
  }
794
854
  case "tool_call": {
795
855
  current.flushAssistantText();
856
+ trackRunningTool(current, u, "pending");
796
857
  emit({
797
858
  ...base(threadId, current.turnId),
798
859
  type: "item.started",
@@ -805,6 +866,7 @@ export function createAcpDriver(support) {
805
866
  break;
806
867
  }
807
868
  case "tool_call_update": {
869
+ trackRunningTool(current, u);
808
870
  if (u.status === "completed" || u.status === "failed") {
809
871
  current.state.producedItem = true;
810
872
  emit({
@@ -992,8 +1054,8 @@ export function createAcpDriver(support) {
992
1054
  // transformEnv-policy supports (opencode). mcpServers are session
993
1055
  // establishment inputs — they ride session/new and session/load over
994
1056
  // the wire — and the harness mints fresh integration bearer tokens
995
- // every turn, so they must not respawn the process; a change instead
996
- // re-establishes the session below (see sessionKey).
1057
+ // every turn. Most agents apply changes on live load; those that cache
1058
+ // the old MCP clients must resume on a fresh process (see sessionKey).
997
1059
  // The env the spawned child actually receives is part of the
998
1060
  // contract too, and arrives hashed as envFingerprint for the same
999
1061
  // reason.
@@ -1012,7 +1074,8 @@ export function createAcpDriver(support) {
1012
1074
  }
1013
1075
  const pooled = sessions.get(threadId);
1014
1076
  let session;
1015
- if (pooled && !pooled.dead && !pooled.closing && pooled.contractKey === contractKey) {
1077
+ if (pooled && !pooled.dead && !pooled.closing && pooled.contractKey === contractKey
1078
+ && (!support.restartOnMcpChange || pooled.sessionKey === sessionKey)) {
1016
1079
  // adoption cancels the idle countdown — a running turn is not quiet
1017
1080
  if (pooled.idleTimer)
1018
1081
  clearTimeout(pooled.idleTimer);
@@ -1035,7 +1098,13 @@ export function createAcpDriver(support) {
1035
1098
  // respawns the child, every wire call must reach the live record, so
1036
1099
  // nothing captures the connection off it.
1037
1100
  const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => session.acp.request(method, params, timeoutMs, receive, idleMs, idleMessage);
1038
- const state = { settled: false, promptSent: false, text: "", producedItem: false };
1101
+ const state = { settled: false, promptSent: false, text: "", producedItem: false, startupActivity: false, stopped: false };
1102
+ let acknowledge = () => { };
1103
+ let rejectStartup = (_error) => { };
1104
+ const startupAck = turn.startupRecovery ? new Promise((resolve, reject) => {
1105
+ acknowledge = () => resolve({ turnId });
1106
+ rejectStartup = reject;
1107
+ }) : null;
1039
1108
  const asks = new Map();
1040
1109
  const modelOf = (result) => {
1041
1110
  const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.id === (support.selectModel?.configId ?? "model"));
@@ -1083,12 +1152,15 @@ export function createAcpDriver(support) {
1083
1152
  turnConfig,
1084
1153
  controlsHost,
1085
1154
  state,
1155
+ acknowledge,
1086
1156
  asks,
1157
+ runningTools: new Set(),
1087
1158
  interruptTimer: null,
1088
1159
  flushAssistantText,
1089
1160
  receiveModelVariants,
1090
1161
  };
1091
1162
  const interrupt = () => {
1163
+ state.stopped = true;
1092
1164
  if (session.sessionId) {
1093
1165
  session.acp.send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId: session.sessionId } });
1094
1166
  if (current.interruptTimer)
@@ -1105,7 +1177,7 @@ export function createAcpDriver(support) {
1105
1177
  closeSession(threadId, "stop");
1106
1178
  }
1107
1179
  };
1108
- active.set(threadId, { stop: () => closeSession(threadId, "stop"), interrupt, turnId, asks });
1180
+ active.set(threadId, { stop: () => { state.stopped = true; closeSession(threadId, "stop"); }, interrupt, turnId, asks });
1109
1181
  emit({ ...base(threadId, turnId), type: "turn.started" });
1110
1182
  session.current = current;
1111
1183
  (async () => {
@@ -1116,6 +1188,19 @@ export function createAcpDriver(support) {
1116
1188
  // Returns whether this runtime accepts image prompts, for the
1117
1189
  // prompt phase below.
1118
1190
  const handshake = async () => {
1191
+ // Also covers timeout/reset/re-establishment, not just rotated
1192
+ // MCP credentials. Stop remains wired while cleanup is pending.
1193
+ for (const child of retiring.get(threadId) ?? []) {
1194
+ const stopped = await retireChild(threadId, child);
1195
+ if (state.settled || session.closing)
1196
+ throw new Error("session closed");
1197
+ if (!stopped) {
1198
+ closeSession(threadId, "replace-failed");
1199
+ throw new Error(`${support.displayName} could not close its previous tool session. Try again.`);
1200
+ }
1201
+ }
1202
+ if (state.settled || session.closing)
1203
+ throw new Error("session closed");
1119
1204
  if (!session.initResult) {
1120
1205
  session.initResult = await request("initialize", {
1121
1206
  protocolVersion: 1,
@@ -1320,7 +1405,7 @@ export function createAcpDriver(support) {
1320
1405
  let pendingSplitReceipt = null;
1321
1406
  if (halves.stable !== null) {
1322
1407
  const receiptKey = JSON.stringify([threadId, sessionId]);
1323
- const composed = splitSessionPrompt(halves.stable, halves.volatile, readPromptSplitReceipt(DRIVER_KIND, receiptKey), promptTurn.system, promptTurn.text, Boolean(turn.mentionTurn));
1408
+ const composed = splitSessionPrompt(halves.stable, halves.volatile, readPromptSplitReceipt(DRIVER_KIND, receiptKey), promptTurn.system, promptTurn.text, Boolean(turn.mentionTurn), ACP_PROMPT_RE_ANCHOR_TURNS);
1324
1409
  promptInput = { ...promptTurn, system: "", text: composed.text };
1325
1410
  pendingSplitReceipt = { key: receiptKey, receipt: composed.receipt };
1326
1411
  }
@@ -1338,10 +1423,13 @@ export function createAcpDriver(support) {
1338
1423
  if (turn.variant !== undefined && requestedVariantOption().currentValue !== turn.variant) {
1339
1424
  throw new Error(`${support.displayName} changed variant before the prompt`);
1340
1425
  }
1426
+ if (state.settled || state.stopped || session.closing)
1427
+ throw new Error("turn stopped");
1341
1428
  state.promptSent = true;
1429
+ acknowledge();
1342
1430
  const promptIdleMs = promptIdleTimeoutMs();
1343
- const result = await request("session/prompt", { sessionId, prompt: [{ type: "text", text }, ...imageBlocks] }, undefined, undefined, promptIdleMs, `${DRIVER_KIND} went fully silent ${Math.round(promptIdleMs / 1000)} s after the message and the turn was stopped. ` +
1344
- "Raise OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS if this model legitimately takes longer to answer.");
1431
+ const result = await request("session/prompt", { sessionId, prompt: [{ type: "text", text }, ...imageBlocks] }, undefined, undefined, promptIdleMs, `${DRIVER_KIND} sent nothing for ${Math.round(promptIdleMs / 1000)} s with no tool running, so the turn was stopped as stuck. ` +
1432
+ "Send the message again to retry. On a self-hosted server, OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS sets this limit (0 turns it off).");
1345
1433
  if (pendingSplitReceipt) {
1346
1434
  // session/prompt resolving is the acceptance boundary: a
1347
1435
  // rejected prompt leaves the receipt unwritten, so the next
@@ -1387,6 +1475,35 @@ export function createAcpDriver(support) {
1387
1475
  // fallback for existing ACP supports.
1388
1476
  const needsAuth = code === "invalid_credentials" || code === "inactive_subscription"
1389
1477
  || message === support.loginNote;
1478
+ const failure = classifyError({ text: message });
1479
+ const transientStartup = failure.transient || (failure.reason === "unknown" && code === "upstream_outage");
1480
+ const denied = /\b(?:(?:permission|access) denied|(?:approval|permission) (?:required|denied|rejected)|requires? (?:approval|permission)|policy (?:restriction|violation)|(?:blocked|denied|restricted) by (?:the )?policy)\b/i.test(message);
1481
+ if (turn.startupRecovery && transientStartup && !needsAuth && !denied && (!code || code === "upstream_outage")
1482
+ && ![-32700, -32600, -32601, -32602].includes(e?.code)
1483
+ && !state.promptSent && !state.startupActivity && !state.producedItem && !state.text
1484
+ && !state.stopped && !session.closing && !asks.size && !current.runningTools.size) {
1485
+ // Quiesce before killing: the child's close event must not
1486
+ // publish a terminal completion before the harness can recover.
1487
+ state.settled = true;
1488
+ session.current = null;
1489
+ if (current.interruptTimer)
1490
+ clearTimeout(current.interruptTimer);
1491
+ closeSession(threadId, "startup-recovery");
1492
+ const stopped = await killCliTree(session.child).catch(() => false);
1493
+ if (current.interruptTimer)
1494
+ clearTimeout(current.interruptTimer);
1495
+ active.delete(threadId);
1496
+ if (stopped && !state.stopped) {
1497
+ rejectStartup(new TurnNotStartedError(turnId, message));
1498
+ return;
1499
+ }
1500
+ acknowledge();
1501
+ if (!state.stopped)
1502
+ emit({ ...base(threadId, turnId), type: "runtime.error", message });
1503
+ emit({ ...base(threadId, turnId), type: "turn.completed", ok: state.stopped,
1504
+ stopReason: state.stopped ? "cancelled" : "rpc_error", cost: null });
1505
+ return;
1506
+ }
1390
1507
  emit({
1391
1508
  ...base(threadId, turnId),
1392
1509
  type: "runtime.error",
@@ -1406,7 +1523,7 @@ export function createAcpDriver(support) {
1406
1523
  }
1407
1524
  }
1408
1525
  })();
1409
- return { turnId };
1526
+ return startupAck ?? { turnId };
1410
1527
  };
1411
1528
  const snapshot = async () => {
1412
1529
  const env = childEnv();
@@ -1463,6 +1580,10 @@ export function createAcpDriver(support) {
1463
1580
  // idle pooled sessions have no running turn — close them too
1464
1581
  for (const threadId of Array.from(sessions.keys()))
1465
1582
  closeSession(threadId, "stopAll");
1583
+ for (const [threadId, children] of retiring) {
1584
+ for (const child of children)
1585
+ void retireChild(threadId, child);
1586
+ }
1466
1587
  },
1467
1588
  onEvent: (listener) => {
1468
1589
  listeners.add(listener);
@@ -1474,6 +1595,10 @@ export function createAcpDriver(support) {
1474
1595
  stop();
1475
1596
  for (const threadId of Array.from(sessions.keys()))
1476
1597
  closeSession(threadId, "dispose");
1598
+ for (const [threadId, children] of retiring) {
1599
+ for (const child of children)
1600
+ void retireChild(threadId, child);
1601
+ }
1477
1602
  listeners.clear();
1478
1603
  },
1479
1604
  };
@@ -1,3 +1,4 @@
1
+ import { titleCaseModelId } from "../../contracts.js";
1
2
  import { execCli } from "../../procs.js";
2
3
  import { createAcpDriver } from "./core.js";
3
4
  /** Translate an argv `--model` slug into the id this ACP session will accept.
@@ -86,13 +87,8 @@ function asRecord(value) {
86
87
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
87
88
  }
88
89
  function labelFor(id, explicit) {
89
- if (explicit?.trim())
90
- return explicit.trim();
91
- return id
92
- .split(/[-_./]+/g)
93
- .filter(Boolean)
94
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
95
- .join(" ");
90
+ const trimmed = explicit?.trim();
91
+ return trimmed || titleCaseModelId(id, /[-_./]+/);
96
92
  }
97
93
  function pushModel(options, seen, id, label, custom = false) {
98
94
  if (!SLUG.test(id) || seen.has(id))
@@ -16,8 +16,8 @@
16
16
  // options set over the wire (session/set_model, session/set_mode), which is
17
17
  // why both live in configureSession() below and NOT in spawnArgs.
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
- import { homedir } from "node:os";
20
19
  import { join } from "node:path";
20
+ import { userHome } from "../../env-path.js";
21
21
  import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
22
22
  import { createAcpDriver } from "./core.js";
23
23
  // FACTORY_HOME_OVERRIDE replaces the HOME the CLI resolves, NOT the data root:
@@ -34,7 +34,7 @@ import { createAcpDriver } from "./core.js";
34
34
  // out. Names read from the 0.196.0 binary.
35
35
  const AUTH_FILES = ["auth.v2.file", "auth.v2.loginkeychain", "auth.v2.keyring"];
36
36
  function authFilePaths(env) {
37
- const home = env.FACTORY_HOME_OVERRIDE || env.HOME || homedir();
37
+ const home = env.FACTORY_HOME_OVERRIDE || userHome(env);
38
38
  return AUTH_FILES.map((name) => join(home, ".factory", name));
39
39
  }
40
40
  const INJECT_ID_PREFIX = "custom:openmausbot-";
@@ -42,9 +42,6 @@ export function droidInjectId(host, model) {
42
42
  const safe = `${host}-${model}`.replace(/[^a-zA-Z0-9._+-]+/g, "-").replace(/-+/g, "-");
43
43
  return `${INJECT_ID_PREFIX}${safe}`;
44
44
  }
45
- function factoryHome(env) {
46
- return env.FACTORY_HOME_OVERRIDE || env.HOME || env.USERPROFILE || homedir();
47
- }
48
45
  /** Upsert a BYOK custom model so session/set_model can reach the local host. */
49
46
  export function ensureDroidInjectModel(modelId, env = process.env) {
50
47
  const inject = decodeInjectId(modelId);
@@ -54,7 +51,7 @@ export function ensureDroidInjectModel(modelId, env = process.env) {
54
51
  if (!host)
55
52
  return modelId;
56
53
  const id = droidInjectId(inject.host, inject.model);
57
- const dir = join(factoryHome(env), ".factory");
54
+ const dir = join(env.FACTORY_HOME_OVERRIDE || userHome(env), ".factory");
58
55
  mkdirSync(dir, { recursive: true });
59
56
  const path = join(dir, "settings.json");
60
57
  let settings = {};
@@ -105,7 +102,7 @@ export function applyDroidLocalAuthEnv(env, modelId) {
105
102
  env.FACTORY_API_KEY = "openmausbot-local";
106
103
  }
107
104
  function readSettings(env) {
108
- return JSON.parse(readFileSync(join(factoryHome(env), ".factory", "settings.json"), "utf8"));
105
+ return JSON.parse(readFileSync(join(env.FACTORY_HOME_OVERRIDE || userHome(env), ".factory", "settings.json"), "utf8"));
109
106
  }
110
107
  async function resolveModels(env) {
111
108
  let catalog = MODELS;
@@ -26,8 +26,8 @@
26
26
  // the ACP flag + auth method ids follow the published Gemini CLI ACP contract
27
27
  // and should be re-verified end-to-end once the CLI is present.
28
28
  import { existsSync, readFileSync } from "node:fs";
29
- import { homedir } from "node:os";
30
29
  import { join } from "node:path";
30
+ import { userHome } from "../../env-path.js";
31
31
  import { createAcpDriver } from "./core.js";
32
32
  // Prefer an explicit key method, then personal OAuth, then Vertex — but fall
33
33
  // back to whatever the CLI advertises so a new method id still works.
@@ -37,9 +37,9 @@ const AUTH_PREFERENCE = ["gemini-api-key", "oauth-personal", "vertex-ai"];
37
37
  * an enterprise licence may still redeem. A stale consumer credential —
38
38
  * the common case since 2026-06-18 — reads as not signed in, which is the
39
39
  * truthful answer for an engine that would fail its first turn anyway. */
40
- function liveOauthCredential() {
40
+ function liveOauthCredential(env) {
41
41
  try {
42
- const creds = JSON.parse(readFileSync(join(homedir(), ".gemini", "oauth_creds.json"), "utf8"));
42
+ const creds = JSON.parse(readFileSync(join(userHome(env), ".gemini", "oauth_creds.json"), "utf8"));
43
43
  if (typeof creds.access_token !== "string" || creds.access_token.trim().length === 0)
44
44
  return false;
45
45
  const hasRefresh = typeof creds.refresh_token === "string" && creds.refresh_token.trim().length > 0;
@@ -62,7 +62,7 @@ const nonBlank = (value) => Boolean(value?.trim());
62
62
  export function geminiIsAuthenticated(env) {
63
63
  return (nonBlank(env.GEMINI_API_KEY) ||
64
64
  nonBlank(env.GOOGLE_API_KEY) ||
65
- (existsSync(join(homedir(), ".gemini", "oauth_creds.json")) && liveOauthCredential()));
65
+ (existsSync(join(userHome(env), ".gemini", "oauth_creds.json")) && liveOauthCredential(env)));
66
66
  }
67
67
  /** Gemini CLI's approval modes, passed through: `--approval-mode auto_edit`
68
68
  * approves file edits, `--yolo` approves everything; Gemini has no