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.
- package/README.md +26 -2
- package/dist/canonical-mcp-bridge.js +14 -0
- package/dist/canonical-mcp-bridge.js.map +9 -0
- package/dist/chunk-10ct8814.js +429 -0
- package/dist/chunk-10ct8814.js.map +11 -0
- package/dist/chunk-18fzftjf.js +214 -0
- package/dist/chunk-18fzftjf.js.map +10 -0
- package/dist/chunk-1k7t7bnx.js +97 -0
- package/dist/chunk-1k7t7bnx.js.map +10 -0
- package/dist/chunk-1msje2zf.js +23 -0
- package/dist/chunk-1msje2zf.js.map +10 -0
- package/dist/chunk-2dgkah1z.js +706 -0
- package/dist/chunk-2dgkah1z.js.map +14 -0
- package/dist/chunk-3t4dtm26.js +61 -0
- package/dist/chunk-3t4dtm26.js.map +10 -0
- package/dist/chunk-3vn2vnj1.js +51 -0
- package/dist/chunk-3vn2vnj1.js.map +10 -0
- package/dist/chunk-6hknphtc.js +2054 -0
- package/dist/chunk-6hknphtc.js.map +29 -0
- package/dist/chunk-6tfzpcvk.js +2149 -0
- package/dist/chunk-6tfzpcvk.js.map +15 -0
- package/dist/chunk-8g0c4as0.js +23 -0
- package/dist/chunk-8g0c4as0.js.map +10 -0
- package/dist/chunk-9spx3h9n.js +279 -0
- package/dist/chunk-9spx3h9n.js.map +10 -0
- package/dist/chunk-c500xe9z.js +33 -0
- package/dist/chunk-c500xe9z.js.map +10 -0
- package/dist/chunk-e9b2xpff.js +147 -0
- package/dist/chunk-e9b2xpff.js.map +10 -0
- package/dist/chunk-edb07sjp.js +567 -0
- package/dist/chunk-edb07sjp.js.map +14 -0
- package/dist/chunk-eexqsee7.js +368 -0
- package/dist/chunk-eexqsee7.js.map +11 -0
- package/dist/chunk-efyxk97v.js +500 -0
- package/dist/chunk-efyxk97v.js.map +13 -0
- package/dist/chunk-epqxwkvh.js +3534 -0
- package/dist/chunk-epqxwkvh.js.map +20 -0
- package/dist/chunk-f1pzh3xh.js +115 -0
- package/dist/chunk-f1pzh3xh.js.map +10 -0
- package/dist/chunk-f5xgprg1.js +59 -0
- package/dist/chunk-f5xgprg1.js.map +10 -0
- package/dist/chunk-f97qybwk.js +584 -0
- package/dist/chunk-f97qybwk.js.map +11 -0
- package/dist/chunk-fvc7ncxd.js +252 -0
- package/dist/chunk-fvc7ncxd.js.map +10 -0
- package/dist/chunk-g7pvfj5e.js +574 -0
- package/dist/chunk-g7pvfj5e.js.map +14 -0
- package/dist/chunk-h8vmb9gf.js +315 -0
- package/dist/chunk-h8vmb9gf.js.map +10 -0
- package/dist/chunk-hgw7n8cm.js +43 -0
- package/dist/chunk-hgw7n8cm.js.map +10 -0
- package/dist/chunk-hsq7j9jk.js +126 -0
- package/dist/chunk-hsq7j9jk.js.map +10 -0
- package/dist/chunk-hwy3n31w.js +21 -0
- package/dist/chunk-hwy3n31w.js.map +9 -0
- package/dist/chunk-j0kjscb7.js +53 -0
- package/dist/chunk-j0kjscb7.js.map +10 -0
- package/dist/chunk-jzz91jwh.js +70 -0
- package/dist/chunk-jzz91jwh.js.map +10 -0
- package/dist/chunk-k9z47mab.js +247 -0
- package/dist/chunk-k9z47mab.js.map +12 -0
- package/dist/chunk-kbm35t7w.js +1331 -0
- package/dist/chunk-kbm35t7w.js.map +16 -0
- package/dist/chunk-m4veth0c.js +1649 -0
- package/dist/chunk-m4veth0c.js.map +17 -0
- package/dist/chunk-mhty3nna.js +150 -0
- package/dist/chunk-mhty3nna.js.map +11 -0
- package/dist/chunk-mmgh670x.js +248 -0
- package/dist/chunk-mmgh670x.js.map +11 -0
- package/dist/chunk-n0kk5xbq.js +2358 -0
- package/dist/chunk-n0kk5xbq.js.map +26 -0
- package/dist/chunk-nmv41qx9.js +2847 -0
- package/dist/chunk-nmv41qx9.js.map +23 -0
- package/dist/chunk-nqdp52aj.js +113 -0
- package/dist/chunk-nqdp52aj.js.map +12 -0
- package/dist/chunk-ns2m25dz.js +139 -0
- package/dist/chunk-ns2m25dz.js.map +10 -0
- package/dist/chunk-pkdmypdd.js +147 -0
- package/dist/chunk-pkdmypdd.js.map +11 -0
- package/dist/chunk-rmyfmrrr.js +1634 -0
- package/dist/chunk-rmyfmrrr.js.map +15 -0
- package/dist/chunk-sa874j5c.js +9 -0
- package/dist/chunk-sa874j5c.js.map +10 -0
- package/dist/chunk-sgsz2fpq.js +289 -0
- package/dist/chunk-sgsz2fpq.js.map +10 -0
- package/dist/chunk-sn51wnf1.js +3020 -0
- package/dist/chunk-sn51wnf1.js.map +32 -0
- package/dist/chunk-sqhr1a7e.js +587 -0
- package/dist/chunk-sqhr1a7e.js.map +11 -0
- package/dist/chunk-tmj5r2zv.js +2933 -0
- package/dist/chunk-tmj5r2zv.js.map +20 -0
- package/dist/chunk-vefj3880.js +56 -0
- package/dist/chunk-vefj3880.js.map +10 -0
- package/dist/chunk-vp2db42d.js +121 -0
- package/dist/chunk-vp2db42d.js.map +10 -0
- package/dist/chunk-wry79q55.js +108 -0
- package/dist/chunk-wry79q55.js.map +10 -0
- package/dist/chunk-x1arr7ze.js +63 -0
- package/dist/chunk-x1arr7ze.js.map +10 -0
- package/dist/chunk-z2xy5n2q.js +103 -0
- package/dist/chunk-z2xy5n2q.js.map +10 -0
- package/dist/hosted-agent.js +53 -0
- package/dist/hosted-agent.js.map +10 -0
- package/dist/main.js +132 -20
- package/dist/main.js.map +4 -4
- package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
- package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
- package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
- package/dist/runtime/src/agents/archiver.ts +396 -0
- package/dist/runtime/src/agents/auth-check.ts +64 -0
- package/dist/runtime/src/agents/claude-provider.ts +737 -0
- package/dist/runtime/src/agents/claude-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
- package/dist/runtime/src/agents/codex-provider.ts +785 -0
- package/dist/runtime/src/agents/codex-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
- package/dist/runtime/src/agents/contracts.ts +69 -0
- package/dist/runtime/src/agents/deep-map.ts +13 -0
- package/dist/runtime/src/agents/execution-host.ts +91 -0
- package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
- package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
- package/dist/runtime/src/agents/fork.ts +65 -0
- package/dist/runtime/src/agents/hosted-agent.ts +49 -0
- package/dist/runtime/src/agents/idle-archiver.ts +128 -0
- package/dist/runtime/src/agents/index.ts +205 -0
- package/dist/runtime/src/agents/maestro-provider.ts +206 -0
- package/dist/runtime/src/agents/maestro-registry.ts +31 -0
- package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
- package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
- package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
- package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
- package/dist/runtime/src/agents/model-catalog.ts +151 -0
- package/dist/runtime/src/agents/registry.ts +24 -0
- package/dist/runtime/src/agents/rollout/claude.ts +331 -0
- package/dist/runtime/src/agents/rollout/codex.ts +569 -0
- package/dist/runtime/src/agents/rollout/shared.ts +205 -0
- package/dist/runtime/src/agents/self-config-core.ts +415 -0
- package/dist/runtime/src/agents/task-events.ts +44 -0
- package/dist/runtime/src/agents/tool-format.ts +352 -0
- package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
- package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
- package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
- package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
- package/dist/runtime/src/application/submit-user-message.ts +50 -0
- package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
- package/dist/runtime/src/application/switch-topic-model.ts +79 -0
- package/dist/runtime/src/application/topic-service.ts +139 -0
- package/dist/runtime/src/application/vault-command.ts +170 -0
- package/dist/runtime/src/bus.ts +319 -0
- package/dist/runtime/src/index.ts +333 -0
- package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
- package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
- package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
- package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
- package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
- package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
- package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
- package/dist/runtime/src/mcp/runtime-spec.ts +137 -0
- package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
- package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
- package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
- package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
- package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
- package/dist/runtime/src/mcp/system-health-server.ts +130 -0
- package/dist/runtime/src/mcp/task-server.ts +173 -0
- package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
- package/dist/runtime/src/mcp/vault-http.ts +219 -0
- package/dist/runtime/src/mcp/vault-run.ts +153 -0
- package/dist/runtime/src/mcp/vault-server.ts +104 -0
- package/dist/runtime/src/mcp/wiki-server.ts +755 -0
- package/dist/runtime/src/media/file-events.ts +33 -0
- package/dist/runtime/src/media/text-extractor.ts +312 -0
- package/dist/runtime/src/media/video.ts +92 -0
- package/dist/runtime/src/outbox/file-ops.ts +210 -0
- package/dist/runtime/src/outbox/index.ts +8 -0
- package/dist/runtime/src/outbox/utils.ts +51 -0
- package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
- package/dist/runtime/src/platform/config.ts +371 -0
- package/dist/runtime/src/platform/constants.ts +18 -0
- package/dist/runtime/src/platform/debug-users.ts +14 -0
- package/dist/runtime/src/platform/delay.ts +1 -0
- package/dist/runtime/src/platform/error.ts +9 -0
- package/dist/runtime/src/platform/file-utils.ts +16 -0
- package/dist/runtime/src/platform/jsonl.ts +202 -0
- package/dist/runtime/src/platform/lifecycle.ts +174 -0
- package/dist/runtime/src/platform/logger.ts +31 -0
- package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
- package/dist/runtime/src/platform/mcp-config.ts +878 -0
- package/dist/runtime/src/platform/modules.ts +145 -0
- package/dist/runtime/src/platform/node-plugins.ts +46 -0
- package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
- package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
- package/dist/runtime/src/prompts/builders.ts +368 -0
- package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
- package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
- package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
- package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
- package/dist/runtime/src/query/active-rooms.ts +534 -0
- package/dist/runtime/src/query/control.ts +313 -0
- package/dist/runtime/src/query/index.ts +24 -0
- package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
- package/dist/runtime/src/query/session-inbox-path.ts +42 -0
- package/dist/runtime/src/query/state.ts +94 -0
- package/dist/runtime/src/query/types.ts +83 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
- package/dist/runtime/src/runtime/attachments.ts +151 -0
- package/dist/runtime/src/runtime/background-sessions.ts +83 -0
- package/dist/runtime/src/runtime/channel-context.ts +107 -0
- package/dist/runtime/src/runtime/errors.ts +69 -0
- package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
- package/dist/runtime/src/runtime/file-hooks.ts +81 -0
- package/dist/runtime/src/runtime/footer.ts +23 -0
- package/dist/runtime/src/runtime/inbox.ts +946 -0
- package/dist/runtime/src/runtime/tasks.ts +56 -0
- package/dist/runtime/src/runtime/topic-config.ts +29 -0
- package/dist/runtime/src/runtime/turn-runner.ts +2298 -0
- package/dist/runtime/src/runtime/usage-alert.ts +84 -0
- package/dist/runtime/src/runtime/visual-html.ts +109 -0
- package/dist/runtime/src/runtime/visual-store.ts +430 -0
- package/dist/runtime/src/runtime/visuals.ts +171 -0
- package/dist/runtime/src/security/sanitize.ts +27 -0
- package/dist/runtime/src/security/sensitive-path.ts +34 -0
- package/dist/runtime/src/storage/activity-log.ts +152 -0
- package/dist/runtime/src/storage/api-messages.ts +617 -0
- package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
- package/dist/runtime/src/storage/api-topic-config.ts +192 -0
- package/dist/runtime/src/storage/api-topics.ts +844 -0
- package/dist/runtime/src/storage/app-settings.ts +44 -0
- package/dist/runtime/src/storage/browser-profiles.ts +133 -0
- package/dist/runtime/src/storage/conversations.ts +268 -0
- package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
- package/dist/runtime/src/storage/forum/index.ts +4 -0
- package/dist/runtime/src/storage/forum/repository.ts +283 -0
- package/dist/runtime/src/storage/forum/schema.ts +300 -0
- package/dist/runtime/src/storage/forum-db.ts +23 -0
- package/dist/runtime/src/storage/runtime-events.ts +130 -0
- package/dist/runtime/src/storage/runtime-leases.ts +189 -0
- package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
- package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +301 -0
- package/dist/runtime/src/storage/self-schedules.ts +298 -0
- package/dist/runtime/src/storage/session-asks.ts +347 -0
- package/dist/runtime/src/storage/sqlite.ts +129 -0
- package/dist/runtime/src/storage/tasks.ts +179 -0
- package/dist/runtime/src/storage/token-stats.ts +144 -0
- package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
- package/dist/runtime/src/storage/topic-archive.ts +74 -0
- package/dist/runtime/src/storage/topic-settings.ts +67 -0
- package/dist/runtime/src/storage/topic-transcript.ts +98 -0
- package/dist/runtime/src/storage/vault-crypto.ts +65 -0
- package/dist/runtime/src/storage/vault.ts +225 -0
- package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
- package/dist/runtime/src/storage/wiki.ts +85 -0
- package/dist/runtime/src/topics/create.ts +123 -0
- package/dist/runtime/src/topics/derive.ts +398 -0
- package/dist/runtime/src/topics/lifecycle.ts +311 -0
- package/dist/runtime/src/topics/links.ts +7 -0
- package/dist/runtime/src/topics/personal-general.ts +65 -0
- package/dist/runtime/src/topics/session.ts +381 -0
- package/dist/runtime/src/types/api.ts +497 -0
- package/dist/runtime/src/types.ts +257 -0
- package/dist/runtime/src/version.ts +1 -0
- package/dist/runtime/tsconfig.json +13 -0
- package/package.json +28 -3
- package/dist/main.d.ts +0 -3
- 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();
|