pikiloop 0.4.0
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/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-bridge.ts — MCP session bridge orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the main pikiloop process. For each agent stream:
|
|
5
|
+
* 1. Starts a tiny HTTP callback server on localhost (random port).
|
|
6
|
+
* 2. Writes an MCP config JSON pointing to `pikiloop --mcp-serve`.
|
|
7
|
+
* 3. The agent CLI loads that config via its MCP registration mechanism.
|
|
8
|
+
* 4. When the agent calls `send_file`, the MCP server POSTs to our callback.
|
|
9
|
+
* 5. We forward the request to the IM channel and respond with success/failure.
|
|
10
|
+
*
|
|
11
|
+
* Lifecycle: one bridge per stream, created before spawn, stopped after stream ends.
|
|
12
|
+
*/
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { execFile, spawnSync } from 'node:child_process';
|
|
18
|
+
import { promisify } from 'node:util';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { ensurePlaywrightMcpConfigFile, getConfiguredRemoteCdpUrl, getManagedBrowserProfileDir, resolveManagedBrowserCdpEndpoint, resolveManagedBrowserMcpCommand, } from '../../browser-profile.js';
|
|
21
|
+
import { loadUserConfig } from '../../core/config/user-config.js';
|
|
22
|
+
import { MCP_TIMEOUTS, MCP_ARTIFACT_MAX_BYTES } from '../../core/constants.js';
|
|
23
|
+
import { mergeExtensionsForSession, getGlobalExtensionsAsServers } from './extensions.js';
|
|
24
|
+
function sanitizeExecArgv(execArgv) {
|
|
25
|
+
return execArgv.filter(arg => !/^--inspect(?:-brk)?(?:=.*)?$/.test(arg));
|
|
26
|
+
}
|
|
27
|
+
function resolveCurrentCliCommand(runtime, extraArgs) {
|
|
28
|
+
const entryScript = runtime.argv[1] ? path.resolve(runtime.argv[1]) : '';
|
|
29
|
+
const base = path.basename(entryScript).toLowerCase();
|
|
30
|
+
if (!entryScript || !fs.existsSync(entryScript))
|
|
31
|
+
return null;
|
|
32
|
+
if (base !== 'main.js' && base !== 'main.ts' && base !== 'cli.js' && base !== 'cli.ts')
|
|
33
|
+
return null;
|
|
34
|
+
return {
|
|
35
|
+
command: runtime.execPath,
|
|
36
|
+
args: [...sanitizeExecArgv(runtime.execArgv), entryScript, ...extraArgs],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function resolveMcpServerCommand(runtime = {
|
|
40
|
+
execPath: process.execPath,
|
|
41
|
+
execArgv: process.execArgv,
|
|
42
|
+
argv: process.argv,
|
|
43
|
+
moduleUrl: import.meta.url,
|
|
44
|
+
}) {
|
|
45
|
+
const currentProcess = resolveCurrentCliCommand(runtime, ['--mcp-serve']);
|
|
46
|
+
if (currentProcess)
|
|
47
|
+
return currentProcess;
|
|
48
|
+
// Try to find the compiled JS file in the same directory as this module
|
|
49
|
+
const thisDir = path.dirname(fileURLToPath(runtime.moduleUrl));
|
|
50
|
+
const serverScript = path.join(thisDir, 'session-server.js');
|
|
51
|
+
if (fs.existsSync(serverScript)) {
|
|
52
|
+
return { command: 'node', args: [serverScript] };
|
|
53
|
+
}
|
|
54
|
+
// Fallback: use pikiloop CLI with --mcp-serve flag
|
|
55
|
+
const cliScript = path.resolve(thisDir, '../../cli/main.js');
|
|
56
|
+
if (fs.existsSync(cliScript)) {
|
|
57
|
+
return { command: 'node', args: [cliScript, '--mcp-serve'] };
|
|
58
|
+
}
|
|
59
|
+
// Last resort: assume pikiloop is in PATH
|
|
60
|
+
return { command: 'pikiloop', args: ['--mcp-serve'] };
|
|
61
|
+
}
|
|
62
|
+
function parseOptionalBool(value) {
|
|
63
|
+
if (typeof value === 'boolean')
|
|
64
|
+
return value;
|
|
65
|
+
const text = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
66
|
+
if (!text)
|
|
67
|
+
return null;
|
|
68
|
+
if (['1', 'true', 'yes', 'on'].includes(text))
|
|
69
|
+
return true;
|
|
70
|
+
if (['0', 'false', 'no', 'off'].includes(text))
|
|
71
|
+
return false;
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
function boolFromConfigEnv(configValue, envValue, fallback) {
|
|
75
|
+
const envParsed = parseOptionalBool(envValue);
|
|
76
|
+
if (envParsed != null)
|
|
77
|
+
return envParsed;
|
|
78
|
+
const configParsed = parseOptionalBool(configValue);
|
|
79
|
+
if (configParsed != null)
|
|
80
|
+
return configParsed;
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
export function resolveGuiIntegrationConfig(config = loadUserConfig(), env = process.env) {
|
|
84
|
+
// A configured remote CDP endpoint implies the user wants browser automation,
|
|
85
|
+
// so it flips the *default* on. An explicit PIKILOOP_BROWSER_ENABLED / config
|
|
86
|
+
// value still wins (so `=false` can disable even with a CDP URL set). This
|
|
87
|
+
// removes the footgun where setting only PIKILOOP_BROWSER_CDP_URL silently
|
|
88
|
+
// injected no browser server at all.
|
|
89
|
+
const browserEnabled = boolFromConfigEnv(typeof config.browserEnabled === 'boolean' ? config.browserEnabled : config.browserUseProfile, env.PIKILOOP_BROWSER_ENABLED ?? env.PIKILOOP_BROWSER_USE_PROFILE, !!getConfiguredRemoteCdpUrl(env));
|
|
90
|
+
const peekabooEnabled = boolFromConfigEnv(config.peekabooEnabled, env.PIKILOOP_PEEKABOO_ENABLED, false);
|
|
91
|
+
return {
|
|
92
|
+
browserEnabled,
|
|
93
|
+
browserProfileDir: getManagedBrowserProfileDir(),
|
|
94
|
+
browserHeadless: boolFromConfigEnv(config.browserHeadless, env.PIKILOOP_BROWSER_HEADLESS, false),
|
|
95
|
+
peekabooEnabled,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function buildSupplementalMcpServers(gui = resolveGuiIntegrationConfig(), endpoints = {}) {
|
|
99
|
+
const servers = [];
|
|
100
|
+
if (gui.browserEnabled) {
|
|
101
|
+
const profileDir = gui.browserProfileDir || getManagedBrowserProfileDir();
|
|
102
|
+
const cdpEndpoint = (endpoints.cdpEndpoint || '').trim() || null;
|
|
103
|
+
const browserServer = resolveManagedBrowserMcpCommand(profileDir, {
|
|
104
|
+
headless: gui.browserHeadless,
|
|
105
|
+
cdpEndpoint,
|
|
106
|
+
});
|
|
107
|
+
servers.push({
|
|
108
|
+
name: 'pikiloop-browser',
|
|
109
|
+
command: browserServer.command,
|
|
110
|
+
args: browserServer.args,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (gui.peekabooEnabled && process.platform === 'darwin') {
|
|
114
|
+
// Peekaboo — native macOS GUI automation via Accessibility + ScreenCaptureKit.
|
|
115
|
+
// Run the dedicated MCP bin from the multi-bin @steipete/peekaboo package.
|
|
116
|
+
servers.push({
|
|
117
|
+
name: 'peekaboo',
|
|
118
|
+
command: 'npx',
|
|
119
|
+
args: ['-y', '-p', '@steipete/peekaboo', 'peekaboo-mcp'],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return servers;
|
|
123
|
+
}
|
|
124
|
+
export function buildGuiSetupHints(gui = resolveGuiIntegrationConfig()) {
|
|
125
|
+
const hints = [];
|
|
126
|
+
if (gui.browserEnabled) {
|
|
127
|
+
hints.push(`managed browser profile mode enabled; runtime sessions reuse ${gui.browserProfileDir || getManagedBrowserProfileDir()}; configured MCP browser mode=${gui.browserHeadless ? 'headless' : 'headed'}. This mode keeps automation isolated from your everyday browser. If the managed browser is already open, pikiloop will try to attach to it first. When using browser_tabs, use action="new" to open a tab, not "create".`);
|
|
128
|
+
}
|
|
129
|
+
if (gui.peekabooEnabled && process.platform === 'darwin') {
|
|
130
|
+
hints.push('Peekaboo enabled — native macOS GUI tools (see / click / type / scroll / window / menu / app / dock) via Accessibility + ScreenCaptureKit. Prefer element-ID interactions (call `see` first) over raw coordinates.');
|
|
131
|
+
}
|
|
132
|
+
return hints;
|
|
133
|
+
}
|
|
134
|
+
function buildClaudeMcpConfig(servers) {
|
|
135
|
+
return {
|
|
136
|
+
mcpServers: Object.fromEntries(servers.map(server => [
|
|
137
|
+
server.name,
|
|
138
|
+
{ type: 'stdio', command: server.command, args: server.args, ...(server.env ? { env: server.env } : {}) },
|
|
139
|
+
])),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Build the `codex mcp add` argv for a single registered server. Returns
|
|
144
|
+
* `null` when the descriptor lacks the fields needed for its transport
|
|
145
|
+
* (treated as a no-op rather than throwing — keeps a malformed entry from
|
|
146
|
+
* breaking the whole session).
|
|
147
|
+
*
|
|
148
|
+
* HTTP servers can't pass a literal bearer to codex; the CLI only accepts
|
|
149
|
+
* `--bearer-token-env-var <NAME>`. We synthesize a deterministic env-var
|
|
150
|
+
* name per server, stash the token in the supplied `tokenEnv` map, and the
|
|
151
|
+
* caller threads that map into the codex child process via extraEnv.
|
|
152
|
+
*
|
|
153
|
+
* Exported for unit tests; not re-exported from the package surface.
|
|
154
|
+
*/
|
|
155
|
+
export function buildCodexMcpAddArgs(server, tokenEnv) {
|
|
156
|
+
if (server.type === 'http') {
|
|
157
|
+
if (!server.url)
|
|
158
|
+
return null;
|
|
159
|
+
const args = ['mcp', 'add', server.name, '--url', server.url];
|
|
160
|
+
const bearer = extractBearerToken(server.headers);
|
|
161
|
+
if (bearer) {
|
|
162
|
+
const envName = codexBearerEnvName(server.name);
|
|
163
|
+
tokenEnv[envName] = bearer;
|
|
164
|
+
args.push('--bearer-token-env-var', envName);
|
|
165
|
+
}
|
|
166
|
+
return args;
|
|
167
|
+
}
|
|
168
|
+
if (!server.command)
|
|
169
|
+
return null;
|
|
170
|
+
const args = ['mcp', 'add', server.name];
|
|
171
|
+
for (const [k, v] of Object.entries(server.env || {}))
|
|
172
|
+
args.push('--env', `${k}=${v}`);
|
|
173
|
+
args.push('--', server.command, ...(server.args || []));
|
|
174
|
+
return args;
|
|
175
|
+
}
|
|
176
|
+
function extractBearerToken(headers) {
|
|
177
|
+
if (!headers)
|
|
178
|
+
return null;
|
|
179
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
180
|
+
if (k.toLowerCase() !== 'authorization')
|
|
181
|
+
continue;
|
|
182
|
+
const m = /^\s*Bearer\s+(.+)$/i.exec(v);
|
|
183
|
+
if (m)
|
|
184
|
+
return m[1].trim();
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
function codexBearerEnvName(serverName) {
|
|
189
|
+
const safe = serverName.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
190
|
+
return `PIKILOOP_MCP_BEARER_${safe || 'UNNAMED'}`;
|
|
191
|
+
}
|
|
192
|
+
export function buildGeminiMcpConfig(servers) {
|
|
193
|
+
return {
|
|
194
|
+
// Session attachments live under .pikiloop/... and should remain readable to
|
|
195
|
+
// Gemini's built-in file tools even when the project ignores that directory.
|
|
196
|
+
fileFiltering: {
|
|
197
|
+
respectGitIgnore: false,
|
|
198
|
+
respectGeminiIgnore: false,
|
|
199
|
+
},
|
|
200
|
+
mcpServers: Object.fromEntries(servers.map(server => {
|
|
201
|
+
if (server.type === 'http' && server.url) {
|
|
202
|
+
return [
|
|
203
|
+
server.name,
|
|
204
|
+
{
|
|
205
|
+
type: 'http',
|
|
206
|
+
url: server.url,
|
|
207
|
+
...(server.headers && Object.keys(server.headers).length ? { headers: server.headers } : {}),
|
|
208
|
+
trust: true,
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
return [
|
|
213
|
+
server.name,
|
|
214
|
+
{
|
|
215
|
+
command: server.command,
|
|
216
|
+
args: server.args || [],
|
|
217
|
+
...(server.env ? { env: server.env } : {}),
|
|
218
|
+
trust: true,
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
})),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Stale playwright-mcp reaper
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
/**
|
|
228
|
+
* Find and SIGTERM playwright-mcp processes that attach to the same managed
|
|
229
|
+
* Chrome CDP endpoint but are NOT descendants of the current pikiloop process.
|
|
230
|
+
*
|
|
231
|
+
* Background: playwright-mcp is spawned by the agent CLI (e.g. claude) as a
|
|
232
|
+
* child via the mcp-config we write. When the agent CLI is killed ungracefully
|
|
233
|
+
* — or worse, gets reparented to launchd/init and survives across pikiloop
|
|
234
|
+
* restarts — its playwright-mcp child stays alive too. Multiple playwright-mcp
|
|
235
|
+
* instances attached to the same `--cdp-endpoint` cause backend state
|
|
236
|
+
* confusion (microsoft/playwright-mcp#1299, #893) and manifest as
|
|
237
|
+
* `Connection closed` errors or 2+ minute hangs the next time a tool is
|
|
238
|
+
* called. The community's recommended hygiene is one playwright-mcp per
|
|
239
|
+
* agent instance; this sweeper enforces that by reaping orphans from prior
|
|
240
|
+
* runs at the start of every new bridge.
|
|
241
|
+
*
|
|
242
|
+
* Safety: a candidate is only reaped if its `ppid` chain — walked entirely in
|
|
243
|
+
* memory from a single `ps` snapshot — does NOT include the current pikiloop
|
|
244
|
+
* process. In-flight playwright-mcp children of THIS pikiloop (sibling
|
|
245
|
+
* streams) are always spared.
|
|
246
|
+
*/
|
|
247
|
+
/**
|
|
248
|
+
* Pure matcher for the reaper. Returns true when `command` looks like a
|
|
249
|
+
* playwright-mcp process attached to the same CDP endpoint as ours.
|
|
250
|
+
*
|
|
251
|
+
* Accepts both invocation forms we have seen in the wild:
|
|
252
|
+
* - `node <path>/@playwright/mcp/cli.js …` (direct, pikiloop's preferred)
|
|
253
|
+
* - `node <path>/node_modules/.bin/playwright-mcp …` (npm bin symlink,
|
|
254
|
+
* used by `npx @playwright/mcp` and any agent CLI that resolves via PATH)
|
|
255
|
+
*
|
|
256
|
+
* The CDP endpoint must also appear literally in the argv — without that
|
|
257
|
+
* guard a stray `npm exec @playwright/mcp` with its own browser would be
|
|
258
|
+
* killed, and unrelated `node -e <src>` processes whose inline source happens
|
|
259
|
+
* to mention `@playwright/mcp` would also be misidentified.
|
|
260
|
+
*/
|
|
261
|
+
export function _matchPlaywrightMcpProcessCommand(command, normalizedCdpEndpoint) {
|
|
262
|
+
if (!command || !normalizedCdpEndpoint)
|
|
263
|
+
return false;
|
|
264
|
+
const tokens = command.split(/\s+/);
|
|
265
|
+
if (tokens.length < 2)
|
|
266
|
+
return false;
|
|
267
|
+
if (!/(?:^|[\\/])node(?:\.exe)?$/.test(tokens[0]))
|
|
268
|
+
return false;
|
|
269
|
+
const isCliJs = /@playwright[\\/]mcp[\\/]cli\.js$/.test(tokens[1]);
|
|
270
|
+
const isBinSymlink = /[\\/]\.bin[\\/]playwright-mcp(?:\.cmd)?$/.test(tokens[1]);
|
|
271
|
+
if (!isCliJs && !isBinSymlink)
|
|
272
|
+
return false;
|
|
273
|
+
if (!command.includes(normalizedCdpEndpoint))
|
|
274
|
+
return false;
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
// Promisified spawn for codex MCP registration — keeps the per-server add/remove
|
|
278
|
+
// off the event loop (was execFileSync, which blocked per spawn at stream start).
|
|
279
|
+
const execFileAsync = promisify(execFile);
|
|
280
|
+
// Reaping shells out to a full `ps` table scan + per-line regex and only guards
|
|
281
|
+
// against playwright-mcp orphans left by a previous run, so it need not run on
|
|
282
|
+
// every browser-enabled stream start. Throttle it per CDP endpoint.
|
|
283
|
+
const REAP_THROTTLE_MS = 30_000;
|
|
284
|
+
const lastReapAt = new Map();
|
|
285
|
+
function reapStalePlaywrightMcpProcesses(cdpEndpoint) {
|
|
286
|
+
const reaped = [];
|
|
287
|
+
const spared = [];
|
|
288
|
+
if (process.platform === 'win32' || !cdpEndpoint)
|
|
289
|
+
return { reaped, spared };
|
|
290
|
+
const normalized = cdpEndpoint.replace(/\/+$/, '');
|
|
291
|
+
if (Date.now() - (lastReapAt.get(normalized) ?? 0) < REAP_THROTTLE_MS)
|
|
292
|
+
return { reaped, spared };
|
|
293
|
+
lastReapAt.set(normalized, Date.now());
|
|
294
|
+
const result = spawnSync('ps', ['-axo', 'pid=,ppid=,command='], { encoding: 'utf8' });
|
|
295
|
+
if (result.status !== 0)
|
|
296
|
+
return { reaped, spared };
|
|
297
|
+
const ppidByPid = new Map();
|
|
298
|
+
const candidates = [];
|
|
299
|
+
const lines = String(result.stdout || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
300
|
+
for (const line of lines) {
|
|
301
|
+
const m = line.match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
302
|
+
if (!m)
|
|
303
|
+
continue;
|
|
304
|
+
const pid = Number(m[1]);
|
|
305
|
+
const ppid = Number(m[2]);
|
|
306
|
+
if (!Number.isFinite(pid) || !Number.isFinite(ppid))
|
|
307
|
+
continue;
|
|
308
|
+
ppidByPid.set(pid, ppid);
|
|
309
|
+
if (pid === process.pid)
|
|
310
|
+
continue;
|
|
311
|
+
const command = m[3] || '';
|
|
312
|
+
if (!_matchPlaywrightMcpProcessCommand(command, normalized))
|
|
313
|
+
continue;
|
|
314
|
+
candidates.push(pid);
|
|
315
|
+
}
|
|
316
|
+
const isOurDescendant = (pid) => {
|
|
317
|
+
let cur = pid;
|
|
318
|
+
for (let depth = 0; depth < 30 && cur != null && cur > 1; depth++) {
|
|
319
|
+
if (cur === process.pid)
|
|
320
|
+
return true;
|
|
321
|
+
cur = ppidByPid.get(cur);
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
};
|
|
325
|
+
for (const pid of candidates) {
|
|
326
|
+
if (isOurDescendant(pid)) {
|
|
327
|
+
spared.push(pid);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
process.kill(pid, 'SIGTERM');
|
|
332
|
+
reaped.push(pid);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
// Already dead — no-op.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return { reaped, spared };
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Decide which CDP endpoint the per-session playwright/mcp should attach to.
|
|
342
|
+
*
|
|
343
|
+
* When `PIKILOOP_BROWSER_CDP_URL` is set we return it UNCONDITIONALLY (mode
|
|
344
|
+
* `remote`) — without probing it for reachability. This is deliberate: the
|
|
345
|
+
* documented contract is that pikiloop never launches, probes, or kills a local
|
|
346
|
+
* Chrome in remote mode (e.g. inside a headless container that has no Chrome at
|
|
347
|
+
* all). Gating on a reachability ping would let a momentarily-unreachable
|
|
348
|
+
* sidecar fall through to the local-launch branch and silently spawn a browser
|
|
349
|
+
* — exactly the bug reported in #16. Handing `--cdp-endpoint <url>` to
|
|
350
|
+
* playwright/mcp instead surfaces an honest connection error on the first
|
|
351
|
+
* `browser_*` call if the sidecar is down.
|
|
352
|
+
*
|
|
353
|
+
* Without the override, fall back to probing the local managed Chrome via its
|
|
354
|
+
* DevToolsActivePort file (cross-process attach); `none` means leave Chrome
|
|
355
|
+
* unlaunched and let playwright/mcp cold-start one with `--user-data-dir`.
|
|
356
|
+
*/
|
|
357
|
+
export async function resolveBridgeBrowserEndpoint(profileDir = getManagedBrowserProfileDir(), remoteCdpUrl = getConfiguredRemoteCdpUrl()) {
|
|
358
|
+
if (remoteCdpUrl)
|
|
359
|
+
return { endpoint: remoteCdpUrl, mode: 'remote' };
|
|
360
|
+
const local = await resolveManagedBrowserCdpEndpoint(profileDir).catch(() => null);
|
|
361
|
+
return { endpoint: local, mode: local ? 'local-attach' : 'none' };
|
|
362
|
+
}
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Bridge implementation
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
const ARTIFACT_MAX_BYTES = MCP_ARTIFACT_MAX_BYTES;
|
|
367
|
+
const SEND_FILE_TIMEOUT_MS = MCP_TIMEOUTS.sendFile;
|
|
368
|
+
const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
|
369
|
+
function isPhotoFile(filePath) {
|
|
370
|
+
return PHOTO_EXTS.has(path.extname(filePath).toLowerCase());
|
|
371
|
+
}
|
|
372
|
+
/** Check if realFile is inside any of the allowed root directories. */
|
|
373
|
+
function isInsideAllowedRoot(realFile, allowedRoots) {
|
|
374
|
+
for (const root of allowedRoots) {
|
|
375
|
+
try {
|
|
376
|
+
const realRoot = fs.realpathSync(root);
|
|
377
|
+
const rel = path.relative(realRoot, realFile);
|
|
378
|
+
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel))
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
catch { /* root doesn't exist, skip */ }
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
export function resolveSendFilePath(inputPath, workspacePath, stagedFiles = [], workdir) {
|
|
386
|
+
const requested = String(inputPath || '').trim();
|
|
387
|
+
if (!requested)
|
|
388
|
+
return { path: null, error: 'path is required' };
|
|
389
|
+
if (path.isAbsolute(requested))
|
|
390
|
+
return { path: requested };
|
|
391
|
+
const roots = {
|
|
392
|
+
workspace: path.resolve(workspacePath),
|
|
393
|
+
workdir: workdir ? path.resolve(workdir) : '',
|
|
394
|
+
tmp: path.resolve(os.tmpdir()),
|
|
395
|
+
};
|
|
396
|
+
const aliasPrefixes = [
|
|
397
|
+
{ prefix: '@workspace/', root: roots.workspace },
|
|
398
|
+
{ prefix: 'workspace:', root: roots.workspace },
|
|
399
|
+
{ prefix: 'ws:', root: roots.workspace },
|
|
400
|
+
...(roots.workdir ? [
|
|
401
|
+
{ prefix: '@workdir/', root: roots.workdir },
|
|
402
|
+
{ prefix: 'workdir:', root: roots.workdir },
|
|
403
|
+
{ prefix: 'wd:', root: roots.workdir },
|
|
404
|
+
] : []),
|
|
405
|
+
{ prefix: '@tmp/', root: roots.tmp },
|
|
406
|
+
{ prefix: 'tmp:', root: roots.tmp },
|
|
407
|
+
];
|
|
408
|
+
for (const { prefix, root } of aliasPrefixes) {
|
|
409
|
+
if (!requested.startsWith(prefix))
|
|
410
|
+
continue;
|
|
411
|
+
const suffix = requested.slice(prefix.length).trim();
|
|
412
|
+
return { path: suffix ? path.resolve(root, suffix) : root };
|
|
413
|
+
}
|
|
414
|
+
const candidates = [
|
|
415
|
+
path.resolve(roots.workspace, requested),
|
|
416
|
+
...(roots.workdir ? [path.resolve(roots.workdir, requested)] : []),
|
|
417
|
+
];
|
|
418
|
+
for (const candidate of candidates) {
|
|
419
|
+
try {
|
|
420
|
+
fs.realpathSync(candidate);
|
|
421
|
+
return { path: candidate };
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// Try next candidate.
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!requested.includes('/') && !requested.includes(path.sep)) {
|
|
428
|
+
const basenameMatches = new Map();
|
|
429
|
+
const dedupedMatches = [];
|
|
430
|
+
const addMatch = (candidate) => {
|
|
431
|
+
const key = path.resolve(candidate);
|
|
432
|
+
if (basenameMatches.has(key))
|
|
433
|
+
return;
|
|
434
|
+
basenameMatches.set(key, key);
|
|
435
|
+
dedupedMatches.push(key);
|
|
436
|
+
};
|
|
437
|
+
try {
|
|
438
|
+
const tmpCandidate = path.join(roots.tmp, requested);
|
|
439
|
+
if (fs.existsSync(tmpCandidate))
|
|
440
|
+
addMatch(tmpCandidate);
|
|
441
|
+
}
|
|
442
|
+
catch { }
|
|
443
|
+
for (const relPath of stagedFiles) {
|
|
444
|
+
if (path.basename(relPath) !== requested)
|
|
445
|
+
continue;
|
|
446
|
+
addMatch(path.join(roots.workspace, relPath));
|
|
447
|
+
}
|
|
448
|
+
if (dedupedMatches.length === 1)
|
|
449
|
+
return { path: dedupedMatches[0] };
|
|
450
|
+
if (dedupedMatches.length > 1) {
|
|
451
|
+
return {
|
|
452
|
+
path: null,
|
|
453
|
+
error: `ambiguous file name "${requested}"; use @workspace/..., @workdir/..., or @tmp/...`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
path: candidates[0] || null,
|
|
459
|
+
error: `file not found: ${requested}; try @workspace/..., @workdir/..., @tmp/..., or a unique filename`,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
export async function startMcpBridge(opts) {
|
|
463
|
+
const { sessionDir, workspacePath, stagedFiles, sendFile, onInteraction } = opts;
|
|
464
|
+
let hadActivity = false;
|
|
465
|
+
const gui = resolveGuiIntegrationConfig();
|
|
466
|
+
for (const hint of buildGuiSetupHints(gui))
|
|
467
|
+
opts.onLog?.(hint);
|
|
468
|
+
// Lazy browser lifecycle: probe an already-running managed Chrome via
|
|
469
|
+
// <profileDir>/DevToolsActivePort and attach if reachable; otherwise leave
|
|
470
|
+
// Chrome unlaunched and let playwright/mcp launch it with `--user-data-dir`
|
|
471
|
+
// on the first browser_* tool call. Previously the bridge eagerly called
|
|
472
|
+
// `ensureManagedBrowser`, which forced a Chrome window to open at every
|
|
473
|
+
// stream start even when the agent never touched the browser.
|
|
474
|
+
let browserCdpEndpoint = null;
|
|
475
|
+
if (gui.browserEnabled) {
|
|
476
|
+
// Write the playwright/mcp config file (referenced by --config in
|
|
477
|
+
// getManagedBrowserMcpArgs) before the agent CLI spawns playwright/mcp.
|
|
478
|
+
ensurePlaywrightMcpConfigFile();
|
|
479
|
+
const { endpoint, mode } = await resolveBridgeBrowserEndpoint(gui.browserProfileDir);
|
|
480
|
+
browserCdpEndpoint = endpoint;
|
|
481
|
+
if (endpoint) {
|
|
482
|
+
opts.onLog?.(mode === 'remote'
|
|
483
|
+
? `attaching to remote CDP endpoint ${endpoint} (PIKILOOP_BROWSER_CDP_URL); local Chrome launch disabled.`
|
|
484
|
+
: `attaching to existing managed browser at ${endpoint}.`);
|
|
485
|
+
// Clear stale playwright-mcp children still bound to this endpoint (one
|
|
486
|
+
// playwright-mcp per browser, per microsoft/playwright-mcp#1299). Safe for
|
|
487
|
+
// the remote sidecar too — it only ever SIGTERMs local playwright-mcp
|
|
488
|
+
// processes, never the Chrome itself.
|
|
489
|
+
const { reaped, spared } = reapStalePlaywrightMcpProcesses(endpoint);
|
|
490
|
+
if (reaped.length) {
|
|
491
|
+
opts.onLog?.(`reaped ${reaped.length} stale playwright-mcp process(es) attached to ${endpoint}: pid=${reaped.join(',')}${spared.length ? ` (spared in-tree: ${spared.join(',')})` : ''}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
opts.onLog?.('no managed browser running; playwright/mcp will launch one on first browser_* tool call.');
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// Build allowed roots: workspace + workdir + /tmp
|
|
499
|
+
const allowedRoots = [workspacePath];
|
|
500
|
+
if (opts.workdir)
|
|
501
|
+
allowedRoots.push(opts.workdir);
|
|
502
|
+
allowedRoots.push('/tmp', os.tmpdir());
|
|
503
|
+
// ── HTTP callback server ──
|
|
504
|
+
// Started only when an IM-side callback is wired up, to serve:
|
|
505
|
+
// - `im_send_file` → /send-file
|
|
506
|
+
// - `im_ask_user` → /ask-user
|
|
507
|
+
// - structured tool-activity logging from the in-process MCP server → /log
|
|
508
|
+
let callbackServer = null;
|
|
509
|
+
let port = 0;
|
|
510
|
+
const needsCallbackServer = !!sendFile || !!onInteraction;
|
|
511
|
+
if (needsCallbackServer) {
|
|
512
|
+
callbackServer = http.createServer((req, res) => {
|
|
513
|
+
const endpoint = req.url || '';
|
|
514
|
+
const known = endpoint === '/send-file' || endpoint === '/log' || endpoint === '/ask-user';
|
|
515
|
+
if (req.method !== 'POST' || !known) {
|
|
516
|
+
res.writeHead(404);
|
|
517
|
+
res.end();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
// /ask-user blocks until the user replies; disable timeouts for it.
|
|
521
|
+
if (endpoint === '/ask-user') {
|
|
522
|
+
req.setTimeout(0);
|
|
523
|
+
res.setTimeout(0);
|
|
524
|
+
}
|
|
525
|
+
let body = '';
|
|
526
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
527
|
+
// Timeout for receiving the request body
|
|
528
|
+
const bodyTimer = setTimeout(() => {
|
|
529
|
+
req.destroy(new Error('request body timeout'));
|
|
530
|
+
}, MCP_TIMEOUTS.requestBody);
|
|
531
|
+
req.on('end', async () => {
|
|
532
|
+
clearTimeout(bodyTimer);
|
|
533
|
+
try {
|
|
534
|
+
if (endpoint === '/log') {
|
|
535
|
+
const data = JSON.parse(body || '{}');
|
|
536
|
+
const message = typeof data.message === 'string' ? data.message.trim() : '';
|
|
537
|
+
if (message) {
|
|
538
|
+
hadActivity = true;
|
|
539
|
+
opts.onLog?.(message);
|
|
540
|
+
}
|
|
541
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
542
|
+
res.end(JSON.stringify({ ok: true }));
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (endpoint === '/ask-user') {
|
|
546
|
+
if (!onInteraction) {
|
|
547
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
548
|
+
res.end(JSON.stringify({ ok: false, error: 'ask-user is not available for this session' }));
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const data = JSON.parse(body || '{}');
|
|
552
|
+
const question = typeof data.question === 'string' ? data.question.trim() : '';
|
|
553
|
+
if (!question) {
|
|
554
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
555
|
+
res.end(JSON.stringify({ ok: false, error: 'question is required' }));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const header = typeof data.header === 'string' ? data.header.trim() : '';
|
|
559
|
+
const hint = typeof data.hint === 'string' ? data.hint.trim() : '';
|
|
560
|
+
const allowFreeform = data.allowFreeform == null ? true : !!data.allowFreeform;
|
|
561
|
+
const rawOptions = Array.isArray(data.options) ? data.options : [];
|
|
562
|
+
const interactionOptions = rawOptions
|
|
563
|
+
.map((o) => {
|
|
564
|
+
const label = typeof o?.label === 'string' ? o.label.trim() : '';
|
|
565
|
+
const description = typeof o?.description === 'string' ? o.description.trim() : '';
|
|
566
|
+
return label ? { label, description: description || null, value: label } : null;
|
|
567
|
+
})
|
|
568
|
+
.filter((o) => !!o);
|
|
569
|
+
const questionId = 'ask-user';
|
|
570
|
+
const interactionQuestion = {
|
|
571
|
+
id: questionId,
|
|
572
|
+
header: header || 'Question',
|
|
573
|
+
prompt: question,
|
|
574
|
+
options: interactionOptions.length ? interactionOptions : null,
|
|
575
|
+
allowFreeform: interactionOptions.length ? allowFreeform : true,
|
|
576
|
+
};
|
|
577
|
+
const requestId = `ask-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
578
|
+
const interaction = {
|
|
579
|
+
kind: 'user-input',
|
|
580
|
+
id: requestId,
|
|
581
|
+
title: header || 'Pikiloop needs your input',
|
|
582
|
+
hint: hint || null,
|
|
583
|
+
questions: [interactionQuestion],
|
|
584
|
+
resolveWith: (answers) => {
|
|
585
|
+
const values = answers[questionId] || [];
|
|
586
|
+
const text = values.map(v => String(v ?? '').trim()).filter(Boolean).join(' ');
|
|
587
|
+
return { answer: text };
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
hadActivity = true;
|
|
591
|
+
try {
|
|
592
|
+
const response = await onInteraction(interaction);
|
|
593
|
+
const answer = typeof response?.answer === 'string' ? response.answer : '';
|
|
594
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
595
|
+
res.end(JSON.stringify({ ok: true, answer }));
|
|
596
|
+
}
|
|
597
|
+
catch (askErr) {
|
|
598
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
599
|
+
res.end(JSON.stringify({ ok: false, error: askErr?.message || 'ask-user cancelled' }));
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
// endpoint === '/send-file'
|
|
604
|
+
if (!sendFile) {
|
|
605
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
606
|
+
res.end(JSON.stringify({ ok: false, error: 'send-file is not available for this session' }));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const data = JSON.parse(body);
|
|
610
|
+
const relPath = String(data.path || '').trim();
|
|
611
|
+
if (!relPath) {
|
|
612
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
613
|
+
res.end(JSON.stringify({ ok: false, error: 'path is required' }));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
// Resolve and validate path
|
|
617
|
+
const resolved = resolveSendFilePath(relPath, workspacePath, stagedFiles, opts.workdir);
|
|
618
|
+
const absPath = resolved.path;
|
|
619
|
+
let realFile;
|
|
620
|
+
try {
|
|
621
|
+
realFile = fs.realpathSync(String(absPath || ''));
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
625
|
+
res.end(JSON.stringify({ ok: false, error: resolved.error || `file not found: ${relPath}` }));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (!isInsideAllowedRoot(realFile, allowedRoots)) {
|
|
629
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
630
|
+
res.end(JSON.stringify({ ok: false, error: 'file must be inside the workspace, workdir, or /tmp' }));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
// Size check
|
|
634
|
+
const stat = fs.statSync(realFile);
|
|
635
|
+
if (!stat.isFile()) {
|
|
636
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
637
|
+
res.end(JSON.stringify({ ok: false, error: 'not a regular file' }));
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (stat.size > ARTIFACT_MAX_BYTES) {
|
|
641
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
642
|
+
res.end(JSON.stringify({ ok: false, error: `file too large (${stat.size} bytes, max ${ARTIFACT_MAX_BYTES})` }));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
// Auto-detect kind
|
|
646
|
+
const kind = data.kind === 'photo' ? 'photo'
|
|
647
|
+
: data.kind === 'document' ? 'document'
|
|
648
|
+
: isPhotoFile(realFile) ? 'photo'
|
|
649
|
+
: 'document';
|
|
650
|
+
const caption = typeof data.caption === 'string' ? data.caption.trim().slice(0, 1024) || undefined : undefined;
|
|
651
|
+
hadActivity = true;
|
|
652
|
+
const result = await Promise.race([
|
|
653
|
+
sendFile(realFile, { caption, kind }),
|
|
654
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`sendFile timed out after ${SEND_FILE_TIMEOUT_MS / 1000}s`)), SEND_FILE_TIMEOUT_MS)),
|
|
655
|
+
]);
|
|
656
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
657
|
+
res.end(JSON.stringify(result));
|
|
658
|
+
}
|
|
659
|
+
catch (e) {
|
|
660
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
661
|
+
res.end(JSON.stringify({ ok: false, error: e?.message || 'internal error' }));
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
// Per-request body timers above guard against partial uploads.
|
|
666
|
+
callbackServer.headersTimeout = MCP_TIMEOUTS.serverHeaders;
|
|
667
|
+
// /ask-user can block indefinitely; drop the server-wide request timeout
|
|
668
|
+
// when that endpoint is wired up.
|
|
669
|
+
if (onInteraction)
|
|
670
|
+
callbackServer.requestTimeout = 0;
|
|
671
|
+
await new Promise((resolve, reject) => {
|
|
672
|
+
callbackServer.on('error', reject);
|
|
673
|
+
callbackServer.listen(0, '127.0.0.1', () => resolve());
|
|
674
|
+
});
|
|
675
|
+
port = callbackServer.address().port;
|
|
676
|
+
}
|
|
677
|
+
// ── Register MCP server with the agent ──
|
|
678
|
+
const supplementalServers = buildSupplementalMcpServers(gui, { cdpEndpoint: browserCdpEndpoint });
|
|
679
|
+
const servers = [...supplementalServers];
|
|
680
|
+
// Register the pikiloop stdio MCP server when any in-process tool needs the
|
|
681
|
+
// callback channel. `MCP_TOOLS_AVAILABLE` tells the server which tool
|
|
682
|
+
// families to advertise.
|
|
683
|
+
if (port && (sendFile || onInteraction)) {
|
|
684
|
+
const { command, args } = resolveMcpServerCommand();
|
|
685
|
+
const enabledTools = [];
|
|
686
|
+
if (sendFile)
|
|
687
|
+
enabledTools.push('workspace');
|
|
688
|
+
// Codex has native user-input via JSON-RPC; don't expose `im_ask_user`.
|
|
689
|
+
if (onInteraction && opts.agent !== 'codex')
|
|
690
|
+
enabledTools.push('ask-user');
|
|
691
|
+
const envVars = {
|
|
692
|
+
MCP_WORKSPACE_PATH: workspacePath,
|
|
693
|
+
MCP_WORKDIR: opts.workdir || '',
|
|
694
|
+
MCP_AGENT: opts.agent || '',
|
|
695
|
+
MCP_STAGED_FILES: JSON.stringify(stagedFiles),
|
|
696
|
+
MCP_CALLBACK_URL: `http://127.0.0.1:${port}`,
|
|
697
|
+
MCP_LOG_URL: `http://127.0.0.1:${port}/log`,
|
|
698
|
+
MCP_TOOLS_AVAILABLE: enabledTools.join(','),
|
|
699
|
+
};
|
|
700
|
+
servers.unshift({ name: 'pikiloop', command, args, env: envVars });
|
|
701
|
+
}
|
|
702
|
+
// Nothing to register — skip bridge entirely
|
|
703
|
+
if (!servers.length) {
|
|
704
|
+
if (callbackServer)
|
|
705
|
+
await new Promise(resolve => callbackServer.close(() => resolve()));
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
let configPath = '';
|
|
709
|
+
let extraEnv;
|
|
710
|
+
let mcpServers;
|
|
711
|
+
const codexRegisteredNames = [];
|
|
712
|
+
if (opts.agent === 'codex') {
|
|
713
|
+
// Codex: register MCP servers via `codex mcp add/remove`
|
|
714
|
+
// Include global + workspace extensions alongside built-in servers
|
|
715
|
+
const extServers = getGlobalExtensionsAsServers(opts.workdir);
|
|
716
|
+
const allServers = [...extServers, ...servers];
|
|
717
|
+
// Bearer tokens for HTTP MCP servers are injected into codex's process env
|
|
718
|
+
// via extraEnv — codex's `--bearer-token-env-var` only accepts an env name,
|
|
719
|
+
// never a literal token, so the value MUST land in the child env.
|
|
720
|
+
const codexBearerEnv = {};
|
|
721
|
+
// Sequential (codex serializes its own config writes) but async, so the
|
|
722
|
+
// per-server spawns don't block the event loop at stream start.
|
|
723
|
+
for (const server of allServers) {
|
|
724
|
+
const codexArgs = buildCodexMcpAddArgs(server, codexBearerEnv);
|
|
725
|
+
if (!codexArgs)
|
|
726
|
+
continue;
|
|
727
|
+
try {
|
|
728
|
+
await execFileAsync('codex', codexArgs, { timeout: MCP_TIMEOUTS.codexMcpAdd });
|
|
729
|
+
codexRegisteredNames.push(server.name);
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
try {
|
|
733
|
+
await execFileAsync('codex', ['mcp', 'remove', server.name], { timeout: MCP_TIMEOUTS.codexMcpRemove });
|
|
734
|
+
}
|
|
735
|
+
catch { }
|
|
736
|
+
await execFileAsync('codex', codexArgs, { timeout: MCP_TIMEOUTS.codexMcpAdd });
|
|
737
|
+
codexRegisteredNames.push(server.name);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (Object.keys(codexBearerEnv).length) {
|
|
741
|
+
extraEnv = { ...(extraEnv || {}), ...codexBearerEnv };
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
else if (opts.agent === 'gemini') {
|
|
745
|
+
// Gemini CLI 0.32+ loads MCP servers from settings.json rather than --mcp-config.
|
|
746
|
+
// Include global + workspace extensions alongside built-in servers
|
|
747
|
+
const extServers = getGlobalExtensionsAsServers(opts.workdir);
|
|
748
|
+
const allServers = [...extServers, ...servers];
|
|
749
|
+
configPath = path.join(sessionDir, 'gemini-system-settings.json');
|
|
750
|
+
const config = buildGeminiMcpConfig(allServers);
|
|
751
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
752
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
753
|
+
extraEnv = { GEMINI_CLI_SYSTEM_SETTINGS_PATH: configPath };
|
|
754
|
+
}
|
|
755
|
+
else if (opts.agent === 'hermes') {
|
|
756
|
+
// Hermes consumes structured MCP server objects via ACP `session/new`,
|
|
757
|
+
// not a config file path. Resolve the merged server list and expose it
|
|
758
|
+
// on the bridge handle so the driver can translate to ACP's wire format.
|
|
759
|
+
mcpServers = mergeExtensionsForSession(servers, opts.workdir);
|
|
760
|
+
}
|
|
761
|
+
else {
|
|
762
|
+
// Claude: write MCP config JSON for --mcp-config
|
|
763
|
+
// Uses centralized merge: global extensions → .mcp.json files → built-in servers
|
|
764
|
+
configPath = path.join(sessionDir, 'mcp-config.json');
|
|
765
|
+
mcpServers = mergeExtensionsForSession(servers, opts.workdir);
|
|
766
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
767
|
+
fs.writeFileSync(configPath, JSON.stringify({ mcpServers }, null, 2));
|
|
768
|
+
}
|
|
769
|
+
return {
|
|
770
|
+
configPath,
|
|
771
|
+
extraEnv,
|
|
772
|
+
mcpServers,
|
|
773
|
+
hadActivity: () => hadActivity,
|
|
774
|
+
stop: async () => {
|
|
775
|
+
if (callbackServer)
|
|
776
|
+
await new Promise(resolve => callbackServer.close(() => resolve()));
|
|
777
|
+
for (const name of [...codexRegisteredNames].reverse()) {
|
|
778
|
+
try {
|
|
779
|
+
await execFileAsync('codex', ['mcp', 'remove', name], { timeout: MCP_TIMEOUTS.codexMcpRemove });
|
|
780
|
+
}
|
|
781
|
+
catch { }
|
|
782
|
+
}
|
|
783
|
+
if (configPath) {
|
|
784
|
+
try {
|
|
785
|
+
fs.rmSync(configPath, { force: true });
|
|
786
|
+
}
|
|
787
|
+
catch { }
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
}
|