tuna-agent 0.1.1 → 0.1.2

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 (55) hide show
  1. package/dist/browser/actions/download.d.ts +16 -0
  2. package/dist/browser/actions/download.js +39 -0
  3. package/dist/browser/actions/emulation.d.ts +53 -0
  4. package/dist/browser/actions/emulation.js +103 -0
  5. package/dist/browser/actions/evaluate.d.ts +29 -0
  6. package/dist/browser/actions/evaluate.js +92 -0
  7. package/dist/browser/actions/interaction.d.ts +79 -0
  8. package/dist/browser/actions/interaction.js +210 -0
  9. package/dist/browser/actions/keyboard.d.ts +6 -0
  10. package/dist/browser/actions/keyboard.js +9 -0
  11. package/dist/browser/actions/navigation.d.ts +40 -0
  12. package/dist/browser/actions/navigation.js +92 -0
  13. package/dist/browser/actions/wait.d.ts +12 -0
  14. package/dist/browser/actions/wait.js +33 -0
  15. package/dist/browser/browser.d.ts +722 -0
  16. package/dist/browser/browser.js +1066 -0
  17. package/dist/browser/capture/activity.d.ts +22 -0
  18. package/dist/browser/capture/activity.js +39 -0
  19. package/dist/browser/capture/pdf.d.ts +6 -0
  20. package/dist/browser/capture/pdf.js +6 -0
  21. package/dist/browser/capture/response.d.ts +8 -0
  22. package/dist/browser/capture/response.js +28 -0
  23. package/dist/browser/capture/screenshot.d.ts +30 -0
  24. package/dist/browser/capture/screenshot.js +72 -0
  25. package/dist/browser/capture/trace.d.ts +13 -0
  26. package/dist/browser/capture/trace.js +19 -0
  27. package/dist/browser/chrome-launcher.d.ts +8 -0
  28. package/dist/browser/chrome-launcher.js +543 -0
  29. package/dist/browser/connection.d.ts +42 -0
  30. package/dist/browser/connection.js +359 -0
  31. package/dist/browser/index.d.ts +6 -0
  32. package/dist/browser/index.js +3 -0
  33. package/dist/browser/security.d.ts +51 -0
  34. package/dist/browser/security.js +357 -0
  35. package/dist/browser/snapshot/ai-snapshot.d.ts +12 -0
  36. package/dist/browser/snapshot/ai-snapshot.js +47 -0
  37. package/dist/browser/snapshot/aria-snapshot.d.ts +26 -0
  38. package/dist/browser/snapshot/aria-snapshot.js +121 -0
  39. package/dist/browser/snapshot/ref-map.d.ts +31 -0
  40. package/dist/browser/snapshot/ref-map.js +250 -0
  41. package/dist/browser/storage/index.d.ts +36 -0
  42. package/dist/browser/storage/index.js +65 -0
  43. package/dist/browser/types.d.ts +429 -0
  44. package/dist/browser/types.js +2 -0
  45. package/dist/daemon/extension-handlers.d.ts +63 -0
  46. package/dist/daemon/extension-handlers.js +630 -0
  47. package/dist/daemon/index.js +78 -19
  48. package/dist/daemon/ws-client.d.ts +16 -0
  49. package/dist/daemon/ws-client.js +45 -0
  50. package/dist/mcp/browser-server.d.ts +11 -0
  51. package/dist/mcp/browser-server.js +467 -0
  52. package/dist/mcp/knowledge-server.js +43 -18
  53. package/dist/mcp/setup.js +10 -0
  54. package/dist/utils/claude-cli.js +18 -9
  55. package/package.json +2 -1
@@ -0,0 +1,467 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Browser MCP Server for Tuna Agent
4
+ *
5
+ * Stdio-based MCP server that exposes browser automation tools to Claude Code.
6
+ * Uses browserclaw (vendored from OpenClaw) for snapshot+ref browser control.
7
+ *
8
+ * Usage:
9
+ * node browser-server.js [--headless] [--user-data-dir /path/to/chrome/profile] [--cdp-port 9222]
10
+ */
11
+ import { BrowserClaw, setStealthEnabled } from '../browser/index.js';
12
+ function parseArgs() {
13
+ const args = process.argv.slice(2);
14
+ let headless = false; // Default: visible browser (not headless)
15
+ let userDataDir;
16
+ let profileDirectory;
17
+ let cdpPort;
18
+ let cdpUrl;
19
+ for (let i = 0; i < args.length; i++) {
20
+ if (args[i] === '--headless')
21
+ headless = true;
22
+ else if (args[i] === '--user-data-dir' && args[i + 1])
23
+ userDataDir = args[++i];
24
+ else if (args[i] === '--profile-directory' && args[i + 1])
25
+ profileDirectory = args[++i];
26
+ else if (args[i] === '--cdp-port' && args[i + 1])
27
+ cdpPort = parseInt(args[++i], 10);
28
+ else if (args[i] === '--cdp-url' && args[i + 1])
29
+ cdpUrl = args[++i];
30
+ }
31
+ // Disable stealth injection for existing Chrome profiles (sites detect it and force logout)
32
+ if (userDataDir) {
33
+ setStealthEnabled(false);
34
+ }
35
+ return { headless, userDataDir, profileDirectory, cdpPort, cdpUrl };
36
+ }
37
+ // ===== Browser State =====
38
+ let browser = null;
39
+ const pages = new Map(); // targetId → CrawlPage
40
+ async function ensureBrowser(config) {
41
+ if (browser)
42
+ return browser;
43
+ if (config.cdpUrl) {
44
+ browser = await BrowserClaw.connect(config.cdpUrl);
45
+ }
46
+ else {
47
+ browser = await BrowserClaw.launch({
48
+ headless: config.headless,
49
+ userDataDir: config.userDataDir,
50
+ profileDirectory: config.profileDirectory,
51
+ cdpPort: config.cdpPort,
52
+ });
53
+ }
54
+ return browser;
55
+ }
56
+ function sendResponse(res) {
57
+ process.stdout.write(JSON.stringify(res) + '\n');
58
+ }
59
+ function sendResult(id, result) {
60
+ sendResponse({ jsonrpc: '2.0', id: id ?? null, result });
61
+ }
62
+ function sendError(id, code, message) {
63
+ sendResponse({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
64
+ }
65
+ // ===== Tool Definitions =====
66
+ const TOOLS = [
67
+ {
68
+ name: 'browser_open',
69
+ description: 'Open a URL in the browser and return an AI-readable snapshot of the page. Returns a text tree with numbered refs (e1, e2, ...) for interactive elements.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ url: { type: 'string', description: 'URL to navigate to (e.g. https://facebook.com)' },
74
+ },
75
+ required: ['url'],
76
+ },
77
+ },
78
+ {
79
+ name: 'browser_snapshot',
80
+ description: 'Take a snapshot of the current page. Returns an accessibility tree with numbered refs for all interactive elements. Use this after any navigation or page change to get fresh refs.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: {
84
+ page_id: { type: 'string', description: 'Target page ID (optional, uses last active page if omitted)' },
85
+ },
86
+ },
87
+ },
88
+ {
89
+ name: 'browser_click',
90
+ description: 'Click an element by its ref number from a snapshot. Run browser_snapshot first to get refs.',
91
+ inputSchema: {
92
+ type: 'object',
93
+ properties: {
94
+ ref: { type: 'string', description: 'Element ref from snapshot (e.g. "e1", "e5")' },
95
+ page_id: { type: 'string', description: 'Target page ID (optional)' },
96
+ double_click: { type: 'boolean', description: 'Double-click instead of single click' },
97
+ },
98
+ required: ['ref'],
99
+ },
100
+ },
101
+ {
102
+ name: 'browser_type',
103
+ description: 'Type text into an element by its ref number. The element is clicked first, then text is typed.',
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: {
107
+ ref: { type: 'string', description: 'Element ref from snapshot (e.g. "e3")' },
108
+ text: { type: 'string', description: 'Text to type' },
109
+ submit: { type: 'boolean', description: 'Press Enter after typing to submit' },
110
+ slowly: { type: 'boolean', description: 'Type slowly with keystroke delays (more human-like)' },
111
+ page_id: { type: 'string', description: 'Target page ID (optional)' },
112
+ },
113
+ required: ['ref', 'text'],
114
+ },
115
+ },
116
+ {
117
+ name: 'browser_navigate',
118
+ description: 'Navigate to a different URL on the current page.',
119
+ inputSchema: {
120
+ type: 'object',
121
+ properties: {
122
+ url: { type: 'string', description: 'URL to navigate to' },
123
+ page_id: { type: 'string', description: 'Target page ID (optional)' },
124
+ },
125
+ required: ['url'],
126
+ },
127
+ },
128
+ {
129
+ name: 'browser_scroll',
130
+ description: 'Scroll the page or a specific element into view.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: {
134
+ ref: { type: 'string', description: 'Element ref to scroll into view (optional, scrolls page if omitted)' },
135
+ direction: { type: 'string', description: 'Scroll direction: "up" or "down" (default: "down")' },
136
+ page_id: { type: 'string', description: 'Target page ID (optional)' },
137
+ },
138
+ },
139
+ },
140
+ {
141
+ name: 'browser_screenshot',
142
+ description: 'Take a screenshot of the current page. Returns base64-encoded PNG image.',
143
+ inputSchema: {
144
+ type: 'object',
145
+ properties: {
146
+ page_id: { type: 'string', description: 'Target page ID (optional)' },
147
+ full_page: { type: 'boolean', description: 'Capture the full scrollable page (default: false, viewport only)' },
148
+ },
149
+ },
150
+ },
151
+ {
152
+ name: 'browser_tabs',
153
+ description: 'List all open browser tabs with their IDs, URLs, and titles.',
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {},
157
+ },
158
+ },
159
+ {
160
+ name: 'browser_press',
161
+ description: 'Press a keyboard key (e.g. Enter, Tab, Escape, ArrowDown).',
162
+ inputSchema: {
163
+ type: 'object',
164
+ properties: {
165
+ key: { type: 'string', description: 'Key to press (e.g. "Enter", "Tab", "Escape", "ArrowDown")' },
166
+ page_id: { type: 'string', description: 'Target page ID (optional)' },
167
+ },
168
+ required: ['key'],
169
+ },
170
+ },
171
+ {
172
+ name: 'browser_select',
173
+ description: 'Select an option from a dropdown/select element.',
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {
177
+ ref: { type: 'string', description: 'Element ref of the select/dropdown' },
178
+ value: { type: 'string', description: 'Value or label to select' },
179
+ page_id: { type: 'string', description: 'Target page ID (optional)' },
180
+ },
181
+ required: ['ref', 'value'],
182
+ },
183
+ },
184
+ {
185
+ name: 'browser_hover',
186
+ description: 'Hover over an element by its ref number.',
187
+ inputSchema: {
188
+ type: 'object',
189
+ properties: {
190
+ ref: { type: 'string', description: 'Element ref from snapshot' },
191
+ page_id: { type: 'string', description: 'Target page ID (optional)' },
192
+ },
193
+ required: ['ref'],
194
+ },
195
+ },
196
+ {
197
+ name: 'browser_close',
198
+ description: 'Close the browser and clean up resources.',
199
+ inputSchema: {
200
+ type: 'object',
201
+ properties: {},
202
+ },
203
+ },
204
+ ];
205
+ // ===== Request Handler =====
206
+ const serverConfig = parseArgs();
207
+ let activePageId = null;
208
+ async function handleRequest(req) {
209
+ try {
210
+ switch (req.method) {
211
+ case 'initialize':
212
+ sendResult(req.id, {
213
+ protocolVersion: '2024-11-05',
214
+ capabilities: { tools: {} },
215
+ serverInfo: { name: 'tuna-browser', version: '1.0.0' },
216
+ });
217
+ break;
218
+ case 'notifications/initialized':
219
+ break;
220
+ case 'tools/list':
221
+ sendResult(req.id, { tools: TOOLS });
222
+ break;
223
+ case 'tools/call': {
224
+ const toolName = req.params?.name ?? '';
225
+ const args = req.params?.arguments ?? {};
226
+ const result = await handleToolCall(toolName, args);
227
+ sendResult(req.id, result);
228
+ break;
229
+ }
230
+ case 'ping':
231
+ sendResult(req.id, {});
232
+ break;
233
+ default:
234
+ if (req.id !== undefined) {
235
+ sendError(req.id, -32601, `Method not found: ${req.method}`);
236
+ }
237
+ }
238
+ }
239
+ catch (err) {
240
+ const message = err instanceof Error ? err.message : String(err);
241
+ if (req.id !== undefined) {
242
+ sendError(req.id, -32603, message);
243
+ }
244
+ }
245
+ }
246
+ function getPageId(args) {
247
+ return args.page_id || activePageId || undefined;
248
+ }
249
+ async function getPage(args) {
250
+ const pageId = getPageId(args);
251
+ if (pageId && pages.has(pageId)) {
252
+ return pages.get(pageId);
253
+ }
254
+ // Return last active page or first available
255
+ if (activePageId && pages.has(activePageId)) {
256
+ return pages.get(activePageId);
257
+ }
258
+ // No pages open
259
+ throw new Error('No browser page is open. Use browser_open first to navigate to a URL.');
260
+ }
261
+ async function handleToolCall(toolName, args) {
262
+ try {
263
+ switch (toolName) {
264
+ case 'browser_open': {
265
+ const url = args.url;
266
+ if (!url)
267
+ return { content: [{ type: 'text', text: 'Error: url is required' }], isError: true };
268
+ const b = await ensureBrowser(serverConfig);
269
+ const page = await b.open(url);
270
+ const pageId = page.id;
271
+ pages.set(pageId, page);
272
+ activePageId = pageId;
273
+ // Auto-snapshot after opening
274
+ const result = await page.snapshot();
275
+ return {
276
+ content: [{
277
+ type: 'text',
278
+ text: `Page opened: ${url}\nPage ID: ${pageId}\n\n--- Snapshot ---\n${result.snapshot}\n\n--- Stats ---\nRefs: ${Object.keys(result.refs).length} interactive elements`,
279
+ }],
280
+ };
281
+ }
282
+ case 'browser_snapshot': {
283
+ const page = await getPage(args);
284
+ const result = await page.snapshot();
285
+ return {
286
+ content: [{
287
+ type: 'text',
288
+ text: `${result.snapshot}\n\n--- Stats ---\nRefs: ${Object.keys(result.refs).length} interactive elements`,
289
+ }],
290
+ };
291
+ }
292
+ case 'browser_click': {
293
+ const ref = args.ref;
294
+ if (!ref)
295
+ return { content: [{ type: 'text', text: 'Error: ref is required' }], isError: true };
296
+ const page = await getPage(args);
297
+ await page.click(ref, {
298
+ doubleClick: args.double_click,
299
+ });
300
+ // Auto-snapshot after click to show result
301
+ const result = await page.snapshot();
302
+ return {
303
+ content: [{
304
+ type: 'text',
305
+ text: `Clicked "${ref}".\n\n--- Updated Snapshot ---\n${result.snapshot}`,
306
+ }],
307
+ };
308
+ }
309
+ case 'browser_type': {
310
+ const ref = args.ref;
311
+ const text = args.text;
312
+ if (!ref || text === undefined)
313
+ return { content: [{ type: 'text', text: 'Error: ref and text are required' }], isError: true };
314
+ const page = await getPage(args);
315
+ await page.type(ref, text, {
316
+ submit: args.submit,
317
+ slowly: args.slowly,
318
+ });
319
+ return {
320
+ content: [{
321
+ type: 'text',
322
+ text: `Typed "${text}" into "${ref}"${args.submit ? ' and submitted' : ''}.`,
323
+ }],
324
+ };
325
+ }
326
+ case 'browser_navigate': {
327
+ const url = args.url;
328
+ if (!url)
329
+ return { content: [{ type: 'text', text: 'Error: url is required' }], isError: true };
330
+ const page = await getPage(args);
331
+ await page.goto(url);
332
+ const result = await page.snapshot();
333
+ return {
334
+ content: [{
335
+ type: 'text',
336
+ text: `Navigated to: ${url}\n\n--- Snapshot ---\n${result.snapshot}`,
337
+ }],
338
+ };
339
+ }
340
+ case 'browser_scroll': {
341
+ const page = await getPage(args);
342
+ if (args.ref) {
343
+ await page.scrollIntoView(args.ref);
344
+ }
345
+ else {
346
+ // Scroll page via keyboard
347
+ const key = args.direction === 'up' ? 'PageUp' : 'PageDown';
348
+ await page.press(key);
349
+ }
350
+ const result = await page.snapshot();
351
+ return {
352
+ content: [{
353
+ type: 'text',
354
+ text: `Scrolled ${args.direction || 'down'}.\n\n--- Updated Snapshot ---\n${result.snapshot}`,
355
+ }],
356
+ };
357
+ }
358
+ case 'browser_screenshot': {
359
+ const page = await getPage(args);
360
+ const buf = await page.screenshot({
361
+ fullPage: args.full_page,
362
+ });
363
+ return {
364
+ content: [{
365
+ type: 'image',
366
+ data: buf.toString('base64'),
367
+ mimeType: 'image/png',
368
+ }],
369
+ };
370
+ }
371
+ case 'browser_tabs': {
372
+ const b = await ensureBrowser(serverConfig);
373
+ const tabs = await b.tabs();
374
+ if (tabs.length === 0) {
375
+ return { content: [{ type: 'text', text: 'No tabs open.' }] };
376
+ }
377
+ const listing = tabs.map((t) => `- ${t.title || 'Untitled'} (ID: ${t.targetId})\n URL: ${t.url}`).join('\n');
378
+ return { content: [{ type: 'text', text: `Open tabs:\n${listing}` }] };
379
+ }
380
+ case 'browser_press': {
381
+ const key = args.key;
382
+ if (!key)
383
+ return { content: [{ type: 'text', text: 'Error: key is required' }], isError: true };
384
+ const page = await getPage(args);
385
+ await page.press(key);
386
+ return { content: [{ type: 'text', text: `Pressed "${key}".` }] };
387
+ }
388
+ case 'browser_select': {
389
+ const ref = args.ref;
390
+ const value = args.value;
391
+ if (!ref || !value)
392
+ return { content: [{ type: 'text', text: 'Error: ref and value are required' }], isError: true };
393
+ const page = await getPage(args);
394
+ await page.select(ref, value);
395
+ return { content: [{ type: 'text', text: `Selected "${value}" in "${ref}".` }] };
396
+ }
397
+ case 'browser_hover': {
398
+ const ref = args.ref;
399
+ if (!ref)
400
+ return { content: [{ type: 'text', text: 'Error: ref is required' }], isError: true };
401
+ const page = await getPage(args);
402
+ await page.hover(ref);
403
+ return { content: [{ type: 'text', text: `Hovered over "${ref}".` }] };
404
+ }
405
+ case 'browser_close': {
406
+ if (browser) {
407
+ await browser.stop();
408
+ browser = null;
409
+ pages.clear();
410
+ activePageId = null;
411
+ }
412
+ return { content: [{ type: 'text', text: 'Browser closed.' }] };
413
+ }
414
+ default:
415
+ return { content: [{ type: 'text', text: `Unknown tool: ${toolName}` }], isError: true };
416
+ }
417
+ }
418
+ catch (err) {
419
+ const message = err instanceof Error ? err.message : String(err);
420
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
421
+ }
422
+ }
423
+ // ===== Stdio Transport =====
424
+ function startServer() {
425
+ process.stderr.write(`[browser-mcp] Starting (headless: ${serverConfig.headless})\n`);
426
+ let buffer = '';
427
+ process.stdin.setEncoding('utf-8');
428
+ process.stdin.on('data', (chunk) => {
429
+ buffer += chunk;
430
+ const lines = buffer.split('\n');
431
+ buffer = lines.pop() ?? '';
432
+ for (const line of lines) {
433
+ const trimmed = line.trim();
434
+ if (!trimmed)
435
+ continue;
436
+ try {
437
+ const req = JSON.parse(trimmed);
438
+ handleRequest(req).catch((err) => {
439
+ process.stderr.write(`[browser-mcp] Error handling ${req.method}: ${err}\n`);
440
+ });
441
+ }
442
+ catch {
443
+ process.stderr.write(`[browser-mcp] Failed to parse JSON: ${trimmed.slice(0, 100)}\n`);
444
+ }
445
+ }
446
+ });
447
+ process.stdin.on('end', async () => {
448
+ process.stderr.write('[browser-mcp] stdin closed, cleaning up browser\n');
449
+ if (browser) {
450
+ await browser.stop().catch(() => { });
451
+ }
452
+ process.exit(0);
453
+ });
454
+ // Cleanup on process exit
455
+ process.on('SIGTERM', async () => {
456
+ if (browser)
457
+ await browser.stop().catch(() => { });
458
+ process.exit(0);
459
+ });
460
+ process.on('SIGINT', async () => {
461
+ if (browser)
462
+ await browser.stop().catch(() => { });
463
+ process.exit(0);
464
+ });
465
+ }
466
+ // ===== Main =====
467
+ startServer();
@@ -58,10 +58,12 @@ function sendError(id, code, message) {
58
58
  const TOOLS = [
59
59
  {
60
60
  name: 'list_knowledge',
61
- description: 'List all knowledge documents accessible to this agent. Returns document names, descriptions, and IDs.',
61
+ description: 'List knowledge items accessible to this agent. Can list root items or items inside a specific folder.',
62
62
  inputSchema: {
63
63
  type: 'object',
64
- properties: {},
64
+ properties: {
65
+ parent_id: { type: 'string', description: 'Folder ID to list children of. Omit to list root-level items.' },
66
+ },
65
67
  },
66
68
  },
67
69
  {
@@ -77,15 +79,17 @@ const TOOLS = [
77
79
  },
78
80
  {
79
81
  name: 'create_knowledge',
80
- description: 'Create a new knowledge document. Content should be in markdown format.',
82
+ description: 'Create a new knowledge document or folder. Content should be in markdown format for documents.',
81
83
  inputSchema: {
82
84
  type: 'object',
83
85
  properties: {
84
- name: { type: 'string', description: 'Name/title of the knowledge document' },
85
- content: { type: 'string', description: 'Markdown content of the document' },
86
- description: { type: 'string', description: 'Short description of what this document contains' },
86
+ name: { type: 'string', description: 'Name/title of the knowledge item' },
87
+ content: { type: 'string', description: 'Markdown content (required for documents, omit for folders)' },
88
+ description: { type: 'string', description: 'Short description of what this item contains' },
89
+ kind: { type: 'string', enum: ['document', 'folder'], description: 'Type of knowledge item. Default: document' },
90
+ parent_id: { type: 'string', description: 'Parent folder ID to create inside. Omit for root level.' },
87
91
  },
88
- required: ['name', 'content'],
92
+ required: ['name'],
89
93
  },
90
94
  },
91
95
  {
@@ -147,13 +151,22 @@ async function handleToolCall(config, toolName, args) {
147
151
  try {
148
152
  switch (toolName) {
149
153
  case 'list_knowledge': {
150
- const data = await apiCall(config, 'GET', `/agent-knowledge?agent_id=${config.agentId}`);
154
+ let url = `/agent-knowledge?agent_id=${config.agentId}`;
155
+ if (args.parent_id)
156
+ url += `&parent_id=${args.parent_id}`;
157
+ const data = await apiCall(config, 'GET', url);
151
158
  const items = data.items || [];
152
159
  if (items.length === 0) {
153
- return { content: [{ type: 'text', text: 'No knowledge documents found for this agent.' }] };
160
+ return { content: [{ type: 'text', text: 'No knowledge items found.' }] };
154
161
  }
155
- const listing = items.map((k) => `- **${k.name}** (ID: ${k._id})\n ${k.description || 'No description'}\n Size: ${k.file_size} bytes | Updated: ${k.updated_at}`).join('\n\n');
156
- return { content: [{ type: 'text', text: `Found ${items.length} knowledge document(s):\n\n${listing}` }] };
162
+ const listing = items.map((k) => {
163
+ const icon = k.kind === 'folder' ? '📁' : '📄';
164
+ const meta = k.kind === 'folder'
165
+ ? `${k.children_count || 0} items`
166
+ : `${k.file_size} bytes`;
167
+ return `- ${icon} **${k.name}** (ID: ${k._id}) [${k.kind}]\n ${k.description || 'No description'}\n ${meta} | Updated: ${k.updated_at}`;
168
+ }).join('\n\n');
169
+ return { content: [{ type: 'text', text: `Found ${items.length} knowledge item(s):\n\n${listing}` }] };
157
170
  }
158
171
  case 'read_knowledge': {
159
172
  if (!args.knowledge_id) {
@@ -168,16 +181,28 @@ async function handleToolCall(config, toolName, args) {
168
181
  };
169
182
  }
170
183
  case 'create_knowledge': {
171
- if (!args.name || !args.content) {
172
- return { content: [{ type: 'text', text: 'Error: name and content are required' }], isError: true };
184
+ const isFolder = args.kind === 'folder';
185
+ if (!args.name) {
186
+ return { content: [{ type: 'text', text: 'Error: name is required' }], isError: true };
187
+ }
188
+ if (!isFolder && !args.content) {
189
+ return { content: [{ type: 'text', text: 'Error: content is required for documents' }], isError: true };
173
190
  }
174
- const data = await apiCall(config, 'POST', '/agent-knowledge', {
191
+ const body = {
175
192
  name: args.name,
176
- content: args.content,
177
- description: args.description || '',
178
193
  agent_id: config.agentId,
179
- });
180
- return { content: [{ type: 'text', text: `Knowledge "${data.name}" created (ID: ${data._id})` }] };
194
+ };
195
+ if (args.kind)
196
+ body.kind = args.kind;
197
+ if (args.content)
198
+ body.content = args.content;
199
+ if (args.description)
200
+ body.description = args.description;
201
+ if (args.parent_id)
202
+ body.parent_id = args.parent_id;
203
+ const data = await apiCall(config, 'POST', '/agent-knowledge', body);
204
+ const label = isFolder ? 'Folder' : 'Knowledge';
205
+ return { content: [{ type: 'text', text: `${label} "${data.name}" created (ID: ${data._id})` }] };
181
206
  }
182
207
  case 'update_knowledge': {
183
208
  if (!args.knowledge_id) {
package/dist/mcp/setup.js CHANGED
@@ -11,6 +11,7 @@ const MCP_CONFIG_PATH = path.join(MCP_CONFIG_DIR, 'mcp-config.json');
11
11
  */
12
12
  export function setupMcpConfig(config) {
13
13
  const knowledgeServerPath = path.join(__dirname, 'knowledge-server.js');
14
+ const browserServerPath = path.join(__dirname, 'browser-server.js');
14
15
  const mcpConfig = {
15
16
  mcpServers: {
16
17
  'tuna-knowledge': {
@@ -22,6 +23,10 @@ export function setupMcpConfig(config) {
22
23
  '--agent-id', config.agentId,
23
24
  ],
24
25
  },
26
+ 'tuna-browser': {
27
+ command: process.execPath,
28
+ args: [browserServerPath, '--user-data-dir', path.join(process.env.HOME || '', '.config', 'tuna-browser', 'chrome-profile')],
29
+ },
25
30
  },
26
31
  };
27
32
  if (!fs.existsSync(MCP_CONFIG_DIR)) {
@@ -47,6 +52,7 @@ export function getMcpConfigPath() {
47
52
  */
48
53
  export function writeAgentFolderMcpConfig(agentFolderPath, config) {
49
54
  const knowledgeServerPath = path.join(__dirname, 'knowledge-server.js');
55
+ const browserServerPath = path.join(__dirname, 'browser-server.js');
50
56
  try {
51
57
  const mcpJsonPath = path.join(agentFolderPath, '.mcp.json');
52
58
  // Read existing .mcp.json to preserve other servers (e.g. playwright)
@@ -66,6 +72,10 @@ export function writeAgentFolderMcpConfig(agentFolderPath, config) {
66
72
  '--agent-id', config.agentId,
67
73
  ],
68
74
  },
75
+ 'tuna-browser': {
76
+ command: process.execPath,
77
+ args: [browserServerPath, '--user-data-dir', path.join(process.env.HOME || '', '.config', 'tuna-browser', 'chrome-profile')],
78
+ },
69
79
  };
70
80
  fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2));
71
81
  console.log(`[MCP] Agent folder .mcp.json written to ${mcpJsonPath}`);
@@ -127,18 +127,27 @@ export function runClaude(options) {
127
127
  `${process.env.HOME}/.local/bin`,
128
128
  `${process.env.HOME}/.nvm/versions/node/v20.10.0/bin`,
129
129
  ].filter(Boolean).join(':');
130
+ // Build env: inherit process.env but strip Claude Code session markers
131
+ // so the spawned claude process is not blocked by nested-session detection.
132
+ const spawnEnv = {};
133
+ for (const [k, v] of Object.entries(process.env)) {
134
+ if (v !== undefined)
135
+ spawnEnv[k] = v;
136
+ }
137
+ // Remove nested-session markers
138
+ delete spawnEnv['CLAUDECODE'];
139
+ delete spawnEnv['CLAUDE_CODE_ENTRYPOINT'];
140
+ delete spawnEnv['CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING'];
141
+ spawnEnv['HOME'] = process.env.HOME || '';
142
+ spawnEnv['PATH'] = spawnPath;
143
+ if (options.agentTeam) {
144
+ spawnEnv['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'] = '1';
145
+ spawnEnv['CLAUDE_CODE_TEAMMATE_MODE'] = 'in-process';
146
+ }
130
147
  const proc = spawn(claudeBin, args, {
131
148
  cwd: options.cwd,
132
149
  stdio: [useInteractiveStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
133
- env: {
134
- ...process.env,
135
- HOME: process.env.HOME || '',
136
- PATH: spawnPath,
137
- ...(options.agentTeam ? {
138
- CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
139
- CLAUDE_CODE_TEAMMATE_MODE: 'in-process',
140
- } : {}),
141
- },
150
+ env: spawnEnv,
142
151
  });
143
152
  // 30-minute timeout by default
144
153
  const timeoutMs = options.timeoutMs ?? 30 * 60 * 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuna-agent",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Tuna Agent - Run AI coding tasks on your machine",
5
5
  "bin": {
6
6
  "tuna-agent": "dist/cli/index.js"
@@ -16,6 +16,7 @@
16
16
  "dependencies": {
17
17
  "chalk": "^5.4.0",
18
18
  "commander": "^13.0.0",
19
+ "playwright-core": "^1.58.2",
19
20
  "ws": "^8.18.0",
20
21
  "zod": "^4.3.6"
21
22
  },