tuna-agent 0.1.0 → 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 (68) hide show
  1. package/dist/agents/claude-code-adapter.d.ts +3 -1
  2. package/dist/agents/claude-code-adapter.js +28 -4
  3. package/dist/agents/factory.d.ts +2 -1
  4. package/dist/agents/factory.js +2 -2
  5. package/dist/browser/actions/download.d.ts +16 -0
  6. package/dist/browser/actions/download.js +39 -0
  7. package/dist/browser/actions/emulation.d.ts +53 -0
  8. package/dist/browser/actions/emulation.js +103 -0
  9. package/dist/browser/actions/evaluate.d.ts +29 -0
  10. package/dist/browser/actions/evaluate.js +92 -0
  11. package/dist/browser/actions/interaction.d.ts +79 -0
  12. package/dist/browser/actions/interaction.js +210 -0
  13. package/dist/browser/actions/keyboard.d.ts +6 -0
  14. package/dist/browser/actions/keyboard.js +9 -0
  15. package/dist/browser/actions/navigation.d.ts +40 -0
  16. package/dist/browser/actions/navigation.js +92 -0
  17. package/dist/browser/actions/wait.d.ts +12 -0
  18. package/dist/browser/actions/wait.js +33 -0
  19. package/dist/browser/browser.d.ts +722 -0
  20. package/dist/browser/browser.js +1066 -0
  21. package/dist/browser/capture/activity.d.ts +22 -0
  22. package/dist/browser/capture/activity.js +39 -0
  23. package/dist/browser/capture/pdf.d.ts +6 -0
  24. package/dist/browser/capture/pdf.js +6 -0
  25. package/dist/browser/capture/response.d.ts +8 -0
  26. package/dist/browser/capture/response.js +28 -0
  27. package/dist/browser/capture/screenshot.d.ts +30 -0
  28. package/dist/browser/capture/screenshot.js +72 -0
  29. package/dist/browser/capture/trace.d.ts +13 -0
  30. package/dist/browser/capture/trace.js +19 -0
  31. package/dist/browser/chrome-launcher.d.ts +8 -0
  32. package/dist/browser/chrome-launcher.js +543 -0
  33. package/dist/browser/connection.d.ts +42 -0
  34. package/dist/browser/connection.js +359 -0
  35. package/dist/browser/index.d.ts +6 -0
  36. package/dist/browser/index.js +3 -0
  37. package/dist/browser/security.d.ts +51 -0
  38. package/dist/browser/security.js +357 -0
  39. package/dist/browser/snapshot/ai-snapshot.d.ts +12 -0
  40. package/dist/browser/snapshot/ai-snapshot.js +47 -0
  41. package/dist/browser/snapshot/aria-snapshot.d.ts +26 -0
  42. package/dist/browser/snapshot/aria-snapshot.js +121 -0
  43. package/dist/browser/snapshot/ref-map.d.ts +31 -0
  44. package/dist/browser/snapshot/ref-map.js +250 -0
  45. package/dist/browser/storage/index.d.ts +36 -0
  46. package/dist/browser/storage/index.js +65 -0
  47. package/dist/browser/types.d.ts +429 -0
  48. package/dist/browser/types.js +2 -0
  49. package/dist/cli/commands/extension.d.ts +10 -0
  50. package/dist/cli/commands/extension.js +86 -0
  51. package/dist/cli/index.js +12 -0
  52. package/dist/daemon/extension-handlers.d.ts +63 -0
  53. package/dist/daemon/extension-handlers.js +630 -0
  54. package/dist/daemon/index.js +173 -44
  55. package/dist/daemon/ws-client.d.ts +28 -8
  56. package/dist/daemon/ws-client.js +68 -62
  57. package/dist/mcp/browser-server.d.ts +11 -0
  58. package/dist/mcp/browser-server.js +467 -0
  59. package/dist/mcp/knowledge-server.d.ts +11 -0
  60. package/dist/mcp/knowledge-server.js +263 -0
  61. package/dist/mcp/setup.d.ts +20 -0
  62. package/dist/mcp/setup.js +94 -0
  63. package/dist/types/index.d.ts +2 -0
  64. package/dist/utils/claude-cli.d.ts +2 -0
  65. package/dist/utils/claude-cli.js +29 -9
  66. package/dist/utils/message-schemas.d.ts +4 -1
  67. package/dist/utils/message-schemas.js +6 -1
  68. package/package.json +2 -1
@@ -2,15 +2,17 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import { AgentWebSocketClient } from './ws-client.js';
5
- import { removePid } from '../config/store.js';
6
- import { validatePath } from '../utils/validate-path.js';
5
+ import { removePid, loadConfig, saveConfig } from '../config/store.js';
7
6
  import { validateMessage } from '../utils/message-schemas.js';
8
7
  import { createAgentAdapter } from '../agents/factory.js';
9
8
  import { loadPMState, savePMState, clearPMState } from './pm-state.js';
10
9
  import { chatWithPM } from '../pm/planner.js';
11
10
  import { executePlanAndReport, simplifyMarkdown, waitForInput } from '../utils/execution-helpers.js';
12
11
  import { runClaude } from '../utils/claude-cli.js';
12
+ import { handleGetHistory, handleRetryVideo, handleGenerateIdeas, handleGenerateScript, handleGenerateScene, handleGenerateScenes, handleRenderVideo, handleListCharacters, handleCreateCharacter, handleSaveCharacterSelection, } from './extension-handlers.js';
13
13
  import { downloadAttachments, cleanupAttachments } from '../utils/image-download.js';
14
+ import { scanSkills } from '../utils/skill-scanner.js';
15
+ import { setupMcpConfig } from '../mcp/setup.js';
14
16
  /**
15
17
  * Start the agent daemon.
16
18
  * Connects to API via WebSocket, receives tasks, delegates to agent adapter.
@@ -21,7 +23,14 @@ export async function startDaemon(config) {
21
23
  type: config.agentType || 'claude-code',
22
24
  ...config.agentConfig,
23
25
  };
24
- const adapter = createAgentAdapter(agentConfig);
26
+ const adapter = createAgentAdapter(agentConfig, config);
27
+ // Setup MCP config for Knowledge server
28
+ try {
29
+ setupMcpConfig(config);
30
+ }
31
+ catch (err) {
32
+ console.warn(`[Daemon] MCP config setup failed (non-fatal):`, err);
33
+ }
25
34
  // Health check on startup
26
35
  const health = await adapter.checkHealth();
27
36
  if (!health.ok) {
@@ -58,8 +67,8 @@ export async function startDaemon(config) {
58
67
  switch (type) {
59
68
  case 'connected':
60
69
  console.log(`[Daemon] Connected as "${msg.name}" (${msg.agentId})`);
61
- // Recover any tasks orphaned by previous daemon crash/restart
62
- ws.send({ action: 'recover_orphaned_tasks' });
70
+ // Recover orphaned tasks pass activeTaskId so API won't fail a task we're still running
71
+ ws.send({ action: 'recover_orphaned_tasks', activeTaskId: currentTaskId ?? undefined });
63
72
  break;
64
73
  case 'task_assigned': {
65
74
  const task = msg.task;
@@ -67,6 +76,10 @@ export async function startDaemon(config) {
67
76
  console.error('[Daemon] Received task_assigned without task data');
68
77
  break;
69
78
  }
79
+ // Resolve tilde in repoPath (e.g. ~/agents/co-founder → /Users/admin/agents/co-founder)
80
+ if (task.repoPath?.startsWith('~/')) {
81
+ task.repoPath = path.join(os.homedir(), task.repoPath.slice(2));
82
+ }
70
83
  if (activeTasks >= MAX_CONCURRENT) {
71
84
  console.log(`[Daemon] Busy — rejecting task ${task.id}`);
72
85
  ws.send({ action: 'task_rejected', taskId: task.id, reason: 'agent_busy' });
@@ -76,6 +89,15 @@ export async function startDaemon(config) {
76
89
  currentTaskId = task.id;
77
90
  currentTaskAbort = new AbortController();
78
91
  console.log(`[Daemon] Received task: ${task.id} — ${task.description.slice(0, 80)} (attachments: ${task.attachments?.length ?? 0})`);
92
+ // Update MCP config with the task's agent ID so knowledge server uses correct attribution
93
+ if (task.agentId) {
94
+ try {
95
+ setupMcpConfig({ ...config, agentId: task.agentId });
96
+ }
97
+ catch (err) {
98
+ console.warn(`[Daemon] MCP config update for task failed (non-fatal):`, err);
99
+ }
100
+ }
79
101
  try {
80
102
  await adapter.handleTask(task, ws, pendingInputResolvers, currentTaskAbort.signal, pendingPermissionResolvers);
81
103
  }
@@ -117,42 +139,21 @@ export async function startDaemon(config) {
117
139
  }
118
140
  case 'command': {
119
141
  const command = msg.command;
120
- if (command === 'scan_workspace') {
121
- const rawPath = msg.workspace_path || '~/tuna-workspace';
122
- const wsPath = rawPath.startsWith('~')
123
- ? path.join(os.homedir(), rawPath.slice(1))
124
- : rawPath;
125
- try {
126
- validatePath(wsPath, os.homedir());
127
- }
128
- catch (err) {
129
- const errMsg = err instanceof Error ? err.message : String(err);
130
- console.error(`[Daemon] Workspace path validation failed: ${errMsg}`);
131
- ws.send({ action: 'workspace_scanned', projects: [] });
132
- break;
133
- }
134
- // Resolve agent folder paths (sent by API)
135
- const rawFolders = msg.agent_folders || [];
136
- const agentFolders = rawFolders.map(f => f.startsWith('~') ? path.join(os.homedir(), f.slice(1)) : f);
137
- console.log(`[Daemon] Scanning workspace: ${wsPath}, agent folders: ${agentFolders.length}`);
138
- ws.setWorkspacePath(wsPath);
139
- ws.setAgentFolders(agentFolders);
140
- try {
141
- const entries = fs.readdirSync(wsPath, { withFileTypes: true });
142
- const projects = entries
143
- .filter(e => e.isDirectory() && !e.name.startsWith('.'))
144
- .map(e => ({
145
- name: e.name,
146
- path: path.join(wsPath, e.name),
147
- }));
148
- ws.send({ action: 'workspace_scanned', projects });
149
- console.log(`[Daemon] Found ${projects.length} projects in workspace`);
150
- }
151
- catch (err) {
152
- const errMsg = err instanceof Error ? err.message : String(err);
153
- console.error(`[Daemon] Workspace scan failed: ${errMsg}`);
154
- ws.send({ action: 'workspace_scanned', projects: [] });
155
- }
142
+ if (command === 'rescan_agent_skills') {
143
+ const agentId = msg.agent_id;
144
+ const rawFolder = msg.folder_path || '';
145
+ const rawWsPath = msg.workspace_path || '~/tuna-workspace';
146
+ const wsPath = rawWsPath.startsWith('~')
147
+ ? path.join(os.homedir(), rawWsPath.slice(1))
148
+ : rawWsPath;
149
+ const folder = rawFolder.startsWith('~')
150
+ ? path.join(os.homedir(), rawFolder.slice(1))
151
+ : rawFolder;
152
+ console.log(`[Daemon] Rescan skills for agent ${agentId}, folder: ${folder || '(none)'}`);
153
+ const folders = folder ? [folder] : [];
154
+ const skills = scanSkills(wsPath, folders);
155
+ ws.send({ action: 'agent_skills_scanned', agent_id: agentId, skills });
156
+ console.log(`[Daemon] Scanned ${skills.length} skill(s) for agent ${agentId}`);
156
157
  }
157
158
  else if (command === 'analyze_skill') {
158
159
  const skillId = msg.skill_id;
@@ -180,12 +181,15 @@ export async function startDaemon(config) {
180
181
  const analyzePrompt = `You are analyzing a Claude Code skill file (markdown prompt template). Extract the following structured information from the content below:
181
182
 
182
183
  1. **description**: A short summary of what this skill does in under 80 characters. Be very concise — like a subtitle, not a full sentence.
183
- 2. **actions**: Sub-commands or modes available in this skill. Look for sections, headings, or conditional logic that indicate different actions/modes the user can invoke. Each action has a "name" (short, lowercase, no spaces — use hyphens) and "description" (what it does, max 100 chars).
184
- 3. **parameters**: Input parameters the skill accepts. Look for {{param_name}} placeholders, $ARGUMENTS references, or documented inputs. Each parameter has "key", "label", "type" (text/number/select/multiline), "required" (boolean), "default_value", "options" (for select type), "placeholder".
184
+ 2. **actions**: Sub-commands or modes available in this skill. Look for sections, headings, or conditional logic that indicate different actions/modes the user can invoke. Each action has:
185
+ - "name" (short, lowercase, no spaces use hyphens)
186
+ - "description" (what it does, max 100 chars)
187
+ - "params" (array of parameter KEY strings that are relevant to THIS specific action — only include params that this action actually uses)
188
+ 3. **parameters**: ALL input parameters the skill accepts (the full definitions). Look for {{param_name}} placeholders, $ARGUMENTS references, or documented inputs. Each parameter has "key", "label", "type" (text/number/select/multiline), "required" (boolean), "default_value", "options" (for select type), "placeholder".
185
189
  - IMPORTANT: If a parameter has a finite set of known values (e.g. listed in config files, enums, documented choices), use type "select" and populate the "options" array with ALL known values. Only use type "text" when the input is truly free-form.
186
190
 
187
191
  Respond with ONLY valid JSON, no markdown, no explanation:
188
- {"description":"...","actions":[{"name":"...","description":"..."}],"parameters":[{"key":"...","label":"...","type":"select","required":false,"options":["opt1","opt2"]}]}
192
+ {"description":"...","actions":[{"name":"...","description":"...","params":["param_key1","param_key2"]}],"parameters":[{"key":"...","label":"...","type":"select","required":false,"options":["opt1","opt2"]}]}
189
193
 
190
194
  If no actions are found, return an empty array. If no parameters found, return an empty array.
191
195
 
@@ -287,8 +291,133 @@ ${skillContent.slice(0, 15000)}`;
287
291
  }
288
292
  break;
289
293
  }
294
+ case 'extension_task': {
295
+ const extCode = msg.code;
296
+ const extTaskId = msg.taskId;
297
+ const extTask = msg.task;
298
+ const extStream = msg.stream;
299
+ if (!extCode || !extTaskId || !extTask) {
300
+ console.warn('[Daemon] extension_task missing required fields');
301
+ break;
302
+ }
303
+ console.log(`[Daemon] Extension task ${extTaskId}: ${extTask.substring(0, 80)}`);
304
+ // ── Specialized handlers (no Claude needed) ──────────────────────────
305
+ if (extTask === 'get_history') {
306
+ handleGetHistory(ws, extCode, extTaskId);
307
+ break;
308
+ }
309
+ if (extTask === 'generate_ideas') {
310
+ (async () => {
311
+ await handleGenerateIdeas(ws, extCode, extTaskId, msg.topic);
312
+ })();
313
+ break;
314
+ }
315
+ if (extTask === 'generate_script') {
316
+ (async () => {
317
+ await handleGenerateScript(ws, extCode, extTaskId, msg.idea, msg.topic, msg.style);
318
+ })();
319
+ break;
320
+ }
321
+ if (extTask === 'retry_video') {
322
+ const videoId = msg.videoId;
323
+ handleRetryVideo(ws, extCode, extTaskId, videoId);
324
+ break;
325
+ }
326
+ if (extTask === 'generate_scene') {
327
+ (async () => {
328
+ await handleGenerateScene(ws, extCode, extTaskId, msg.sceneIdx, msg.prompt, msg.aspectRatio || 'portrait');
329
+ })();
330
+ break;
331
+ }
332
+ if (extTask === 'generate_scenes') {
333
+ (async () => {
334
+ await handleGenerateScenes(ws, extCode, extTaskId, msg.scenes, msg.aspectRatio || 'portrait');
335
+ })();
336
+ break;
337
+ }
338
+ if (extTask === 'render_video') {
339
+ (async () => {
340
+ await handleRenderVideo(ws, extCode, extTaskId, {
341
+ scenes: msg.scenes,
342
+ script: msg.script,
343
+ title: msg.title,
344
+ aspectRatio: msg.aspectRatio || 'portrait',
345
+ voiceId: msg.voiceId || '',
346
+ subtitleStyle: msg.subtitleStyle || 'karaoke',
347
+ });
348
+ })();
349
+ break;
350
+ }
351
+ if (extTask === 'list_characters') {
352
+ handleListCharacters(ws, extCode, extTaskId);
353
+ break;
354
+ }
355
+ if (extTask === 'create_character') {
356
+ (async () => {
357
+ await handleCreateCharacter(ws, extCode, extTaskId, msg.name, msg.displayName, msg.prompt, msg.outputCount || 2, msg.orientation || 'portrait');
358
+ })();
359
+ break;
360
+ }
361
+ if (extTask === 'save_character_selection') {
362
+ handleSaveCharacterSelection(ws, extCode, extTaskId, msg.characterId, msg.selectedImages || []);
363
+ break;
364
+ }
365
+ // ── Claude-powered tasks (ideas, script, etc.) ────────────────────────
366
+ (async () => {
367
+ try {
368
+ const result = await runClaude({
369
+ prompt: extTask,
370
+ cwd: os.homedir(),
371
+ maxTurns: 10,
372
+ outputFormat: extStream ? 'stream-json' : 'json',
373
+ lightweight: true,
374
+ timeoutMs: 120000,
375
+ onStreamLine: extStream ? (data) => {
376
+ if (data.type === 'stream_event') {
377
+ const event = data.event;
378
+ if (event?.type === 'content_block_delta') {
379
+ const delta = event.delta;
380
+ if (delta?.type === 'text_delta' && delta.text) {
381
+ ws.sendExtensionStream(extCode, extTaskId, delta.text);
382
+ }
383
+ }
384
+ }
385
+ } : undefined,
386
+ });
387
+ ws.sendExtensionDone(extCode, extTaskId, { script: result.result, text: result.result });
388
+ console.log(`[Daemon] Extension task ${extTaskId} done`);
389
+ }
390
+ catch (err) {
391
+ const errMsg = err instanceof Error ? err.message : String(err);
392
+ console.error(`[Daemon] Extension task ${extTaskId} error: ${errMsg}`);
393
+ ws.sendExtensionDone(extCode, extTaskId, { error: errMsg });
394
+ }
395
+ })();
396
+ break;
397
+ }
290
398
  case 'pong':
291
399
  break;
400
+ case 'flow_command_result': {
401
+ const fcId = msg.id;
402
+ const fcResult = msg.result;
403
+ if (fcId) {
404
+ ws.resolveFlowCommand(fcId, fcResult || {});
405
+ }
406
+ break;
407
+ }
408
+ case 'extension_unpaired': {
409
+ // Extension signed out — remove this code from local config so it's not restored on reconnect
410
+ const unpairCode = msg.code;
411
+ if (unpairCode) {
412
+ const cfg = loadConfig();
413
+ if (cfg?.extensionCodes) {
414
+ const newCodes = cfg.extensionCodes.filter(c => c !== unpairCode);
415
+ saveConfig({ ...cfg, extensionCodes: newCodes });
416
+ console.log(`[Daemon] Extension unpaired: ${unpairCode}`);
417
+ }
418
+ }
419
+ break;
420
+ }
292
421
  case 'error':
293
422
  console.error(`[Daemon] Server error: ${msg.message}`);
294
423
  break;
@@ -10,9 +10,8 @@ export declare class AgentWebSocketClient {
10
10
  private running;
11
11
  private onMessage;
12
12
  private onAuthFailed?;
13
- private heartbeatCount;
14
- private _workspacePath;
15
- private _agentFolders;
13
+ /** Pending flow command callbacks keyed by request ID */
14
+ private _flowCallbacks;
16
15
  constructor(config: AgentConfig, onMessage: MessageHandler, onAuthFailed?: (code: number, reason: string) => void);
17
16
  connect(): void;
18
17
  disconnect(): void;
@@ -94,14 +93,35 @@ export declare class AgentWebSocketClient {
94
93
  context?: string;
95
94
  startedAt?: string;
96
95
  }): boolean;
96
+ /**
97
+ * Stream a text chunk back to the Chrome extension for an extension task.
98
+ */
99
+ sendExtensionStream(code: string, taskId: string, text: string): boolean;
100
+ /**
101
+ * Signal that an extension task is complete.
102
+ */
103
+ sendExtensionDone(code: string, taskId: string, result: Record<string, unknown>): boolean;
104
+ /**
105
+ * Send progress update for an extension task.
106
+ */
107
+ sendExtensionProgress(code: string, taskId: string, progress: number): boolean;
108
+ /**
109
+ * Send a custom-typed event to the extension (generic forwarder via extension_event action).
110
+ */
111
+ sendExtensionEvent(code: string, event: Record<string, unknown>): boolean;
112
+ /**
113
+ * Send a flow command to the extension and wait for the result.
114
+ * Returns a Promise that resolves with the command result.
115
+ */
116
+ sendFlowCommand(code: string, command: Record<string, unknown>, timeout?: number): Promise<Record<string, unknown>>;
117
+ /**
118
+ * Resolve a pending flow command callback (called from message handler).
119
+ * Returns true if the callback was found and resolved.
120
+ */
121
+ resolveFlowCommand(id: string, result: Record<string, unknown>): boolean;
97
122
  get isConnected(): boolean;
98
123
  private _connect;
99
124
  private _startHeartbeat;
100
- /** Set workspace path (called when scan_workspace command is received) */
101
- setWorkspacePath(wsPath: string): void;
102
- /** Set agent folder paths for skill scanning */
103
- setAgentFolders(folders: string[]): void;
104
- private _scanWorkspace;
105
125
  private _stopHeartbeat;
106
126
  private _scheduleReconnect;
107
127
  }
@@ -1,9 +1,5 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
1
  import WebSocket from 'ws';
5
2
  import { getSystemLoad, detectCapabilities } from '../system/info.js';
6
- import { scanSkills } from '../utils/skill-scanner.js';
7
3
  /** Close codes that indicate a permanent error — do NOT reconnect. */
8
4
  const PERMANENT_CLOSE_CODES = new Set([
9
5
  4001, // Authentication failed (invalid token)
@@ -19,9 +15,8 @@ export class AgentWebSocketClient {
19
15
  running = false;
20
16
  onMessage;
21
17
  onAuthFailed;
22
- heartbeatCount = 0;
23
- _workspacePath = null;
24
- _agentFolders = [];
18
+ /** Pending flow command callbacks keyed by request ID */
19
+ _flowCallbacks = new Map();
25
20
  constructor(config, onMessage, onAuthFailed) {
26
21
  this.config = config;
27
22
  this.onMessage = onMessage;
@@ -159,6 +154,67 @@ export class AgentWebSocketClient {
159
154
  timestamp: message.startedAt || new Date().toISOString(),
160
155
  });
161
156
  }
157
+ /**
158
+ * Stream a text chunk back to the Chrome extension for an extension task.
159
+ */
160
+ sendExtensionStream(code, taskId, text) {
161
+ return this.send({ action: 'extension_task_stream', code, taskId, text });
162
+ }
163
+ /**
164
+ * Signal that an extension task is complete.
165
+ */
166
+ sendExtensionDone(code, taskId, result) {
167
+ return this.send({ action: 'extension_task_done', code, taskId, result });
168
+ }
169
+ /**
170
+ * Send progress update for an extension task.
171
+ */
172
+ sendExtensionProgress(code, taskId, progress) {
173
+ return this.send({ action: 'extension_task_progress', code, taskId, progress });
174
+ }
175
+ /**
176
+ * Send a custom-typed event to the extension (generic forwarder via extension_event action).
177
+ */
178
+ sendExtensionEvent(code, event) {
179
+ return this.send({ action: 'extension_event', code, event });
180
+ }
181
+ /**
182
+ * Send a flow command to the extension and wait for the result.
183
+ * Returns a Promise that resolves with the command result.
184
+ */
185
+ sendFlowCommand(code, command, timeout = 60000) {
186
+ return new Promise((resolve, reject) => {
187
+ const id = `fc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
188
+ const timer = setTimeout(() => {
189
+ this._flowCallbacks.delete(id);
190
+ reject(new Error(`Flow command timed out after ${timeout}ms: ${command.type}`));
191
+ }, timeout);
192
+ this._flowCallbacks.set(id, { resolve, reject, timer });
193
+ const sent = this.sendExtensionEvent(code, {
194
+ type: 'flow_command',
195
+ id,
196
+ command,
197
+ });
198
+ if (!sent) {
199
+ clearTimeout(timer);
200
+ this._flowCallbacks.delete(id);
201
+ reject(new Error('WebSocket not connected — cannot send flow command'));
202
+ }
203
+ });
204
+ }
205
+ /**
206
+ * Resolve a pending flow command callback (called from message handler).
207
+ * Returns true if the callback was found and resolved.
208
+ */
209
+ resolveFlowCommand(id, result) {
210
+ const cb = this._flowCallbacks.get(id);
211
+ if (!cb)
212
+ return false;
213
+ clearTimeout(cb.timer);
214
+ this._flowCallbacks.delete(id);
215
+ cb.resolve(result);
216
+ return true;
217
+ }
162
218
  get isConnected() {
163
219
  return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
164
220
  }
@@ -177,6 +233,11 @@ export class AgentWebSocketClient {
177
233
  this.reconnectAttempts = 0;
178
234
  console.log('[WS] Connected to API server');
179
235
  this._startHeartbeat();
236
+ // Restore extension pairings so server can send agent_connected to already-connected extensions
237
+ const codes = this.config.extensionCodes;
238
+ if (codes && codes.length > 0) {
239
+ this.send({ action: 'restore_extensions', codes });
240
+ }
180
241
  });
181
242
  this.ws.on('message', (raw) => {
182
243
  try {
@@ -210,70 +271,15 @@ export class AgentWebSocketClient {
210
271
  }
211
272
  _startHeartbeat() {
212
273
  this._stopHeartbeat();
213
- this.heartbeatCount = 0;
214
- // Scan workspace once on connect (delayed 3s to let connection stabilize)
215
- setTimeout(() => this._scanWorkspace(), 3000);
216
274
  this.heartbeatTimer = setInterval(() => {
217
- this.heartbeatCount++;
218
275
  const agentType = this.config.agentType || 'claude-code';
219
276
  this.send({
220
277
  action: 'heartbeat',
221
278
  system_load: getSystemLoad(),
222
279
  capabilities: detectCapabilities(agentType),
223
280
  });
224
- // Scan workspace every 20th heartbeat (~10 min)
225
- if (this.heartbeatCount % 20 === 0) {
226
- this._scanWorkspace();
227
- }
228
281
  }, 30000);
229
282
  }
230
- /** Set workspace path (called when scan_workspace command is received) */
231
- setWorkspacePath(wsPath) {
232
- this._workspacePath = wsPath;
233
- }
234
- /** Set agent folder paths for skill scanning */
235
- setAgentFolders(folders) {
236
- this._agentFolders = folders;
237
- }
238
- _scanWorkspace() {
239
- const wsPath = this._workspacePath || path.join(os.homedir(), 'tuna-workspace');
240
- try {
241
- if (!fs.existsSync(wsPath))
242
- return;
243
- const entries = fs.readdirSync(wsPath, { withFileTypes: true });
244
- const projects = entries
245
- .filter(e => e.isDirectory() && !e.name.startsWith('.'))
246
- .map(e => ({ name: e.name, path: path.join(wsPath, e.name) }));
247
- this.send({ action: 'workspace_scanned', projects });
248
- }
249
- catch {
250
- // Silent fail — non-critical
251
- }
252
- // Scan skills from .claude/commands/ directories
253
- // Merge API-provided folders with local config scan_paths
254
- try {
255
- let folders = [...this._agentFolders];
256
- if (folders.length === 0) {
257
- // Fallback: read scan_paths from local config
258
- const configPath = path.join(os.homedir(), '.tuna-agent', 'config.json');
259
- try {
260
- const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
261
- if (Array.isArray(cfg.scan_paths)) {
262
- folders = cfg.scan_paths;
263
- }
264
- }
265
- catch { /* no config or parse error */ }
266
- }
267
- const skills = scanSkills(wsPath, folders);
268
- if (skills.length > 0) {
269
- this.send({ action: 'skills_scanned', skills });
270
- console.log(`[WS] Scanned ${skills.length} skill(s) from ${folders.length} folder(s)`);
271
- }
272
- }
273
- catch {
274
- // Silent fail — non-critical
275
- }
276
- }
277
283
  _stopHeartbeat() {
278
284
  if (this.heartbeatTimer) {
279
285
  clearInterval(this.heartbeatTimer);
@@ -0,0 +1,11 @@
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
+ export {};