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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. 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
+ }