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,637 @@
1
+ /**
2
+ * MCP extension management — CRUD, catalog merge, health check, session merge.
3
+ *
4
+ * Global extensions live in ~/.pikiloop/setting.json under extensions.mcp.
5
+ * Workspace extensions live in <workdir>/.mcp.json (standard format).
6
+ *
7
+ * getCatalogItems() produces the unified list the dashboard renders:
8
+ * recommended-registry entries merged with installed entries, with a single
9
+ * state field per item (recommended | needs_auth | disabled | ready | unhealthy).
10
+ *
11
+ * mergeExtensionsForSession() is called by bridge.ts before spawning an agent —
12
+ * it resolves disabled flags, expands OAuth Bearer headers from the token store,
13
+ * and hands the final config map to the agent CLI.
14
+ */
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { spawn } from 'node:child_process';
19
+ import { loadUserConfig, saveUserConfig } from '../../core/config/user-config.js';
20
+ import { getRecommendedMcpServers, } from './registry.js';
21
+ import { hasValidMcpToken, injectOAuthHeaders } from './oauth.js';
22
+ // ---------------------------------------------------------------------------
23
+ // Global extensions (setting.json)
24
+ // ---------------------------------------------------------------------------
25
+ export function loadGlobalMcpExtensions() {
26
+ const config = loadUserConfig();
27
+ const mcp = config.extensions?.mcp;
28
+ if (!mcp || typeof mcp !== 'object')
29
+ return [];
30
+ return Object.entries(mcp).map(([name, cfg]) => ({
31
+ name,
32
+ config: cfg,
33
+ scope: 'global',
34
+ }));
35
+ }
36
+ export function addGlobalMcpExtension(name, config) {
37
+ const userConfig = loadUserConfig();
38
+ const extensions = userConfig.extensions ?? {};
39
+ const mcp = { ...(extensions.mcp ?? {}) };
40
+ mcp[name] = config;
41
+ saveUserConfig({ ...userConfig, extensions: { ...extensions, mcp } });
42
+ }
43
+ export function removeGlobalMcpExtension(name) {
44
+ const userConfig = loadUserConfig();
45
+ const mcp = { ...(userConfig.extensions?.mcp ?? {}) };
46
+ if (!(name in mcp))
47
+ return false;
48
+ delete mcp[name];
49
+ saveUserConfig({
50
+ ...userConfig,
51
+ extensions: { ...userConfig.extensions, mcp },
52
+ });
53
+ return true;
54
+ }
55
+ export function updateGlobalMcpExtension(name, patch) {
56
+ const userConfig = loadUserConfig();
57
+ const mcp = { ...(userConfig.extensions?.mcp ?? {}) };
58
+ if (!(name in mcp))
59
+ return false;
60
+ mcp[name] = { ...mcp[name], ...patch };
61
+ saveUserConfig({
62
+ ...userConfig,
63
+ extensions: { ...userConfig.extensions, mcp },
64
+ });
65
+ return true;
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // Workspace extensions (.mcp.json)
69
+ // ---------------------------------------------------------------------------
70
+ function workspaceMcpJsonPath(workdir) {
71
+ return path.join(workdir, '.mcp.json');
72
+ }
73
+ function readMcpJson(filePath) {
74
+ try {
75
+ const raw = fs.readFileSync(filePath, 'utf-8');
76
+ const parsed = JSON.parse(raw);
77
+ const servers = parsed?.mcpServers ?? parsed;
78
+ if (typeof servers === 'object' && servers !== null && !Array.isArray(servers)) {
79
+ return servers;
80
+ }
81
+ }
82
+ catch { /* not found or invalid */ }
83
+ return {};
84
+ }
85
+ function writeMcpJson(filePath, servers) {
86
+ const content = JSON.stringify({ mcpServers: servers }, null, 2) + '\n';
87
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
88
+ fs.writeFileSync(filePath, content);
89
+ }
90
+ export function loadWorkspaceMcpExtensions(workdir) {
91
+ const mcpPath = workspaceMcpJsonPath(workdir);
92
+ const servers = readMcpJson(mcpPath);
93
+ return Object.entries(servers).map(([name, cfg]) => ({
94
+ name,
95
+ config: cfg,
96
+ scope: 'workspace',
97
+ source: mcpPath,
98
+ }));
99
+ }
100
+ export function addWorkspaceMcpExtension(workdir, name, config) {
101
+ const mcpPath = workspaceMcpJsonPath(workdir);
102
+ const servers = readMcpJson(mcpPath);
103
+ servers[name] = config;
104
+ writeMcpJson(mcpPath, servers);
105
+ }
106
+ export function removeWorkspaceMcpExtension(workdir, name) {
107
+ const mcpPath = workspaceMcpJsonPath(workdir);
108
+ const servers = readMcpJson(mcpPath);
109
+ if (!(name in servers))
110
+ return false;
111
+ delete servers[name];
112
+ writeMcpJson(mcpPath, servers);
113
+ return true;
114
+ }
115
+ export function updateWorkspaceMcpExtension(workdir, name, patch) {
116
+ const mcpPath = workspaceMcpJsonPath(workdir);
117
+ const servers = readMcpJson(mcpPath);
118
+ if (!(name in servers))
119
+ return false;
120
+ servers[name] = { ...servers[name], ...patch };
121
+ writeMcpJson(mcpPath, servers);
122
+ return true;
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // Unified listing
126
+ // ---------------------------------------------------------------------------
127
+ export function listAllMcpExtensions(workdir) {
128
+ const global = loadGlobalMcpExtensions();
129
+ const workspace = workdir ? loadWorkspaceMcpExtensions(workdir) : [];
130
+ const claudeMcp = [];
131
+ if (workdir) {
132
+ const claudePath = path.join(workdir, '.claude', '.mcp.json');
133
+ const servers = readMcpJson(claudePath);
134
+ for (const [name, cfg] of Object.entries(servers)) {
135
+ claudeMcp.push({ name, config: cfg, scope: 'workspace', source: claudePath });
136
+ }
137
+ }
138
+ return [...global, ...workspace, ...claudeMcp];
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Catalog — merged recommended + installed with state computation
142
+ // ---------------------------------------------------------------------------
143
+ function cmdSummary(config) {
144
+ if (config.type === 'http' && config.url)
145
+ return config.url;
146
+ const cmd = config.command || '';
147
+ const args = (config.args || []).filter(a => a !== '-y');
148
+ return [cmd, ...args].join(' ').trim();
149
+ }
150
+ /**
151
+ * Generic @modelcontextprotocol/server-* demos that historically shipped in the
152
+ * recommended list but were later removed (no product identity, overlap with
153
+ * built-in agent capabilities like search/time). We hide them from the catalog
154
+ * UI so old installs don't clutter the Connected section. The configs are kept
155
+ * in setting.json untouched — users can still edit by hand if they want.
156
+ */
157
+ const HIDDEN_GENERIC_DEMO_PACKAGES = new Set([
158
+ '@modelcontextprotocol/server-time',
159
+ '@modelcontextprotocol/server-fetch',
160
+ '@modelcontextprotocol/server-memory',
161
+ 'mcp-server-time',
162
+ 'mcp-server-fetch',
163
+ 'mcp-server-memory',
164
+ ]);
165
+ const HIDDEN_GENERIC_DEMO_NAMES = new Set(['time', 'fetch', 'memory']);
166
+ function isGenericDemoEntry(entry) {
167
+ if (HIDDEN_GENERIC_DEMO_NAMES.has(entry.name.toLowerCase()))
168
+ return true;
169
+ const args = entry.config.args || [];
170
+ return args.some(a => HIDDEN_GENERIC_DEMO_PACKAGES.has(a));
171
+ }
172
+ function transportSummary(transport) {
173
+ if (transport.type === 'http')
174
+ return transport.url;
175
+ return [transport.command, ...transport.args.filter(a => a !== '-y')].join(' ');
176
+ }
177
+ function hasRequiredCredentials(config, auth) {
178
+ if (auth.type !== 'credentials')
179
+ return true;
180
+ const bag = { ...(config.env || {}), ...(config.headers || {}) };
181
+ for (const field of auth.fields) {
182
+ if (!field.required)
183
+ continue;
184
+ if (!bag[field.key] || !String(bag[field.key]).trim())
185
+ return false;
186
+ }
187
+ return true;
188
+ }
189
+ function computeStateForInstalled(config, auth, id, unhealthyIds) {
190
+ if (config.enabled === false || config.disabled === true)
191
+ return 'disabled';
192
+ if (auth.type === 'credentials' && !hasRequiredCredentials(config, auth))
193
+ return 'needs_auth';
194
+ if (auth.type === 'mcp-oauth' && !hasValidMcpToken(id))
195
+ return 'needs_auth';
196
+ if (unhealthyIds?.has(id))
197
+ return 'unhealthy';
198
+ return 'ready';
199
+ }
200
+ /**
201
+ * Produce the unified catalog for the dashboard: every recommended registry
202
+ * entry, plus any custom installed entries the user added, each with a
203
+ * computed state field.
204
+ *
205
+ * When `scope` is provided, recommended entries are filtered to those whose
206
+ * `recommendedScope` matches (or is `'both'`). Custom entries are filtered
207
+ * by where they are installed — `scope: 'global'` excludes workspace entries
208
+ * and vice versa.
209
+ */
210
+ export function getCatalogItems(opts = {}) {
211
+ const recommended = getRecommendedMcpServers();
212
+ const installed = [
213
+ ...loadGlobalMcpExtensions(),
214
+ ...(opts.workdir ? loadWorkspaceMcpExtensions(opts.workdir) : []),
215
+ ];
216
+ // Build lookup: catalogId -> installed entry (preferring global).
217
+ const installedByCatalogId = new Map();
218
+ const customEntries = [];
219
+ for (const entry of installed) {
220
+ const catalogId = entry.config.catalogId;
221
+ if (catalogId && recommended.some(r => r.id === catalogId)) {
222
+ if (!installedByCatalogId.has(catalogId))
223
+ installedByCatalogId.set(catalogId, entry);
224
+ }
225
+ else {
226
+ customEntries.push(entry);
227
+ }
228
+ }
229
+ const scopeMatchesRec = (rec) => {
230
+ if (!opts.scope)
231
+ return true;
232
+ return rec.recommendedScope === opts.scope || rec.recommendedScope === 'both';
233
+ };
234
+ const scopeMatchesEntry = (entry) => {
235
+ if (!opts.scope)
236
+ return true;
237
+ if (opts.scope === 'both')
238
+ return true;
239
+ return entry.scope === opts.scope;
240
+ };
241
+ const items = [];
242
+ // Builtin items derive their installed/state from a top-level config flag
243
+ // rather than `extensions.mcp`. Each catalogId maps to one flag — extend the
244
+ // switch when adding a new builtin.
245
+ const userConfig = loadUserConfig();
246
+ const builtinInstalled = (catalogId) => {
247
+ if (catalogId === 'pikiloop-browser')
248
+ return userConfig.browserEnabled === true;
249
+ if (catalogId === 'peekaboo')
250
+ return userConfig.peekabooEnabled === true;
251
+ return false;
252
+ };
253
+ // 1. Registry entries — preserve registry ordering.
254
+ for (const rec of recommended) {
255
+ if (!scopeMatchesRec(rec))
256
+ continue;
257
+ const entry = installedByCatalogId.get(rec.id);
258
+ let state;
259
+ let installed;
260
+ let installedKey;
261
+ let scope;
262
+ let config;
263
+ if (rec.isBuiltin) {
264
+ installed = builtinInstalled(rec.id);
265
+ state = installed ? 'ready' : 'recommended';
266
+ installedKey = installed ? rec.id : undefined;
267
+ // Leave scope undefined: builtins aren't tied to global/workspace
268
+ // storage, and the catalog UI's scope filter ignores items with no scope.
269
+ scope = undefined;
270
+ }
271
+ else {
272
+ state = entry
273
+ ? computeStateForInstalled(entry.config, rec.auth, rec.id, opts.unhealthyIds)
274
+ : 'recommended';
275
+ installed = !!entry;
276
+ installedKey = entry?.name;
277
+ scope = entry?.scope;
278
+ config = entry?.config;
279
+ }
280
+ items.push({
281
+ id: rec.id,
282
+ name: rec.name,
283
+ description: rec.description,
284
+ descriptionZh: rec.descriptionZh,
285
+ category: rec.category,
286
+ iconSlug: rec.iconSlug,
287
+ iconUrl: rec.iconUrl,
288
+ homepage: rec.homepage,
289
+ transport: { type: rec.transport.type, summary: transportSummary(rec.transport) },
290
+ auth: rec.auth,
291
+ state,
292
+ isRecommended: true,
293
+ installed,
294
+ scope,
295
+ config,
296
+ installedKey,
297
+ recommendedScope: rec.recommendedScope,
298
+ isBuiltin: rec.isBuiltin,
299
+ });
300
+ }
301
+ // 2. Custom entries — user-added servers not in the recommended registry.
302
+ for (const entry of customEntries) {
303
+ if (!scopeMatchesEntry(entry))
304
+ continue;
305
+ if (isGenericDemoEntry(entry))
306
+ continue;
307
+ const auth = { type: 'none' };
308
+ const state = computeStateForInstalled(entry.config, auth, entry.name, opts.unhealthyIds);
309
+ items.push({
310
+ id: entry.name,
311
+ name: entry.name,
312
+ description: cmdSummary(entry.config),
313
+ descriptionZh: cmdSummary(entry.config),
314
+ category: 'custom',
315
+ transport: {
316
+ type: entry.config.type === 'http' ? 'http' : 'stdio',
317
+ summary: cmdSummary(entry.config),
318
+ },
319
+ auth,
320
+ state,
321
+ isRecommended: false,
322
+ installed: true,
323
+ scope: entry.scope,
324
+ config: entry.config,
325
+ installedKey: entry.name,
326
+ });
327
+ }
328
+ return items;
329
+ }
330
+ export function getCatalogItem(id, opts = {}) {
331
+ return getCatalogItems(opts).find(i => i.id === id);
332
+ }
333
+ /**
334
+ * Build an `McpServerConfig` from a recommended entry plus user-supplied
335
+ * credentials. Used when installing a recommended server via the catalog flow.
336
+ */
337
+ export function buildInstalledConfigFromRecommended(rec, opts = { enabled: false }) {
338
+ const creds = opts.credentials || {};
339
+ if (rec.transport.type === 'stdio') {
340
+ const env = {};
341
+ if (rec.auth.type === 'credentials') {
342
+ for (const f of rec.auth.fields)
343
+ if (creds[f.key])
344
+ env[f.key] = creds[f.key];
345
+ }
346
+ return {
347
+ type: 'stdio',
348
+ command: rec.transport.command,
349
+ args: rec.transport.args,
350
+ ...(Object.keys(env).length ? { env } : {}),
351
+ enabled: opts.enabled,
352
+ catalogId: rec.id,
353
+ };
354
+ }
355
+ const headers = {};
356
+ if (rec.auth.type === 'credentials') {
357
+ // Convention: first non-empty credential becomes Authorization: Bearer <value>.
358
+ // Matches how Stripe, Perplexity, and similar providers expect the token.
359
+ const first = rec.auth.fields.find(f => creds[f.key]);
360
+ if (first)
361
+ headers.Authorization = `Bearer ${creds[first.key]}`;
362
+ }
363
+ return {
364
+ type: 'http',
365
+ url: rec.transport.url,
366
+ ...(Object.keys(headers).length ? { headers } : {}),
367
+ enabled: opts.enabled,
368
+ catalogId: rec.id,
369
+ };
370
+ }
371
+ // ---------------------------------------------------------------------------
372
+ // Merge for session — called by bridge.ts
373
+ // ---------------------------------------------------------------------------
374
+ /**
375
+ * Build the merged MCP server list for a session.
376
+ * Priority (low → high): global → workspace .mcp.json → .claude/.mcp.json → ~/.claude/.mcp.json → builtins.
377
+ * Disabled servers are filtered out. OAuth Bearer headers are injected for
378
+ * any http-type server that has a valid token in the token store.
379
+ */
380
+ export function mergeExtensionsForSession(builtinServers, workdir) {
381
+ const merged = {};
382
+ // 1. Global extensions from setting.json (lowest priority)
383
+ const userConfig = loadUserConfig();
384
+ const globalMcp = userConfig.extensions?.mcp;
385
+ if (globalMcp) {
386
+ for (const [name, cfg] of Object.entries(globalMcp)) {
387
+ if (cfg.enabled === false || cfg.disabled)
388
+ continue;
389
+ if (cfg.type === 'http' && cfg.url) {
390
+ const oauthKey = cfg.catalogId || name;
391
+ const headers = injectOAuthHeaders(oauthKey, { headers: cfg.headers });
392
+ merged[name] = {
393
+ type: 'http',
394
+ url: cfg.url,
395
+ ...(Object.keys(headers).length ? { headers } : {}),
396
+ };
397
+ }
398
+ else if (cfg.command) {
399
+ merged[name] = {
400
+ type: 'stdio',
401
+ command: cfg.command,
402
+ args: cfg.args || [],
403
+ ...(cfg.env ? { env: cfg.env } : {}),
404
+ };
405
+ }
406
+ }
407
+ }
408
+ // 2. Workspace .mcp.json files (overwrite global)
409
+ if (workdir) {
410
+ for (const candidate of [
411
+ path.join(workdir, '.mcp.json'),
412
+ path.join(workdir, '.claude', '.mcp.json'),
413
+ path.join(os.homedir(), '.claude', '.mcp.json'),
414
+ ]) {
415
+ try {
416
+ const raw = fs.readFileSync(candidate, 'utf-8');
417
+ const parsed = JSON.parse(raw);
418
+ const servers = parsed?.mcpServers ?? parsed;
419
+ if (servers && typeof servers === 'object') {
420
+ for (const [name, cfg] of Object.entries(servers)) {
421
+ if (cfg?.disabled === true) {
422
+ delete merged[name];
423
+ }
424
+ else {
425
+ Object.assign(merged, { [name]: cfg });
426
+ }
427
+ }
428
+ }
429
+ }
430
+ catch { /* skip */ }
431
+ }
432
+ }
433
+ // 3. Built-in servers (highest priority)
434
+ for (const server of builtinServers) {
435
+ merged[server.name] = {
436
+ type: 'stdio',
437
+ command: server.command,
438
+ args: server.args,
439
+ ...(server.env ? { env: server.env } : {}),
440
+ };
441
+ }
442
+ // Filter out any remaining disabled entries
443
+ for (const [name, cfg] of Object.entries(merged)) {
444
+ if (cfg?.disabled === true || cfg?.enabled === false) {
445
+ delete merged[name];
446
+ }
447
+ }
448
+ return merged;
449
+ }
450
+ /**
451
+ * Convert global + workspace extensions to RegisteredMcpServer[] for Codex
452
+ * and Gemini agents that consume server arrays instead of merged configs.
453
+ *
454
+ * Supports both stdio and HTTP transports. For HTTP entries, OAuth Bearer
455
+ * headers are injected from the token store (same path as
456
+ * mergeExtensionsForSession), so a one-time global authorization carries
457
+ * across every workspace.
458
+ */
459
+ export function getGlobalExtensionsAsServers(workdir) {
460
+ const merged = new Map();
461
+ const toEntry = (name, cfg) => {
462
+ if (cfg.type === 'http' && cfg.url) {
463
+ const oauthKey = cfg.catalogId || name;
464
+ const headers = injectOAuthHeaders(oauthKey, { headers: cfg.headers });
465
+ return {
466
+ name,
467
+ type: 'http',
468
+ url: cfg.url,
469
+ ...(Object.keys(headers).length ? { headers } : {}),
470
+ };
471
+ }
472
+ if (cfg.command) {
473
+ return {
474
+ name,
475
+ type: 'stdio',
476
+ command: cfg.command,
477
+ args: cfg.args || [],
478
+ ...(cfg.env ? { env: cfg.env } : {}),
479
+ };
480
+ }
481
+ return null;
482
+ };
483
+ const userConfig = loadUserConfig();
484
+ const globalMcp = userConfig.extensions?.mcp;
485
+ if (globalMcp) {
486
+ for (const [name, cfg] of Object.entries(globalMcp)) {
487
+ if (cfg.enabled === false || cfg.disabled)
488
+ continue;
489
+ const entry = toEntry(name, cfg);
490
+ if (entry)
491
+ merged.set(name, entry);
492
+ }
493
+ }
494
+ if (workdir) {
495
+ const wsServers = readMcpJson(workspaceMcpJsonPath(workdir));
496
+ for (const [name, cfg] of Object.entries(wsServers)) {
497
+ if (cfg.disabled) {
498
+ merged.delete(name);
499
+ continue;
500
+ }
501
+ const entry = toEntry(name, cfg);
502
+ if (entry)
503
+ merged.set(name, entry);
504
+ }
505
+ }
506
+ return [...merged.values()];
507
+ }
508
+ const HEALTH_CACHE_TTL_MS = 10 * 60 * 1000;
509
+ const healthCache = new Map();
510
+ function healthFingerprint(config) {
511
+ return JSON.stringify({
512
+ type: config.type || 'stdio',
513
+ url: config.url,
514
+ command: config.command,
515
+ args: config.args,
516
+ hasEnv: !!config.env && Object.keys(config.env).length > 0,
517
+ });
518
+ }
519
+ export function getCachedHealth(id, config) {
520
+ const entry = healthCache.get(id);
521
+ if (!entry)
522
+ return undefined;
523
+ if (Date.now() - entry.cachedAt > HEALTH_CACHE_TTL_MS)
524
+ return undefined;
525
+ if (entry.fingerprint !== healthFingerprint(config))
526
+ return undefined;
527
+ return entry.result;
528
+ }
529
+ export function cacheHealth(id, config, result) {
530
+ healthCache.set(id, { result, fingerprint: healthFingerprint(config), cachedAt: Date.now() });
531
+ }
532
+ export async function checkMcpHealth(config, timeoutMs = 10_000) {
533
+ if (config.type === 'http') {
534
+ try {
535
+ const start = Date.now();
536
+ const controller = new AbortController();
537
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
538
+ const res = await fetch(config.url, { signal: controller.signal, method: 'GET' });
539
+ clearTimeout(timer);
540
+ return { ok: res.ok || res.status === 405 || res.status === 401, elapsedMs: Date.now() - start };
541
+ }
542
+ catch (e) {
543
+ return { ok: false, error: e?.message || 'unreachable' };
544
+ }
545
+ }
546
+ if (!config.command)
547
+ return { ok: false, error: 'no command specified' };
548
+ return new Promise((resolve) => {
549
+ const start = Date.now();
550
+ let checkInterval = null;
551
+ const cleanup = () => {
552
+ if (checkInterval) {
553
+ clearInterval(checkInterval);
554
+ checkInterval = null;
555
+ }
556
+ clearTimeout(timer);
557
+ };
558
+ const child = spawn(config.command, config.args || [], {
559
+ stdio: ['pipe', 'pipe', 'pipe'],
560
+ env: { ...process.env, ...config.env },
561
+ });
562
+ const timer = setTimeout(() => {
563
+ cleanup();
564
+ child.kill();
565
+ resolve({ ok: false, error: `timeout after ${timeoutMs}ms`, elapsedMs: Date.now() - start });
566
+ }, timeoutMs);
567
+ let stdout = '';
568
+ child.stdout?.on('data', (data) => { stdout += data.toString(); });
569
+ child.on('error', (err) => {
570
+ cleanup();
571
+ resolve({ ok: false, error: err.message, elapsedMs: Date.now() - start });
572
+ });
573
+ const initRequest = JSON.stringify({
574
+ jsonrpc: '2.0',
575
+ id: 1,
576
+ method: 'initialize',
577
+ params: {
578
+ protocolVersion: '2024-11-05',
579
+ capabilities: {},
580
+ clientInfo: { name: 'pikiloop-health-check', version: '1.0.0' },
581
+ },
582
+ });
583
+ const header = `Content-Length: ${Buffer.byteLength(initRequest)}\r\n\r\n`;
584
+ try {
585
+ child.stdin?.write(header + initRequest);
586
+ }
587
+ catch {
588
+ cleanup();
589
+ resolve({ ok: false, error: 'failed to write to stdin', elapsedMs: Date.now() - start });
590
+ return;
591
+ }
592
+ checkInterval = setInterval(() => {
593
+ const hasResponse = stdout.includes('"result"') || stdout.includes('"serverInfo"');
594
+ if (!hasResponse)
595
+ return;
596
+ cleanup();
597
+ const toolsRequest = JSON.stringify({
598
+ jsonrpc: '2.0',
599
+ id: 2,
600
+ method: 'tools/list',
601
+ params: {},
602
+ });
603
+ const toolsHeader = `Content-Length: ${Buffer.byteLength(toolsRequest)}\r\n\r\n`;
604
+ try {
605
+ child.stdin?.write(toolsHeader + toolsRequest);
606
+ }
607
+ catch { /* best-effort */ }
608
+ setTimeout(() => {
609
+ child.kill();
610
+ const tools = [];
611
+ try {
612
+ const jsonMatches = stdout.match(/\{[^{}]*"tools"\s*:\s*\[[\s\S]*?\]\s*[^{}]*\}/g);
613
+ if (jsonMatches) {
614
+ for (const m of jsonMatches) {
615
+ try {
616
+ const parsed = JSON.parse(m);
617
+ if (Array.isArray(parsed.tools)) {
618
+ for (const tool of parsed.tools)
619
+ if (tool.name)
620
+ tools.push(tool.name);
621
+ }
622
+ if (parsed.result?.tools) {
623
+ for (const tool of parsed.result.tools)
624
+ if (tool.name)
625
+ tools.push(tool.name);
626
+ }
627
+ }
628
+ catch { /* try next match */ }
629
+ }
630
+ }
631
+ }
632
+ catch { /* best effort */ }
633
+ resolve({ ok: true, tools: tools.length ? tools : undefined, elapsedMs: Date.now() - start });
634
+ }, 1500);
635
+ }, 100);
636
+ });
637
+ }