negotium 0.1.7 → 0.1.9

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 (270) hide show
  1. package/README.md +26 -2
  2. package/dist/canonical-mcp-bridge.js +14 -0
  3. package/dist/canonical-mcp-bridge.js.map +9 -0
  4. package/dist/chunk-10ct8814.js +429 -0
  5. package/dist/chunk-10ct8814.js.map +11 -0
  6. package/dist/chunk-18fzftjf.js +214 -0
  7. package/dist/chunk-18fzftjf.js.map +10 -0
  8. package/dist/chunk-1k7t7bnx.js +97 -0
  9. package/dist/chunk-1k7t7bnx.js.map +10 -0
  10. package/dist/chunk-1msje2zf.js +23 -0
  11. package/dist/chunk-1msje2zf.js.map +10 -0
  12. package/dist/chunk-2dgkah1z.js +706 -0
  13. package/dist/chunk-2dgkah1z.js.map +14 -0
  14. package/dist/chunk-3t4dtm26.js +61 -0
  15. package/dist/chunk-3t4dtm26.js.map +10 -0
  16. package/dist/chunk-3vn2vnj1.js +51 -0
  17. package/dist/chunk-3vn2vnj1.js.map +10 -0
  18. package/dist/chunk-6hknphtc.js +2054 -0
  19. package/dist/chunk-6hknphtc.js.map +29 -0
  20. package/dist/chunk-6tfzpcvk.js +2149 -0
  21. package/dist/chunk-6tfzpcvk.js.map +15 -0
  22. package/dist/chunk-8g0c4as0.js +23 -0
  23. package/dist/chunk-8g0c4as0.js.map +10 -0
  24. package/dist/chunk-9spx3h9n.js +279 -0
  25. package/dist/chunk-9spx3h9n.js.map +10 -0
  26. package/dist/chunk-c500xe9z.js +33 -0
  27. package/dist/chunk-c500xe9z.js.map +10 -0
  28. package/dist/chunk-e9b2xpff.js +147 -0
  29. package/dist/chunk-e9b2xpff.js.map +10 -0
  30. package/dist/chunk-edb07sjp.js +567 -0
  31. package/dist/chunk-edb07sjp.js.map +14 -0
  32. package/dist/chunk-eexqsee7.js +368 -0
  33. package/dist/chunk-eexqsee7.js.map +11 -0
  34. package/dist/chunk-efyxk97v.js +500 -0
  35. package/dist/chunk-efyxk97v.js.map +13 -0
  36. package/dist/chunk-epqxwkvh.js +3534 -0
  37. package/dist/chunk-epqxwkvh.js.map +20 -0
  38. package/dist/chunk-f1pzh3xh.js +115 -0
  39. package/dist/chunk-f1pzh3xh.js.map +10 -0
  40. package/dist/chunk-f5xgprg1.js +59 -0
  41. package/dist/chunk-f5xgprg1.js.map +10 -0
  42. package/dist/chunk-f97qybwk.js +584 -0
  43. package/dist/chunk-f97qybwk.js.map +11 -0
  44. package/dist/chunk-fvc7ncxd.js +252 -0
  45. package/dist/chunk-fvc7ncxd.js.map +10 -0
  46. package/dist/chunk-g7pvfj5e.js +574 -0
  47. package/dist/chunk-g7pvfj5e.js.map +14 -0
  48. package/dist/chunk-h8vmb9gf.js +315 -0
  49. package/dist/chunk-h8vmb9gf.js.map +10 -0
  50. package/dist/chunk-hgw7n8cm.js +43 -0
  51. package/dist/chunk-hgw7n8cm.js.map +10 -0
  52. package/dist/chunk-hsq7j9jk.js +126 -0
  53. package/dist/chunk-hsq7j9jk.js.map +10 -0
  54. package/dist/chunk-hwy3n31w.js +21 -0
  55. package/dist/chunk-hwy3n31w.js.map +9 -0
  56. package/dist/chunk-j0kjscb7.js +53 -0
  57. package/dist/chunk-j0kjscb7.js.map +10 -0
  58. package/dist/chunk-jzz91jwh.js +70 -0
  59. package/dist/chunk-jzz91jwh.js.map +10 -0
  60. package/dist/chunk-k9z47mab.js +247 -0
  61. package/dist/chunk-k9z47mab.js.map +12 -0
  62. package/dist/chunk-kbm35t7w.js +1331 -0
  63. package/dist/chunk-kbm35t7w.js.map +16 -0
  64. package/dist/chunk-m4veth0c.js +1649 -0
  65. package/dist/chunk-m4veth0c.js.map +17 -0
  66. package/dist/chunk-mhty3nna.js +150 -0
  67. package/dist/chunk-mhty3nna.js.map +11 -0
  68. package/dist/chunk-mmgh670x.js +248 -0
  69. package/dist/chunk-mmgh670x.js.map +11 -0
  70. package/dist/chunk-n0kk5xbq.js +2358 -0
  71. package/dist/chunk-n0kk5xbq.js.map +26 -0
  72. package/dist/chunk-nmv41qx9.js +2847 -0
  73. package/dist/chunk-nmv41qx9.js.map +23 -0
  74. package/dist/chunk-nqdp52aj.js +113 -0
  75. package/dist/chunk-nqdp52aj.js.map +12 -0
  76. package/dist/chunk-ns2m25dz.js +139 -0
  77. package/dist/chunk-ns2m25dz.js.map +10 -0
  78. package/dist/chunk-pkdmypdd.js +147 -0
  79. package/dist/chunk-pkdmypdd.js.map +11 -0
  80. package/dist/chunk-rmyfmrrr.js +1634 -0
  81. package/dist/chunk-rmyfmrrr.js.map +15 -0
  82. package/dist/chunk-sa874j5c.js +9 -0
  83. package/dist/chunk-sa874j5c.js.map +10 -0
  84. package/dist/chunk-sgsz2fpq.js +289 -0
  85. package/dist/chunk-sgsz2fpq.js.map +10 -0
  86. package/dist/chunk-sn51wnf1.js +3020 -0
  87. package/dist/chunk-sn51wnf1.js.map +32 -0
  88. package/dist/chunk-sqhr1a7e.js +587 -0
  89. package/dist/chunk-sqhr1a7e.js.map +11 -0
  90. package/dist/chunk-tmj5r2zv.js +2933 -0
  91. package/dist/chunk-tmj5r2zv.js.map +20 -0
  92. package/dist/chunk-vefj3880.js +56 -0
  93. package/dist/chunk-vefj3880.js.map +10 -0
  94. package/dist/chunk-vp2db42d.js +121 -0
  95. package/dist/chunk-vp2db42d.js.map +10 -0
  96. package/dist/chunk-wry79q55.js +108 -0
  97. package/dist/chunk-wry79q55.js.map +10 -0
  98. package/dist/chunk-x1arr7ze.js +63 -0
  99. package/dist/chunk-x1arr7ze.js.map +10 -0
  100. package/dist/chunk-z2xy5n2q.js +103 -0
  101. package/dist/chunk-z2xy5n2q.js.map +10 -0
  102. package/dist/hosted-agent.js +53 -0
  103. package/dist/hosted-agent.js.map +10 -0
  104. package/dist/main.js +132 -20
  105. package/dist/main.js.map +4 -4
  106. package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
  107. package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
  108. package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
  109. package/dist/runtime/src/agents/archiver.ts +396 -0
  110. package/dist/runtime/src/agents/auth-check.ts +64 -0
  111. package/dist/runtime/src/agents/claude-provider.ts +737 -0
  112. package/dist/runtime/src/agents/claude-registry.ts +115 -0
  113. package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
  114. package/dist/runtime/src/agents/codex-provider.ts +785 -0
  115. package/dist/runtime/src/agents/codex-registry.ts +115 -0
  116. package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
  117. package/dist/runtime/src/agents/contracts.ts +69 -0
  118. package/dist/runtime/src/agents/deep-map.ts +13 -0
  119. package/dist/runtime/src/agents/execution-host.ts +91 -0
  120. package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
  121. package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
  122. package/dist/runtime/src/agents/fork.ts +65 -0
  123. package/dist/runtime/src/agents/hosted-agent.ts +49 -0
  124. package/dist/runtime/src/agents/idle-archiver.ts +128 -0
  125. package/dist/runtime/src/agents/index.ts +205 -0
  126. package/dist/runtime/src/agents/maestro-provider.ts +206 -0
  127. package/dist/runtime/src/agents/maestro-registry.ts +31 -0
  128. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
  129. package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
  130. package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
  131. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
  132. package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
  133. package/dist/runtime/src/agents/model-catalog.ts +151 -0
  134. package/dist/runtime/src/agents/registry.ts +24 -0
  135. package/dist/runtime/src/agents/rollout/claude.ts +331 -0
  136. package/dist/runtime/src/agents/rollout/codex.ts +569 -0
  137. package/dist/runtime/src/agents/rollout/shared.ts +205 -0
  138. package/dist/runtime/src/agents/self-config-core.ts +415 -0
  139. package/dist/runtime/src/agents/task-events.ts +44 -0
  140. package/dist/runtime/src/agents/tool-format.ts +352 -0
  141. package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
  142. package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
  143. package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
  144. package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
  145. package/dist/runtime/src/application/submit-user-message.ts +50 -0
  146. package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
  147. package/dist/runtime/src/application/switch-topic-model.ts +79 -0
  148. package/dist/runtime/src/application/topic-service.ts +139 -0
  149. package/dist/runtime/src/application/vault-command.ts +170 -0
  150. package/dist/runtime/src/bus.ts +319 -0
  151. package/dist/runtime/src/index.ts +333 -0
  152. package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
  153. package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
  154. package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
  155. package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
  156. package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
  157. package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
  158. package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
  159. package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
  160. package/dist/runtime/src/mcp/runtime-spec.ts +137 -0
  161. package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
  162. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
  163. package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
  164. package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
  165. package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
  166. package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
  167. package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
  168. package/dist/runtime/src/mcp/system-health-server.ts +130 -0
  169. package/dist/runtime/src/mcp/task-server.ts +173 -0
  170. package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
  171. package/dist/runtime/src/mcp/vault-http.ts +219 -0
  172. package/dist/runtime/src/mcp/vault-run.ts +153 -0
  173. package/dist/runtime/src/mcp/vault-server.ts +104 -0
  174. package/dist/runtime/src/mcp/wiki-server.ts +755 -0
  175. package/dist/runtime/src/media/file-events.ts +33 -0
  176. package/dist/runtime/src/media/text-extractor.ts +312 -0
  177. package/dist/runtime/src/media/video.ts +92 -0
  178. package/dist/runtime/src/outbox/file-ops.ts +210 -0
  179. package/dist/runtime/src/outbox/index.ts +8 -0
  180. package/dist/runtime/src/outbox/utils.ts +51 -0
  181. package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
  182. package/dist/runtime/src/platform/config.ts +371 -0
  183. package/dist/runtime/src/platform/constants.ts +18 -0
  184. package/dist/runtime/src/platform/debug-users.ts +14 -0
  185. package/dist/runtime/src/platform/delay.ts +1 -0
  186. package/dist/runtime/src/platform/error.ts +9 -0
  187. package/dist/runtime/src/platform/file-utils.ts +16 -0
  188. package/dist/runtime/src/platform/jsonl.ts +202 -0
  189. package/dist/runtime/src/platform/lifecycle.ts +174 -0
  190. package/dist/runtime/src/platform/logger.ts +31 -0
  191. package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
  192. package/dist/runtime/src/platform/mcp-config.ts +878 -0
  193. package/dist/runtime/src/platform/modules.ts +145 -0
  194. package/dist/runtime/src/platform/node-plugins.ts +46 -0
  195. package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
  196. package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
  197. package/dist/runtime/src/prompts/builders.ts +368 -0
  198. package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
  199. package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
  200. package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
  201. package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
  202. package/dist/runtime/src/query/active-rooms.ts +534 -0
  203. package/dist/runtime/src/query/control.ts +313 -0
  204. package/dist/runtime/src/query/index.ts +24 -0
  205. package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
  206. package/dist/runtime/src/query/session-inbox-path.ts +42 -0
  207. package/dist/runtime/src/query/state.ts +94 -0
  208. package/dist/runtime/src/query/types.ts +83 -0
  209. package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
  210. package/dist/runtime/src/runtime/attachments.ts +151 -0
  211. package/dist/runtime/src/runtime/background-sessions.ts +83 -0
  212. package/dist/runtime/src/runtime/channel-context.ts +107 -0
  213. package/dist/runtime/src/runtime/errors.ts +69 -0
  214. package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
  215. package/dist/runtime/src/runtime/file-hooks.ts +81 -0
  216. package/dist/runtime/src/runtime/footer.ts +23 -0
  217. package/dist/runtime/src/runtime/inbox.ts +946 -0
  218. package/dist/runtime/src/runtime/tasks.ts +56 -0
  219. package/dist/runtime/src/runtime/topic-config.ts +29 -0
  220. package/dist/runtime/src/runtime/turn-runner.ts +2298 -0
  221. package/dist/runtime/src/runtime/usage-alert.ts +84 -0
  222. package/dist/runtime/src/runtime/visual-html.ts +109 -0
  223. package/dist/runtime/src/runtime/visual-store.ts +430 -0
  224. package/dist/runtime/src/runtime/visuals.ts +171 -0
  225. package/dist/runtime/src/security/sanitize.ts +27 -0
  226. package/dist/runtime/src/security/sensitive-path.ts +34 -0
  227. package/dist/runtime/src/storage/activity-log.ts +152 -0
  228. package/dist/runtime/src/storage/api-messages.ts +617 -0
  229. package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
  230. package/dist/runtime/src/storage/api-topic-config.ts +192 -0
  231. package/dist/runtime/src/storage/api-topics.ts +844 -0
  232. package/dist/runtime/src/storage/app-settings.ts +44 -0
  233. package/dist/runtime/src/storage/browser-profiles.ts +133 -0
  234. package/dist/runtime/src/storage/conversations.ts +268 -0
  235. package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
  236. package/dist/runtime/src/storage/forum/index.ts +4 -0
  237. package/dist/runtime/src/storage/forum/repository.ts +283 -0
  238. package/dist/runtime/src/storage/forum/schema.ts +300 -0
  239. package/dist/runtime/src/storage/forum-db.ts +23 -0
  240. package/dist/runtime/src/storage/runtime-events.ts +130 -0
  241. package/dist/runtime/src/storage/runtime-leases.ts +189 -0
  242. package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
  243. package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
  244. package/dist/runtime/src/storage/runtime-turn-requests.ts +301 -0
  245. package/dist/runtime/src/storage/self-schedules.ts +298 -0
  246. package/dist/runtime/src/storage/session-asks.ts +347 -0
  247. package/dist/runtime/src/storage/sqlite.ts +129 -0
  248. package/dist/runtime/src/storage/tasks.ts +179 -0
  249. package/dist/runtime/src/storage/token-stats.ts +144 -0
  250. package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
  251. package/dist/runtime/src/storage/topic-archive.ts +74 -0
  252. package/dist/runtime/src/storage/topic-settings.ts +67 -0
  253. package/dist/runtime/src/storage/topic-transcript.ts +98 -0
  254. package/dist/runtime/src/storage/vault-crypto.ts +65 -0
  255. package/dist/runtime/src/storage/vault.ts +225 -0
  256. package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
  257. package/dist/runtime/src/storage/wiki.ts +85 -0
  258. package/dist/runtime/src/topics/create.ts +123 -0
  259. package/dist/runtime/src/topics/derive.ts +398 -0
  260. package/dist/runtime/src/topics/lifecycle.ts +311 -0
  261. package/dist/runtime/src/topics/links.ts +7 -0
  262. package/dist/runtime/src/topics/personal-general.ts +65 -0
  263. package/dist/runtime/src/topics/session.ts +381 -0
  264. package/dist/runtime/src/types/api.ts +497 -0
  265. package/dist/runtime/src/types.ts +257 -0
  266. package/dist/runtime/src/version.ts +1 -0
  267. package/dist/runtime/tsconfig.json +13 -0
  268. package/package.json +28 -3
  269. package/dist/main.d.ts +0 -3
  270. package/dist/main.d.ts.map +0 -1
@@ -0,0 +1,1233 @@
1
+ import { type ChildProcess, execFileSync, spawn } from "node:child_process";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import {
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import {
16
+ BROWSER_PROFILES_DIR,
17
+ PATCHRIGHT_MCP_BIN,
18
+ PLAYWRIGHT_BASE_PORT,
19
+ PLAYWRIGHT_MAX_PORT,
20
+ PLAYWRIGHT_MCP_BIN,
21
+ PLAYWRIGHT_PORTS_DIR,
22
+ resolveBrowserProxy,
23
+ } from "#platform/config";
24
+ import { delay } from "#platform/delay";
25
+
26
+ import { logger } from "#platform/logger";
27
+ import { sanitizeTopicName } from "#security/sanitize";
28
+ import {
29
+ assignTopicBrowserProfile,
30
+ getBrowserProfileOwner,
31
+ getTopicBrowserProfile,
32
+ hasBrowserProfileTopic,
33
+ normalizeBrowserProfileName,
34
+ } from "#storage/browser-profiles";
35
+
36
+ export { PLAYWRIGHT_PORTS_DIR };
37
+
38
+ /** Multiple topics assigned to one profile reuse one browser process. */
39
+ export function makeInstanceKey(userId: string, topic: string | undefined): string {
40
+ if (!topic) return makeBrowserProfileInstanceKey(userId, "default");
41
+ const ownerId = getBrowserProfileOwner(topic, userId);
42
+ const profile = migrateLegacyTopicProfile(ownerId, topic);
43
+ return makeBrowserProfileInstanceKey(ownerId, profile);
44
+ }
45
+
46
+ export function makeBrowserProfileInstanceKey(ownerId: string, rawProfile: string): string {
47
+ return `profile:${encodeURIComponent(ownerId)}:${normalizeBrowserProfileName(rawProfile)}`;
48
+ }
49
+
50
+ export function legacyBrowserProfileName(topic: string): string {
51
+ return `legacy_${createHash("sha256").update(topic).digest("hex").slice(0, 12)}`;
52
+ }
53
+
54
+ function migrateLegacyTopicProfile(ownerId: string, topic: string): string {
55
+ const current = getTopicBrowserProfile(topic);
56
+ if (current !== "default" || !hasBrowserProfileTopic(topic)) return current;
57
+
58
+ const legacyDir = resolve(BROWSER_PROFILES_DIR, sanitizeTopicName(topic));
59
+ if (!existsSync(legacyDir)) return current;
60
+
61
+ const profile = legacyBrowserProfileName(topic);
62
+ const profileDir = resolveProfileDir(ownerId, profile);
63
+ mkdirSync(dirname(profileDir), { recursive: true });
64
+ if (!existsSync(profileDir)) renameSync(legacyDir, profileDir);
65
+ assignTopicBrowserProfile({ topicId: topic, actorUserId: ownerId, profile });
66
+ logger.info({ ownerId, topic, profile }, "Adopted legacy topic browser profile");
67
+ return profile;
68
+ }
69
+
70
+ interface InstanceKeyParts {
71
+ ownerId: string;
72
+ profile: string;
73
+ }
74
+
75
+ function parseInstanceKey(instanceKey: string): InstanceKeyParts {
76
+ const match = /^profile:([^:]+):(.+)$/.exec(instanceKey);
77
+ if (!match) return { ownerId: "legacy", profile: sanitizeTopicName(instanceKey) };
78
+ return {
79
+ ownerId: decodeURIComponent(match[1]!),
80
+ profile: normalizeBrowserProfileName(match[2]!),
81
+ };
82
+ }
83
+
84
+ function portFileName(instanceKey: string): string {
85
+ return createHash("sha256").update(instanceKey).digest("hex").slice(0, 24);
86
+ }
87
+
88
+ function writePortFile(instanceKey: string, port: number) {
89
+ try {
90
+ mkdirSync(PLAYWRIGHT_PORTS_DIR, { recursive: true });
91
+ writeFileSync(join(PLAYWRIGHT_PORTS_DIR, portFileName(instanceKey)), String(port));
92
+ } catch (e) {
93
+ logger.warn({ err: e, instanceKey, port }, "Failed to save playwright port file");
94
+ }
95
+ }
96
+
97
+ function deletePortFile(instanceKey: string) {
98
+ try {
99
+ unlinkSync(join(PLAYWRIGHT_PORTS_DIR, portFileName(instanceKey)));
100
+ } catch (e) {
101
+ logger.warn({ err: e, instanceKey }, "Failed to delete playwright port file");
102
+ }
103
+ }
104
+
105
+ function readPortFile(instanceKey: string): number | null {
106
+ try {
107
+ const port = Number.parseInt(
108
+ readFileSync(join(PLAYWRIGHT_PORTS_DIR, portFileName(instanceKey)), "utf8").trim(),
109
+ 10,
110
+ );
111
+ return Number.isInteger(port) ? port : null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ const BASE_PORT = PLAYWRIGHT_BASE_PORT;
118
+ const MAX_PORT = PLAYWRIGHT_MAX_PORT;
119
+ const MAX_IDLE_MS = 2 * 60 * 60 * 1000; // 2 hours idle → eligible for eviction
120
+
121
+ interface PlaywrightInstance {
122
+ process: ChildProcess;
123
+ port: number;
124
+ userId: string;
125
+ startedAt: number;
126
+ lastUsedAt: number;
127
+ capability: string;
128
+ }
129
+
130
+ const instances = new Map<string, PlaywrightInstance>();
131
+
132
+ // Track used ports to avoid collisions
133
+ const usedPorts = new Set<number>();
134
+
135
+ // Prevent concurrent spawns for the same key
136
+ const spawning = new Map<string, Promise<number | null>>();
137
+
138
+ /** Serialize destructive profile maintenance with normal browser startup. */
139
+ export async function withPlaywrightInstanceMaintenance<T>(
140
+ rawKeys: string[],
141
+ operation: () => Promise<T>,
142
+ ): Promise<T> {
143
+ const keys = [...new Set(rawKeys)].sort();
144
+ for (;;) {
145
+ const pending = keys
146
+ .map((key) => spawning.get(key))
147
+ .filter((promise): promise is Promise<number | null> => promise !== undefined);
148
+ if (pending.length > 0) {
149
+ await Promise.allSettled(pending);
150
+ continue;
151
+ }
152
+
153
+ let releaseBarrier!: () => void;
154
+ const barrier = new Promise<number | null>((resolveBarrier) => {
155
+ releaseBarrier = () => resolveBarrier(null);
156
+ });
157
+ // No await occurs between the registry check and registration, so another
158
+ // event-loop task cannot acquire any of these keys concurrently.
159
+ for (const key of keys) spawning.set(key, barrier);
160
+
161
+ try {
162
+ return await operation();
163
+ } finally {
164
+ for (const key of keys) {
165
+ if (spawning.get(key) === barrier) spawning.delete(key);
166
+ }
167
+ releaseBarrier();
168
+ }
169
+ }
170
+ }
171
+
172
+ // A shared profile may have several concurrent turns. Reference counts keep an
173
+ // idle sweep from evicting the process until every borrower has finished.
174
+ const pinnedInstances = new Map<string, number>();
175
+
176
+ export function pinPlaywrightInstance(instanceKey: string): void {
177
+ pinnedInstances.set(instanceKey, (pinnedInstances.get(instanceKey) ?? 0) + 1);
178
+ }
179
+
180
+ export function unpinPlaywrightInstance(instanceKey: string): void {
181
+ const count = pinnedInstances.get(instanceKey);
182
+ if (count === undefined) return;
183
+ if (count <= 1) pinnedInstances.delete(instanceKey);
184
+ else pinnedInstances.set(instanceKey, count - 1);
185
+ }
186
+
187
+ export function getPlaywrightCapability(instanceKey: string): string | undefined {
188
+ return instances.get(instanceKey)?.capability;
189
+ }
190
+
191
+ // --- Abnormal exit notification callback ---
192
+ // Fired when a Playwright MCP child process exits with a non-zero / non-null
193
+ // code that did NOT originate from our own killInstance() (those paths are
194
+ // intentional cleanups, not crashes). Consumers use this to abort any
195
+ // in-flight `handleAgentQuery` whose Claude SDK is waiting on a tool result
196
+ // the dead MCP can never deliver — preventing 20+ minute typing-only stalls.
197
+ type PlaywrightExitHandler = (instanceKey: string, code: number | null) => void;
198
+ let _onPlaywrightExit: PlaywrightExitHandler | null = null;
199
+ export function onPlaywrightExit(handler: PlaywrightExitHandler) {
200
+ _onPlaywrightExit = handler;
201
+ }
202
+
203
+ /**
204
+ * Evict the oldest idle instance to free a port.
205
+ * Returns the evicted port, or null when no instance was eligible.
206
+ */
207
+ function evictIdleInstance(): number | null {
208
+ const now = Date.now();
209
+ const oldestKey = selectIdleEvictionKey(
210
+ instances,
211
+ pinnedInstances.keys(),
212
+ spawning.keys(),
213
+ now,
214
+ MAX_IDLE_MS,
215
+ );
216
+ if (oldestKey) {
217
+ const instance = instances.get(oldestKey);
218
+ if (!instance) return null;
219
+ const idleMin = ((now - instance.lastUsedAt) / 60000).toFixed(0);
220
+ logger.info({ key: oldestKey, idleMin }, "Evicting idle playwright instance");
221
+ killInstance(oldestKey);
222
+ return instance.port;
223
+ }
224
+ return null;
225
+ }
226
+
227
+ /** Select an idle instance while excluding active borrowers and lifecycle work. */
228
+ export function selectIdleEvictionKey(
229
+ candidates: Iterable<[string, { lastUsedAt: number }]>,
230
+ pinnedKeys: Iterable<string>,
231
+ busyKeys: Iterable<string>,
232
+ now: number,
233
+ maxIdleMs: number,
234
+ ): string | null {
235
+ const pinned = new Set(pinnedKeys);
236
+ const busy = new Set(busyKeys);
237
+ let oldest: { key: string; lastUsedAt: number } | null = null;
238
+ for (const [key, instance] of candidates) {
239
+ if (pinned.has(key) || busy.has(key)) continue;
240
+ if (now - instance.lastUsedAt < maxIdleMs) continue;
241
+ if (!oldest || instance.lastUsedAt < oldest.lastUsedAt) {
242
+ oldest = { key, lastUsedAt: instance.lastUsedAt };
243
+ }
244
+ }
245
+ return oldest?.key ?? null;
246
+ }
247
+
248
+ /**
249
+ * Find an available port, killing zombie processes if needed.
250
+ * If all ports are taken, evicts the oldest idle instance.
251
+ *
252
+ * `expectedUserDataDir` is forwarded to `killPlaywrightOnPort` so that only a
253
+ * playwright-mcp serving this caller's own userDataDir is treated as a zombie.
254
+ * Without that filter, another process with its own empty instances map could
255
+ * mistake a live topic Playwright for an orphan and SIGKILL it.
256
+ */
257
+ async function allocatePort(expectedUserDataDir?: string): Promise<number> {
258
+ for (let port = BASE_PORT; port <= MAX_PORT; port++) {
259
+ if (usedPorts.has(port)) continue;
260
+ usedPorts.add(port); // Reserve immediately before any await to prevent concurrent allocation
261
+
262
+ // Check if something else is already on this port (zombie from previous run)
263
+ if (isPortInUse(port)) {
264
+ logger.warn({ port }, "Port occupied by external process, attempting cleanup");
265
+ await killPlaywrightOnPort(port, expectedUserDataDir);
266
+ if (isPortInUse(port)) {
267
+ usedPorts.delete(port); // Still occupied — release reservation and try next
268
+ continue;
269
+ }
270
+ }
271
+
272
+ return port;
273
+ }
274
+
275
+ // All ports used — try evicting an idle instance
276
+ const evictedPort = evictIdleInstance();
277
+ if (evictedPort !== null) {
278
+ // SIGTERM only requests shutdown. Do not hand the port to a replacement
279
+ // until the old listener is actually gone, and recheck every candidate in
280
+ // case another allocator claimed a different port while we were waiting.
281
+ await waitForPortRelease(evictedPort);
282
+ const reusablePort = selectReusablePort(BASE_PORT, MAX_PORT, usedPorts, isPortInUse);
283
+ if (reusablePort !== null) {
284
+ usedPorts.add(reusablePort);
285
+ return reusablePort;
286
+ }
287
+ }
288
+
289
+ throw new Error(
290
+ `No available ports for Playwright MCP (${instances.size} active instances, range ${BASE_PORT}-${MAX_PORT})`,
291
+ );
292
+ }
293
+
294
+ /** Select a currently unreserved and unoccupied port without mutating state. */
295
+ export function selectReusablePort(
296
+ minPort: number,
297
+ maxPort: number,
298
+ reservedPorts: ReadonlySet<number>,
299
+ isOccupied: (port: number) => boolean,
300
+ ): number | null {
301
+ for (let port = minPort; port <= maxPort; port++) {
302
+ if (reservedPorts.has(port) || isOccupied(port)) continue;
303
+ return port;
304
+ }
305
+ return null;
306
+ }
307
+
308
+ function releasePort(port: number) {
309
+ usedPorts.delete(port);
310
+ }
311
+
312
+ function isPortInUse(port: number): boolean {
313
+ try {
314
+ execFileSync("lsof", ["-i", `:${port}`, "-t"], { stdio: "pipe" });
315
+ return true;
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Extract the value passed to `--user-data-dir` in a process command line.
323
+ * Returns null if absent or malformed. Supports both space- and equals-style
324
+ * (`--user-data-dir /path` vs `--user-data-dir=/path`); playwright-mcp uses
325
+ * the former, but the regex stays lenient.
326
+ *
327
+ * Exported only for unit-test access; treat as internal.
328
+ */
329
+ export function extractUserDataDirArg(cmdline: string): string | null {
330
+ const m = cmdline.match(/--user-data-dir(?:\s+|=)(\S+)/);
331
+ return m ? m[1] : null;
332
+ }
333
+
334
+ async function killPlaywrightOnPort(port: number, expectedUserDataDir?: string) {
335
+ try {
336
+ // Only kill mcp-patchright processes on this port, not arbitrary services
337
+ const pids = execFileSync("lsof", ["-i", `:${port}`, "-t"], { stdio: "pipe" })
338
+ .toString()
339
+ .trim();
340
+ if (!pids) return;
341
+ for (const pid of pids.split("\n")) {
342
+ try {
343
+ const cmdline = execFileSync("ps", ["-p", pid, "-o", "command="], { stdio: "pipe" })
344
+ .toString()
345
+ .trim();
346
+ if (!cmdline.includes("mcp-patchright")) {
347
+ logger.warn(
348
+ { port, pid, cmdline: cmdline.slice(0, 80) },
349
+ "Port occupied by non-browser-MCP process, skipping",
350
+ );
351
+ continue;
352
+ }
353
+ // Cross-process safety: when the caller knows which userDataDir its own
354
+ // Playwright will use, only treat a matching instance as a zombie.
355
+ // A playwright-mcp serving a different userDataDir belongs to another
356
+ // process (different topic / different bot instance) and must NOT be
357
+ // killed.
358
+ if (expectedUserDataDir) {
359
+ const otherDataDir = extractUserDataDirArg(cmdline);
360
+ if (!browserProcessMatchesExpectedProfile(cmdline, expectedUserDataDir)) {
361
+ logger.warn(
362
+ {
363
+ port,
364
+ pid,
365
+ otherDataDir,
366
+ expectedUserDataDir,
367
+ },
368
+ "Port occupied by another topic's playwright-mcp, skipping",
369
+ );
370
+ continue;
371
+ }
372
+ }
373
+ const pidNum = parseInt(pid, 10);
374
+ if (!Number.isNaN(pidNum)) {
375
+ // Reap Chrome children before the node parent — once the parent dies
376
+ // they reparent to init and `pgrep -P` can no longer find them,
377
+ // leaving orphan Chrome holding the same user-data-dir.
378
+ killProcessTreeChildren(pidNum);
379
+ process.kill(pidNum, "SIGKILL");
380
+ }
381
+ logger.info({ pid, port }, "Killed zombie mcp-patchright");
382
+ } catch (e) {
383
+ logger.warn({ err: e, port }, "Failed to inspect process occupying port");
384
+ }
385
+ }
386
+ } catch (e) {
387
+ logger.warn({ err: e, port }, "Failed to check processes on port");
388
+ }
389
+ // Wait for port to actually be released
390
+ const start = Date.now();
391
+ while (Date.now() - start < 3000) {
392
+ if (!isPortInUse(port)) return;
393
+ await delay(200);
394
+ }
395
+ }
396
+
397
+ /** Require positive command-line proof before killing a profile-scoped process. */
398
+ export function browserProcessMatchesExpectedProfile(
399
+ cmdline: string,
400
+ expectedUserDataDir: string,
401
+ ): boolean {
402
+ const actualUserDataDir = extractUserDataDirArg(cmdline);
403
+ return actualUserDataDir !== null && resolve(actualUserDataDir) === resolve(expectedUserDataDir);
404
+ }
405
+
406
+ /**
407
+ * Kill all leftover browser-MCP processes from previous bot runs.
408
+ * Call once at startup. Matches mcp-patchright and the legacy playwright-mcp
409
+ * so upgrades reap orphaned servers holding ports.
410
+ */
411
+ export function cleanupZombiePlaywright(): void {
412
+ try {
413
+ const pids = execFileSync("pgrep", ["-f", "mcp-patchright|playwright-mcp"], {
414
+ stdio: "pipe",
415
+ })
416
+ .toString()
417
+ .trim();
418
+ if (pids) {
419
+ logger.info({ pids: pids.replace(/\n/g, ", ") }, "Cleaning up zombie browser-MCP processes");
420
+ for (const pid of pids.split("\n")) {
421
+ const pidNum = parseInt(pid.trim(), 10);
422
+ if (!Number.isNaN(pidNum)) {
423
+ try {
424
+ const cmdline = execFileSync("ps", ["-p", String(pidNum), "-o", "command="], {
425
+ stdio: "pipe",
426
+ })
427
+ .toString()
428
+ .trim();
429
+ const userDataDir = extractUserDataDirArg(cmdline);
430
+ if (!userDataDir || !resolve(userDataDir).startsWith(resolve(BROWSER_PROFILES_DIR))) {
431
+ logger.warn(
432
+ { pid: pidNum, userDataDir },
433
+ "Skipping browser-MCP cleanup outside Otium browser profile dir",
434
+ );
435
+ continue;
436
+ }
437
+ // Reap Chrome children before the node parent (see killPlaywrightOnPort).
438
+ killProcessTreeChildren(pidNum);
439
+ process.kill(pidNum, "SIGKILL");
440
+ } catch (e) {
441
+ logger.warn({ err: e, pid: pidNum }, "Failed to kill leftover browser-MCP process");
442
+ }
443
+ }
444
+ }
445
+ }
446
+ } catch {
447
+ // No processes found — good
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Kill any browser process (the node MCP or the Chrome it launched) still
453
+ * holding a specific user-data-dir. Used on the crash-exit path: once the MCP
454
+ * parent dies on its own, its Chrome subtree reparents to init, so `pgrep -P`
455
+ * can no longer find it — but the Chrome browser process still carries
456
+ * `--user-data-dir <path>` in argv, so we match on the path instead. Bounded to
457
+ * Otium's profile dir so unrelated Chrome is never touched.
458
+ */
459
+ function killBrowserProcsForUserDataDir(userDataDir: string): void {
460
+ const target = resolve(userDataDir);
461
+ if (!target.startsWith(resolve(BROWSER_PROFILES_DIR))) return;
462
+ let pids: string;
463
+ try {
464
+ pids = execFileSync("pgrep", ["-f", "--", userDataDir], { stdio: "pipe" }).toString().trim();
465
+ } catch {
466
+ return; // no matches
467
+ }
468
+ if (!pids) return;
469
+ for (const pid of pids.split("\n")) {
470
+ const pidNum = parseInt(pid.trim(), 10);
471
+ if (Number.isNaN(pidNum) || pidNum === process.pid) continue;
472
+ try {
473
+ const cmdline = execFileSync("ps", ["-p", String(pidNum), "-o", "command="], {
474
+ stdio: "pipe",
475
+ })
476
+ .toString()
477
+ .trim();
478
+ const argDir = extractUserDataDirArg(cmdline);
479
+ // Only the process that actually owns this exact profile dir; renderer
480
+ // grandchildren carry no --user-data-dir and are reaped via the tree walk.
481
+ if (!argDir || resolve(argDir) !== target) continue;
482
+ killProcessTreeChildren(pidNum);
483
+ process.kill(pidNum, "SIGKILL");
484
+ logger.info({ pid: pidNum, userDataDir }, "Reaped orphaned browser process by user-data-dir");
485
+ } catch (e) {
486
+ logger.debug({ err: e, pid: pidNum, userDataDir }, "Failed to reap browser proc by dir");
487
+ }
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Pure orphan selection: given browser processes tagged with their
493
+ * `--user-data-dir`, the set of dirs owned by live tracked instances, and the
494
+ * profile root, return the pids to reap — those under our profile dir whose
495
+ * owning instance is no longer tracked (a dead MCP's leftover Chrome, or a
496
+ * stale MCP from a previous run). Exported for unit tests.
497
+ */
498
+ export function selectOrphanBrowserPids(
499
+ procs: Array<{ pid: number; userDataDir: string | null }>,
500
+ liveUserDataDirs: Iterable<string>,
501
+ profileRoot: string,
502
+ selfPid: number,
503
+ ): number[] {
504
+ const root = resolve(profileRoot);
505
+ const live = new Set([...liveUserDataDirs].map((d) => resolve(d)));
506
+ const out: number[] = [];
507
+ for (const { pid, userDataDir } of procs) {
508
+ if (pid === selfPid || !userDataDir) continue;
509
+ const dir = resolve(userDataDir);
510
+ if (!dir.startsWith(root)) continue; // never touch Chrome outside our dir
511
+ if (live.has(dir)) continue; // belongs to a tracked instance
512
+ out.push(pid);
513
+ }
514
+ return out;
515
+ }
516
+
517
+ /**
518
+ * Periodic sweep that reaps orphaned browser processes the tracked-instance map
519
+ * has lost sight of. The 30-min idle eviction only touches instances still in
520
+ * the map; when an MCP dies on its own its Chrome escapes the map entirely, so
521
+ * without this nothing reclaims it until the next process restart. Cascading
522
+ * orphans under memory pressure are exactly this failure mode.
523
+ */
524
+ function reapOrphanBrowsers(): void {
525
+ const profileRoot = resolve(BROWSER_PROFILES_DIR);
526
+ let pids: string;
527
+ try {
528
+ pids = execFileSync("pgrep", ["-f", "--", profileRoot], { stdio: "pipe" }).toString().trim();
529
+ } catch {
530
+ return; // no browser processes at all
531
+ }
532
+ if (!pids) return;
533
+
534
+ const procs: Array<{ pid: number; userDataDir: string | null }> = [];
535
+ for (const pid of pids.split("\n")) {
536
+ const pidNum = parseInt(pid.trim(), 10);
537
+ if (Number.isNaN(pidNum)) continue;
538
+ try {
539
+ const cmdline = execFileSync("ps", ["-p", String(pidNum), "-o", "command="], {
540
+ stdio: "pipe",
541
+ })
542
+ .toString()
543
+ .trim();
544
+ procs.push({ pid: pidNum, userDataDir: extractUserDataDirArg(cmdline) });
545
+ } catch {
546
+ // Process vanished between pgrep and ps — nothing to reap.
547
+ }
548
+ }
549
+
550
+ const liveDirs = [...instances.keys()].map(resolveUserDataDir);
551
+ const orphanPids = selectOrphanBrowserPids(procs, liveDirs, profileRoot, process.pid);
552
+ for (const pid of orphanPids) {
553
+ try {
554
+ killProcessTreeChildren(pid);
555
+ process.kill(pid, "SIGKILL");
556
+ logger.info({ pid }, "Reaped orphaned browser process (janitor)");
557
+ } catch (e) {
558
+ logger.debug({ err: e, pid }, "Janitor failed to reap orphaned browser process");
559
+ }
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Health check — can we reach the SSE endpoint?
565
+ */
566
+ async function isHealthy(port: number): Promise<boolean> {
567
+ for (const path of ["/health", "/sse?owner=__negotium_health__"]) {
568
+ try {
569
+ const res = await fetch(`http://127.0.0.1:${port}${path}`, {
570
+ signal: AbortSignal.timeout(2000),
571
+ });
572
+ await res.body?.cancel();
573
+ if (res.ok) return true;
574
+ } catch {
575
+ // Try the transport endpoint when this runtime has no /health route.
576
+ }
577
+ }
578
+ return false;
579
+ }
580
+
581
+ /**
582
+ * Clean up Chrome Singleton files (SingletonLock, SingletonSocket, SingletonCookie)
583
+ * in a user data directory. These stale files prevent browser relaunch after crash.
584
+ */
585
+ function cleanSingletonFiles(userDataDir: string): void {
586
+ try {
587
+ const files = readdirSync(userDataDir);
588
+ for (const f of files) {
589
+ if (f.startsWith("Singleton")) {
590
+ try {
591
+ unlinkSync(resolve(userDataDir, f));
592
+ logger.info({ file: f, userDataDir }, "Removed stale Singleton file");
593
+ } catch (e) {
594
+ logger.warn({ err: e, file: f }, "Failed to remove stale Chrome Singleton file");
595
+ }
596
+ }
597
+ }
598
+ } catch {
599
+ // Directory may not exist
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Kill the full process subtree spawned by a Playwright MCP instance.
605
+ *
606
+ * Chrome fans out into zygote/renderer/gpu grandchildren, so killing only
607
+ * direct children leaves orphaned Chrome processes after retries/aborts. Walk
608
+ * the tree before signaling so descendants are still discoverable.
609
+ */
610
+ function killProcessTreeChildren(pid: number): void {
611
+ try {
612
+ const children = execFileSync("pgrep", ["-P", String(pid)], { stdio: "pipe" })
613
+ .toString()
614
+ .trim();
615
+ if (!children) return;
616
+ const childPids = children
617
+ .split("\n")
618
+ .map((cpid) => parseInt(cpid, 10))
619
+ .filter((pidNum) => !Number.isNaN(pidNum));
620
+
621
+ for (const childPid of childPids) {
622
+ killProcessTreeChildren(childPid);
623
+ }
624
+
625
+ for (const signal of ["SIGTERM", "SIGKILL"] as const) {
626
+ for (const childPid of childPids) {
627
+ try {
628
+ process.kill(childPid, signal);
629
+ } catch (e) {
630
+ logger.debug({ err: e, pid: childPid, signal }, "Failed to signal browser child process");
631
+ }
632
+ }
633
+ }
634
+ logger.info({ parentPid: pid, childPids }, "Killed browser-MCP child process tree");
635
+ } catch {
636
+ // No children found — fine
637
+ }
638
+ }
639
+
640
+ function ownerDirectory(ownerId: string): string {
641
+ const digest = createHash("sha256").update(ownerId).digest("hex").slice(0, 16);
642
+ return `${sanitizeTopicName(ownerId).slice(0, 24)}_${digest}`;
643
+ }
644
+
645
+ function resolveProfileDir(ownerId: string, profile: string): string {
646
+ return resolve(BROWSER_PROFILES_DIR, "profiles", ownerDirectory(ownerId), profile);
647
+ }
648
+
649
+ /** Resolve the shared profile userDataDir for an instanceKey. */
650
+ function resolveUserDataDir(instanceKey: string): string {
651
+ const { ownerId, profile } = parseInstanceKey(instanceKey);
652
+ return resolveProfileDir(ownerId, profile);
653
+ }
654
+
655
+ /**
656
+ * Kill and clean up a specific instance.
657
+ * Also kills Chrome child processes and cleans up Singleton files.
658
+ * @param keepPort - If true, don't release the port (for same-port respawn).
659
+ */
660
+ function killInstance(instanceKey: string, opts?: { keepPort?: boolean }) {
661
+ const inst = instances.get(instanceKey);
662
+ if (!inst) return;
663
+
664
+ // Kill Chrome children first (before killing the MCP server)
665
+ if (inst.process.pid) {
666
+ killProcessTreeChildren(inst.process.pid);
667
+ }
668
+
669
+ try {
670
+ inst.process.kill("SIGTERM");
671
+ } catch (e) {
672
+ logger.warn({ err: e, instanceKey }, "Failed to kill playwright instance");
673
+ }
674
+
675
+ // Drop the spawn-time error/exit handlers. Cleanup is done synchronously
676
+ // here, so the late-firing listeners only add duplicate instances.delete()
677
+ // calls and keep the process reference alive until exit. Removing them
678
+ // also lets callers (e.g. killAllPlaywright) attach their own exit waiter
679
+ // without the spawn handler racing against it.
680
+ inst.process.removeAllListeners("error");
681
+ inst.process.removeAllListeners("exit");
682
+
683
+ cleanSingletonFiles(resolveUserDataDir(instanceKey));
684
+
685
+ if (!opts?.keepPort) releasePort(inst.port);
686
+ instances.delete(instanceKey);
687
+ deletePortFile(instanceKey);
688
+ logger.info(
689
+ { instanceKey, port: inst.port, keepPort: !!opts?.keepPort },
690
+ "Killed Playwright MCP (with cleanup)",
691
+ );
692
+ }
693
+
694
+ /**
695
+ * Spawn a browser MCP HTTP server for a shared named profile. The profile
696
+ * identity is encoded in `instanceKey` and decoded inside `resolveUserDataDir`.
697
+ */
698
+ /**
699
+ * Spawn a Playwright MCP process for the given instanceKey.
700
+ * @param instanceKey Unique key identifying this instance.
701
+ * @param userId Owner user ID (stored in the instance record).
702
+ * @param reservedPort When provided the process binds to this already-reserved
703
+ * port (restart path). When omitted a fresh port is allocated.
704
+ */
705
+ async function spawnPlaywright(
706
+ instanceKey: string,
707
+ userId: string,
708
+ reservedPort?: number,
709
+ browserBin = PLAYWRIGHT_MCP_BIN,
710
+ allowFallback = true,
711
+ ): Promise<number> {
712
+ const userDataDir = resolveUserDataDir(instanceKey);
713
+ // Forward userDataDir to allocatePort so the in-port zombie scan can
714
+ // distinguish "my own orphan to kill" from "another process's live
715
+ // instance to skip past" — see allocatePort docstring.
716
+ const port = reservedPort ?? (await allocatePort(userDataDir));
717
+ mkdirSync(userDataDir, { recursive: true });
718
+
719
+ const mcpArgs = [
720
+ "--port",
721
+ String(port),
722
+ // Pin to IPv4. `--host 127.0.0.1` keeps every transport on the same
723
+ // address family. Without it, a `localhost`→`::1`-only bind breaks the
724
+ // Maestro MCP client (its SSEClientTransport forces `127.0.0.1` because
725
+ // the Node `eventsource` package's IPv6 lookup historically failed), so
726
+ // browser tools would silently disappear from maestro turns.
727
+ "--host",
728
+ "127.0.0.1",
729
+ "--user-data-dir",
730
+ userDataDir,
731
+ // NOTE: mcp-patchright throws on unknown CLI args, so the old
732
+ // @playwright/mcp flags are intentionally gone:
733
+ // --shared-browser-context → mcp-patchright always uses one persistent
734
+ // context per userDataDir (launchPersistentContext), so it's implicit.
735
+ // --browser chrome → defaults to the "chrome" channel already
736
+ // (requires real Google Chrome at /opt/google/chrome/chrome).
737
+ // --init-script <stealth> → unneeded; Patchright is stealth by default.
738
+ ];
739
+ // Headed by default, matching clawgram's production behavior.
740
+
741
+ // Pass the egress proxy to the child through the environment rather than
742
+ // argv so the credentials never surface in `ps`/`/proc` command lines. The
743
+ // launcher (scripts/mcp-patchright-http.mjs) reads these NEGOTIUM_BROWSER_PROXY_*
744
+ // vars and hands them to Playwright's per-context proxy option.
745
+ const proxy = resolveBrowserProxy();
746
+ const capability = randomBytes(32).toString("hex");
747
+ const childEnv = {
748
+ ...process.env,
749
+ NEGOTIUM_BROWSER_CAPABILITY: capability,
750
+ ...(proxy
751
+ ? {
752
+ NEGOTIUM_BROWSER_PROXY_SERVER: proxy.server,
753
+ ...(proxy.username ? { NEGOTIUM_BROWSER_PROXY_USERNAME: proxy.username } : {}),
754
+ ...(proxy.password ? { NEGOTIUM_BROWSER_PROXY_PASSWORD: proxy.password } : {}),
755
+ ...(proxy.bypass ? { NEGOTIUM_BROWSER_PROXY_BYPASS: proxy.bypass } : {}),
756
+ }
757
+ : {}),
758
+ };
759
+ if (proxy) {
760
+ logger.info({ instanceKey, proxyServer: proxy.server }, "Browser egress proxy enabled");
761
+ }
762
+
763
+ const command = browserBin.endsWith(".mjs") ? process.execPath : browserBin;
764
+ const args = browserBin.endsWith(".mjs") ? [browserBin, ...mcpArgs] : mcpArgs;
765
+ const proc = spawn(command, args, {
766
+ stdio: "ignore",
767
+ detached: false,
768
+ env: childEnv,
769
+ });
770
+
771
+ // When the MCP dies on its own (crash / OOM), its Chrome subtree is reparented
772
+ // to init and escapes the tracked-instance map. Reap it by user-data-dir here
773
+ // so a crash can't leak an orphan Chrome that keeps holding memory (the exact
774
+ // cascade that piles up dozens of zombies under pressure). killInstance()
775
+ // removes these listeners before signalling, so this only runs on real deaths.
776
+ const reapCrashedBrowser = () => {
777
+ const userDataDir = resolveUserDataDir(instanceKey);
778
+ killBrowserProcsForUserDataDir(userDataDir);
779
+ cleanSingletonFiles(userDataDir);
780
+ };
781
+
782
+ proc.once("error", (err) => {
783
+ logger.error({ err, instanceKey }, "Playwright MCP error");
784
+ if (instances.get(instanceKey)?.process === proc) {
785
+ releasePort(port);
786
+ instances.delete(instanceKey);
787
+ reapCrashedBrowser();
788
+ }
789
+ });
790
+
791
+ proc.once("exit", (code) => {
792
+ logger.info({ instanceKey, code }, "Playwright MCP exited");
793
+ // `wasOurs` distinguishes a crash from our own killInstance(): the latter
794
+ // calls removeAllListeners("exit") + instances.delete() BEFORE the kill
795
+ // signal lands, so this listener either doesn't fire or sees wasOurs=false.
796
+ // wasOurs=true here therefore implies the MCP died on its own — Claude
797
+ // SDK queries waiting on its tool results need to be aborted.
798
+ const wasOurs = instances.get(instanceKey)?.process === proc;
799
+ if (wasOurs) {
800
+ releasePort(port);
801
+ instances.delete(instanceKey);
802
+ reapCrashedBrowser();
803
+ _onPlaywrightExit?.(instanceKey, code);
804
+ }
805
+ });
806
+
807
+ const now = Date.now();
808
+ instances.set(instanceKey, {
809
+ process: proc,
810
+ port,
811
+ userId,
812
+ startedAt: now,
813
+ lastUsedAt: now,
814
+ capability,
815
+ });
816
+
817
+ const ready =
818
+ (await waitForServer(port, 10_000)) && (await supportsOwnerCleanup(port, capability));
819
+ if (!ready) {
820
+ const exitCode = proc.exitCode;
821
+ killInstance(instanceKey);
822
+ if (allowFallback && browserBin !== PATCHRIGHT_MCP_BIN) {
823
+ logger.warn(
824
+ { instanceKey, browserBin, fallback: PATCHRIGHT_MCP_BIN },
825
+ "Preferred browser MCP unavailable or lacks owner isolation; using Patchright fallback",
826
+ );
827
+ return spawnPlaywright(instanceKey, userId, undefined, PATCHRIGHT_MCP_BIN, false);
828
+ }
829
+ throw new Error(
830
+ `Playwright MCP failed health check after spawn on port ${port}` +
831
+ (exitCode === null ? "" : ` (exitCode=${exitCode})`),
832
+ );
833
+ }
834
+ writePortFile(instanceKey, port);
835
+ logger.info({ instanceKey, port, pid: proc.pid, ready }, "Playwright MCP started");
836
+ return port;
837
+ }
838
+
839
+ async function supportsOwnerCleanup(port: number, capability: string): Promise<boolean> {
840
+ try {
841
+ const response = await fetch(`http://127.0.0.1:${port}/owners`, {
842
+ method: "DELETE",
843
+ headers: {
844
+ "X-Browser-Owner": "__negotium_capability_probe__",
845
+ "X-Browser-Capability": capability,
846
+ },
847
+ signal: AbortSignal.timeout(2000),
848
+ });
849
+ await response.body?.cancel();
850
+ return response.ok;
851
+ } catch {
852
+ return false;
853
+ }
854
+ }
855
+
856
+ /**
857
+ * Ensure a healthy browser MCP server is running for this topic's profile.
858
+ * Topics assigned to the same profile reuse one process and data directory.
859
+ * - If running and healthy → reuse
860
+ * - If running but unhealthy → kill and respawn
861
+ * - If not running → spawn
862
+ */
863
+ export async function ensurePlaywright(userId: string, topic?: string): Promise<number> {
864
+ const instanceKey = makeInstanceKey(userId, topic);
865
+
866
+ // If a spawn/restart is already in progress for this key, wait for it
867
+ const inProgress = spawning.get(instanceKey);
868
+ if (inProgress) {
869
+ const port = await inProgress;
870
+ if (port !== null) return port;
871
+ // Restart failed — re-enter: by now another caller may have registered a
872
+ // newer attempt to join, otherwise we start one ourselves.
873
+ return ensurePlaywright(userId, topic);
874
+ }
875
+
876
+ // The ENTIRE health-check → kill → spawn sequence lives inside the spawning
877
+ // promise. The old shape awaited isHealthy() between the guard check and
878
+ // spawning.set(), so two concurrent callers could both pass the guard: one
879
+ // would killInstance() the instance the other had just spawned, or both
880
+ // would spawn and the second instances.set() orphaned the first process
881
+ // (its port leaked from usedPorts until restart). With no await between
882
+ // spawning.get() above and spawning.set() below, the guard is airtight on
883
+ // a single-threaded event loop.
884
+ const promise = (async (): Promise<number> => {
885
+ const existing = instances.get(instanceKey);
886
+
887
+ if (existing && !existing.process.killed && existing.process.exitCode === null) {
888
+ if (await isHealthy(existing.port)) {
889
+ existing.lastUsedAt = Date.now();
890
+ return existing.port;
891
+ }
892
+ logger.warn({ instanceKey }, "Playwright MCP unresponsive, restarting");
893
+ const oldPort = existing.port;
894
+ killInstance(instanceKey);
895
+ await waitForPortRelease(oldPort);
896
+ cleanSingletonFiles(resolveUserDataDir(instanceKey));
897
+ } else if (existing) {
898
+ releasePort(existing.port);
899
+ instances.delete(instanceKey);
900
+ cleanSingletonFiles(resolveUserDataDir(instanceKey));
901
+ }
902
+
903
+ return spawnPlaywright(instanceKey, userId);
904
+ })().finally(() => spawning.delete(instanceKey));
905
+ spawning.set(instanceKey, promise);
906
+ return promise;
907
+ }
908
+
909
+ async function waitForPortRelease(port: number, timeoutMs = 3000): Promise<void> {
910
+ const start = Date.now();
911
+ while (Date.now() - start < timeoutMs) {
912
+ if (!isPortInUse(port)) return;
913
+ await delay(200);
914
+ }
915
+ }
916
+
917
+ /** Start or reuse a named shared profile before assigning more topics to it. */
918
+ export async function ensureBrowserProfile(ownerId: string, rawProfile: string): Promise<number> {
919
+ const instanceKey = makeBrowserProfileInstanceKey(ownerId, rawProfile);
920
+ const inProgress = spawning.get(instanceKey);
921
+ if (inProgress) {
922
+ const port = await inProgress;
923
+ if (port !== null && (await isHealthy(port))) return port;
924
+ if (port === null) return ensureBrowserProfile(ownerId, rawProfile);
925
+ throw new Error(`Browser profile "${rawProfile}" failed to start.`);
926
+ }
927
+
928
+ const promise = (async (): Promise<number> => {
929
+ const publishedPort = readPortFile(instanceKey);
930
+ const existing = instances.get(instanceKey);
931
+ if (publishedPort !== null && !existing) {
932
+ // A port file without an in-memory instance belongs to a previous runtime.
933
+ // Its per-process capability and ChildProcess handle cannot be recovered,
934
+ // so adopting it would bypass owner cleanup and lifecycle tracking. Reap
935
+ // only the browser serving this exact profile, then spawn a tracked one.
936
+ logger.warn(
937
+ { instanceKey, publishedPort },
938
+ "Recycling untracked browser profile process from stale port file",
939
+ );
940
+ await killPlaywrightOnPort(publishedPort, resolveUserDataDir(instanceKey));
941
+ await waitForPortRelease(publishedPort);
942
+ deletePortFile(instanceKey);
943
+ }
944
+
945
+ if (existing && !existing.process.killed && existing.process.exitCode === null) {
946
+ if (await isHealthy(existing.port)) {
947
+ existing.lastUsedAt = Date.now();
948
+ return existing.port;
949
+ }
950
+ const oldPort = existing.port;
951
+ killInstance(instanceKey);
952
+ await waitForPortRelease(oldPort);
953
+ cleanSingletonFiles(resolveUserDataDir(instanceKey));
954
+ }
955
+ const port = await spawnPlaywright(instanceKey, ownerId);
956
+ if (!(await isHealthy(port))) {
957
+ killInstance(instanceKey);
958
+ throw new Error(`Browser profile "${rawProfile}" did not pass its health check.`);
959
+ }
960
+ return port;
961
+ })().finally(() => spawning.delete(instanceKey));
962
+ spawning.set(instanceKey, promise);
963
+ return promise;
964
+ }
965
+
966
+ /** Close only one topic/job's tabs while preserving the shared profile. */
967
+ export async function closeBrowserOwnerTabs(
968
+ ownerId: string,
969
+ rawProfile: string,
970
+ owner: string,
971
+ ): Promise<number> {
972
+ const instanceKey = makeBrowserProfileInstanceKey(ownerId, rawProfile);
973
+ const inProgress = spawning.get(instanceKey);
974
+ if (inProgress) await inProgress;
975
+ const instance = instances.get(instanceKey);
976
+ if (!instance) return 0;
977
+ const port = instance.port;
978
+
979
+ const response = await fetch(`http://127.0.0.1:${port}/owners`, {
980
+ method: "DELETE",
981
+ headers: {
982
+ "X-Browser-Owner": owner,
983
+ "X-Browser-Capability": instance.capability,
984
+ },
985
+ signal: AbortSignal.timeout(5000),
986
+ });
987
+ if (!response.ok) throw new Error(`browser MCP owner cleanup failed (${response.status})`);
988
+ const result = (await response.json()) as { closed?: number };
989
+ return typeof result.closed === "number" ? result.closed : 0;
990
+ }
991
+
992
+ /**
993
+ * Poll until the SSE server responds, or timeout.
994
+ * Returns true if the server is healthy, false on timeout.
995
+ */
996
+ async function waitForServer(port: number, timeoutMs: number): Promise<boolean> {
997
+ const start = Date.now();
998
+ while (Date.now() - start < timeoutMs) {
999
+ if (await isHealthy(port)) return true;
1000
+ await delay(300);
1001
+ }
1002
+ logger.warn({ port, timeoutMs }, "Playwright MCP not ready before timeout");
1003
+ return false;
1004
+ }
1005
+
1006
+ /**
1007
+ * Kill all Playwright MCP instances for a specific user.
1008
+ */
1009
+ export function killPlaywrightForUser(userId: string): void {
1010
+ logger.info({ userId }, "killPlaywrightForUser: no user-scoped browser profile to kill");
1011
+ }
1012
+
1013
+ /**
1014
+ * Legacy API retained for callers that still request topic-scoped shutdown.
1015
+ * Shared profiles cannot be killed through a single topic.
1016
+ */
1017
+ export function killPlaywrightForTopic(userId: string, topic: string): void {
1018
+ logger.info(
1019
+ { userId, topic },
1020
+ "killPlaywrightForTopic skipped: topic uses a shared browser profile",
1021
+ );
1022
+ }
1023
+
1024
+ /**
1025
+ * Resolve the on-disk Playwright user-data-dir for a topic/profile id.
1026
+ * Useful for external callers that need to inspect / copy / delete the dir without
1027
+ * touching the running process.
1028
+ */
1029
+ export function resolveTopicProfileDir(userId: string, topic: string): string {
1030
+ return resolveUserDataDir(makeInstanceKey(userId, topic));
1031
+ }
1032
+
1033
+ export interface CloneProfileResult {
1034
+ copied: boolean;
1035
+ srcDir: string;
1036
+ dstDir: string;
1037
+ /** Set when `copied=false` to explain why (e.g. src-missing, same-dir, copy-failed:…) */
1038
+ reason?: string;
1039
+ }
1040
+
1041
+ /**
1042
+ * Assign a child to its parent's shared profile when owners match. Browser
1043
+ * credentials never cross an owner boundary.
1044
+ *
1045
+ * Safety: if the parent instance is currently running, we kill it first to flush
1046
+ * SQLite WAL state to disk, then copy. The parent is NOT respawned here — the
1047
+ * next user message on the parent topic will trigger ensurePlaywright() to bring
1048
+ * it back. This avoids a race where copy and live writes interleave.
1049
+ *
1050
+ * Copy strategy:
1051
+ * - macOS APFS: `cp -cR` triggers clonefile() (metadata-only, ms-level).
1052
+ * - Other platforms, or if clonefile fails: regular recursive copy.
1053
+ */
1054
+ export async function cloneProfileForChild(opts: {
1055
+ userId: string;
1056
+ srcTopic: string;
1057
+ dstTopic: string;
1058
+ }): Promise<CloneProfileResult> {
1059
+ const srcOwner = getBrowserProfileOwner(opts.srcTopic, opts.userId);
1060
+ const dstOwner = getBrowserProfileOwner(opts.dstTopic, opts.userId);
1061
+ if (srcOwner !== dstOwner) {
1062
+ const dstDir = resolveProfileDir(dstOwner, getTopicBrowserProfile(opts.dstTopic));
1063
+ return {
1064
+ copied: false,
1065
+ srcDir: resolveProfileDir(srcOwner, getTopicBrowserProfile(opts.srcTopic)),
1066
+ dstDir,
1067
+ reason: "cross-owner-fresh-profile",
1068
+ };
1069
+ }
1070
+ if (srcOwner === dstOwner && hasBrowserProfileTopic(opts.dstTopic)) {
1071
+ const profile = getTopicBrowserProfile(opts.srcTopic);
1072
+ assignTopicBrowserProfile({ topicId: opts.dstTopic, actorUserId: dstOwner, profile });
1073
+ const sharedDir = resolveProfileDir(srcOwner, profile);
1074
+ return {
1075
+ copied: false,
1076
+ srcDir: sharedDir,
1077
+ dstDir: sharedDir,
1078
+ reason: "shared-profile-assignment",
1079
+ };
1080
+ }
1081
+
1082
+ const srcKey = makeInstanceKey(opts.userId, opts.srcTopic);
1083
+ const dstKey = makeInstanceKey(opts.userId, opts.dstTopic);
1084
+ const srcDir = resolveUserDataDir(srcKey);
1085
+ const dstDir = resolveUserDataDir(dstKey);
1086
+
1087
+ if (srcDir === dstDir) {
1088
+ return { copied: false, srcDir, dstDir, reason: "same-dir" };
1089
+ }
1090
+
1091
+ return withPlaywrightInstanceMaintenance([srcKey, dstKey], async () => {
1092
+ // Quiesce parent if running so Chrome flushes SQLite (Cookies, Login Data)
1093
+ // before we read the bytes. The maintenance barrier prevents a concurrent
1094
+ // ensurePlaywright() from reopening either profile until copying finishes.
1095
+ //
1096
+ // killInstance() sends SIGTERM and removes the instance map entry synchronously,
1097
+ // but Chrome's subprocess exit + on-disk flush is async. Empirically 1.0–1.5s
1098
+ // is enough for the cookie store to settle; we wait 1.5s to be safe.
1099
+ const parentWasLive = instances.has(srcKey);
1100
+ if (parentWasLive) {
1101
+ logger.info({ srcKey }, "Quiescing parent Playwright before profile clone");
1102
+ killInstance(srcKey);
1103
+ await delay(1500);
1104
+ }
1105
+
1106
+ // Defensive: dst should be brand-new but kill any stray instance just in case
1107
+ if (instances.has(dstKey)) {
1108
+ killInstance(dstKey);
1109
+ await delay(500);
1110
+ }
1111
+
1112
+ if (!existsSync(srcDir)) {
1113
+ return { copied: false, srcDir, dstDir, reason: "src-missing" };
1114
+ }
1115
+
1116
+ try {
1117
+ if (existsSync(dstDir)) {
1118
+ rmSync(dstDir, { recursive: true, force: true });
1119
+ }
1120
+ mkdirSync(dirname(dstDir), { recursive: true });
1121
+ if (process.platform === "darwin") {
1122
+ try {
1123
+ // `-c` requests clonefile() on APFS for a fast copy-on-write clone.
1124
+ execFileSync("cp", ["-cR", srcDir, dstDir], { stdio: "pipe" });
1125
+ } catch {
1126
+ cpSync(srcDir, dstDir, { recursive: true });
1127
+ }
1128
+ } else {
1129
+ cpSync(srcDir, dstDir, { recursive: true });
1130
+ }
1131
+ } catch (e) {
1132
+ const reason = `copy-failed: ${e instanceof Error ? e.message : String(e)}`;
1133
+ logger.warn({ srcDir, dstDir, err: e }, "Playwright profile clone failed");
1134
+ return { copied: false, srcDir, dstDir, reason };
1135
+ }
1136
+
1137
+ // Strip per-process locks copied from the parent so the child Chrome can
1138
+ // launch on its fresh dir without a fake "another instance is running" error.
1139
+ cleanSingletonFiles(dstDir);
1140
+ for (const f of ["DevToolsActivePort", "LOCK"]) {
1141
+ try {
1142
+ unlinkSync(resolve(dstDir, f));
1143
+ } catch {
1144
+ // Not all profiles have these — ignore.
1145
+ }
1146
+ }
1147
+
1148
+ logger.info({ srcKey, dstKey, srcDir, dstDir }, "Cloned Playwright profile for child topic");
1149
+ return { copied: true, srcDir, dstDir };
1150
+ });
1151
+ }
1152
+
1153
+ /**
1154
+ * Compatibility API for hard-delete callers. Shared profile directories are
1155
+ * preserved; lifecycle cleanup closes only the deleted topic's owner tabs.
1156
+ */
1157
+ export function deleteTopicProfileDir(
1158
+ userId: string,
1159
+ topic: string,
1160
+ ): { deleted: boolean; dir: string } {
1161
+ const key = makeInstanceKey(userId, topic);
1162
+ const dir = resolveUserDataDir(key);
1163
+ logger.info({ dir, userId, topic }, "Preserved shared browser profile on topic deletion");
1164
+ return { deleted: false, dir };
1165
+ }
1166
+
1167
+ /**
1168
+ * Kill all running Playwright MCP instances and wait for them to exit.
1169
+ * Call on bot shutdown. Waits up to 3s per instance before giving up.
1170
+ */
1171
+ export async function killAllPlaywright(): Promise<void> {
1172
+ const procs = [...instances.entries()].map(([key, inst]) => ({ key, proc: inst.process }));
1173
+ for (const { key } of procs) killInstance(key);
1174
+
1175
+ await Promise.all(
1176
+ procs.map(async ({ key, proc }) => {
1177
+ if (await waitForChildProcessExit(proc, 3000)) return;
1178
+ logger.warn({ instanceKey: key, pid: proc.pid }, "Playwright MCP ignored SIGTERM");
1179
+ try {
1180
+ proc.kill("SIGKILL");
1181
+ } catch (err) {
1182
+ logger.warn({ err, instanceKey: key, pid: proc.pid }, "Failed to SIGKILL Playwright MCP");
1183
+ }
1184
+ if (!(await waitForChildProcessExit(proc, 1000))) {
1185
+ logger.warn({ instanceKey: key, pid: proc.pid }, "Playwright MCP did not report exit");
1186
+ }
1187
+ }),
1188
+ );
1189
+ }
1190
+
1191
+ /** Wait for an actual process exit; ChildProcess.killed only means a signal was sent. */
1192
+ export function waitForChildProcessExit(proc: ChildProcess, timeoutMs: number): Promise<boolean> {
1193
+ if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve(true);
1194
+
1195
+ return new Promise<boolean>((resolveWait) => {
1196
+ let settled = false;
1197
+ let timer: ReturnType<typeof setTimeout>;
1198
+ const finish = (exited: boolean) => {
1199
+ if (settled) return;
1200
+ settled = true;
1201
+ clearTimeout(timer);
1202
+ proc.off("exit", onExit);
1203
+ resolveWait(exited);
1204
+ };
1205
+ const onExit = () => finish(true);
1206
+
1207
+ proc.once("exit", onExit);
1208
+ timer = setTimeout(() => finish(proc.exitCode !== null || proc.signalCode !== null), timeoutMs);
1209
+ if (typeof timer === "object") timer.unref?.();
1210
+
1211
+ // Close the gap between the initial state check and listener registration.
1212
+ if (proc.exitCode !== null || proc.signalCode !== null) finish(true);
1213
+ });
1214
+ }
1215
+
1216
+ // Proactively evict all idle instances every 30 minutes
1217
+ // Every 30 minutes: evict idle instances, then reap any orphaned browser the
1218
+ // tracked-instance map has lost sight of. The orphan reap is only a backstop —
1219
+ // the crash-exit handler already reaps in real time (killBrowserProcsForUserDataDir),
1220
+ // so this catches just the rare miss (an exit event that never fired). A single
1221
+ // low-frequency sweep keeps it cheap: one `pgrep` that returns nothing when no
1222
+ // browser is running, a few `ps` calls only while automation is active.
1223
+ setInterval(
1224
+ () => {
1225
+ while (evictIdleInstance() !== null) {}
1226
+ try {
1227
+ reapOrphanBrowsers();
1228
+ } catch (e) {
1229
+ logger.debug({ err: e }, "reapOrphanBrowsers sweep failed");
1230
+ }
1231
+ },
1232
+ 30 * 60 * 1000,
1233
+ ).unref();