pikiloom 0.4.15 → 0.4.17

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 (41) hide show
  1. package/README.md +2 -1
  2. package/dashboard/dist/assets/{AgentTab-CKoy_-w4.js → AgentTab-CDVhy5K1.js} +1 -1
  3. package/dashboard/dist/assets/{DirBrowser-DpbuN0OL.js → DirBrowser-BElI1-4D.js} +1 -1
  4. package/dashboard/dist/assets/{ExtensionsTab-ymr7K8dU.js → ExtensionsTab-BB8ipJ77.js} +1 -1
  5. package/dashboard/dist/assets/{IMAccessTab-CaTtCn3l.js → IMAccessTab-IZt_yXoG.js} +1 -1
  6. package/dashboard/dist/assets/{Modal-DA-9kJxp.js → Modal-C1EAGSL1.js} +1 -1
  7. package/dashboard/dist/assets/{Modals-BkLIRnNK.js → Modals-DYUV5yR9.js} +1 -1
  8. package/dashboard/dist/assets/{Select-B0pZtuzF.js → Select-BnsbE6Qv.js} +1 -1
  9. package/dashboard/dist/assets/SessionPanel-Ca_TVTT1.js +1 -0
  10. package/dashboard/dist/assets/{SystemTab-B9TcGMzc.js → SystemTab-Dk6k2OTt.js} +1 -1
  11. package/dashboard/dist/assets/index-CK-3CNRp.js +3 -0
  12. package/dashboard/dist/assets/index-CnJsD381.js +23 -0
  13. package/dashboard/dist/assets/index-dzfjF9Js.css +1 -0
  14. package/dashboard/dist/assets/{shared-i_XUH0xm.js → shared-CZVD0MJD.js} +1 -1
  15. package/dashboard/dist/index.html +2 -2
  16. package/dist/agent/artifacts.js +160 -0
  17. package/dist/agent/images.js +51 -24
  18. package/dist/agent/index.js +4 -2
  19. package/dist/agent/mcp/bridge.js +201 -7
  20. package/dist/agent/mcp/extensions.js +20 -9
  21. package/dist/agent/mcp/tools/workspace.js +4 -3
  22. package/dist/agent/stream.js +3 -2
  23. package/dist/bot/bot.js +83 -4
  24. package/dist/bot/commands.js +48 -2
  25. package/dist/bot/menu.js +1 -0
  26. package/dist/bot/session-hub.js +1 -1
  27. package/dist/channels/dingtalk/bot.js +9 -1
  28. package/dist/channels/discord/bot.js +9 -1
  29. package/dist/channels/feishu/bot.js +8 -1
  30. package/dist/channels/slack/bot.js +9 -1
  31. package/dist/channels/telegram/bot.js +8 -1
  32. package/dist/channels/wecom/bot.js +9 -1
  33. package/dist/channels/weixin/bot.js +9 -1
  34. package/dist/cli/main.js +1 -0
  35. package/dist/dashboard/routes/config.js +134 -12
  36. package/dist/dashboard/routes/sessions.js +108 -27
  37. package/package.json +1 -1
  38. package/dashboard/dist/assets/SessionPanel-CYQtZZNX.js +0 -1
  39. package/dashboard/dist/assets/index-BCYshErN.js +0 -3
  40. package/dashboard/dist/assets/index-C5irxzzD.js +0 -23
  41. package/dashboard/dist/assets/index-FD86DEDF.css +0 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * artifacts.ts — delivered-artifact manifest (the single source of truth for
3
+ * "files the agent handed to the user during a session").
4
+ *
5
+ * When the agent calls `im_send_file`, the bot materializes the file into the
6
+ * session attachments dir and appends a record here. IM channels additionally
7
+ * push the bytes to their chat; the dashboard serves the materialized copy over
8
+ * HTTP and renders it. Recording is terminal-agnostic, so a session watched
9
+ * from BOTH a chat and the dashboard shows the same deliveries on either side,
10
+ * and they survive a page reload / workspace cleanup.
11
+ *
12
+ * The manifest lives next to the MCP-buffered image attachments
13
+ * (`sessionAttachmentsDir`), which is already an allowlist root for the
14
+ * dashboard attachment endpoint — so materialized artifacts are servable with
15
+ * no new trust boundary.
16
+ */
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { sessionAttachmentsDir } from './images.js';
20
+ import { agentWarn } from './utils.js';
21
+ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
22
+ const MIME_BY_EXT = {
23
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
24
+ '.webp': 'image/webp', '.svg': 'image/svg+xml',
25
+ '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8',
26
+ '.csv': 'text/csv; charset=utf-8', '.json': 'application/json', '.xml': 'application/xml',
27
+ '.html': 'text/html; charset=utf-8', '.zip': 'application/zip', '.gz': 'application/gzip',
28
+ '.tar': 'application/x-tar', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
29
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.log': 'text/plain; charset=utf-8',
30
+ };
31
+ /** Best-effort MIME for a delivered artifact; `application/octet-stream` when unknown. */
32
+ export function mimeForArtifact(filePath) {
33
+ return MIME_BY_EXT[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
34
+ }
35
+ function manifestPath(agent, sessionId) {
36
+ return path.join(sessionAttachmentsDir(agent, sessionId), 'delivered.jsonl');
37
+ }
38
+ function deliveredDir(agent, sessionId) {
39
+ return path.join(sessionAttachmentsDir(agent, sessionId), 'delivered');
40
+ }
41
+ function sanitizeName(name) {
42
+ return (name || 'file').replace(/[/\\\0]+/g, '_').replace(/^\.+/, '').slice(0, 200) || 'file';
43
+ }
44
+ /**
45
+ * Materialize `srcPath` into the session's delivered-artifacts dir so it is
46
+ * (a) servable by the dashboard attachment endpoint (the dir is an allowlist
47
+ * root) and (b) durable across workspace cleanup. Hardlinks when possible,
48
+ * falling back to a copy across filesystems. The on-disk name is stamped to
49
+ * avoid collisions; the pristine basename travels in the manifest.
50
+ */
51
+ export function stageDeliveredArtifact(agent, sessionId, srcPath) {
52
+ const fileName = sanitizeName(path.basename(srcPath));
53
+ const stat = fs.statSync(srcPath);
54
+ const dir = deliveredDir(agent, sessionId);
55
+ fs.mkdirSync(dir, { recursive: true });
56
+ const stamp = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
57
+ const dest = path.join(dir, `${stamp}-${fileName}`);
58
+ try {
59
+ fs.linkSync(srcPath, dest);
60
+ }
61
+ catch {
62
+ fs.copyFileSync(srcPath, dest);
63
+ }
64
+ return { path: dest, fileName, fileSize: stat.size, fileMime: mimeForArtifact(fileName) };
65
+ }
66
+ /** Append a record to the session's delivered-artifact manifest. */
67
+ export function recordDeliveredArtifact(agent, sessionId, entry) {
68
+ try {
69
+ const file = manifestPath(agent, sessionId);
70
+ fs.mkdirSync(path.dirname(file), { recursive: true });
71
+ fs.appendFileSync(file, JSON.stringify(entry) + '\n');
72
+ }
73
+ catch (e) {
74
+ agentWarn(`[artifacts] record failed: ${e?.message || e}`);
75
+ }
76
+ }
77
+ /**
78
+ * Materialize + record a delivered artifact in one step. Best-effort: returns
79
+ * the stored record (with the materialized path), or null on failure — delivery
80
+ * must never crash the stream.
81
+ */
82
+ export function deliverArtifact(agent, sessionId, srcPath, opts) {
83
+ try {
84
+ const staged = stageDeliveredArtifact(agent, sessionId, srcPath);
85
+ const record = {
86
+ ts: Date.now(),
87
+ ...(opts.taskId ? { taskId: opts.taskId } : {}),
88
+ path: staged.path,
89
+ fileName: staged.fileName,
90
+ fileMime: staged.fileMime,
91
+ fileSize: staged.fileSize,
92
+ kind: opts.kind,
93
+ ...(opts.caption ? { caption: opts.caption } : {}),
94
+ };
95
+ recordDeliveredArtifact(agent, sessionId, record);
96
+ return record;
97
+ }
98
+ catch (e) {
99
+ agentWarn(`[artifacts] deliver failed for ${srcPath}: ${e?.message || e}`);
100
+ return null;
101
+ }
102
+ }
103
+ /** Read the delivered-artifact manifest for a session (tolerant of partial lines). */
104
+ export function readDeliveredArtifacts(agent, sessionId) {
105
+ const file = manifestPath(agent, sessionId);
106
+ let raw;
107
+ try {
108
+ raw = fs.readFileSync(file, 'utf-8');
109
+ }
110
+ catch {
111
+ return [];
112
+ }
113
+ const out = [];
114
+ for (const line of raw.split('\n')) {
115
+ const t = line.trim();
116
+ if (!t)
117
+ continue;
118
+ try {
119
+ const rec = JSON.parse(t);
120
+ if (rec && typeof rec.path === 'string' && rec.fileName)
121
+ out.push(rec);
122
+ }
123
+ catch { /* skip malformed line */ }
124
+ }
125
+ return out;
126
+ }
127
+ /**
128
+ * Project the delivered-artifact manifest into pre-transport MessageBlocks
129
+ * (content = `file://<abs>`). The dashboard read path rewrites these to
130
+ * attachment HTTP URLs via `rewriteAttachmentBlocksForTransport`. Records whose
131
+ * file no longer exists on disk are dropped.
132
+ */
133
+ export function deliveredArtifactBlocks(agent, sessionId) {
134
+ const blocks = [];
135
+ for (const a of readDeliveredArtifacts(agent, sessionId)) {
136
+ if (!fs.existsSync(a.path))
137
+ continue;
138
+ if (a.kind === 'photo' && PHOTO_EXTS.has(path.extname(a.fileName).toLowerCase())) {
139
+ blocks.push({
140
+ type: 'image',
141
+ content: `file://${a.path}`,
142
+ imagePath: a.path,
143
+ imageMime: a.fileMime,
144
+ ...(a.caption ? { imageCaption: a.caption } : {}),
145
+ });
146
+ }
147
+ else {
148
+ blocks.push({
149
+ type: 'file',
150
+ content: `file://${a.path}`,
151
+ filePath: a.path,
152
+ fileMime: a.fileMime,
153
+ fileName: a.fileName,
154
+ fileSize: a.fileSize,
155
+ ...(a.caption ? { fileCaption: a.caption } : {}),
156
+ });
157
+ }
158
+ }
159
+ return blocks;
160
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
- * images.ts — unified image pipeline for the agent layer.
2
+ * images.ts — unified image (and on-disk attachment) pipeline for the agent
3
+ * layer.
3
4
  *
4
5
  * Every driver produces image blocks through `attachAgentImage`, every IM
5
6
  * channel consumes them through `materializeImage`, and every dashboard
6
- * response routes large images through the attachment HTTP endpoint via
7
- * `rewriteImageBlocksForTransport`. The shape isolates *where the bytes live*
7
+ * response routes on-disk image/file blocks through the attachment HTTP
8
+ * endpoint via `rewriteAttachmentBlocksForTransport` (delivered non-image files
9
+ * — see agent/artifacts.ts — ride the same transport). The shape isolates
10
+ * *where the bytes live*
8
11
  * from *how a renderer wants to consume them*, so adding a new driver source
9
12
  * (Codex `image_generation_call`, Claude MCP `tool_result` image, Gemini Imagen,
10
13
  * future) or a new transport (a fourth IM channel, a CLI exporter, an OG-image
@@ -25,7 +28,7 @@
25
28
  * - `content` always carries a directly-renderable reference: a `data:` URL
26
29
  * for inline bytes, or an attachment HTTP URL for files on disk.
27
30
  * - Below `INLINE_THRESHOLD_BYTES`, drivers may inline as `data:`. Above the
28
- * threshold, `rewriteImageBlocksForTransport` substitutes a relative
31
+ * threshold, `rewriteAttachmentBlocksForTransport` substitutes a relative
29
32
  * `/api/sessions/:agent/:id/attachment?...` URL — keeps RichMessage
30
33
  * payloads small even when a session has many large images.
31
34
  * - IM channels prefer `imagePath` over decoding `content`, avoiding wasted
@@ -192,7 +195,7 @@ export function attachAgentImage(opts) {
192
195
  }
193
196
  else {
194
197
  // Sentinel: the transport layer rewrites this into an HTTP URL with the
195
- // right session context. Renderers that bypass `rewriteImageBlocksForTransport`
198
+ // right session context. Renderers that bypass `rewriteAttachmentBlocksForTransport`
196
199
  // see a `file://` URL and can still resolve it via `materializeImage` since
197
200
  // `imagePath` is also populated.
198
201
  content = `file://${abs}`;
@@ -321,29 +324,53 @@ export function decodeAttachmentPathParam(value) {
321
324
  return Buffer.from(normalized, 'base64').toString('utf-8');
322
325
  }
323
326
  /**
324
- * Rewrite a block array for transport: any image block whose `content` is a
325
- * `file://` sentinel becomes an attachment HTTP URL, and any inline `data:`
326
- * URL above the threshold is also promoted to an HTTP URL (keeping the
327
- * RichMessage payload compact even when a session has many large images).
327
+ * Build the dashboard attachment URL that serves `absPath` for a given session.
328
+ * Single source for the `/api/sessions/:agent/:id/attachment?p=…` shape so the
329
+ * transport rewrite, live stream snapshots, and any future caller stay in sync.
330
+ * `downloadName` is carried as an opaque `&n=` hint the endpoint uses for the
331
+ * Content-Disposition filename (the pristine basename, not the on-disk one).
332
+ */
333
+ export function attachmentUrl(agent, sessionId, absPath, opts = {}) {
334
+ const base = (opts.apiBase || '/api/sessions').replace(/\/+$/, '');
335
+ const encoded = encodePathForUrl(absPath);
336
+ const name = opts.downloadName ? `&n=${encodeURIComponent(opts.downloadName)}` : '';
337
+ return `${base}/${encodeURIComponent(agent)}/${encodeURIComponent(sessionId)}/attachment?p=${encoded}${name}`;
338
+ }
339
+ /**
340
+ * Rewrite a block array for transport. Image and file blocks whose `content`
341
+ * is a `file://` sentinel (or which carry an on-disk `imagePath`/`filePath`)
342
+ * become attachment HTTP URLs; inline `data:` image URLs are left untouched so
343
+ * the dashboard renders them directly. Keeps RichMessage payloads compact even
344
+ * when a session delivered many large artifacts.
328
345
  *
329
346
  * Pure: returns a new array; the input is not mutated.
330
347
  */
331
- export function rewriteImageBlocksForTransport(blocks, ctx) {
332
- const base = (ctx.apiBase || '/api/sessions').replace(/\/+$/, '');
348
+ export function rewriteAttachmentBlocksForTransport(blocks, ctx) {
333
349
  return blocks.map(block => {
334
- if (block.type !== 'image')
335
- return block;
336
- const sourcePath = block.imagePath
337
- || (block.content?.startsWith('file://') ? block.content.slice('file://'.length) : '');
338
- if (!sourcePath)
339
- return block;
340
- // Inline content under the threshold: leave the data URL alone — the
341
- // dashboard renders it directly, no extra request.
342
- if (block.content?.startsWith('data:'))
343
- return block;
344
- const encoded = encodePathForUrl(sourcePath);
345
- const url = `${base}/${encodeURIComponent(ctx.agent)}/${encodeURIComponent(ctx.sessionId)}/attachment?p=${encoded}`;
346
- return { ...block, content: url, imagePath: sourcePath };
350
+ if (block.type === 'image') {
351
+ const sourcePath = block.imagePath
352
+ || (block.content?.startsWith('file://') ? block.content.slice('file://'.length) : '');
353
+ if (!sourcePath)
354
+ return block;
355
+ // Inline content under the threshold: leave the data URL alone — the
356
+ // dashboard renders it directly, no extra request.
357
+ if (block.content?.startsWith('data:'))
358
+ return block;
359
+ const url = attachmentUrl(ctx.agent, ctx.sessionId, sourcePath, { apiBase: ctx.apiBase });
360
+ return { ...block, content: url, imagePath: sourcePath };
361
+ }
362
+ if (block.type === 'file') {
363
+ const sourcePath = block.filePath
364
+ || (block.content?.startsWith('file://') ? block.content.slice('file://'.length) : '');
365
+ if (!sourcePath)
366
+ return block;
367
+ const url = attachmentUrl(ctx.agent, ctx.sessionId, sourcePath, {
368
+ apiBase: ctx.apiBase,
369
+ downloadName: block.fileName || undefined,
370
+ });
371
+ return { ...block, content: url, filePath: sourcePath };
372
+ }
373
+ return block;
347
374
  });
348
375
  }
349
376
  // ---------------------------------------------------------------------------
@@ -18,8 +18,10 @@ import './drivers/codex.js';
18
18
  import './drivers/gemini.js';
19
19
  import './drivers/hermes.js';
20
20
  export { IMAGE_EXTS } from './types.js';
21
- // ── Re-export: image pipeline ──────────────────────────────────────────────
22
- export { attachAgentImage, attachInlineImage, materializeImage, rewriteImageBlocksForTransport, resolveAllowedAttachmentPath, allowedAttachmentRoots, decodeAttachmentPathParam, sessionAttachmentsDir, codexHome, } from './images.js';
21
+ // ── Re-export: attachment pipeline (images + delivered files) ───────────────
22
+ export { attachAgentImage, attachInlineImage, materializeImage, rewriteAttachmentBlocksForTransport, attachmentUrl, resolveAllowedAttachmentPath, allowedAttachmentRoots, decodeAttachmentPathParam, sessionAttachmentsDir, codexHome, } from './images.js';
23
+ // ── Re-export: delivered-artifact manifest ──────────────────────────────────
24
+ export { deliverArtifact, readDeliveredArtifacts, deliveredArtifactBlocks, mimeForArtifact, } from './artifacts.js';
23
25
  // ── Re-export: utilities ────────────────────────────────────────────────────
24
26
  export { Q, agentLog, agentWarn, agentError, dedupeStrings, numberOrNull, normalizeStreamPreviewPlan, parseTodoWriteAsPlan, normalizeActivityLine, pushRecentActivity, detectClaudeApiError, isRetryableClaudeApiError, detectClaudeModelError, claudeModelErrorMessage, firstNonEmptyLine, shortValue, normalizeErrorMessage, joinErrorMessages, appendSystemPrompt, mimeForExt, computeContext, buildStreamPreviewMeta, summarizeClaudeToolUse, summarizeClaudeToolResult, previewToolCallInput, previewToolCallResult, roundPercent, toIsoFromEpochSeconds, normalizeUsageStatus, labelFromWindowMinutes, usageWindowFromRateLimit, parseJsonTail, modelFamily, normalizeClaudeModelId, emptyUsage, readTailLines, stripInjectedPrompts, sanitizeSessionUserPreviewText, SESSION_PREVIEW_IMAGE_PLACEHOLDER_RE, CLAUDE_AT_MENTION_IMAGE_RE, extractClaudeAtMentionImagePaths, stripClaudeAtMentionImages, isPendingSessionId, emitSessionIdUpdate, sessionListDisplayTitle, } from './utils.js';
25
27
  // ── Re-export: session management ───────────────────────────────────────────
@@ -110,6 +110,58 @@ export const PEEKABOO_MCP_ARGV = ['-y', '-p', PEEKABOO_NPX_PACKAGE, 'peekaboo-mc
110
110
  */
111
111
  export const PEEKABOO_WARM_ARGV = ['-y', '-p', PEEKABOO_NPX_PACKAGE, 'peekaboo', '--version'];
112
112
  let peekabooWarmStarted = false;
113
+ const PEEKABOO_ENV_ALLOWLIST = [
114
+ 'HOME',
115
+ 'PATH',
116
+ 'USER',
117
+ 'LOGNAME',
118
+ 'SHELL',
119
+ 'TMPDIR',
120
+ 'TEMP',
121
+ 'TMP',
122
+ 'LANG',
123
+ 'LC_ALL',
124
+ 'LC_CTYPE',
125
+ ];
126
+ const PEEKABOO_DEFAULT_PATH = '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';
127
+ function cleanEnvString(value) {
128
+ if (typeof value !== 'string')
129
+ return null;
130
+ const trimmed = value.trim();
131
+ if (!trimmed || trimmed.includes('\0'))
132
+ return null;
133
+ return trimmed;
134
+ }
135
+ /**
136
+ * Peekaboo only needs a normal user shell environment so npx can resolve/cache
137
+ * the package. Do not inherit the full pikiloom/agent environment here: it may
138
+ * contain provider API keys, channel tokens, OAuth bearer values, or database
139
+ * URLs that a GUI MCP server has no reason to see.
140
+ */
141
+ export function buildPeekabooChildEnv(env = process.env) {
142
+ const safe = {};
143
+ for (const key of PEEKABOO_ENV_ALLOWLIST) {
144
+ const value = cleanEnvString(env[key]);
145
+ if (value)
146
+ safe[key] = value;
147
+ }
148
+ safe.PATH ||= PEEKABOO_DEFAULT_PATH;
149
+ safe.HOME ||= os.homedir();
150
+ safe.PIKILOOM_MCP_SERVER = 'peekaboo';
151
+ safe.npm_config_yes = 'true';
152
+ return safe;
153
+ }
154
+ function peekabooEnvArgs(env) {
155
+ return Object.entries(env).map(([key, value]) => `${key}=${value}`);
156
+ }
157
+ function buildPeekabooMcpServer() {
158
+ const safeEnv = buildPeekabooChildEnv();
159
+ return {
160
+ name: 'peekaboo',
161
+ command: '/usr/bin/env',
162
+ args: ['-i', ...peekabooEnvArgs(safeEnv), 'npx', ...PEEKABOO_MCP_ARGV],
163
+ };
164
+ }
113
165
  /**
114
166
  * Pre-warm the Peekaboo npx package so the agent's MCP server connects instantly.
115
167
  *
@@ -132,7 +184,11 @@ export function ensurePeekabooWarm() {
132
184
  return;
133
185
  peekabooWarmStarted = true;
134
186
  try {
135
- const child = spawn('npx', PEEKABOO_WARM_ARGV, { stdio: 'ignore', detached: true });
187
+ const child = spawn('npx', PEEKABOO_WARM_ARGV, {
188
+ stdio: 'ignore',
189
+ detached: true,
190
+ env: buildPeekabooChildEnv(),
191
+ });
136
192
  // npx missing / spawn failure: clear the latch so a later call can retry.
137
193
  child.on('error', () => { peekabooWarmStarted = false; });
138
194
  child.unref();
@@ -159,11 +215,7 @@ export function buildSupplementalMcpServers(gui = resolveGuiIntegrationConfig(),
159
215
  if (gui.peekabooEnabled && process.platform === 'darwin') {
160
216
  // Peekaboo — native macOS GUI automation via Accessibility + ScreenCaptureKit.
161
217
  // Run the dedicated MCP bin from the multi-bin @steipete/peekaboo package.
162
- servers.push({
163
- name: 'peekaboo',
164
- command: 'npx',
165
- args: [...PEEKABOO_MCP_ARGV],
166
- });
218
+ servers.push(buildPeekabooMcpServer());
167
219
  }
168
220
  return servers;
169
221
  }
@@ -235,6 +287,33 @@ function codexBearerEnvName(serverName) {
235
287
  const safe = serverName.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
236
288
  return `PIKILOOM_MCP_BEARER_${safe || 'UNNAMED'}`;
237
289
  }
290
+ const REDACTED = '[REDACTED]';
291
+ const SENSITIVE_CONFIG_KEY_RE = /(authorization|bearer|token|secret|password|passwd|api[_-]?key|credential|cookie|session|connection[_-]?string|dsn)/i;
292
+ const URL_PASSWORD_RE = /([a-z][a-z0-9+.-]*:\/\/)([^/\s:@]+):([^@\s/]+)@/gi;
293
+ const QUERY_SECRET_RE = /([?&](?:access_token|api[_-]?key|key|token|secret|password|passwd)=)[^&\s]+/gi;
294
+ function redactStringForLog(key, value) {
295
+ if (SENSITIVE_CONFIG_KEY_RE.test(key)) {
296
+ const bearer = /^\s*Bearer\s+/i.test(value);
297
+ return bearer ? `Bearer ${REDACTED}` : REDACTED;
298
+ }
299
+ return value
300
+ .replace(URL_PASSWORD_RE, `$1$2:${REDACTED}@`)
301
+ .replace(QUERY_SECRET_RE, `$1${REDACTED}`);
302
+ }
303
+ function redactForLog(value, key = '') {
304
+ if (typeof value === 'string')
305
+ return redactStringForLog(key, value);
306
+ if (Array.isArray(value))
307
+ return value.map(item => redactForLog(item, key));
308
+ if (!value || typeof value !== 'object')
309
+ return value;
310
+ return Object.fromEntries(Object.entries(value)
311
+ .map(([childKey, childValue]) => [childKey, redactForLog(childValue, childKey)]));
312
+ }
313
+ export function redactMcpConfigForLog(configPath) {
314
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
315
+ return JSON.stringify(redactForLog(parsed), null, 2);
316
+ }
238
317
  export function buildGeminiMcpConfig(servers) {
239
318
  return {
240
319
  // Session attachments live under .pikiloom/... and should remain readable to
@@ -383,6 +462,116 @@ function reapStalePlaywrightMcpProcesses(cdpEndpoint) {
383
462
  }
384
463
  return { reaped, spared };
385
464
  }
465
+ // ---------------------------------------------------------------------------
466
+ // Stale peekaboo-mcp reaper
467
+ // ---------------------------------------------------------------------------
468
+ function commandTokenBase(token) {
469
+ return path.basename(token.replace(/^"+|"+$/g, '')).replace(/\.(?:cmd|exe)$/i, '').toLowerCase();
470
+ }
471
+ /**
472
+ * Pure matcher for stale-process hygiene. It intentionally targets the
473
+ * long-running MCP server only, not the quick cache warm (`peekaboo --version`).
474
+ */
475
+ export function _matchPeekabooMcpProcessCommand(command) {
476
+ if (!command || !command.includes('peekaboo-mcp'))
477
+ return false;
478
+ const tokens = command.split(/\s+/).filter(Boolean);
479
+ if (!tokens.length)
480
+ return false;
481
+ if (commandTokenBase(tokens[0]) === 'node' && (tokens[1] === '-e' || tokens[1] === '--eval'))
482
+ return false;
483
+ const hasMcpBin = tokens.some(token => token === 'peekaboo-mcp'
484
+ || /(?:^|[\\/])peekaboo-mcp(?:$|\s)/.test(token)
485
+ || /(?:^|[\\/])peekaboo-mcp$/.test(token));
486
+ if (!hasMcpBin)
487
+ return false;
488
+ const hasPackage = command.includes('@steipete/peekaboo')
489
+ || command.includes('@steipete\\peekaboo')
490
+ || command.includes('/@steipete/peekaboo/');
491
+ const launcher = commandTokenBase(tokens[0]);
492
+ const knownLauncher = launcher === 'env' || launcher === 'npx' || launcher === 'npm' || launcher === 'node' || launcher === 'peekaboo-mcp';
493
+ return hasPackage || knownLauncher;
494
+ }
495
+ function commandLooksLikeLiveMcpController(command) {
496
+ if (!command)
497
+ return false;
498
+ const text = command.toLowerCase();
499
+ if (/\bpikiloom\b/.test(text) || text.includes('pikiloom@'))
500
+ return true;
501
+ const first = commandTokenBase(command.split(/\s+/)[0] || '');
502
+ return first === 'claude' || first === 'codex' || first === 'gemini' || first === 'hermes';
503
+ }
504
+ const PEEKABOO_REAP_THROTTLE_MS = 30_000;
505
+ const PEEKABOO_REAP_FORCE_AFTER_MS = 2_000;
506
+ let lastPeekabooReapAt = 0;
507
+ function reapStalePeekabooMcpProcesses() {
508
+ const reaped = [];
509
+ const spared = [];
510
+ if (process.platform !== 'darwin')
511
+ return { reaped, spared };
512
+ if (Date.now() - lastPeekabooReapAt < PEEKABOO_REAP_THROTTLE_MS)
513
+ return { reaped, spared };
514
+ lastPeekabooReapAt = Date.now();
515
+ const result = spawnSync('ps', ['-axo', 'pid=,ppid=,command='], { encoding: 'utf8' });
516
+ if (result.status !== 0)
517
+ return { reaped, spared };
518
+ const ppidByPid = new Map();
519
+ const commandByPid = new Map();
520
+ const candidates = [];
521
+ const lines = String(result.stdout || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
522
+ for (const line of lines) {
523
+ const m = line.match(/^(\d+)\s+(\d+)\s+(.*)$/);
524
+ if (!m)
525
+ continue;
526
+ const pid = Number(m[1]);
527
+ const ppid = Number(m[2]);
528
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid))
529
+ continue;
530
+ const command = m[3] || '';
531
+ ppidByPid.set(pid, ppid);
532
+ commandByPid.set(pid, command);
533
+ if (pid === process.pid)
534
+ continue;
535
+ if (_matchPeekabooMcpProcessCommand(command))
536
+ candidates.push(pid);
537
+ }
538
+ const hasProtectedAncestor = (pid) => {
539
+ let cur = pid;
540
+ for (let depth = 0; depth < 40 && cur != null && cur > 1; depth++) {
541
+ if (cur === process.pid)
542
+ return true;
543
+ const command = commandByPid.get(cur) || '';
544
+ if (cur !== pid && commandLooksLikeLiveMcpController(command))
545
+ return true;
546
+ cur = ppidByPid.get(cur);
547
+ }
548
+ return false;
549
+ };
550
+ for (const pid of candidates) {
551
+ if (hasProtectedAncestor(pid)) {
552
+ spared.push(pid);
553
+ continue;
554
+ }
555
+ try {
556
+ process.kill(pid, 'SIGTERM');
557
+ const forceTimer = setTimeout(() => {
558
+ try {
559
+ process.kill(pid, 0);
560
+ process.kill(pid, 'SIGKILL');
561
+ }
562
+ catch {
563
+ // Process already exited or cannot be signalled — no-op.
564
+ }
565
+ }, PEEKABOO_REAP_FORCE_AFTER_MS);
566
+ forceTimer.unref?.();
567
+ reaped.push(pid);
568
+ }
569
+ catch {
570
+ // Already dead — no-op.
571
+ }
572
+ }
573
+ return { reaped, spared };
574
+ }
386
575
  /**
387
576
  * Decide which CDP endpoint the per-session playwright/mcp should attach to.
388
577
  *
@@ -514,8 +703,13 @@ export async function startMcpBridge(opts) {
514
703
  // Peekaboo ships a native binary behind npx; warm its cache out-of-band so the
515
704
  // agent's MCP server connects instantly instead of hanging at "Still
516
705
  // connecting" on a cold first-run download (see ensurePeekabooWarm).
517
- if (gui.peekabooEnabled)
706
+ if (gui.peekabooEnabled) {
518
707
  ensurePeekabooWarm();
708
+ const { reaped, spared } = reapStalePeekabooMcpProcesses();
709
+ if (reaped.length) {
710
+ opts.onLog?.(`reaped ${reaped.length} stale peekaboo-mcp process(es): pid=${reaped.join(',')}${spared.length ? ` (spared active: ${spared.join(',')})` : ''}`);
711
+ }
712
+ }
519
713
  // Lazy browser lifecycle: probe an already-running managed Chrome via
520
714
  // <profileDir>/DevToolsActivePort and attach if reachable; otherwise leave
521
715
  // Chrome unlaunched and let playwright/mcp launch it with `--user-data-dir`
@@ -17,6 +17,7 @@ import os from 'node:os';
17
17
  import path from 'node:path';
18
18
  import { spawn } from 'node:child_process';
19
19
  import { loadUserConfig, saveUserConfig } from '../../core/config/user-config.js';
20
+ import { terminateProcessTree } from '../../core/process-control.js';
20
21
  import { getRecommendedMcpServers, } from './registry.js';
21
22
  import { hasValidMcpToken, injectOAuthHeaders } from './oauth.js';
22
23
  // ---------------------------------------------------------------------------
@@ -548,6 +549,7 @@ export async function checkMcpHealth(config, timeoutMs = 10_000) {
548
549
  return new Promise((resolve) => {
549
550
  const start = Date.now();
550
551
  let checkInterval = null;
552
+ let settled = false;
551
553
  const cleanup = () => {
552
554
  if (checkInterval) {
553
555
  clearInterval(checkInterval);
@@ -555,20 +557,29 @@ export async function checkMcpHealth(config, timeoutMs = 10_000) {
555
557
  }
556
558
  clearTimeout(timer);
557
559
  };
560
+ const stopChildTree = () => {
561
+ terminateProcessTree(child, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 1500 });
562
+ };
563
+ const finish = (result) => {
564
+ if (settled)
565
+ return;
566
+ settled = true;
567
+ cleanup();
568
+ resolve(result);
569
+ };
558
570
  const child = spawn(config.command, config.args || [], {
559
571
  stdio: ['pipe', 'pipe', 'pipe'],
560
572
  env: { ...process.env, ...config.env },
573
+ detached: process.platform !== 'win32',
561
574
  });
562
575
  const timer = setTimeout(() => {
563
- cleanup();
564
- child.kill();
565
- resolve({ ok: false, error: `timeout after ${timeoutMs}ms`, elapsedMs: Date.now() - start });
576
+ stopChildTree();
577
+ finish({ ok: false, error: `timeout after ${timeoutMs}ms`, elapsedMs: Date.now() - start });
566
578
  }, timeoutMs);
567
579
  let stdout = '';
568
580
  child.stdout?.on('data', (data) => { stdout += data.toString(); });
569
581
  child.on('error', (err) => {
570
- cleanup();
571
- resolve({ ok: false, error: err.message, elapsedMs: Date.now() - start });
582
+ finish({ ok: false, error: err.message, elapsedMs: Date.now() - start });
572
583
  });
573
584
  const initRequest = JSON.stringify({
574
585
  jsonrpc: '2.0',
@@ -585,8 +596,8 @@ export async function checkMcpHealth(config, timeoutMs = 10_000) {
585
596
  child.stdin?.write(header + initRequest);
586
597
  }
587
598
  catch {
588
- cleanup();
589
- resolve({ ok: false, error: 'failed to write to stdin', elapsedMs: Date.now() - start });
599
+ stopChildTree();
600
+ finish({ ok: false, error: 'failed to write to stdin', elapsedMs: Date.now() - start });
590
601
  return;
591
602
  }
592
603
  checkInterval = setInterval(() => {
@@ -606,7 +617,7 @@ export async function checkMcpHealth(config, timeoutMs = 10_000) {
606
617
  }
607
618
  catch { /* best-effort */ }
608
619
  setTimeout(() => {
609
- child.kill();
620
+ stopChildTree();
610
621
  const tools = [];
611
622
  try {
612
623
  const jsonMatches = stdout.match(/\{[^{}]*"tools"\s*:\s*\[[\s\S]*?\]\s*[^{}]*\}/g);
@@ -630,7 +641,7 @@ export async function checkMcpHealth(config, timeoutMs = 10_000) {
630
641
  }
631
642
  }
632
643
  catch { /* best effort */ }
633
- resolve({ ok: true, tools: tools.length ? tools : undefined, elapsedMs: Date.now() - start });
644
+ finish({ ok: true, tools: tools.length ? tools : undefined, elapsedMs: Date.now() - start });
634
645
  }, 1500);
635
646
  }, 100);
636
647
  });
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * tools/workspace.ts — Workspace file tools.
3
3
  *
4
- * workspace_list_files — returns workspace path, staged files, and directory listing
5
- * workspace_send_filesends a file back to the IM chat
4
+ * im_list_files — returns workspace path, staged files, and directory listing
5
+ * im_send_filedelivers a file to the user via the active terminal
6
+ * (IM chat push and/or the dashboard attachment endpoint)
6
7
  */
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
@@ -28,7 +29,7 @@ const tools = [
28
29
  },
29
30
  {
30
31
  name: 'im_send_file',
31
- description: 'Send a file to the user in IM.',
32
+ description: 'Send a file to the user through the active terminal (IM chat or web dashboard). Use this to hand over screenshots, reports, archives, or generated assets — the file is delivered and stays retrievable even when the user is connected remotely. Prefer this over printing a local filesystem path, which a remote user cannot open.',
32
33
  inputSchema: {
33
34
  type: 'object',
34
35
  properties: {
@@ -473,7 +473,7 @@ export async function doStream(opts) {
473
473
  // Start MCP bridge for IM tools (when sendFile is available) and/or supplemental servers (browser, etc.)
474
474
  let bridge = null;
475
475
  try {
476
- const { startMcpBridge } = await import('./mcp/bridge.js');
476
+ const { startMcpBridge, redactMcpConfigForLog } = await import('./mcp/bridge.js');
477
477
  const sessionDir = path.dirname(session.workspacePath);
478
478
  bridge = await startMcpBridge({
479
479
  sessionDir,
@@ -498,7 +498,8 @@ export async function doStream(opts) {
498
498
  else
499
499
  agentLog('[mcp] bridge registered with codex');
500
500
  try {
501
- agentLog(`[mcp] config content:\n${fs.readFileSync(bridge.configPath, 'utf-8')}`);
501
+ if (bridge.configPath)
502
+ agentLog(`[mcp] config content:\n${redactMcpConfigForLog(bridge.configPath)}`);
502
503
  }
503
504
  catch { }
504
505
  ;