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.
- package/README.md +2 -1
- package/dashboard/dist/assets/{AgentTab-CKoy_-w4.js → AgentTab-CDVhy5K1.js} +1 -1
- package/dashboard/dist/assets/{DirBrowser-DpbuN0OL.js → DirBrowser-BElI1-4D.js} +1 -1
- package/dashboard/dist/assets/{ExtensionsTab-ymr7K8dU.js → ExtensionsTab-BB8ipJ77.js} +1 -1
- package/dashboard/dist/assets/{IMAccessTab-CaTtCn3l.js → IMAccessTab-IZt_yXoG.js} +1 -1
- package/dashboard/dist/assets/{Modal-DA-9kJxp.js → Modal-C1EAGSL1.js} +1 -1
- package/dashboard/dist/assets/{Modals-BkLIRnNK.js → Modals-DYUV5yR9.js} +1 -1
- package/dashboard/dist/assets/{Select-B0pZtuzF.js → Select-BnsbE6Qv.js} +1 -1
- package/dashboard/dist/assets/SessionPanel-Ca_TVTT1.js +1 -0
- package/dashboard/dist/assets/{SystemTab-B9TcGMzc.js → SystemTab-Dk6k2OTt.js} +1 -1
- package/dashboard/dist/assets/index-CK-3CNRp.js +3 -0
- package/dashboard/dist/assets/index-CnJsD381.js +23 -0
- package/dashboard/dist/assets/index-dzfjF9Js.css +1 -0
- package/dashboard/dist/assets/{shared-i_XUH0xm.js → shared-CZVD0MJD.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/dist/agent/artifacts.js +160 -0
- package/dist/agent/images.js +51 -24
- package/dist/agent/index.js +4 -2
- package/dist/agent/mcp/bridge.js +201 -7
- package/dist/agent/mcp/extensions.js +20 -9
- package/dist/agent/mcp/tools/workspace.js +4 -3
- package/dist/agent/stream.js +3 -2
- package/dist/bot/bot.js +83 -4
- package/dist/bot/commands.js +48 -2
- package/dist/bot/menu.js +1 -0
- package/dist/bot/session-hub.js +1 -1
- package/dist/channels/dingtalk/bot.js +9 -1
- package/dist/channels/discord/bot.js +9 -1
- package/dist/channels/feishu/bot.js +8 -1
- package/dist/channels/slack/bot.js +9 -1
- package/dist/channels/telegram/bot.js +8 -1
- package/dist/channels/wecom/bot.js +9 -1
- package/dist/channels/weixin/bot.js +9 -1
- package/dist/cli/main.js +1 -0
- package/dist/dashboard/routes/config.js +134 -12
- package/dist/dashboard/routes/sessions.js +108 -27
- package/package.json +1 -1
- package/dashboard/dist/assets/SessionPanel-CYQtZZNX.js +0 -1
- package/dashboard/dist/assets/index-BCYshErN.js +0 -3
- package/dashboard/dist/assets/index-C5irxzzD.js +0 -23
- 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
|
+
}
|
package/dist/agent/images.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* images.ts — unified image pipeline for the agent
|
|
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
|
|
7
|
-
*
|
|
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, `
|
|
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 `
|
|
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
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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
|
|
332
|
-
const base = (ctx.apiBase || '/api/sessions').replace(/\/+$/, '');
|
|
348
|
+
export function rewriteAttachmentBlocksForTransport(blocks, ctx) {
|
|
333
349
|
return blocks.map(block => {
|
|
334
|
-
if (block.type
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
package/dist/agent/index.js
CHANGED
|
@@ -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:
|
|
22
|
-
export { attachAgentImage, attachInlineImage, materializeImage,
|
|
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 ───────────────────────────────────────────
|
package/dist/agent/mcp/bridge.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* im_list_files — returns workspace path, staged files, and directory listing
|
|
5
|
+
* im_send_file — delivers 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
|
|
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: {
|
package/dist/agent/stream.js
CHANGED
|
@@ -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
|
-
|
|
501
|
+
if (bridge.configPath)
|
|
502
|
+
agentLog(`[mcp] config content:\n${redactMcpConfigForLog(bridge.configPath)}`);
|
|
502
503
|
}
|
|
503
504
|
catch { }
|
|
504
505
|
;
|