@shawnowen/comet-mcp 2.3.1 → 2.4.1

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 (85) hide show
  1. package/README.md +86 -19
  2. package/dist/alert-dispatcher.d.ts +23 -0
  3. package/dist/alert-dispatcher.js +101 -0
  4. package/dist/bound-session.d.ts +23 -0
  5. package/dist/bound-session.js +119 -0
  6. package/dist/bridge-config.d.ts +6 -0
  7. package/dist/bridge-config.js +78 -0
  8. package/dist/cdp-client.d.ts +40 -4
  9. package/dist/cdp-client.js +502 -155
  10. package/dist/comet-ai.d.ts +15 -0
  11. package/dist/comet-ai.js +114 -38
  12. package/dist/delegate-binding.d.ts +19 -0
  13. package/dist/delegate-binding.js +73 -0
  14. package/dist/discovery/capability-entry.d.ts +215 -0
  15. package/dist/discovery/capability-entry.js +13 -0
  16. package/dist/discovery/description-template.d.ts +40 -0
  17. package/dist/discovery/description-template.js +61 -0
  18. package/dist/discovery/golden-queries.fixture.d.ts +22 -0
  19. package/dist/discovery/golden-queries.fixture.js +137 -0
  20. package/dist/discovery/mcp-source.d.ts +38 -0
  21. package/dist/discovery/mcp-source.js +70 -0
  22. package/dist/discovery/metadata-completeness.d.ts +48 -0
  23. package/dist/discovery/metadata-completeness.js +83 -0
  24. package/dist/discovery/registry.d.ts +35 -0
  25. package/dist/discovery/registry.js +35 -0
  26. package/dist/discovery/safety.d.ts +44 -0
  27. package/dist/discovery/safety.js +59 -0
  28. package/dist/discovery/schema-validator.d.ts +36 -0
  29. package/dist/discovery/schema-validator.js +257 -0
  30. package/dist/discovery/source-error.d.ts +47 -0
  31. package/dist/discovery/source-error.js +95 -0
  32. package/dist/discovery/tool-meta.d.ts +41 -0
  33. package/dist/discovery/tool-meta.js +229 -0
  34. package/dist/discovery/virtual-tools.d.ts +20 -0
  35. package/dist/discovery/virtual-tools.js +69 -0
  36. package/dist/http-server.js +2067 -47
  37. package/dist/index.js +3163 -710
  38. package/dist/observer.d.ts +47 -0
  39. package/dist/observer.js +516 -0
  40. package/dist/session-registry.d.ts +57 -0
  41. package/dist/session-registry.js +500 -0
  42. package/dist/sidecar-artifacts.d.ts +49 -0
  43. package/dist/sidecar-artifacts.js +146 -0
  44. package/dist/snapshot-capture.d.ts +3 -0
  45. package/dist/snapshot-capture.js +91 -0
  46. package/dist/tab-group-archive.js +3 -1
  47. package/dist/tab-groups.d.ts +7 -0
  48. package/dist/tab-groups.js +21 -3
  49. package/dist/task-thread-aggregator.d.ts +34 -0
  50. package/dist/task-thread-aggregator.js +480 -0
  51. package/dist/task-thread-canonical.d.ts +142 -0
  52. package/dist/task-thread-canonical.js +116 -0
  53. package/dist/types.d.ts +237 -0
  54. package/dist/window-bindings.d.ts +112 -0
  55. package/dist/window-bindings.js +476 -0
  56. package/extension/background.js +1556 -300
  57. package/extension/icons/icon.svg +9 -0
  58. package/extension/icons/icon128.png +0 -0
  59. package/extension/icons/icon16.png +0 -0
  60. package/extension/icons/icon48.png +0 -0
  61. package/extension/manifest.json +19 -4
  62. package/extension/session-logic.js +2383 -0
  63. package/extension/session-manager.html +299 -0
  64. package/extension/sidepanel.css +5323 -528
  65. package/extension/sidepanel.html +282 -2
  66. package/extension/sidepanel.js +10075 -951
  67. package/extension/window-policy.js +162 -0
  68. package/package.json +10 -7
  69. package/vendor/lifecycle-mcp-adapter.mjs +103 -0
  70. package/vendor/lifecycle-metadata.mjs +252 -0
  71. package/vendor/readiness-report.mjs +742 -0
  72. package/dist/cdp-client.d.ts.map +0 -1
  73. package/dist/cdp-client.js.map +0 -1
  74. package/dist/comet-ai.d.ts.map +0 -1
  75. package/dist/comet-ai.js.map +0 -1
  76. package/dist/http-server.d.ts.map +0 -1
  77. package/dist/http-server.js.map +0 -1
  78. package/dist/index.d.ts.map +0 -1
  79. package/dist/index.js.map +0 -1
  80. package/dist/tab-group-archive.d.ts.map +0 -1
  81. package/dist/tab-group-archive.js.map +0 -1
  82. package/dist/tab-groups.d.ts.map +0 -1
  83. package/dist/tab-groups.js.map +0 -1
  84. package/dist/types.d.ts.map +0 -1
  85. package/dist/types.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,20 +1,252 @@
1
1
  #!/usr/bin/env node
2
2
  // Comet Browser MCP Server
3
3
  // Claude Code ↔ Perplexity Comet bidirectional interaction
4
- // 14 tools: 9 browsing + 1 tab groups + 4 lifecycle
4
+ // 25 tools: 9 browsing + 2 direct interaction + 1 tab groups + 4 lifecycle + 2 orchestration + 5 parity + 2 safe observe (observe + peek)
5
5
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
8
- import { cometClient } from "./cdp-client.js";
9
- import { cometAI } from "./comet-ai.js";
10
- // @ts-ignore .mjs adapter for lifecycle event parity (Spec 078 AC-2)
11
- import { emitLifecycleEvent, createMCPLifecycleEnvelope } from "../../scripts/lifecycle-mcp-adapter.mjs";
12
- import { appendFileSync, readFileSync } from "fs";
13
- import { execFile } from "child_process";
14
- import { promisify } from "util";
8
+ import { cometClient as globalCometClient } from "./cdp-client.js";
9
+ import { CometAI, cometAI as globalCometAI } from "./comet-ai.js";
10
+ import { sessionRegistry } from "./session-registry.js";
11
+ import { drainMcpAlertQueue, formatAlertsForResponse, dispatchAlert } from "./alert-dispatcher.js";
12
+ import { loadBridgeConfig } from "./bridge-config.js";
13
+ import { BoundSessionError, resolveBoundSession } from "./bound-session.js";
14
+ import { sidecarArtifactStore } from "./sidecar-artifacts.js";
15
+ import { windowBindingStore } from "./window-bindings.js";
16
+ import { createOrReuseDelegateBinding } from "./delegate-binding.js";
17
+ /**
18
+ * Resolve the current session's CDP client and CometAI, or fall back to globals.
19
+ * This ensures each agent uses its own isolated tab connection.
20
+ * Also runs a health check and reconnects if the WebSocket is dead.
21
+ */
22
+ async function getSessionClients() {
23
+ const session = sessionRegistry.getCurrent();
24
+ const client = session?.cdpClient ?? globalCometClient;
25
+ const ai = session?.cometAI ?? globalCometAI;
26
+ // Proactive health check — catches dead WebSockets before tool calls fail
27
+ if (client.isConnected) {
28
+ await client.ensureHealthyConnection();
29
+ }
30
+ return { client, ai };
31
+ }
32
+ async function disconnectCdpClientsAfterTool() {
33
+ const clients = new Set();
34
+ const session = sessionRegistry.getCurrent();
35
+ if (session?.cdpClient)
36
+ clients.add(session.cdpClient);
37
+ clients.add(globalCometClient);
38
+ for (const client of clients) {
39
+ if (!client.isConnected)
40
+ continue;
41
+ try {
42
+ await client.disconnect();
43
+ }
44
+ catch (err) {
45
+ console.warn(`[comet-bridge] CDP post-tool disconnect failed: ${err instanceof Error ? err.message : String(err)}`);
46
+ }
47
+ }
48
+ }
49
+ // Protocol enforcement (Spec 016, FR-006, T009-T012)
50
+ // Tool safety classification (Spec 034 — MCP Tool Parity, session safety)
51
+ // SAFE_OBSERVE: No session required, read-only, zero side effects
52
+ // BROWSING: Requires active session (comet_connect first), may modify browser state
53
+ // LIFECYCLE/ORCHESTRATION: No session required, infrastructure-level
54
+ const SAFE_OBSERVE_TOOLS = new Set([
55
+ "comet_observe", // Read-only: /json/list + extension bridge + agent-registry
56
+ ]);
57
+ const BROWSING_TOOLS = new Set([
58
+ "comet_ask",
59
+ "comet_poll",
60
+ "comet_stop",
61
+ "comet_screenshot",
62
+ "comet_mode",
63
+ "comet_shortcut",
64
+ "comet_read_page",
65
+ "comet_wait_for_idle",
66
+ "comet_interact",
67
+ "comet_navigate",
68
+ "comet_pdf",
69
+ "comet_scrape",
70
+ "comet_network",
71
+ "comet_automate",
72
+ "comet_domain",
73
+ ]);
74
+ const BOUND_ROUTING_TOOLS = new Set([
75
+ "comet_ask",
76
+ "comet_poll",
77
+ "comet_stop",
78
+ "comet_screenshot",
79
+ "comet_mode",
80
+ "comet_shortcut",
81
+ "comet_read_page",
82
+ "comet_wait_for_idle",
83
+ "comet_interact",
84
+ "comet_navigate",
85
+ "comet_task_status",
86
+ "comet_peek",
87
+ "comet_pdf",
88
+ "comet_scrape",
89
+ "comet_network",
90
+ "comet_automate",
91
+ "comet_domain",
92
+ ]);
93
+ // Tools with DESTRUCTIVE side effects — these are flagged in documentation
94
+ // as "use with caution" because they modify browser state beyond the calling agent's scope
95
+ const DESTRUCTIVE_ACTIONS = new Set([
96
+ "comet_stop", // Terminates running agent tasks
97
+ "comet_navigate", // Changes the active tab's URL
98
+ ]);
99
+ const tabGroupWarnedSessions = new Set();
100
+ function requireSession(toolName, args) {
101
+ const config = loadBridgeConfig();
102
+ if (!config.protocolEnforcement.requireConnectBeforeBrowse)
103
+ return null;
104
+ // Safe observe tools never require session
105
+ if (SAFE_OBSERVE_TOOLS.has(toolName))
106
+ return null;
107
+ // comet_tab_groups with action="list" or "list_tabs" is read-only — no session needed
108
+ if (toolName === "comet_tab_groups") {
109
+ const action = args?.action;
110
+ if (action === "list" || action === "list_tabs" || action === "list_archived") {
111
+ return null; // Read-only tab group queries bypass session requirement
112
+ }
113
+ }
114
+ if (!BROWSING_TOOLS.has(toolName))
115
+ return null;
116
+ const session = sessionRegistry.getCurrent();
117
+ if (!session) {
118
+ dispatchAlert({
119
+ type: "PROTOCOL_VIOLATION",
120
+ message: `Tool "${toolName}" called without active session. Call comet_connect first.`,
121
+ context: { toolName },
122
+ });
123
+ // Suggest safe alternatives
124
+ const safeAlternative = toolName === "comet_screenshot" || toolName === "comet_read_page"
125
+ ? `\n\nSafe alternative: Use comet_peek(targetId) to read/screenshot any tab without a session.\nOr use comet_observe(action='snapshot') to see all tabs and groups.`
126
+ : toolName === "comet_tab_groups"
127
+ ? `\n\nSafe alternative: comet_tab_groups(action='list') works without a session for read-only queries.`
128
+ : "";
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: `Protocol error: No active connection. Call comet_connect first.\n\nRequired sequence:\n1. comet_connect (establish CDP session with agent identity)\n2. ${toolName} (your requested tool)\n\nSuggested action: Call comet_connect with agentId and taskThreadId parameters.${safeAlternative}`,
134
+ },
135
+ ],
136
+ isError: true,
137
+ };
138
+ }
139
+ return null;
140
+ }
141
+ async function requireBoundRouting(toolName, args) {
142
+ if (!BOUND_ROUTING_TOOLS.has(toolName))
143
+ return null;
144
+ try {
145
+ const resolved = await resolveBoundSession(sessionRegistry.getCurrent(), args);
146
+ const session = sessionRegistry.getCurrent();
147
+ if (resolved.binding.targetId && session) {
148
+ await session?.cdpClient.connect(resolved.binding.targetId);
149
+ }
150
+ return null;
151
+ }
152
+ catch (err) {
153
+ if (err instanceof BoundSessionError) {
154
+ dispatchAlert({
155
+ type: "PROTOCOL_VIOLATION",
156
+ message: err.message,
157
+ context: { toolName, code: err.code, repairAction: err.repairAction },
158
+ });
159
+ return {
160
+ content: [{ type: "text", text: err.toMcpText(toolName) }],
161
+ isError: true,
162
+ };
163
+ }
164
+ throw err;
165
+ }
166
+ }
167
+ function getTabGroupWarning(toolName) {
168
+ const config = loadBridgeConfig();
169
+ if (!config.protocolEnforcement.warnMissingTabGroup)
170
+ return "";
171
+ const session = sessionRegistry.getCurrent();
172
+ if (!session || session.tabGroupId !== null)
173
+ return "";
174
+ if (tabGroupWarnedSessions.has(session.sessionKey))
175
+ return "";
176
+ tabGroupWarnedSessions.add(session.sessionKey);
177
+ return `\n\n⚠️ Warning: No tab group assigned for this session. For multi-agent safety, create a tab group with comet_tab_groups(action='create', title='${session.taskThreadId}').`;
178
+ }
179
+ function assertAgentRuntimeProfileArg(profile) {
180
+ if (profile === undefined ||
181
+ profile === null ||
182
+ profile === "" ||
183
+ profile === "agent" ||
184
+ profile === "oe") {
185
+ return profile === "agent" ? "oe" : profile;
186
+ }
187
+ throw new Error(`PROFILE_OWNERSHIP_VIOLATION: profile ${String(profile)} is not an agent-owned Comet runtime profile`);
188
+ }
189
+ // Safe CSS selector escaping — prevents injection via user-supplied selectors (Spec 034 review)
190
+ function safeSelector(sel) {
191
+ return JSON.stringify(sel);
192
+ }
193
+ // Validate variable name is a safe JS identifier (Spec 034 review — prevents variable name injection)
194
+ function isValidIdentifier(name) {
195
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
196
+ }
197
+ // prettier-ignore
198
+ // @ts-ignore — .mjs adapter for lifecycle event parity (Spec 078 AC-2).
199
+ // Vendored from ../../scripts/ into ./vendor/ by the prebuild step so the
200
+ // published npm package is self-contained (see package.json "prebuild").
201
+ import { emitLifecycleEvent, createMCPLifecycleEnvelope } from "../vendor/lifecycle-mcp-adapter.mjs";
202
+ import { appendFileSync, readFileSync, writeFileSync, mkdirSync } from "fs";
15
203
  import { homedir } from "os";
16
- import { join } from "path";
17
- const execFileAsync = promisify(execFile);
204
+ import { join, dirname } from "path";
205
+ // Duplicate process detection (Spec 016, FR-010, T005)
206
+ const PID_FILE_PATH = join(homedir(), ".claude", "comet-browser", "comet-mcp.pid");
207
+ function checkDuplicateProcess() {
208
+ try {
209
+ const existingPid = readFileSync(PID_FILE_PATH, "utf-8").trim();
210
+ if (existingPid) {
211
+ const existingPidNumber = parseInt(existingPid, 10);
212
+ if (existingPidNumber === process.pid)
213
+ return;
214
+ // Check if the process is still alive
215
+ let processAlive = false;
216
+ try {
217
+ process.kill(existingPidNumber, 0); // signal 0 = existence check
218
+ processAlive = true;
219
+ }
220
+ catch {
221
+ // Process is dead — stale PID file, safe to overwrite
222
+ }
223
+ if (processAlive) {
224
+ // Process is alive — fail closed so multiple controllers cannot compete.
225
+ dispatchAlert({
226
+ type: "DUPLICATE_PROCESS",
227
+ message: `Another comet-mcp process is running (PID ${existingPidNumber}). Current PID: ${process.pid}. Refusing to start a competing controller.`,
228
+ context: { existingPid: existingPidNumber, currentPid: process.pid },
229
+ });
230
+ throw new Error(`DUPLICATE_PROCESS: comet-mcp PID ${existingPidNumber} already owns ${PID_FILE_PATH}; refusing to start PID ${process.pid}`);
231
+ }
232
+ }
233
+ }
234
+ catch (err) {
235
+ if (err instanceof Error && err.message.startsWith("DUPLICATE_PROCESS:")) {
236
+ throw err;
237
+ }
238
+ // No PID file exists — first run
239
+ }
240
+ // Write our PID
241
+ try {
242
+ mkdirSync(dirname(PID_FILE_PATH), { recursive: true });
243
+ writeFileSync(PID_FILE_PATH, String(process.pid));
244
+ }
245
+ catch {
246
+ // Best effort
247
+ }
248
+ }
249
+ checkDuplicateProcess();
18
250
  // JSONL outbox for lifecycle events → orchestration layer
19
251
  const OUTBOX_PATH = join(homedir(), "equabot", "agent-chat", "outbox-comet.jsonl");
20
252
  const INBOX_PATH = join(homedir(), "equabot", "agent-chat", "inbox-comet.jsonl");
@@ -23,31 +255,138 @@ function appendJsonl(path, obj) {
23
255
  try {
24
256
  appendFileSync(path, JSON.stringify(obj) + "\n");
25
257
  }
26
- catch { /* graceful degradation — file may not exist */ }
258
+ catch (err) {
259
+ console.warn(`[comet-bridge] JSONL write failed to ${path}: ${err instanceof Error ? err.message : err}`);
260
+ }
27
261
  }
28
262
  function readJsonSafe(path) {
29
263
  try {
30
264
  return JSON.parse(readFileSync(path, "utf-8"));
31
265
  }
32
- catch {
266
+ catch (err) {
267
+ if (err?.code !== "ENOENT") {
268
+ console.warn(`[comet-bridge] JSON read/parse failed for ${path}: ${err instanceof Error ? err.message : err}`);
269
+ }
270
+ return null;
271
+ }
272
+ }
273
+ async function attachRunIdToCurrentBinding(runId, bindingIdArg) {
274
+ if (!runId)
33
275
  return null;
276
+ const session = sessionRegistry.getCurrent();
277
+ const bindingId = bindingIdArg ?? session?.codexBinding?.bindingId;
278
+ if (!bindingId)
279
+ return null;
280
+ const binding = await windowBindingStore.addRunId(bindingId, runId);
281
+ if (session?.codexBinding?.bindingId === binding.bindingId) {
282
+ session.codexBinding = binding;
34
283
  }
284
+ return binding;
285
+ }
286
+ async function transitionBindingByRunId(runId, status) {
287
+ if (!runId)
288
+ return null;
289
+ return windowBindingStore.transitionByRunId(runId, status);
35
290
  }
36
291
  const TOOLS = [
37
292
  {
38
293
  name: "comet_connect",
39
- description: "Connect to Comet browser (auto-starts if needed)",
40
- inputSchema: { type: "object", properties: {} },
294
+ description: "Connect to Comet browser (auto-starts if needed). Creates a NEW tab and tab group " +
295
+ "for this agent session never closes existing tabs or interferes with other agents. " +
296
+ "Always positions browser at full-screen bounds on the top display (agents workspace) while keeping browser tabs visible.",
297
+ inputSchema: {
298
+ type: "object",
299
+ properties: {
300
+ taskGoal: {
301
+ type: "string",
302
+ description: "REQUIRED. Describe the purpose of this browser session. Used to name the session and initialize the orchestrator thread.",
303
+ },
304
+ agentId: {
305
+ type: "string",
306
+ description: "Agent identity for tracking (e.g. 'claude-code-main'). Used in tab group name.",
307
+ },
308
+ taskThreadId: {
309
+ type: "string",
310
+ description: "Task thread identifier. Tab group is named after this.",
311
+ },
312
+ codexSessionId: {
313
+ type: "string",
314
+ description: "Codex runtime/session identifier for durable Comet window binding.",
315
+ },
316
+ projectThreadId: {
317
+ type: "string",
318
+ description: "Codex project-thread identifier that owns one active Comet window binding.",
319
+ },
320
+ projectThreadFamily: {
321
+ type: "string",
322
+ description: "Optional worktree/project-thread family for worktree orchestrator scope.",
323
+ },
324
+ worktreePath: {
325
+ type: "string",
326
+ description: "Absolute Codex worktree path used for binding ownership and scope checks.",
327
+ },
328
+ repoSlug: {
329
+ type: "string",
330
+ description: "Repository slug for the Codex session, for example EQUAStart/equa-comet-browser-control.",
331
+ },
332
+ branchName: {
333
+ type: "string",
334
+ description: "Git branch name for the Codex session.",
335
+ },
336
+ codexSessionRole: {
337
+ type: "string",
338
+ enum: ["session_agent", "worktree_orchestrator", "fleet_orchestrator"],
339
+ description: "Binding authorization role for this Codex session.",
340
+ },
341
+ codexSessionKey: {
342
+ type: "string",
343
+ description: "Optional explicit Codex binding session key. Defaults to codexSessionId:projectThreadId.",
344
+ },
345
+ strictCodexIdentity: {
346
+ type: "boolean",
347
+ description: "Fail closed when required Codex identity fields cannot be supplied or derived.",
348
+ },
349
+ profile: {
350
+ type: "string",
351
+ enum: ["agent", "oe"],
352
+ description: "Agent-owned Comet runtime browser profile. Human-owned profiles such as moon are never valid mutation targets.",
353
+ },
354
+ tabGroupColor: {
355
+ type: "string",
356
+ enum: ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"],
357
+ description: "Color for the new tab group (default: blue)",
358
+ },
359
+ url: {
360
+ type: "string",
361
+ description: "Initial URL to open (default: https://www.perplexity.ai/)",
362
+ },
363
+ },
364
+ required: ["taskGoal"],
365
+ },
41
366
  },
42
367
  {
43
368
  name: "comet_ask",
44
- description: "Send a prompt to Comet/Perplexity and wait for the complete response (blocking). Ideal for tasks requiring real browser interaction (login walls, dynamic content, filling forms) or deep research with agentic browsing.",
369
+ description: "Send a prompt to Comet/Perplexity and wait for the complete response (blocking). Ideal for tasks requiring real browser interaction (login walls, dynamic content, filling forms) or deep research with agentic browsing. " +
370
+ "Use sidecar: true to keep the current page visible and ask via the Perplexity Assistant sidebar — best for interacting with the active tab (clicking, form filling, reading page content).",
45
371
  inputSchema: {
46
372
  type: "object",
47
373
  properties: {
48
- prompt: { type: "string", description: "Question or task for Comet - focus on goals and context" },
374
+ prompt: {
375
+ type: "string",
376
+ description: "Question or task for Comet - focus on goals and context",
377
+ },
49
378
  newChat: { type: "boolean", description: "Start a fresh conversation (default: false)" },
50
379
  timeout: { type: "number", description: "Max wait time in ms (default: 15000 = 15s)" },
380
+ sidecar: {
381
+ type: "boolean",
382
+ description: "Use the Perplexity Assistant sidecar instead of navigating to perplexity.ai. " +
383
+ "Uses an already-open assistant sidebar without activating Comet or sending OS keyboard shortcuts. " +
384
+ "Best for tasks that interact with the active tab: clicking buttons, filling forms, reading page content. (default: false)",
385
+ },
386
+ sidecarResultId: {
387
+ type: "string",
388
+ description: "Optional existing sidecar result artifact ID to refresh or correlate.",
389
+ },
51
390
  },
52
391
  required: ["prompt"],
53
392
  },
@@ -55,7 +394,19 @@ const TOOLS = [
55
394
  {
56
395
  name: "comet_poll",
57
396
  description: "Check agent status and progress. Call repeatedly to monitor agentic tasks.",
58
- inputSchema: { type: "object", properties: {} },
397
+ inputSchema: {
398
+ type: "object",
399
+ properties: {
400
+ sidecarResultId: {
401
+ type: "string",
402
+ description: "Return a persisted sidecar result artifact by ID.",
403
+ },
404
+ sidecarContextKey: {
405
+ type: "string",
406
+ description: "Return the latest persisted sidecar result for this binding context.",
407
+ },
408
+ },
409
+ },
59
410
  },
60
411
  {
61
412
  name: "comet_stop",
@@ -93,7 +444,19 @@ const TOOLS = [
93
444
  properties: {
94
445
  action: {
95
446
  type: "string",
96
- enum: ["list", "list_tabs", "create", "update", "move", "ungroup", "delete", "save_group", "restore_group", "archive_group", "list_archived"],
447
+ enum: [
448
+ "list",
449
+ "list_tabs",
450
+ "create",
451
+ "update",
452
+ "move",
453
+ "ungroup",
454
+ "delete",
455
+ "save_group",
456
+ "restore_group",
457
+ "archive_group",
458
+ "list_archived",
459
+ ],
97
460
  description: "The tab group operation to perform",
98
461
  },
99
462
  tabIds: {
@@ -136,16 +499,16 @@ const TOOLS = [
136
499
  },
137
500
  {
138
501
  name: "comet_shortcut",
139
- description: "Trigger a Comet Query Shortcut (e.g. /fact-check, /mailtodo). "
140
- + "These are reusable AI prompts with pre-configured modes and sources. "
141
- + "Type '/' to discover available shortcuts.",
502
+ description: "Trigger a Comet Query Shortcut (e.g. /fact-check, /mailtodo). " +
503
+ "These are reusable AI prompts with pre-configured modes and sources. " +
504
+ "Type '/' to discover available shortcuts.",
142
505
  inputSchema: {
143
506
  type: "object",
144
507
  properties: {
145
508
  shortcut: {
146
509
  type: "string",
147
- description: "Shortcut name (e.g. 'fact-check', 'mailtodo', 'prep-next-meeting'). "
148
- + "Omit the leading slash — it will be added automatically.",
510
+ description: "Shortcut name (e.g. 'fact-check', 'mailtodo', 'prep-next-meeting'). " +
511
+ "Omit the leading slash — it will be added automatically.",
149
512
  },
150
513
  context: {
151
514
  type: "string",
@@ -161,17 +524,17 @@ const TOOLS = [
161
524
  },
162
525
  {
163
526
  name: "comet_read_page",
164
- description: "Extract content from the current page. Returns structured accessibility tree "
165
- + "and/or clean text. Use 'tree' mode for interactive elements (buttons, links, forms), "
166
- + "'text' mode for readable content, or 'both' for full extraction.",
527
+ description: "Extract content from the current page. Returns structured accessibility tree " +
528
+ "and/or clean text. Use 'tree' mode for interactive elements (buttons, links, forms), " +
529
+ "'text' mode for readable content, or 'both' for full extraction.",
167
530
  inputSchema: {
168
531
  type: "object",
169
532
  properties: {
170
533
  mode: {
171
534
  type: "string",
172
535
  enum: ["tree", "text", "both"],
173
- description: "Extraction mode: 'tree' = accessibility tree (roles, names, values), "
174
- + "'text' = clean markdown-like text, 'both' = both formats. Default: 'text'.",
536
+ description: "Extraction mode: 'tree' = accessibility tree (roles, names, values), " +
537
+ "'text' = clean markdown-like text, 'both' = both formats. Default: 'text'.",
175
538
  },
176
539
  maxDepth: {
177
540
  type: "number",
@@ -184,11 +547,105 @@ const TOOLS = [
184
547
  },
185
548
  },
186
549
  },
550
+ {
551
+ name: "comet_interact",
552
+ description: "Directly interact with the current page — click, type, check/uncheck, select, scroll, extract text, or run JavaScript. " +
553
+ "NO Perplexity AI involved. Executes actions via CDP on the active tab. " +
554
+ "Use comet_read_page first to find selectors, then comet_interact to act on them. " +
555
+ "Supports multiple sequential actions in one call.",
556
+ inputSchema: {
557
+ type: "object",
558
+ properties: {
559
+ actions: {
560
+ type: "array",
561
+ description: "Ordered list of actions to execute sequentially on the current page.",
562
+ items: {
563
+ type: "object",
564
+ properties: {
565
+ action: {
566
+ type: "string",
567
+ enum: [
568
+ "click",
569
+ "type",
570
+ "fill",
571
+ "press",
572
+ "check",
573
+ "uncheck",
574
+ "select",
575
+ "scroll",
576
+ "wait",
577
+ "extract",
578
+ "evaluate",
579
+ ],
580
+ description: "click: click element | type: keystroke-by-keystroke | fill: clear + set value | " +
581
+ "press: keyboard key (Enter, Tab, Escape, etc.) | check/uncheck: checkbox toggle | " +
582
+ "select: dropdown option | scroll: scroll page | wait: pause (ms) or wait for selector | " +
583
+ "extract: get element text | evaluate: run arbitrary JS",
584
+ },
585
+ selector: {
586
+ type: "string",
587
+ description: "CSS selector for the target element. Supports standard CSS (e.g. '#id', '.class', " +
588
+ "'button[aria-label=\"Submit\"]', 'tr:nth-child(3) td.amount'). " +
589
+ "Use comet_read_page mode='tree' to discover selectors.",
590
+ },
591
+ value: {
592
+ type: "string",
593
+ description: "Value for type/fill/select actions. For select, use the option value or visible text.",
594
+ },
595
+ key: {
596
+ type: "string",
597
+ description: "Key name for press action (e.g. 'Enter', 'Tab', 'Escape', 'ArrowDown').",
598
+ },
599
+ script: {
600
+ type: "string",
601
+ description: "JavaScript to execute for evaluate action. Return value is captured.",
602
+ },
603
+ direction: {
604
+ type: "string",
605
+ enum: ["up", "down", "left", "right"],
606
+ description: "Scroll direction (default: 'down').",
607
+ },
608
+ amount: {
609
+ type: "number",
610
+ description: "Scroll amount in pixels (default: 500).",
611
+ },
612
+ ms: {
613
+ type: "number",
614
+ description: "Wait duration in milliseconds (for wait action without selector).",
615
+ },
616
+ optional: {
617
+ type: "boolean",
618
+ description: "If true, failure of this action won't abort the remaining actions (default: false).",
619
+ },
620
+ },
621
+ required: ["action"],
622
+ },
623
+ },
624
+ },
625
+ required: ["actions"],
626
+ },
627
+ },
628
+ {
629
+ name: "comet_navigate",
630
+ description: "Navigate the current tab to a URL. Does NOT open Perplexity — navigates the active tab directly. " +
631
+ "Use this to go to QBO, Mercury, Google Drive, or any URL before using comet_interact or comet_read_page.",
632
+ inputSchema: {
633
+ type: "object",
634
+ properties: {
635
+ url: { type: "string", description: "URL to navigate to." },
636
+ waitForIdle: {
637
+ type: "boolean",
638
+ description: "Wait for network idle after navigation (default: true).",
639
+ },
640
+ },
641
+ required: ["url"],
642
+ },
643
+ },
187
644
  {
188
645
  name: "comet_wait_for_idle",
189
- description: "Wait for the current page's network activity to settle (no pending requests "
190
- + "for a specified duration). Use after navigation or triggering dynamic content loads. "
191
- + "Returns a summary of network activity observed.",
646
+ description: "Wait for the current page's network activity to settle (no pending requests " +
647
+ "for a specified duration). Use after navigation or triggering dynamic content loads. " +
648
+ "Returns a summary of network activity observed.",
192
649
  inputSchema: {
193
650
  type: "object",
194
651
  properties: {
@@ -213,6 +670,7 @@ const TOOLS = [
213
670
  runId: { type: "string", description: "Unique run identifier" },
214
671
  taskThreadId: { type: "string", description: "Task-thread identifier" },
215
672
  agentId: { type: "string", description: "Agent identity (optional for single-agent)" },
673
+ bindingId: { type: "string", description: "Codex window binding to attach this run to" },
216
674
  route: { type: "string", enum: ["mcp", "cli", "http"], description: "Execution channel" },
217
675
  deferred: { type: "boolean", description: "Start in pending state (default: false)" },
218
676
  },
@@ -261,12 +719,27 @@ const TOOLS = [
261
719
  name: "comet_task_status",
262
720
  description: "Get unified status for a Comet browser task. Combines session-manifest.json state, " +
263
721
  "extension ring buffer events, and lifecycle metadata into one response. " +
264
- "Query by groupId or threadId.",
722
+ "Query by bindingId, sessionKey, projectThreadId, runId, groupId, or threadId.",
265
723
  inputSchema: {
266
724
  type: "object",
267
725
  properties: {
726
+ bindingId: { type: "string", description: "Durable Codex window binding ID to check" },
727
+ sessionKey: { type: "string", description: "Codex session key to check" },
728
+ projectThreadId: { type: "string", description: "Codex project thread ID to check" },
729
+ runId: { type: "string", description: "Lifecycle run ID to check" },
730
+ sidecarResultId: {
731
+ type: "string",
732
+ description: "Persisted sidecar result artifact ID to include",
733
+ },
734
+ sidecarContextKey: {
735
+ type: "string",
736
+ description: "Sidecar binding context key to include latest result for",
737
+ },
268
738
  groupId: { type: "number", description: "Tab group ID to check" },
269
- threadId: { type: "string", description: "Thread ID to check (returns all sessions for thread)" },
739
+ threadId: {
740
+ type: "string",
741
+ description: "Legacy task-thread ID to check (returns sessions in this binding scope)",
742
+ },
270
743
  },
271
744
  },
272
745
  },
@@ -296,13 +769,268 @@ const TOOLS = [
296
769
  description: "Task IDs this task depends on",
297
770
  },
298
771
  agentId: { type: "string", description: "Agent identity for tracking" },
772
+ bindingId: { type: "string", description: "Existing binding to reuse for dispatch" },
773
+ windowId: { type: "number", description: "Explicit Comet window ID for direct binding" },
774
+ tabGroupId: { type: "number", description: "Explicit tab group ID for direct binding" },
775
+ groupId: { type: "number", description: "Legacy alias for tabGroupId" },
776
+ targetId: { type: "string", description: "Explicit CDP target ID for direct binding" },
777
+ codexSessionId: { type: "string", description: "Codex caller session ID" },
778
+ projectThreadId: { type: "string", description: "Codex project thread ID" },
779
+ worktreePath: { type: "string", description: "Codex worktree path" },
780
+ repoSlug: { type: "string", description: "Repository slug" },
781
+ branchName: { type: "string", description: "Branch name" },
782
+ sessionKey: { type: "string", description: "Codex session key" },
783
+ codexSessionRole: {
784
+ type: "string",
785
+ enum: ["session_agent", "worktree_orchestrator", "fleet_orchestrator"],
786
+ description: "Codex binding role",
787
+ },
299
788
  },
300
789
  required: ["threadId", "instruction"],
301
790
  },
302
791
  },
792
+ {
793
+ name: "comet_observe",
794
+ description: "Passively observe browser state without disrupting active agents. Returns tab groups, " +
795
+ "agent ownership, activity status, and optionally per-tab thumbnails. " +
796
+ "Actions: snapshot (full browser state), status (compact ownership table), " +
797
+ "detail (drill into one group), health (lightweight check).",
798
+ inputSchema: {
799
+ type: "object",
800
+ properties: {
801
+ action: {
802
+ type: "string",
803
+ enum: ["snapshot", "status", "detail", "health"],
804
+ description: "snapshot: full browser state (all tabs, groups, agents). " +
805
+ "status: tab group ownership and activity summary. " +
806
+ "detail: deep info on one specific tab group. " +
807
+ "health: lightweight browser health check.",
808
+ },
809
+ group: {
810
+ type: "string",
811
+ description: "Tab group name — required for 'detail' action, optional filter for 'snapshot' and 'status'.",
812
+ },
813
+ agentId: {
814
+ type: "string",
815
+ description: "Filter results to a specific agent ID. Optional for 'snapshot' and 'status'.",
816
+ },
817
+ urlPattern: {
818
+ type: "string",
819
+ description: "Filter tabs by URL pattern (substring match). Optional for 'snapshot' and 'detail'.",
820
+ },
821
+ thumbnails: {
822
+ type: "boolean",
823
+ description: "Include per-tab viewport screenshots. Default false. Adds ~100-200ms per tab.",
824
+ },
825
+ },
826
+ required: ["action"],
827
+ },
828
+ },
829
+ // ── Safe observation tool (no session required) ──
830
+ {
831
+ name: "comet_peek",
832
+ description: "Binding-scoped read-only observation of the caller's own bound tab. " +
833
+ "SAFE: Does not create tabs, close tabs, navigate, or modify any state. " +
834
+ "Rejects target IDs outside the caller's authorized Codex window binding.",
835
+ inputSchema: {
836
+ type: "object",
837
+ properties: {
838
+ targetId: {
839
+ type: "string",
840
+ description: "CDP target ID of the tab to peek at. Get this from comet_observe(action='snapshot').",
841
+ },
842
+ action: {
843
+ type: "string",
844
+ enum: ["screenshot", "read", "info"],
845
+ description: "screenshot: capture PNG of the tab. read: extract page text/accessibility tree. info: return URL, title, and load state.",
846
+ },
847
+ },
848
+ required: ["targetId", "action"],
849
+ },
850
+ },
851
+ // ── Parity tools (close gaps with CLI scripts) ──
852
+ {
853
+ name: "comet_pdf",
854
+ description: "Generate a PDF from the current page or a URL. Navigates if a URL is provided, " +
855
+ "then uses CDP Page.printToPDF. Returns the file path of the saved PDF.",
856
+ inputSchema: {
857
+ type: "object",
858
+ properties: {
859
+ url: {
860
+ type: "string",
861
+ description: "URL to navigate to before printing. If omitted, prints the current page.",
862
+ },
863
+ name: {
864
+ type: "string",
865
+ description: "Output filename (without .pdf extension). Defaults to page title or timestamp.",
866
+ },
867
+ format: {
868
+ type: "string",
869
+ enum: ["Letter", "Legal", "A4", "A3", "Tabloid"],
870
+ description: "Paper size. Default: Letter.",
871
+ },
872
+ landscape: { type: "boolean", description: "Landscape orientation. Default: false." },
873
+ margin: { type: "number", description: "Margin in inches. Default: 0.5." },
874
+ scale: { type: "number", description: "Scale factor (0.1–2.0). Default: 1." },
875
+ printBackground: {
876
+ type: "boolean",
877
+ description: "Include background graphics. Default: true.",
878
+ },
879
+ hideSelectors: {
880
+ type: "string",
881
+ description: "Comma-separated CSS selectors to hide before printing.",
882
+ },
883
+ },
884
+ },
885
+ },
886
+ {
887
+ name: "comet_scrape",
888
+ description: "Extract structured data from the current page or a URL. Supports CSS selector extraction, " +
889
+ "table parsing, JSON-LD extraction, list items, and attribute extraction. " +
890
+ "Optionally auto-scrolls to load lazy content.",
891
+ inputSchema: {
892
+ type: "object",
893
+ properties: {
894
+ url: {
895
+ type: "string",
896
+ description: "URL to navigate to before scraping. If omitted, scrapes the current page.",
897
+ },
898
+ selector: { type: "string", description: "CSS selector to extract content from." },
899
+ mode: {
900
+ type: "string",
901
+ enum: ["text", "table", "json-ld", "list", "attr", "multi"],
902
+ description: "Extraction mode. text: innerText (default). table: parse <table> as JSON rows. json-ld: extract JSON-LD. list: extract <li> items. attr: extract a specific attribute. multi: all matching elements.",
903
+ },
904
+ attr: { type: "string", description: "Attribute name to extract (when mode is 'attr')." },
905
+ scroll: {
906
+ type: "boolean",
907
+ description: "Auto-scroll page to load lazy content before scraping. Default: false.",
908
+ },
909
+ waitFor: { type: "string", description: "CSS selector to wait for before scraping." },
910
+ },
911
+ },
912
+ },
913
+ {
914
+ name: "comet_network",
915
+ description: "Capture and analyze network traffic on the current page. Enables CDP Network domain, " +
916
+ "records requests/responses, and optionally intercepts or blocks requests. " +
917
+ "Actions: capture (record traffic), block (block URL patterns), intercept (mock responses).",
918
+ inputSchema: {
919
+ type: "object",
920
+ properties: {
921
+ action: {
922
+ type: "string",
923
+ enum: ["capture", "block", "intercept"],
924
+ description: "capture: record traffic for a duration. block: block URL patterns. intercept: mock responses for URL patterns.",
925
+ },
926
+ url: {
927
+ type: "string",
928
+ description: "URL to navigate to before capturing. If omitted, captures on the current page.",
929
+ },
930
+ duration: { type: "number", description: "Capture duration in ms. Default: 10000." },
931
+ filter: { type: "string", description: "URL substring to filter captured requests." },
932
+ resourceType: {
933
+ type: "string",
934
+ description: "Filter by resource type: XHR, Fetch, Document, Stylesheet, Image, Script, Font.",
935
+ },
936
+ pattern: {
937
+ type: "string",
938
+ description: "URL pattern to block or intercept (substring match).",
939
+ },
940
+ mockResponse: {
941
+ type: "string",
942
+ description: "JSON string to return for intercepted requests.",
943
+ },
944
+ mockStatus: {
945
+ type: "number",
946
+ description: "HTTP status code for mock responses. Default: 200.",
947
+ },
948
+ includeHeaders: {
949
+ type: "boolean",
950
+ description: "Include request/response headers in capture output. Default: false.",
951
+ },
952
+ },
953
+ required: ["action"],
954
+ },
955
+ },
956
+ {
957
+ name: "comet_automate",
958
+ description: "Execute a multi-step browser workflow. Each step is a tool+args object. Supports: " +
959
+ "navigate, click, fill, type, press, select, wait, screenshot, extract (with variable storage), " +
960
+ "assert (verify content), evaluate (run JS), and conditional logic (if/loop). " +
961
+ "Steps execute sequentially; aborts on first failure unless step has optional:true.",
962
+ inputSchema: {
963
+ type: "object",
964
+ properties: {
965
+ steps: {
966
+ type: "array",
967
+ items: {
968
+ type: "object",
969
+ properties: {
970
+ tool: {
971
+ type: "string",
972
+ description: "Step type: navigate, click, fill, type, press, select, wait, screenshot, extract, assert, evaluate, if, loop",
973
+ },
974
+ selector: { type: "string" },
975
+ url: { type: "string" },
976
+ value: { type: "string" },
977
+ variable: {
978
+ type: "string",
979
+ description: "Store extracted value in this variable name.",
980
+ },
981
+ contains: { type: "string", description: "Assert text contains this string." },
982
+ expression: { type: "string", description: "JS expression to evaluate." },
983
+ condition: { type: "string", description: "JS condition for if/loop." },
984
+ then: { type: "array", description: "Steps to run if condition is true." },
985
+ items: {
986
+ type: "string",
987
+ description: "Variable name containing array to loop over.",
988
+ },
989
+ each: { type: "array", description: "Steps to run for each item." },
990
+ optional: {
991
+ type: "boolean",
992
+ description: "If true, failure doesn't abort the workflow.",
993
+ },
994
+ name: { type: "string", description: "Screenshot name." },
995
+ },
996
+ required: ["tool"],
997
+ },
998
+ description: "Array of workflow steps to execute sequentially.",
999
+ },
1000
+ verbose: { type: "boolean", description: "Show detailed output per step. Default: false." },
1001
+ },
1002
+ required: ["steps"],
1003
+ },
1004
+ },
1005
+ {
1006
+ name: "comet_domain",
1007
+ description: "Route to a domain-specific playbook for authenticated sites. Handles login checks, " +
1008
+ "navigation patterns, and domain-aware interaction for: QBO (QuickBooks Online), " +
1009
+ "Mercury (banking), GitHub, Google (Drive/Sheets/Docs), and SALT (tax). " +
1010
+ "Checks auth status and provides domain-specific guidance.",
1011
+ inputSchema: {
1012
+ type: "object",
1013
+ properties: {
1014
+ domain: {
1015
+ type: "string",
1016
+ enum: ["qbo", "mercury", "github", "google", "salt"],
1017
+ description: "Target domain playbook.",
1018
+ },
1019
+ action: {
1020
+ type: "string",
1021
+ enum: ["check-auth", "navigate", "status"],
1022
+ description: "check-auth: verify logged in. navigate: go to domain home. status: domain session status.",
1023
+ },
1024
+ path: {
1025
+ type: "string",
1026
+ description: "Specific path within the domain (e.g., '/reports' for QBO).",
1027
+ },
1028
+ },
1029
+ required: ["domain"],
1030
+ },
1031
+ },
303
1032
  ];
304
- const CC_LIFECYCLE_URL = process.env.COMET_CC_LIFECYCLE_URL
305
- || "http://localhost:3001/command-center/api/comet/lifecycle";
1033
+ const CC_LIFECYCLE_URL = process.env.COMET_CC_LIFECYCLE_URL || "http://localhost:3001/command-center/api/comet/lifecycle";
306
1034
  async function callLifecycleEndpoint(payload) {
307
1035
  const resp = await fetch(CC_LIFECYCLE_URL, {
308
1036
  method: "POST",
@@ -320,92 +1048,261 @@ const server = new Server({ name: "comet-bridge", version: "2.4.0" }, { capabili
320
1048
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
321
1049
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
322
1050
  const { name, arguments: args } = request.params;
323
- try {
324
- switch (name) {
325
- case "comet_connect": {
326
- // Auto-start Comet with debug port (will restart if running without it)
327
- const startResult = await cometClient.startComet(9222);
328
- // Get all tabs and clean up - close all except one
329
- const targets = await cometClient.listTargets();
330
- const pageTabs = targets.filter(t => t.type === 'page');
331
- // Close extra tabs, keep only one
332
- if (pageTabs.length > 1) {
333
- for (let i = 1; i < pageTabs.length; i++) {
334
- try {
335
- await cometClient.closeTab(pageTabs[i].id);
1051
+ // Resolve session-scoped clients for isolation (Spec 034)
1052
+ // Health check runs here — reconnects dead WebSockets before any tool executes
1053
+ const { client: cometClient, ai: cometAI } = await getSessionClients();
1054
+ // Execute handler, then append any queued alerts to the response (Spec 016, FR-011)
1055
+ const result = await (async () => {
1056
+ // Protocol enforcement check prerequisites before browsing tools (Spec 016, FR-006, T009-T010)
1057
+ const protocolError = requireSession(name, args);
1058
+ if (protocolError)
1059
+ return protocolError;
1060
+ const bindingError = await requireBoundRouting(name, args);
1061
+ if (bindingError)
1062
+ return bindingError;
1063
+ try {
1064
+ switch (name) {
1065
+ case "comet_connect": {
1066
+ // Spec 037: Require taskGoal for session startup gate
1067
+ const taskGoal = args?.taskGoal;
1068
+ if (!taskGoal || taskGoal.trim().length === 0) {
1069
+ return {
1070
+ content: [
1071
+ {
1072
+ type: "text",
1073
+ text: 'Error: taskGoal is required. Describe what this browser session is for.\n\nExample: comet_connect({ taskGoal: "Research Google Drive documents for OTB accounting project" })',
1074
+ },
1075
+ ],
1076
+ isError: true,
1077
+ };
1078
+ }
1079
+ // Spec 034: Use SessionRegistry for isolated, safe connections.
1080
+ // NEVER closes existing tabs. NEVER kills the browser.
1081
+ const session = await sessionRegistry.register({
1082
+ agentId: args?.agentId || undefined,
1083
+ taskThreadId: args?.taskThreadId || undefined,
1084
+ url: args?.url || undefined,
1085
+ tabGroupColor: args?.tabGroupColor || undefined,
1086
+ port: 9222,
1087
+ taskGoal: taskGoal.trim(),
1088
+ codexSessionId: args?.codexSessionId || undefined,
1089
+ projectThreadId: args?.projectThreadId || undefined,
1090
+ projectThreadFamily: args?.projectThreadFamily || undefined,
1091
+ worktreePath: args?.worktreePath || undefined,
1092
+ repoSlug: args?.repoSlug || undefined,
1093
+ branchName: args?.branchName || undefined,
1094
+ codexSessionRole: args?.codexSessionRole || undefined,
1095
+ codexSessionKey: args?.codexSessionKey || undefined,
1096
+ strictCodexIdentity: args?.strictCodexIdentity || undefined,
1097
+ profile: assertAgentRuntimeProfileArg(args?.profile),
1098
+ });
1099
+ const groupInfo = session.tabGroupId !== null
1100
+ ? `tab group: "${session.taskThreadId.slice(0, 50)}" (id: ${session.tabGroupId}, color: ${session.tabGroupColor})`
1101
+ : "tab group: skipped (extension not available)";
1102
+ // Spec 037: Send task goal as first prompt to create orchestrator thread
1103
+ let orchestratorUrl;
1104
+ try {
1105
+ const orchestratorPrompt = `Take control of the browser. ${taskGoal.trim()}`;
1106
+ await session.cometAI.sendPrompt(orchestratorPrompt);
1107
+ // Poll for URL change (max 10 attempts, 1s apart) instead of hardcoded delay
1108
+ for (let attempt = 0; attempt < 10; attempt++) {
1109
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1110
+ const evalResult = await session.cdpClient.safeEvaluate("window.location.href");
1111
+ const currentUrl = evalResult?.result?.value;
1112
+ if (currentUrl &&
1113
+ !currentUrl.includes("/b/home") &&
1114
+ currentUrl.includes("perplexity.ai/search/")) {
1115
+ orchestratorUrl = currentUrl;
1116
+ session.orchestratorUrl = orchestratorUrl;
1117
+ sessionRegistry.updateSessionUrl(session.sessionKey, orchestratorUrl);
1118
+ break;
1119
+ }
336
1120
  }
337
- catch { /* ignore */ }
338
1121
  }
1122
+ catch (goalErr) {
1123
+ // Non-fatal: session is usable without orchestrator URL
1124
+ console.warn(`[comet-bridge] Orchestrator thread creation failed: ${goalErr instanceof Error ? goalErr.message : goalErr}`);
1125
+ }
1126
+ // Log to orchestration outbox
1127
+ appendJsonl(OUTBOX_PATH, {
1128
+ ts: Math.floor(Date.now() / 1000),
1129
+ from: session.agentId,
1130
+ to: "orchestration",
1131
+ type: "update",
1132
+ task: session.taskThreadId,
1133
+ msg: `Connected: agent=${session.agentId}, ${groupInfo}, goal="${session.sessionName}"`,
1134
+ });
1135
+ const summary = [
1136
+ `Agent: ${session.agentId}`,
1137
+ `Session: ${session.sessionName || session.taskThreadId}`,
1138
+ `Goal: ${taskGoal.trim()}`,
1139
+ `Thread: ${session.taskThreadId}`,
1140
+ orchestratorUrl ? `Orchestrator: ${orchestratorUrl}` : "Orchestrator: initializing...",
1141
+ groupInfo,
1142
+ `Display: top (agents workspace, full-screen bounds)`,
1143
+ ].join("\n");
1144
+ return { content: [{ type: "text", text: summary }] };
339
1145
  }
340
- // Get fresh tab list
341
- const freshTargets = await cometClient.listTargets();
342
- const anyPage = freshTargets.find(t => t.type === 'page');
343
- if (anyPage) {
344
- await cometClient.connect(anyPage.id);
345
- // Always navigate to Perplexity home for clean state
346
- await cometClient.navigate("https://www.perplexity.ai/", true);
347
- await new Promise(resolve => setTimeout(resolve, 1500));
348
- return { content: [{ type: "text", text: `${startResult}\nConnected to Perplexity (cleaned ${pageTabs.length - 1} old tabs)` }] };
349
- }
350
- // No tabs at all - create a new one
351
- const newTab = await cometClient.newTab("https://www.perplexity.ai/");
352
- await new Promise(resolve => setTimeout(resolve, 2000)); // Wait for page load
353
- await cometClient.connect(newTab.id);
354
- return { content: [{ type: "text", text: `${startResult}\nCreated new tab and navigated to Perplexity` }] };
355
- }
356
- case "comet_ask": {
357
- let prompt = args?.prompt;
358
- const timeout = args?.timeout || 15000; // Default 15s, use poll for longer tasks
359
- const newChat = args?.newChat || false;
360
- // Validate prompt
361
- if (!prompt || prompt.trim().length === 0) {
362
- return { content: [{ type: "text", text: "Error: prompt cannot be empty" }] };
363
- }
364
- // Normalize prompt - convert markdown/bullets to natural text
365
- prompt = prompt
366
- .replace(/^[-*•]\s*/gm, '') // Remove bullet points
367
- .replace(/\n+/g, ' ') // Collapse newlines to spaces
368
- .replace(/\s+/g, ' ') // Collapse multiple spaces
369
- .trim();
370
- // For newChat: full reset (same as comet_connect) to handle post-agentic state
371
- if (newChat) {
372
- // Clean up extra tabs (fixes CDP state after agentic browsing)
373
- const targets = await cometClient.listTargets();
374
- const pageTabs = targets.filter(t => t.type === 'page');
375
- if (pageTabs.length > 1) {
376
- for (let i = 1; i < pageTabs.length; i++) {
1146
+ case "comet_ask": {
1147
+ let prompt = args?.prompt;
1148
+ const timeout = args?.timeout || 15000; // Default 15s, use poll for longer tasks
1149
+ const newChat = args?.newChat || false;
1150
+ // Validate prompt
1151
+ if (!prompt || prompt.trim().length === 0) {
1152
+ return { content: [{ type: "text", text: "Error: prompt cannot be empty" }] };
1153
+ }
1154
+ // Normalize prompt - convert markdown/bullets to natural text
1155
+ prompt = prompt
1156
+ .replace(/^[-*•]\s*/gm, "") // Remove bullet points
1157
+ .replace(/\n+/g, " ") // Collapse newlines to spaces
1158
+ .replace(/\s+/g, " ") // Collapse multiple spaces
1159
+ .trim();
1160
+ const useSidecar = args?.sidecar || false;
1161
+ // ── Sidecar mode: keep current page, open assistant sidebar, ask there ──
1162
+ if (useSidecar) {
1163
+ const boundResolution = await resolveBoundSession(sessionRegistry.getCurrent(), args);
1164
+ // Capture current page URL for context
1165
+ let currentPageUrl = "unknown";
1166
+ try {
1167
+ const urlRes = await cometClient.evaluate("window.location.href");
1168
+ currentPageUrl = urlRes.result.value || "unknown";
1169
+ }
1170
+ catch {
1171
+ /* continue */
1172
+ }
1173
+ const sidecarArtifact = await sidecarArtifactStore.create({
1174
+ sidecarContextKey: boundResolution.binding.sidecarContextKey,
1175
+ bindingId: boundResolution.binding.bindingId,
1176
+ sessionKey: boundResolution.binding.sessionKey,
1177
+ projectThreadId: boundResolution.binding.projectThreadId,
1178
+ windowId: boundResolution.binding.windowId,
1179
+ targetId: boundResolution.binding.targetId,
1180
+ prompt,
1181
+ currentPageUrl,
1182
+ });
1183
+ // Attach to an existing sidecar and get a CDP client connected to it.
1184
+ const sidecarClient = await cometClient.connectToSidecar({
1185
+ windowId: boundResolution.binding.windowId,
1186
+ targetId: boundResolution.binding.targetId ?? undefined,
1187
+ });
1188
+ if (!sidecarClient) {
1189
+ await sidecarArtifactStore.update(sidecarArtifact.sidecarResultId, {
1190
+ status: "failed",
1191
+ error: "Perplexity sidecar is not already open. Focus-safe automation will not activate Comet or send Option+A.",
1192
+ });
1193
+ return {
1194
+ content: [
1195
+ {
1196
+ type: "text",
1197
+ text: "Error: Perplexity sidecar is not already open. Focus-safe automation will not activate Comet or send Option+A through the operator keyboard.",
1198
+ },
1199
+ ],
1200
+ isError: true,
1201
+ };
1202
+ }
1203
+ try {
1204
+ // Create a CometAI instance bound to the sidecar's CDP client
1205
+ const sidecarAI = new CometAI(sidecarClient);
1206
+ // Send prompt to the sidecar input
1207
+ await sidecarAI.sendPrompt(prompt);
1208
+ await sidecarArtifactStore.update(sidecarArtifact.sidecarResultId, {
1209
+ status: "working",
1210
+ });
1211
+ // Poll sidecar for response completion
1212
+ const startTime = Date.now();
1213
+ const stepsCollected = [];
1214
+ while (Date.now() - startTime < timeout) {
1215
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1216
+ const status = await sidecarAI.getAgentStatus();
1217
+ for (const step of status.steps) {
1218
+ if (!stepsCollected.includes(step)) {
1219
+ stepsCollected.push(step);
1220
+ }
1221
+ }
1222
+ if (status.status === "completed" && status.response) {
1223
+ await sidecarArtifactStore.update(sidecarArtifact.sidecarResultId, {
1224
+ status: "completed",
1225
+ response: status.response,
1226
+ steps: stepsCollected,
1227
+ currentStep: status.currentStep,
1228
+ });
1229
+ return {
1230
+ content: [
1231
+ {
1232
+ type: "text",
1233
+ text: [
1234
+ `[sidecar → ${currentPageUrl}]`,
1235
+ `sidecarContextKey: ${boundResolution.binding.sidecarContextKey}`,
1236
+ `sidecarResultId: ${sidecarArtifact.sidecarResultId}`,
1237
+ "",
1238
+ status.response,
1239
+ ].join("\n"),
1240
+ },
1241
+ ],
1242
+ };
1243
+ }
1244
+ await sidecarArtifactStore.update(sidecarArtifact.sidecarResultId, {
1245
+ status: "working",
1246
+ steps: stepsCollected,
1247
+ currentStep: status.currentStep,
1248
+ });
1249
+ }
1250
+ // Timed out — return in-progress status
1251
+ const finalStatus = await sidecarAI.getAgentStatus();
1252
+ const timedOutArtifact = await sidecarArtifactStore.update(sidecarArtifact.sidecarResultId, {
1253
+ status: "timed_out",
1254
+ response: finalStatus.response,
1255
+ steps: stepsCollected,
1256
+ currentStep: finalStatus.currentStep,
1257
+ });
1258
+ let msg = `Sidecar task in progress on ${currentPageUrl}\n`;
1259
+ msg += `sidecarContextKey: ${timedOutArtifact.sidecarContextKey}\n`;
1260
+ msg += `sidecarResultId: ${timedOutArtifact.sidecarResultId}\n`;
1261
+ msg += `Status: ${finalStatus.status.toUpperCase()}\n`;
1262
+ if (finalStatus.currentStep)
1263
+ msg += `Current: ${finalStatus.currentStep}\n`;
1264
+ if (stepsCollected.length > 0) {
1265
+ msg += `\nSteps:\n${stepsCollected.map((s) => ` • ${s}`).join("\n")}\n`;
1266
+ }
1267
+ msg += `\nUse comet_poll with sidecarResultId to check progress.`;
1268
+ return { content: [{ type: "text", text: msg }] };
1269
+ }
1270
+ catch (err) {
1271
+ await sidecarArtifactStore.update(sidecarArtifact.sidecarResultId, {
1272
+ status: "failed",
1273
+ error: err instanceof Error ? err.message : String(err),
1274
+ });
1275
+ throw err;
1276
+ }
1277
+ finally {
377
1278
  try {
378
- await cometClient.closeTab(pageTabs[i].id);
1279
+ await sidecarClient.disconnect();
1280
+ }
1281
+ catch {
1282
+ /* already disconnected */
379
1283
  }
380
- catch { /* ignore */ }
381
1284
  }
382
1285
  }
383
- // Fresh connect to remaining tab
384
- const freshTargets = await cometClient.listTargets();
385
- const mainTab = freshTargets.find(t => t.type === 'page');
386
- if (mainTab) {
387
- await cometClient.connect(mainTab.id);
388
- }
389
- // Navigate to Perplexity home
390
- await cometClient.navigate("https://www.perplexity.ai/", true);
391
- await new Promise(resolve => setTimeout(resolve, 1500));
392
- }
393
- else {
394
- // Not newChat - just ensure we're on Perplexity
395
- const tabs = await cometClient.listTabsCategorized();
396
- if (tabs.main) {
397
- await cometClient.connect(tabs.main.id);
398
- }
399
- const urlResult = await cometClient.evaluate('window.location.href');
400
- const currentUrl = urlResult.result.value;
401
- const isOnPerplexity = currentUrl?.includes('perplexity.ai');
402
- if (!isOnPerplexity) {
1286
+ // ── Standard mode: navigate to Perplexity and ask there ──
1287
+ // For newChat: navigate the CURRENT connected tab to Perplexity home
1288
+ // NEVER close other tabs other agents may be working in them
1289
+ if (newChat) {
1290
+ // Navigate the currently connected tab to Perplexity home
403
1291
  await cometClient.navigate("https://www.perplexity.ai/", true);
404
- await new Promise(resolve => setTimeout(resolve, 2000));
1292
+ await new Promise((resolve) => setTimeout(resolve, 1500));
405
1293
  }
406
- }
407
- // Capture old response state BEFORE sending prompt (for follow-up detection)
408
- const oldStateResult = await cometClient.evaluate(`
1294
+ else {
1295
+ // Not newChat - stay on the bound tab and only navigate that tab if needed.
1296
+ const urlResult = await cometClient.evaluate("window.location.href");
1297
+ const currentUrl = urlResult.result.value;
1298
+ const isOnPerplexity = currentUrl?.includes("perplexity.ai");
1299
+ if (!isOnPerplexity) {
1300
+ await cometClient.navigate("https://www.perplexity.ai/", true);
1301
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1302
+ }
1303
+ }
1304
+ // Capture old response state BEFORE sending prompt (for follow-up detection)
1305
+ const oldStateResult = await cometClient.evaluate(`
409
1306
  (() => {
410
1307
  const proseEls = document.querySelectorAll('[class*="prose"]');
411
1308
  const lastProse = proseEls[proseEls.length - 1];
@@ -415,17 +1312,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
415
1312
  };
416
1313
  })()
417
1314
  `);
418
- const oldState = oldStateResult.result.value;
419
- // Send the prompt
420
- await cometAI.sendPrompt(prompt);
421
- // Wait for completion
422
- const startTime = Date.now();
423
- const stepsCollected = [];
424
- let sawNewResponse = false;
425
- while (Date.now() - startTime < timeout) {
426
- await new Promise(resolve => setTimeout(resolve, 2000)); // Poll every 2s
427
- // Check if we have a NEW response (more prose elements or different text)
428
- const currentStateResult = await cometClient.evaluate(`
1315
+ const oldState = oldStateResult.result.value;
1316
+ // Send the prompt
1317
+ await cometAI.sendPrompt(prompt);
1318
+ // Wait for completion
1319
+ const startTime = Date.now();
1320
+ const stepsCollected = [];
1321
+ let sawNewResponse = false;
1322
+ while (Date.now() - startTime < timeout) {
1323
+ await new Promise((resolve) => setTimeout(resolve, 2000)); // Poll every 2s
1324
+ // Check if we have a NEW response (more prose elements or different text)
1325
+ const currentStateResult = await cometClient.evaluate(`
429
1326
  (() => {
430
1327
  const proseEls = document.querySelectorAll('[class*="prose"]');
431
1328
  const lastProse = proseEls[proseEls.length - 1];
@@ -435,84 +1332,215 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
435
1332
  };
436
1333
  })()
437
1334
  `);
438
- const currentState = currentStateResult.result.value;
439
- // Detect new response
440
- if (!sawNewResponse) {
441
- if (currentState.count > oldState.count ||
442
- (currentState.lastText && currentState.lastText !== oldState.lastText)) {
443
- sawNewResponse = true;
1335
+ const currentState = currentStateResult.result.value;
1336
+ // Detect new response
1337
+ if (!sawNewResponse) {
1338
+ if (currentState.count > oldState.count ||
1339
+ (currentState.lastText && currentState.lastText !== oldState.lastText)) {
1340
+ sawNewResponse = true;
1341
+ }
444
1342
  }
445
- }
446
- const status = await cometAI.getAgentStatus();
447
- // Collect steps
448
- for (const step of status.steps) {
449
- if (!stepsCollected.includes(step)) {
450
- stepsCollected.push(step);
1343
+ const status = await cometAI.getAgentStatus();
1344
+ // Failure detection heuristics (Spec 016, FR-002, T017)
1345
+ if (await cometAI.detectVoiceMode()) {
1346
+ dispatchAlert({
1347
+ type: "PERPLEXITY_VOICE_MODE",
1348
+ message: "Perplexity entered voice/listening mode instead of processing the text prompt.",
1349
+ consumerId: sessionRegistry.getCurrent()?.agentId ?? null,
1350
+ sessionKey: sessionRegistry.getCurrent()?.sessionKey ?? null,
1351
+ });
1352
+ return {
1353
+ content: [
1354
+ {
1355
+ type: "text",
1356
+ text: "Error: Perplexity entered voice/listening mode instead of processing your prompt.\n\nSuggested action: Retry with newChat: true to get a fresh Perplexity session.",
1357
+ },
1358
+ ],
1359
+ isError: true,
1360
+ };
1361
+ }
1362
+ if (status.status === "idle" && (await cometAI.detectIdleNoNavigation())) {
1363
+ dispatchAlert({
1364
+ type: "PERPLEXITY_IDLE_NO_NAV",
1365
+ message: "Task went IDLE without navigating — Perplexity did not process the request.",
1366
+ consumerId: sessionRegistry.getCurrent()?.agentId ?? null,
1367
+ sessionKey: sessionRegistry.getCurrent()?.sessionKey ?? null,
1368
+ });
1369
+ return {
1370
+ content: [
1371
+ {
1372
+ type: "text",
1373
+ text: "Error: Task went IDLE without navigating. Perplexity did not process the request.\n\nSuggested action: Retry the task. If persistent, use comet_stop then retry with newChat: true.",
1374
+ },
1375
+ ],
1376
+ isError: true,
1377
+ };
1378
+ }
1379
+ // Collect steps
1380
+ for (const step of status.steps) {
1381
+ if (!stepsCollected.includes(step)) {
1382
+ stepsCollected.push(step);
1383
+ }
1384
+ }
1385
+ // Task completed - return result directly (but only if we saw a NEW response)
1386
+ if (status.status === "completed" && sawNewResponse) {
1387
+ // Context bleed detection (Spec 016, FR-002, T017)
1388
+ const urlMatch = prompt.match(/https?:\/\/[^\s"')]+/);
1389
+ if (urlMatch && (await cometAI.detectContextBleed(urlMatch[0]))) {
1390
+ dispatchAlert({
1391
+ type: "PERPLEXITY_CONTEXT_BLEED",
1392
+ message: `Response content appears to be from a different URL than requested (${urlMatch[0]}).`,
1393
+ consumerId: sessionRegistry.getCurrent()?.agentId ?? null,
1394
+ sessionKey: sessionRegistry.getCurrent()?.sessionKey ?? null,
1395
+ context: { requestedUrl: urlMatch[0], actualUrl: status.agentBrowsingUrl },
1396
+ });
1397
+ return {
1398
+ content: [
1399
+ {
1400
+ type: "text",
1401
+ text: `Error: Context bleed detected — response content appears to be from a different URL than requested.\nRequested: ${urlMatch[0]}\nActual browsing: ${status.agentBrowsingUrl || "unknown"}\n\nSuggested action: Use newChat: true to prevent context from prior conversation bleeding into this task.`,
1402
+ },
1403
+ ],
1404
+ isError: true,
1405
+ };
1406
+ }
1407
+ return {
1408
+ content: [
1409
+ {
1410
+ type: "text",
1411
+ text: status.response || "Task completed (no response text extracted)",
1412
+ },
1413
+ ],
1414
+ };
451
1415
  }
452
1416
  }
453
- // Task completed - return result directly (but only if we saw a NEW response)
454
- if (status.status === 'completed' && sawNewResponse) {
455
- return { content: [{ type: "text", text: status.response || 'Task completed (no response text extracted)' }] };
1417
+ // Still working after initial wait - return "in progress" (non-blocking)
1418
+ const finalStatus = await cometAI.getAgentStatus();
1419
+ let inProgressMsg = `Task in progress (${stepsCollected.length} steps so far).\n`;
1420
+ inProgressMsg += `Status: ${finalStatus.status.toUpperCase()}\n`;
1421
+ if (finalStatus.currentStep) {
1422
+ inProgressMsg += `Current: ${finalStatus.currentStep}\n`;
456
1423
  }
1424
+ if (finalStatus.agentBrowsingUrl) {
1425
+ inProgressMsg += `Browsing: ${finalStatus.agentBrowsingUrl}\n`;
1426
+ }
1427
+ if (stepsCollected.length > 0) {
1428
+ inProgressMsg += `\nSteps:\n${stepsCollected.map((s) => ` • ${s}`).join("\n")}\n`;
1429
+ }
1430
+ inProgressMsg += `\nUse comet_poll to check progress or comet_stop to cancel.`;
1431
+ return { content: [{ type: "text", text: inProgressMsg }] };
457
1432
  }
458
- // Still working after initial wait - return "in progress" (non-blocking)
459
- const finalStatus = await cometAI.getAgentStatus();
460
- let inProgressMsg = `Task in progress (${stepsCollected.length} steps so far).\n`;
461
- inProgressMsg += `Status: ${finalStatus.status.toUpperCase()}\n`;
462
- if (finalStatus.currentStep) {
463
- inProgressMsg += `Current: ${finalStatus.currentStep}\n`;
464
- }
465
- if (finalStatus.agentBrowsingUrl) {
466
- inProgressMsg += `Browsing: ${finalStatus.agentBrowsingUrl}\n`;
467
- }
468
- if (stepsCollected.length > 0) {
469
- inProgressMsg += `\nSteps:\n${stepsCollected.map(s => ` • ${s}`).join('\n')}\n`;
470
- }
471
- inProgressMsg += `\nUse comet_poll to check progress or comet_stop to cancel.`;
472
- return { content: [{ type: "text", text: inProgressMsg }] };
473
- }
474
- case "comet_poll": {
475
- const status = await cometAI.getAgentStatus();
476
- // If completed, return the response directly (most useful case)
477
- if (status.status === 'completed' && status.response) {
478
- return { content: [{ type: "text", text: status.response }] };
479
- }
480
- // Still working - return progress info
481
- let output = `Status: ${status.status.toUpperCase()}\n`;
482
- if (status.agentBrowsingUrl) {
483
- output += `Browsing: ${status.agentBrowsingUrl}\n`;
484
- }
485
- if (status.currentStep) {
486
- output += `Current: ${status.currentStep}\n`;
1433
+ case "comet_poll": {
1434
+ const sidecarResultId = args?.sidecarResultId;
1435
+ const sidecarContextKey = args?.sidecarContextKey;
1436
+ if (sidecarResultId || sidecarContextKey) {
1437
+ const artifact = sidecarResultId
1438
+ ? await sidecarArtifactStore.get(sidecarResultId)
1439
+ : await sidecarArtifactStore.latestForContext(sidecarContextKey);
1440
+ if (!artifact) {
1441
+ return {
1442
+ content: [
1443
+ {
1444
+ type: "text",
1445
+ text: `No sidecar result artifact found for ${JSON.stringify({ sidecarResultId, sidecarContextKey })}`,
1446
+ },
1447
+ ],
1448
+ };
1449
+ }
1450
+ const resolved = await resolveBoundSession(sessionRegistry.getCurrent(), args);
1451
+ if (artifact.bindingId !== resolved.binding.bindingId) {
1452
+ return {
1453
+ content: [
1454
+ {
1455
+ type: "text",
1456
+ text: `Binding error (OWNERSHIP_VIOLATION) before comet_poll: sidecar result ${artifact.sidecarResultId} belongs to binding ${artifact.bindingId}.`,
1457
+ },
1458
+ ],
1459
+ isError: true,
1460
+ };
1461
+ }
1462
+ return {
1463
+ content: [{ type: "text", text: JSON.stringify(artifact, null, 2) }],
1464
+ };
1465
+ }
1466
+ const status = await cometAI.getAgentStatus();
1467
+ // Failure detection heuristics on poll (Spec 016, FR-002, T018)
1468
+ if (await cometAI.detectVoiceMode()) {
1469
+ dispatchAlert({
1470
+ type: "PERPLEXITY_VOICE_MODE",
1471
+ message: "Perplexity entered voice/listening mode.",
1472
+ consumerId: sessionRegistry.getCurrent()?.agentId ?? null,
1473
+ sessionKey: sessionRegistry.getCurrent()?.sessionKey ?? null,
1474
+ });
1475
+ return {
1476
+ content: [
1477
+ {
1478
+ type: "text",
1479
+ text: "Error: Perplexity entered voice/listening mode.\n\nSuggested action: Retry with newChat: true.",
1480
+ },
1481
+ ],
1482
+ isError: true,
1483
+ };
1484
+ }
1485
+ if (status.status === "idle" && (await cometAI.detectIdleNoNavigation())) {
1486
+ dispatchAlert({
1487
+ type: "PERPLEXITY_IDLE_NO_NAV",
1488
+ message: "Task went IDLE without navigating.",
1489
+ consumerId: sessionRegistry.getCurrent()?.agentId ?? null,
1490
+ sessionKey: sessionRegistry.getCurrent()?.sessionKey ?? null,
1491
+ });
1492
+ return {
1493
+ content: [
1494
+ {
1495
+ type: "text",
1496
+ text: "Error: Task went IDLE without navigating.\n\nSuggested action: Retry the task, or use comet_stop then retry with newChat: true.",
1497
+ },
1498
+ ],
1499
+ isError: true,
1500
+ };
1501
+ }
1502
+ // If completed, return the response directly (most useful case)
1503
+ if (status.status === "completed" && status.response) {
1504
+ return { content: [{ type: "text", text: status.response }] };
1505
+ }
1506
+ // Still working - return progress info
1507
+ let output = `Status: ${status.status.toUpperCase()}\n`;
1508
+ if (status.agentBrowsingUrl) {
1509
+ output += `Browsing: ${status.agentBrowsingUrl}\n`;
1510
+ }
1511
+ if (status.currentStep) {
1512
+ output += `Current: ${status.currentStep}\n`;
1513
+ }
1514
+ if (status.steps.length > 0) {
1515
+ output += `\nSteps:\n${status.steps.map((s) => ` • ${s}`).join("\n")}\n`;
1516
+ }
1517
+ if (status.status === "working") {
1518
+ output += `\n[Use comet_stop to interrupt, or comet_screenshot to see current page]`;
1519
+ }
1520
+ return { content: [{ type: "text", text: output }] };
487
1521
  }
488
- if (status.steps.length > 0) {
489
- output += `\nSteps:\n${status.steps.map(s => ` • ${s}`).join('\n')}\n`;
1522
+ case "comet_stop": {
1523
+ const stopped = await cometAI.stopAgent();
1524
+ return {
1525
+ content: [
1526
+ {
1527
+ type: "text",
1528
+ text: stopped ? "Agent stopped" : "No active agent to stop",
1529
+ },
1530
+ ],
1531
+ };
490
1532
  }
491
- if (status.status === 'working') {
492
- output += `\n[Use comet_stop to interrupt, or comet_screenshot to see current page]`;
1533
+ case "comet_screenshot": {
1534
+ const result = await cometClient.screenshot("png");
1535
+ return {
1536
+ content: [{ type: "image", data: result.data, mimeType: "image/png" }],
1537
+ };
493
1538
  }
494
- return { content: [{ type: "text", text: output }] };
495
- }
496
- case "comet_stop": {
497
- const stopped = await cometAI.stopAgent();
498
- return {
499
- content: [{
500
- type: "text",
501
- text: stopped ? "Agent stopped" : "No active agent to stop",
502
- }],
503
- };
504
- }
505
- case "comet_screenshot": {
506
- const result = await cometClient.screenshot("png");
507
- return {
508
- content: [{ type: "image", data: result.data, mimeType: "image/png" }],
509
- };
510
- }
511
- case "comet_mode": {
512
- const mode = args?.mode;
513
- // If no mode provided, show current mode
514
- if (!mode) {
515
- const result = await cometClient.evaluate(`
1539
+ case "comet_mode": {
1540
+ const mode = args?.mode;
1541
+ // If no mode provided, show current mode
1542
+ if (!mode) {
1543
+ const result = await cometClient.evaluate(`
516
1544
  (() => {
517
1545
  // Try button group first (wide screen)
518
1546
  const modes = ['Search', 'Research', 'Labs', 'Learn'];
@@ -534,41 +1562,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
534
1562
  return 'search';
535
1563
  })()
536
1564
  `);
537
- const currentMode = result.result.value;
538
- const descriptions = {
539
- search: 'Basic web search',
540
- research: 'Deep research with comprehensive analysis',
541
- labs: 'Analytics, visualizations, and coding',
542
- learn: 'Educational content and explanations'
543
- };
544
- let output = `Current mode: ${currentMode}\n\nAvailable modes:\n`;
545
- for (const [m, desc] of Object.entries(descriptions)) {
546
- const marker = m === currentMode ? "→" : " ";
547
- output += `${marker} ${m}: ${desc}\n`;
1565
+ const currentMode = result.result.value;
1566
+ const descriptions = {
1567
+ search: "Basic web search",
1568
+ research: "Deep research with comprehensive analysis",
1569
+ labs: "Analytics, visualizations, and coding",
1570
+ learn: "Educational content and explanations",
1571
+ };
1572
+ let output = `Current mode: ${currentMode}\n\nAvailable modes:\n`;
1573
+ for (const [m, desc] of Object.entries(descriptions)) {
1574
+ const marker = m === currentMode ? "→" : " ";
1575
+ output += `${marker} ${m}: ${desc}\n`;
1576
+ }
1577
+ return { content: [{ type: "text", text: output }] };
548
1578
  }
549
- return { content: [{ type: "text", text: output }] };
550
- }
551
- // Switch mode
552
- const modeMap = {
553
- search: "Search",
554
- research: "Research",
555
- labs: "Labs",
556
- learn: "Learn",
557
- };
558
- const ariaLabel = modeMap[mode];
559
- if (!ariaLabel) {
560
- return {
561
- content: [{ type: "text", text: `Invalid mode: ${mode}. Use: search, research, labs, learn` }],
562
- isError: true,
1579
+ // Switch mode
1580
+ const modeMap = {
1581
+ search: "Search",
1582
+ research: "Research",
1583
+ labs: "Labs",
1584
+ learn: "Learn",
563
1585
  };
564
- }
565
- // Navigate to Perplexity first if not there
566
- const state = cometClient.currentState;
567
- if (!state.currentUrl?.includes("perplexity.ai")) {
568
- await cometClient.navigate("https://www.perplexity.ai/", true);
569
- }
570
- // Try both UI patterns: button group (wide) and dropdown (narrow)
571
- const result = await cometClient.evaluate(`
1586
+ const ariaLabel = modeMap[mode];
1587
+ if (!ariaLabel) {
1588
+ return {
1589
+ content: [
1590
+ { type: "text", text: `Invalid mode: ${mode}. Use: search, research, labs, learn` },
1591
+ ],
1592
+ isError: true,
1593
+ };
1594
+ }
1595
+ // Navigate to Perplexity first if not there
1596
+ const state = cometClient.currentState;
1597
+ if (!state.currentUrl?.includes("perplexity.ai")) {
1598
+ await cometClient.navigate("https://www.perplexity.ai/", true);
1599
+ }
1600
+ // Try both UI patterns: button group (wide) and dropdown (narrow)
1601
+ const result = await cometClient.evaluate(`
572
1602
  (() => {
573
1603
  // Strategy 1: Direct button (wide screen)
574
1604
  const btn = document.querySelector('button[aria-label="${ariaLabel}"]');
@@ -593,11 +1623,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
593
1623
  return { success: false, error: "Mode selector not found" };
594
1624
  })()
595
1625
  `);
596
- const clickResult = result.result.value;
597
- if (clickResult.success && clickResult.needsSelect) {
598
- // Wait for dropdown to open, then select the mode
599
- await new Promise(resolve => setTimeout(resolve, 300));
600
- const selectResult = await cometClient.evaluate(`
1626
+ const clickResult = result.result.value;
1627
+ if (clickResult.success && clickResult.needsSelect) {
1628
+ // Wait for dropdown to open, then select the mode
1629
+ await new Promise((resolve) => setTimeout(resolve, 300));
1630
+ const selectResult = await cometClient.evaluate(`
601
1631
  (() => {
602
1632
  // Look for dropdown menu items
603
1633
  const items = document.querySelectorAll('[role="menuitem"], [role="option"], button');
@@ -610,41 +1640,44 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
610
1640
  return { success: false, error: "Mode option not found in dropdown" };
611
1641
  })()
612
1642
  `);
613
- const selectRes = selectResult.result.value;
614
- if (selectRes.success) {
1643
+ const selectRes = selectResult.result.value;
1644
+ if (selectRes.success) {
1645
+ return { content: [{ type: "text", text: `Switched to ${mode} mode` }] };
1646
+ }
1647
+ else {
1648
+ return {
1649
+ content: [{ type: "text", text: `Failed: ${selectRes.error}` }],
1650
+ isError: true,
1651
+ };
1652
+ }
1653
+ }
1654
+ if (clickResult.success) {
615
1655
  return { content: [{ type: "text", text: `Switched to ${mode} mode` }] };
616
1656
  }
617
1657
  else {
618
- return { content: [{ type: "text", text: `Failed: ${selectRes.error}` }], isError: true };
1658
+ return {
1659
+ content: [{ type: "text", text: `Failed to switch mode: ${clickResult.error}` }],
1660
+ isError: true,
1661
+ };
619
1662
  }
620
1663
  }
621
- if (clickResult.success) {
622
- return { content: [{ type: "text", text: `Switched to ${mode} mode` }] };
623
- }
624
- else {
625
- return {
626
- content: [{ type: "text", text: `Failed to switch mode: ${clickResult.error}` }],
627
- isError: true,
628
- };
629
- }
630
- }
631
- case "comet_shortcut": {
632
- const shortcut = (args?.shortcut).replace(/^\//, "");
633
- const context = args?.context;
634
- const timeout = args?.timeout || 30000;
635
- // Ensure we're on Perplexity
636
- const tabs = await cometClient.listTabsCategorized();
637
- if (tabs.main) {
638
- await cometClient.connect(tabs.main.id);
639
- }
640
- const urlResult = await cometClient.evaluate('window.location.href');
641
- const currentUrl = urlResult.result.value;
642
- if (!currentUrl?.includes('perplexity.ai')) {
643
- await cometClient.navigate("https://www.perplexity.ai/", true);
644
- await new Promise(resolve => setTimeout(resolve, 2000));
645
- }
646
- // Capture old state for response detection
647
- const oldStateResult = await cometClient.evaluate(`
1664
+ case "comet_shortcut": {
1665
+ const shortcut = (args?.shortcut).replace(/^\//, "");
1666
+ const context = args?.context;
1667
+ const timeout = args?.timeout || 30000;
1668
+ // Ensure we're on Perplexity
1669
+ const tabs = await cometClient.listTabsCategorized();
1670
+ if (tabs.main) {
1671
+ await cometClient.connect(tabs.main.id);
1672
+ }
1673
+ const urlResult = await cometClient.evaluate("window.location.href");
1674
+ const currentUrl = urlResult.result.value;
1675
+ if (!currentUrl?.includes("perplexity.ai")) {
1676
+ await cometClient.navigate("https://www.perplexity.ai/", true);
1677
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1678
+ }
1679
+ // Capture old state for response detection
1680
+ const oldStateResult = await cometClient.evaluate(`
648
1681
  (() => {
649
1682
  const proseEls = document.querySelectorAll('[class*="prose"]');
650
1683
  const lastProse = proseEls[proseEls.length - 1];
@@ -654,15 +1687,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
654
1687
  };
655
1688
  })()
656
1689
  `);
657
- const oldState = oldStateResult.result.value;
658
- // Send the shortcut
659
- await cometAI.sendShortcut(shortcut, context);
660
- // Poll for response with timeout
661
- const startTime = Date.now();
662
- let sawNewResponse = false;
663
- while (Date.now() - startTime < timeout) {
664
- await new Promise(resolve => setTimeout(resolve, 2000));
665
- const currentStateResult = await cometClient.evaluate(`
1690
+ const oldState = oldStateResult.result.value;
1691
+ // Send the shortcut
1692
+ await cometAI.sendShortcut(shortcut, context);
1693
+ // Poll for response with timeout
1694
+ const startTime = Date.now();
1695
+ let sawNewResponse = false;
1696
+ while (Date.now() - startTime < timeout) {
1697
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1698
+ const currentStateResult = await cometClient.evaluate(`
666
1699
  (() => {
667
1700
  const proseEls = document.querySelectorAll('[class*="prose"]');
668
1701
  const lastProse = proseEls[proseEls.length - 1];
@@ -672,502 +1705,1922 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
672
1705
  };
673
1706
  })()
674
1707
  `);
675
- const currentState = currentStateResult.result.value;
676
- if (!sawNewResponse) {
677
- if (currentState.count > oldState.count ||
678
- (currentState.lastText && currentState.lastText !== oldState.lastText)) {
679
- sawNewResponse = true;
680
- }
681
- }
682
- const status = await cometAI.getAgentStatus();
683
- if (status.status === 'completed' && sawNewResponse) {
684
- return { content: [{ type: "text", text: status.response || 'Shortcut completed (no response text extracted)' }] };
685
- }
686
- }
687
- // Timed out — return status so user can poll
688
- const finalStatus = await cometAI.getAgentStatus();
689
- let msg = `Shortcut /${shortcut} in progress (timed out after ${timeout}ms).\n`;
690
- msg += `Status: ${finalStatus.status.toUpperCase()}\n`;
691
- if (finalStatus.currentStep)
692
- msg += `Current: ${finalStatus.currentStep}\n`;
693
- msg += `\nUse comet_poll to check progress.`;
694
- return { content: [{ type: "text", text: msg }] };
695
- }
696
- case "comet_read_page": {
697
- const mode = args?.mode || "text";
698
- const maxDepth = args?.maxDepth || 5;
699
- const maxLength = args?.maxLength || 12000;
700
- const parts = [];
701
- if (mode === "tree" || mode === "both") {
702
- const tree = await cometClient.getAccessibilityTree(maxDepth, maxLength);
703
- parts.push("## Accessibility Tree\n" + tree);
704
- }
705
- if (mode === "text" || mode === "both") {
706
- const text = await cometClient.getPageText(maxLength);
707
- parts.push("## Page Text\n" + text);
708
- }
709
- return { content: [{ type: "text", text: parts.join("\n\n") }] };
710
- }
711
- case "comet_wait_for_idle": {
712
- const idleTime = args?.idleTime || 1500;
713
- const timeout = args?.timeout || 15000;
714
- const result = await cometClient.waitForNetworkIdle({ idleTime, timeout });
715
- const status = result.idle ? "Network idle reached" : "Timeout — network still active";
716
- const summary = [
717
- status,
718
- `Waited: ${result.waitedMs}ms`,
719
- `Requests: ${result.totalRequests} total, ${result.totalCompleted} completed, ${result.totalFailed} failed`,
720
- result.pendingRequests > 0 ? `Still pending: ${result.pendingRequests}` : "",
721
- ].filter(Boolean).join("\n");
722
- return { content: [{ type: "text", text: summary }] };
723
- }
724
- case "comet_tab_groups": {
725
- const { tabGroupsClient } = await import("./tab-groups.js");
726
- const action = args?.action;
727
- try {
728
- switch (action) {
729
- case "list": {
730
- const groups = await tabGroupsClient.listGroups();
731
- if (groups.length === 0) {
732
- return { content: [{ type: "text", text: "No tab groups found." }] };
1708
+ const currentState = currentStateResult.result.value;
1709
+ if (!sawNewResponse) {
1710
+ if (currentState.count > oldState.count ||
1711
+ (currentState.lastText && currentState.lastText !== oldState.lastText)) {
1712
+ sawNewResponse = true;
733
1713
  }
734
- const lines = groups.map((g) => `[${g.id}] "${g.title || "(untitled)"}" (${g.color}${g.collapsed ? ", collapsed" : ""})`);
735
- return { content: [{ type: "text", text: `Tab groups:\n${lines.join("\n")}` }] };
736
1714
  }
737
- case "list_tabs": {
738
- const tabs = await tabGroupsClient.listTabs();
739
- const lines = tabs.map((t) => `[tab:${t.id}] group:${t.groupId === -1 ? "none" : t.groupId} "${t.title}" ${t.url}`);
740
- return { content: [{ type: "text", text: `Tabs (${tabs.length}):\n${lines.join("\n")}` }] };
741
- }
742
- case "create": {
743
- const tabIds = args?.tabIds;
744
- if (!tabIds || tabIds.length === 0) {
745
- return { content: [{ type: "text", text: "Error: tabIds required for create" }], isError: true };
746
- }
747
- const result = await tabGroupsClient.createGroup({
748
- tabIds,
749
- title: args?.title,
750
- color: args?.color,
751
- });
1715
+ const status = await cometAI.getAgentStatus();
1716
+ if (status.status === "completed" && sawNewResponse) {
752
1717
  return {
753
- content: [{
1718
+ content: [
1719
+ {
754
1720
  type: "text",
755
- text: `Created group ${result.groupId}: "${result.group.title || "(untitled)"}" (${result.group.color})`,
756
- }],
1721
+ text: status.response || "Shortcut completed (no response text extracted)",
1722
+ },
1723
+ ],
757
1724
  };
758
1725
  }
759
- case "update": {
760
- const groupId = args?.groupId;
761
- if (groupId === undefined) {
762
- return { content: [{ type: "text", text: "Error: groupId required for update" }], isError: true };
763
- }
764
- const group = await tabGroupsClient.updateGroup({
765
- groupId,
766
- title: args?.title,
767
- color: args?.color,
768
- collapsed: args?.collapsed,
769
- });
770
- return {
771
- content: [{
772
- type: "text",
773
- text: `Updated group ${group.id}: "${group.title || "(untitled)"}" (${group.color}${group.collapsed ? ", collapsed" : ""})`,
774
- }],
775
- };
1726
+ }
1727
+ // Timed out — return status so user can poll
1728
+ const finalStatus = await cometAI.getAgentStatus();
1729
+ let msg = `Shortcut /${shortcut} in progress (timed out after ${timeout}ms).\n`;
1730
+ msg += `Status: ${finalStatus.status.toUpperCase()}\n`;
1731
+ if (finalStatus.currentStep)
1732
+ msg += `Current: ${finalStatus.currentStep}\n`;
1733
+ msg += `\nUse comet_poll to check progress.`;
1734
+ return { content: [{ type: "text", text: msg }] };
1735
+ }
1736
+ case "comet_read_page": {
1737
+ const mode = args?.mode || "text";
1738
+ const maxDepth = args?.maxDepth || 5;
1739
+ const maxLength = args?.maxLength || 12000;
1740
+ const parts = [];
1741
+ if (mode === "tree" || mode === "both") {
1742
+ const tree = await cometClient.getAccessibilityTree(maxDepth, maxLength);
1743
+ parts.push("## Accessibility Tree\n" + tree);
1744
+ }
1745
+ if (mode === "text" || mode === "both") {
1746
+ const text = await cometClient.getPageText(maxLength);
1747
+ parts.push("## Page Text\n" + text);
1748
+ }
1749
+ return { content: [{ type: "text", text: parts.join("\n\n") }] };
1750
+ }
1751
+ case "comet_interact": {
1752
+ const actions = args?.actions;
1753
+ if (!actions || actions.length === 0) {
1754
+ return {
1755
+ content: [
1756
+ { type: "text", text: "Error: actions array is required and must not be empty." },
1757
+ ],
1758
+ };
1759
+ }
1760
+ const results = [];
1761
+ for (const act of actions) {
1762
+ try {
1763
+ switch (act.action) {
1764
+ case "click": {
1765
+ if (!act.selector)
1766
+ throw new Error("click requires a selector");
1767
+ const clicked = await cometClient.safeEvaluate(`
1768
+ (() => {
1769
+ const el = document.querySelector(${JSON.stringify(act.selector)});
1770
+ if (!el) return JSON.stringify({ ok: false, error: 'Element not found: ${act.selector.replace(/'/g, "\\'")}' });
1771
+ el.scrollIntoView({ block: 'center' });
1772
+ el.click();
1773
+ return JSON.stringify({ ok: true, tag: el.tagName, text: (el.textContent || '').trim().substring(0, 80) });
1774
+ })()
1775
+ `);
1776
+ const clickRes = JSON.parse(clicked.result.value);
1777
+ if (!clickRes.ok)
1778
+ throw new Error(clickRes.error);
1779
+ results.push({
1780
+ action: "click",
1781
+ success: true,
1782
+ result: `Clicked <${clickRes.tag}> "${clickRes.text}"`,
1783
+ });
1784
+ break;
1785
+ }
1786
+ case "type": {
1787
+ if (!act.selector)
1788
+ throw new Error("type requires a selector");
1789
+ if (!act.value)
1790
+ throw new Error("type requires a value");
1791
+ // Focus the element first
1792
+ await cometClient.safeEvaluate(`
1793
+ (() => {
1794
+ const el = document.querySelector(${JSON.stringify(act.selector)});
1795
+ if (!el) throw new Error('Element not found');
1796
+ el.focus();
1797
+ })()
1798
+ `);
1799
+ // Type character by character using CDP Input events
1800
+ for (const char of act.value) {
1801
+ await cometClient.pressKey(char);
1802
+ await new Promise((r) => setTimeout(r, 50));
1803
+ }
1804
+ results.push({
1805
+ action: "type",
1806
+ success: true,
1807
+ result: `Typed ${act.value.length} chars`,
1808
+ });
1809
+ break;
1810
+ }
1811
+ case "fill": {
1812
+ if (!act.selector)
1813
+ throw new Error("fill requires a selector");
1814
+ const fillRes = await cometClient.safeEvaluate(`
1815
+ (() => {
1816
+ const el = document.querySelector(${JSON.stringify(act.selector)});
1817
+ if (!el) return JSON.stringify({ ok: false, error: 'Element not found' });
1818
+ el.focus();
1819
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
1820
+ const nativeSetter = Object.getOwnPropertyDescriptor(
1821
+ el.tagName === 'INPUT' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype, 'value'
1822
+ )?.set;
1823
+ if (nativeSetter) nativeSetter.call(el, ${JSON.stringify(act.value || "")});
1824
+ else el.value = ${JSON.stringify(act.value || "")};
1825
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1826
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1827
+ } else if (el.contentEditable === 'true') {
1828
+ el.focus();
1829
+ document.execCommand('selectAll', false, null);
1830
+ document.execCommand('insertText', false, ${JSON.stringify(act.value || "")});
1831
+ }
1832
+ return JSON.stringify({ ok: true });
1833
+ })()
1834
+ `);
1835
+ const fRes = JSON.parse(fillRes.result.value);
1836
+ if (!fRes.ok)
1837
+ throw new Error(fRes.error);
1838
+ results.push({
1839
+ action: "fill",
1840
+ success: true,
1841
+ result: `Filled with "${(act.value || "").substring(0, 40)}"`,
1842
+ });
1843
+ break;
1844
+ }
1845
+ case "press": {
1846
+ const key = act.key || act.value || "Enter";
1847
+ if (act.selector) {
1848
+ await cometClient.safeEvaluate(`
1849
+ (() => {
1850
+ const el = document.querySelector(${JSON.stringify(act.selector)});
1851
+ if (el) el.focus();
1852
+ })()
1853
+ `);
1854
+ }
1855
+ await cometClient.pressKey(key);
1856
+ results.push({ action: "press", success: true, result: `Pressed ${key}` });
1857
+ break;
1858
+ }
1859
+ case "check":
1860
+ case "uncheck": {
1861
+ if (!act.selector)
1862
+ throw new Error(`${act.action} requires a selector`);
1863
+ const shouldCheck = act.action === "check";
1864
+ const checkRes = await cometClient.safeEvaluate(`
1865
+ (() => {
1866
+ const el = document.querySelector(${JSON.stringify(act.selector)});
1867
+ if (!el) return JSON.stringify({ ok: false, error: 'Element not found' });
1868
+ const isCheckbox = el.type === 'checkbox' || el.role === 'checkbox' || el.getAttribute('role') === 'checkbox';
1869
+ if (isCheckbox) {
1870
+ if (el.checked !== ${shouldCheck}) {
1871
+ el.click();
1872
+ }
1873
+ return JSON.stringify({ ok: true, checked: ${shouldCheck} });
1874
+ }
1875
+ // Fallback: just click it (toggle behavior)
1876
+ el.scrollIntoView({ block: 'center' });
1877
+ el.click();
1878
+ return JSON.stringify({ ok: true, clicked: true });
1879
+ })()
1880
+ `);
1881
+ const cRes = JSON.parse(checkRes.result.value);
1882
+ if (!cRes.ok)
1883
+ throw new Error(cRes.error);
1884
+ results.push({
1885
+ action: act.action,
1886
+ success: true,
1887
+ result: `${act.action}ed: ${act.selector}`,
1888
+ });
1889
+ break;
1890
+ }
1891
+ case "select": {
1892
+ if (!act.selector)
1893
+ throw new Error("select requires a selector");
1894
+ if (!act.value)
1895
+ throw new Error("select requires a value");
1896
+ const selRes = await cometClient.safeEvaluate(`
1897
+ (() => {
1898
+ const el = document.querySelector(${JSON.stringify(act.selector)});
1899
+ if (!el || el.tagName !== 'SELECT') return JSON.stringify({ ok: false, error: 'SELECT element not found' });
1900
+ // Try by value first, then by text
1901
+ let found = false;
1902
+ for (const opt of el.options) {
1903
+ if (opt.value === ${JSON.stringify(act.value)} || opt.text === ${JSON.stringify(act.value)}) {
1904
+ el.value = opt.value;
1905
+ found = true;
1906
+ break;
776
1907
  }
777
- case "move": {
778
- const groupId = args?.groupId;
779
- const index = args?.index;
780
- if (groupId === undefined || index === undefined) {
781
- return { content: [{ type: "text", text: "Error: groupId and index required for move" }], isError: true };
1908
+ }
1909
+ if (!found) return JSON.stringify({ ok: false, error: 'Option not found: ${act.value}' });
1910
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1911
+ return JSON.stringify({ ok: true, selected: el.value });
1912
+ })()
1913
+ `);
1914
+ const sRes = JSON.parse(selRes.result.value);
1915
+ if (!sRes.ok)
1916
+ throw new Error(sRes.error);
1917
+ results.push({
1918
+ action: "select",
1919
+ success: true,
1920
+ result: `Selected: ${sRes.selected}`,
1921
+ });
1922
+ break;
1923
+ }
1924
+ case "scroll": {
1925
+ const dir = act.direction || "down";
1926
+ const amount = act.amount || 500;
1927
+ const dx = dir === "right" ? amount : dir === "left" ? -amount : 0;
1928
+ const dy = dir === "down" ? amount : dir === "up" ? -amount : 0;
1929
+ if (act.selector) {
1930
+ await cometClient.safeEvaluate(`
1931
+ (() => {
1932
+ const el = document.querySelector(${JSON.stringify(act.selector)});
1933
+ if (el) el.scrollBy(${dx}, ${dy});
1934
+ })()
1935
+ `);
1936
+ }
1937
+ else {
1938
+ await cometClient.safeEvaluate(`window.scrollBy(${dx}, ${dy})`);
1939
+ }
1940
+ results.push({
1941
+ action: "scroll",
1942
+ success: true,
1943
+ result: `Scrolled ${dir} ${amount}px`,
1944
+ });
1945
+ break;
1946
+ }
1947
+ case "wait": {
1948
+ if (act.selector) {
1949
+ // Wait for element to appear
1950
+ const waitTimeout = act.ms || 10000;
1951
+ const start = Date.now();
1952
+ let found = false;
1953
+ while (Date.now() - start < waitTimeout) {
1954
+ const exists = await cometClient.safeEvaluate(`document.querySelector(${JSON.stringify(act.selector)}) !== null`);
1955
+ if (exists.result.value === true) {
1956
+ found = true;
1957
+ break;
1958
+ }
1959
+ await new Promise((r) => setTimeout(r, 300));
1960
+ }
1961
+ if (!found)
1962
+ throw new Error(`Timeout waiting for ${act.selector}`);
1963
+ results.push({
1964
+ action: "wait",
1965
+ success: true,
1966
+ result: `Found: ${act.selector}`,
1967
+ });
1968
+ }
1969
+ else {
1970
+ const ms = act.ms || 1000;
1971
+ await new Promise((r) => setTimeout(r, ms));
1972
+ results.push({ action: "wait", success: true, result: `Waited ${ms}ms` });
1973
+ }
1974
+ break;
1975
+ }
1976
+ case "extract": {
1977
+ if (!act.selector)
1978
+ throw new Error("extract requires a selector");
1979
+ const extRes = await cometClient.safeEvaluate(`
1980
+ (() => {
1981
+ const el = document.querySelector(${JSON.stringify(act.selector)});
1982
+ if (!el) return JSON.stringify({ ok: false, error: 'Element not found' });
1983
+ return JSON.stringify({
1984
+ ok: true,
1985
+ text: el.innerText?.trim() || '',
1986
+ value: el.value || '',
1987
+ tag: el.tagName,
1988
+ checked: el.checked ?? null,
1989
+ href: el.href || null,
1990
+ });
1991
+ })()
1992
+ `);
1993
+ const eRes = JSON.parse(extRes.result.value);
1994
+ if (!eRes.ok)
1995
+ throw new Error(eRes.error);
1996
+ const extracted = eRes.text || eRes.value || `<${eRes.tag}>`;
1997
+ results.push({
1998
+ action: "extract",
1999
+ success: true,
2000
+ result: extracted.substring(0, 2000),
2001
+ });
2002
+ break;
2003
+ }
2004
+ case "evaluate": {
2005
+ if (!act.script)
2006
+ throw new Error("evaluate requires a script");
2007
+ const evalRes = await cometClient.safeEvaluate(`
2008
+ (() => {
2009
+ try {
2010
+ const result = (function() { ${act.script} })();
2011
+ return JSON.stringify({ ok: true, result: String(result ?? 'undefined').substring(0, 4000) });
2012
+ } catch (e) {
2013
+ return JSON.stringify({ ok: false, error: e.message });
2014
+ }
2015
+ })()
2016
+ `);
2017
+ const evRes = JSON.parse(evalRes.result.value);
2018
+ if (!evRes.ok)
2019
+ throw new Error(evRes.error);
2020
+ results.push({ action: "evaluate", success: true, result: evRes.result });
2021
+ break;
2022
+ }
2023
+ default:
2024
+ throw new Error(`Unknown action: ${act.action}`);
782
2025
  }
783
- const group = await tabGroupsClient.moveGroup(groupId, index);
784
- return { content: [{ type: "text", text: `Moved group ${group.id} to index ${index}` }] };
2026
+ // Small pause between actions for DOM to settle
2027
+ await new Promise((r) => setTimeout(r, 100));
785
2028
  }
786
- case "ungroup": {
787
- const tabIds = args?.tabIds;
788
- if (!tabIds || tabIds.length === 0) {
789
- return { content: [{ type: "text", text: "Error: tabIds required for ungroup" }], isError: true };
2029
+ catch (err) {
2030
+ const errorMsg = err instanceof Error ? err.message : String(err);
2031
+ results.push({ action: act.action, success: false, error: errorMsg });
2032
+ if (!act.optional) {
2033
+ // Abort remaining actions
2034
+ results.push({
2035
+ action: "ABORTED",
2036
+ success: false,
2037
+ error: `Stopped after ${act.action} failed`,
2038
+ });
2039
+ break;
790
2040
  }
791
- await tabGroupsClient.ungroupTabs(tabIds);
792
- return { content: [{ type: "text", text: `Ungrouped ${tabIds.length} tab(s)` }] };
793
2041
  }
794
- case "delete": {
795
- const groupId = args?.groupId;
796
- if (groupId === undefined) {
797
- return { content: [{ type: "text", text: "Error: groupId required for delete" }], isError: true };
2042
+ }
2043
+ const output = results
2044
+ .map((r) => `${r.success ? "✓" : "✗"} ${r.action}: ${r.result || r.error || ""}`)
2045
+ .join("\n");
2046
+ const allSucceeded = results.every((r) => r.success);
2047
+ return {
2048
+ content: [{ type: "text", text: output }],
2049
+ isError: !allSucceeded,
2050
+ };
2051
+ }
2052
+ case "comet_navigate": {
2053
+ const url = args?.url;
2054
+ if (!url) {
2055
+ return { content: [{ type: "text", text: "Error: url is required" }] };
2056
+ }
2057
+ const waitForIdle = args?.waitForIdle !== false; // default true
2058
+ const navResult = await cometClient.navigate(url, true, waitForIdle);
2059
+ // Get final URL after any redirects
2060
+ const finalUrl = await cometClient.safeEvaluate("window.location.href");
2061
+ const finalUrlStr = finalUrl.result.value || url;
2062
+ const summary = [
2063
+ `Navigated to: ${finalUrlStr}`,
2064
+ navResult.networkIdle !== undefined
2065
+ ? `Network: ${navResult.networkIdle ? "idle" : "still loading"}`
2066
+ : "",
2067
+ ]
2068
+ .filter(Boolean)
2069
+ .join("\n");
2070
+ return { content: [{ type: "text", text: summary }] };
2071
+ }
2072
+ case "comet_wait_for_idle": {
2073
+ const idleTime = args?.idleTime || 1500;
2074
+ const timeout = args?.timeout || 15000;
2075
+ const result = await cometClient.waitForNetworkIdle({ idleTime, timeout });
2076
+ const status = result.idle ? "Network idle reached" : "Timeout — network still active";
2077
+ const summary = [
2078
+ status,
2079
+ `Waited: ${result.waitedMs}ms`,
2080
+ `Requests: ${result.totalRequests} total, ${result.totalCompleted} completed, ${result.totalFailed} failed`,
2081
+ result.pendingRequests > 0 ? `Still pending: ${result.pendingRequests}` : "",
2082
+ ]
2083
+ .filter(Boolean)
2084
+ .join("\n");
2085
+ return { content: [{ type: "text", text: summary }] };
2086
+ }
2087
+ case "comet_tab_groups": {
2088
+ const { tabGroupsClient } = await import("./tab-groups.js");
2089
+ const action = args?.action;
2090
+ try {
2091
+ switch (action) {
2092
+ case "list": {
2093
+ const groups = await tabGroupsClient.listGroups();
2094
+ if (groups.length === 0) {
2095
+ return { content: [{ type: "text", text: "No tab groups found." }] };
2096
+ }
2097
+ const lines = groups.map((g) => `[${g.id}] "${g.title || "(untitled)"}" (${g.color}${g.collapsed ? ", collapsed" : ""})`);
2098
+ return { content: [{ type: "text", text: `Tab groups:\n${lines.join("\n")}` }] };
798
2099
  }
799
- const tabs = await tabGroupsClient.listTabs();
800
- const groupTabs = tabs.filter((t) => t.groupId === groupId);
801
- if (groupTabs.length === 0) {
802
- return { content: [{ type: "text", text: `No tabs found in group ${groupId}` }] };
2100
+ case "list_tabs": {
2101
+ const tabs = await tabGroupsClient.listTabs();
2102
+ const lines = tabs.map((t) => `[tab:${t.id}] group:${t.groupId === -1 ? "none" : t.groupId} "${t.title}" ${t.url}`);
2103
+ return {
2104
+ content: [{ type: "text", text: `Tabs (${tabs.length}):\n${lines.join("\n")}` }],
2105
+ };
803
2106
  }
804
- await tabGroupsClient.ungroupTabs(groupTabs.map((t) => t.id));
805
- return {
806
- content: [{
807
- type: "text",
808
- text: `Deleted group ${groupId} (ungrouped ${groupTabs.length} tab(s))`,
809
- }],
810
- };
811
- }
812
- case "save_group": {
813
- const { archiveStore } = await import("./tab-group-archive.js");
814
- const groupId = args?.groupId;
815
- const taskThreadId = args?.taskThreadId;
816
- const closeTabs = args?.closeTabs;
817
- if (groupId === undefined || !taskThreadId) {
818
- return { content: [{ type: "text", text: "Error: groupId and taskThreadId required for save_group" }], isError: true };
2107
+ case "create": {
2108
+ const tabIds = args?.tabIds;
2109
+ if (!tabIds || tabIds.length === 0) {
2110
+ return {
2111
+ content: [{ type: "text", text: "Error: tabIds required for create" }],
2112
+ isError: true,
2113
+ };
2114
+ }
2115
+ const result = await tabGroupsClient.createGroup({
2116
+ tabIds,
2117
+ title: args?.title,
2118
+ color: args?.color,
2119
+ });
2120
+ return {
2121
+ content: [
2122
+ {
2123
+ type: "text",
2124
+ text: `Created group ${result.groupId}: "${result.group.title || "(untitled)"}" (${result.group.color})`,
2125
+ },
2126
+ ],
2127
+ };
819
2128
  }
820
- const group = await tabGroupsClient.getGroup(groupId);
821
- const allTabs = await tabGroupsClient.listTabs();
822
- const groupTabs = allTabs.filter((t) => t.groupId === groupId);
823
- const entry = {
824
- taskThreadId,
825
- title: group.title,
826
- color: group.color,
827
- collapsed: group.collapsed,
828
- urls: groupTabs.map((t) => ({ url: t.url, title: t.title })),
829
- archivedAt: new Date().toISOString(),
830
- status: closeTabs ? "archived" : "saved",
831
- };
832
- await archiveStore.save(entry);
833
- if (closeTabs) {
834
- for (const t of groupTabs) {
835
- try {
836
- await tabGroupsClient.ungroupTabs([t.id]);
837
- }
838
- catch { /* tab may already be closed */ }
2129
+ case "update": {
2130
+ const groupId = args?.groupId;
2131
+ if (groupId === undefined) {
2132
+ return {
2133
+ content: [{ type: "text", text: "Error: groupId required for update" }],
2134
+ isError: true,
2135
+ };
839
2136
  }
2137
+ const group = await tabGroupsClient.updateGroup({
2138
+ groupId,
2139
+ title: args?.title,
2140
+ color: args?.color,
2141
+ collapsed: args?.collapsed,
2142
+ });
2143
+ return {
2144
+ content: [
2145
+ {
2146
+ type: "text",
2147
+ text: `Updated group ${group.id}: "${group.title || "(untitled)"}" (${group.color}${group.collapsed ? ", collapsed" : ""})`,
2148
+ },
2149
+ ],
2150
+ };
840
2151
  }
841
- return { content: [{ type: "text", text: `Saved ${groupTabs.length} tab(s) for thread ${taskThreadId} (status: ${entry.status})` }] };
842
- }
843
- case "restore_group": {
844
- const { archiveStore } = await import("./tab-group-archive.js");
845
- const taskThreadId = args?.taskThreadId;
846
- if (!taskThreadId) {
847
- return { content: [{ type: "text", text: "Error: taskThreadId required for restore_group" }], isError: true };
2152
+ case "move": {
2153
+ const groupId = args?.groupId;
2154
+ const index = args?.index;
2155
+ if (groupId === undefined || index === undefined) {
2156
+ return {
2157
+ content: [{ type: "text", text: "Error: groupId and index required for move" }],
2158
+ isError: true,
2159
+ };
2160
+ }
2161
+ const group = await tabGroupsClient.moveGroup(groupId, index);
2162
+ return {
2163
+ content: [{ type: "text", text: `Moved group ${group.id} to index ${index}` }],
2164
+ };
848
2165
  }
849
- const entry = await archiveStore.restore(taskThreadId);
850
- if (!entry) {
851
- return { content: [{ type: "text", text: `No archive found for thread ${taskThreadId}` }], isError: true };
2166
+ case "ungroup": {
2167
+ const tabIds = args?.tabIds;
2168
+ if (!tabIds || tabIds.length === 0) {
2169
+ return {
2170
+ content: [{ type: "text", text: "Error: tabIds required for ungroup" }],
2171
+ isError: true,
2172
+ };
2173
+ }
2174
+ await tabGroupsClient.ungroupTabs(tabIds);
2175
+ return { content: [{ type: "text", text: `Ungrouped ${tabIds.length} tab(s)` }] };
852
2176
  }
853
- const tabIds = [];
854
- for (const u of entry.urls) {
855
- const tabId = await tabGroupsClient.createTab(u.url, false);
856
- if (typeof tabId === "number")
857
- tabIds.push(tabId);
2177
+ case "delete": {
2178
+ const groupId = args?.groupId;
2179
+ if (groupId === undefined) {
2180
+ return {
2181
+ content: [{ type: "text", text: "Error: groupId required for delete" }],
2182
+ isError: true,
2183
+ };
2184
+ }
2185
+ const tabs = await tabGroupsClient.listTabs();
2186
+ const groupTabs = tabs.filter((t) => t.groupId === groupId);
2187
+ if (groupTabs.length === 0) {
2188
+ return { content: [{ type: "text", text: `No tabs found in group ${groupId}` }] };
2189
+ }
2190
+ await tabGroupsClient.ungroupTabs(groupTabs.map((t) => t.id));
2191
+ return {
2192
+ content: [
2193
+ {
2194
+ type: "text",
2195
+ text: `Deleted group ${groupId} (ungrouped ${groupTabs.length} tab(s))`,
2196
+ },
2197
+ ],
2198
+ };
858
2199
  }
859
- if (tabIds.length > 0) {
860
- await tabGroupsClient.createGroup({
861
- tabIds,
862
- title: entry.title,
863
- color: entry.color,
864
- });
2200
+ case "save_group": {
2201
+ const { archiveStore } = await import("./tab-group-archive.js");
2202
+ const groupId = args?.groupId;
2203
+ const taskThreadId = args?.taskThreadId;
2204
+ const closeTabs = args?.closeTabs;
2205
+ if (groupId === undefined || !taskThreadId) {
2206
+ return {
2207
+ content: [
2208
+ {
2209
+ type: "text",
2210
+ text: "Error: groupId and taskThreadId required for save_group",
2211
+ },
2212
+ ],
2213
+ isError: true,
2214
+ };
2215
+ }
2216
+ const group = await tabGroupsClient.getGroup(groupId);
2217
+ const allTabs = await tabGroupsClient.listTabs();
2218
+ const groupTabs = allTabs.filter((t) => t.groupId === groupId);
2219
+ const entry = {
2220
+ taskThreadId,
2221
+ title: group.title,
2222
+ color: group.color,
2223
+ collapsed: group.collapsed,
2224
+ urls: groupTabs.map((t) => ({ url: t.url, title: t.title })),
2225
+ archivedAt: new Date().toISOString(),
2226
+ status: closeTabs ? "archived" : "saved",
2227
+ };
2228
+ await archiveStore.save(entry);
2229
+ if (closeTabs) {
2230
+ for (const t of groupTabs) {
2231
+ try {
2232
+ await tabGroupsClient.ungroupTabs([t.id]);
2233
+ }
2234
+ catch {
2235
+ /* tab may already be closed */
2236
+ }
2237
+ }
2238
+ }
2239
+ return {
2240
+ content: [
2241
+ {
2242
+ type: "text",
2243
+ text: `Saved ${groupTabs.length} tab(s) for thread ${taskThreadId} (status: ${entry.status})`,
2244
+ },
2245
+ ],
2246
+ };
865
2247
  }
866
- return { content: [{ type: "text", text: `Restored ${tabIds.length} tab(s) for thread ${taskThreadId}` }] };
867
- }
868
- case "archive_group": {
869
- const { archiveStore } = await import("./tab-group-archive.js");
870
- const groupId = args?.groupId;
871
- const taskThreadId = args?.taskThreadId;
872
- if (groupId === undefined || !taskThreadId) {
873
- return { content: [{ type: "text", text: "Error: groupId and taskThreadId required for archive_group" }], isError: true };
2248
+ case "restore_group": {
2249
+ const { archiveStore } = await import("./tab-group-archive.js");
2250
+ const taskThreadId = args?.taskThreadId;
2251
+ if (!taskThreadId) {
2252
+ return {
2253
+ content: [
2254
+ { type: "text", text: "Error: taskThreadId required for restore_group" },
2255
+ ],
2256
+ isError: true,
2257
+ };
2258
+ }
2259
+ const entry = await archiveStore.restore(taskThreadId);
2260
+ if (!entry) {
2261
+ return {
2262
+ content: [
2263
+ { type: "text", text: `No archive found for thread ${taskThreadId}` },
2264
+ ],
2265
+ isError: true,
2266
+ };
2267
+ }
2268
+ const tabIds = [];
2269
+ for (const u of entry.urls) {
2270
+ const tabId = await tabGroupsClient.createTab(u.url, false);
2271
+ if (typeof tabId === "number")
2272
+ tabIds.push(tabId);
2273
+ }
2274
+ if (tabIds.length > 0) {
2275
+ await tabGroupsClient.createGroup({
2276
+ tabIds,
2277
+ title: entry.title,
2278
+ color: entry.color,
2279
+ });
2280
+ }
2281
+ return {
2282
+ content: [
2283
+ {
2284
+ type: "text",
2285
+ text: `Restored ${tabIds.length} tab(s) for thread ${taskThreadId}`,
2286
+ },
2287
+ ],
2288
+ };
874
2289
  }
875
- const group = await tabGroupsClient.getGroup(groupId);
876
- const allTabs = await tabGroupsClient.listTabs();
877
- const groupTabs = allTabs.filter((t) => t.groupId === groupId);
878
- const entry = {
879
- taskThreadId,
880
- title: group.title,
881
- color: group.color,
882
- collapsed: group.collapsed,
883
- urls: groupTabs.map((t) => ({ url: t.url, title: t.title })),
884
- archivedAt: new Date().toISOString(),
885
- status: "archived",
886
- };
887
- await archiveStore.save(entry);
888
- const tabIdsToClose = groupTabs.map((t) => t.id);
889
- if (tabIdsToClose.length > 0) {
890
- await tabGroupsClient.closeTabs(tabIdsToClose);
2290
+ case "archive_group": {
2291
+ const { archiveStore } = await import("./tab-group-archive.js");
2292
+ const groupId = args?.groupId;
2293
+ const taskThreadId = args?.taskThreadId;
2294
+ if (groupId === undefined || !taskThreadId) {
2295
+ return {
2296
+ content: [
2297
+ {
2298
+ type: "text",
2299
+ text: "Error: groupId and taskThreadId required for archive_group",
2300
+ },
2301
+ ],
2302
+ isError: true,
2303
+ };
2304
+ }
2305
+ const group = await tabGroupsClient.getGroup(groupId);
2306
+ const allTabs = await tabGroupsClient.listTabs();
2307
+ const groupTabs = allTabs.filter((t) => t.groupId === groupId);
2308
+ const entry = {
2309
+ taskThreadId,
2310
+ title: group.title,
2311
+ color: group.color,
2312
+ collapsed: group.collapsed,
2313
+ urls: groupTabs.map((t) => ({ url: t.url, title: t.title })),
2314
+ archivedAt: new Date().toISOString(),
2315
+ status: "archived",
2316
+ };
2317
+ await archiveStore.save(entry);
2318
+ const tabIdsToClose = groupTabs.map((t) => t.id);
2319
+ if (tabIdsToClose.length > 0) {
2320
+ await tabGroupsClient.closeTabs(tabIdsToClose);
2321
+ }
2322
+ return {
2323
+ content: [
2324
+ {
2325
+ type: "text",
2326
+ text: `Archived ${groupTabs.length} tab(s) for thread ${taskThreadId} (tabs closed)`,
2327
+ },
2328
+ ],
2329
+ };
891
2330
  }
892
- return { content: [{ type: "text", text: `Archived ${groupTabs.length} tab(s) for thread ${taskThreadId} (tabs closed)` }] };
893
- }
894
- case "list_archived": {
895
- const { archiveStore } = await import("./tab-group-archive.js");
896
- const entries = await archiveStore.loadAll();
897
- if (entries.length === 0) {
898
- return { content: [{ type: "text", text: "No archived tab groups." }] };
2331
+ case "list_archived": {
2332
+ const { archiveStore } = await import("./tab-group-archive.js");
2333
+ const entries = await archiveStore.loadAll();
2334
+ if (entries.length === 0) {
2335
+ return { content: [{ type: "text", text: "No archived tab groups." }] };
2336
+ }
2337
+ const lines = entries.map((e) => {
2338
+ const date = e.archivedAt
2339
+ ? new Date(e.archivedAt).toLocaleDateString()
2340
+ : "unknown";
2341
+ return `[${e.taskThreadId}] "${e.title || "(untitled)"}" (${e.urls.length} tabs, ${e.status}, archived ${date})`;
2342
+ });
2343
+ return {
2344
+ content: [
2345
+ {
2346
+ type: "text",
2347
+ text: `Archived tab groups (${entries.length}):\n${lines.join("\n")}`,
2348
+ },
2349
+ ],
2350
+ };
899
2351
  }
900
- const lines = entries.map((e) => {
901
- const date = e.archivedAt ? new Date(e.archivedAt).toLocaleDateString() : "unknown";
902
- return `[${e.taskThreadId}] "${e.title || "(untitled)"}" (${e.urls.length} tabs, ${e.status}, archived ${date})`;
903
- });
904
- return { content: [{ type: "text", text: `Archived tab groups (${entries.length}):\n${lines.join("\n")}` }] };
2352
+ default:
2353
+ return {
2354
+ content: [
2355
+ {
2356
+ type: "text",
2357
+ text: `Unknown action: ${action}. Use: list, list_tabs, create, update, move, ungroup, delete, save_group, restore_group, archive_group, list_archived`,
2358
+ },
2359
+ ],
2360
+ isError: true,
2361
+ };
905
2362
  }
906
- default:
2363
+ }
2364
+ catch (tgError) {
2365
+ const msg = tgError instanceof Error ? tgError.message : String(tgError);
2366
+ if (msg.includes("extension") ||
2367
+ msg.includes("service worker") ||
2368
+ msg.includes("Bridge")) {
907
2369
  return {
908
- content: [{ type: "text", text: `Unknown action: ${action}. Use: list, list_tabs, create, update, move, ungroup, delete, save_group, restore_group, archive_group, list_archived` }],
2370
+ content: [
2371
+ {
2372
+ type: "text",
2373
+ text: `Tab Groups Bridge extension not connected.\n\n` +
2374
+ `To use tab groups:\n` +
2375
+ `1. Open comet://extensions in Comet\n` +
2376
+ `2. Enable "Developer mode"\n` +
2377
+ `3. Click "Load unpacked" and select the extension/ folder from comet-mcp\n` +
2378
+ `4. Try again\n\n` +
2379
+ `Error: ${msg}`,
2380
+ },
2381
+ ],
909
2382
  isError: true,
910
2383
  };
2384
+ }
2385
+ throw tgError;
911
2386
  }
912
2387
  }
913
- catch (tgError) {
914
- const msg = tgError instanceof Error ? tgError.message : String(tgError);
915
- if (msg.includes("extension") || msg.includes("service worker") || msg.includes("Bridge")) {
916
- return {
917
- content: [{
918
- type: "text",
919
- text: `Tab Groups Bridge extension not connected.\n\n` +
920
- `To use tab groups:\n` +
921
- `1. Open comet://extensions in Comet\n` +
922
- `2. Enable "Developer mode"\n` +
923
- `3. Click "Load unpacked" and select the extension/ folder from comet-mcp\n` +
924
- `4. Try again\n\n` +
925
- `Error: ${msg}`,
926
- }],
927
- isError: true,
928
- };
929
- }
930
- throw tgError;
931
- }
932
- }
933
- case "comet_lifecycle_start": {
934
- const result = await callLifecycleEndpoint({
935
- action: "start",
936
- runId: args?.runId,
937
- taskThreadId: args?.taskThreadId,
938
- agentId: args?.agentId,
939
- route: args?.route || "mcp",
940
- deferred: args?.deferred,
941
- });
942
- try {
943
- const env = createMCPLifecycleEnvelope({
2388
+ case "comet_lifecycle_start": {
2389
+ const result = await callLifecycleEndpoint({
2390
+ action: "start",
944
2391
  runId: args?.runId,
945
2392
  taskThreadId: args?.taskThreadId,
946
2393
  agentId: args?.agentId,
947
- toolName: "comet_lifecycle_start",
2394
+ route: args?.route || "mcp",
2395
+ deferred: args?.deferred,
948
2396
  });
949
- emitLifecycleEvent("start", env, { persist: true });
2397
+ const binding = await attachRunIdToCurrentBinding(args?.runId, args?.bindingId);
2398
+ try {
2399
+ const env = createMCPLifecycleEnvelope({
2400
+ runId: args?.runId,
2401
+ taskThreadId: args?.taskThreadId,
2402
+ agentId: args?.agentId,
2403
+ toolName: "comet_lifecycle_start",
2404
+ });
2405
+ emitLifecycleEvent("start", env, { persist: true });
2406
+ }
2407
+ catch {
2408
+ /* graceful degradation — HTTP result already persisted */
2409
+ }
2410
+ // Bridge to JSONL outbox for orchestration
2411
+ appendJsonl(OUTBOX_PATH, {
2412
+ ts: Math.floor(Date.now() / 1000),
2413
+ from: "comet-browser",
2414
+ to: "orchestration",
2415
+ type: "update",
2416
+ task: args?.runId,
2417
+ thread: args?.taskThreadId,
2418
+ msg: `Lifecycle started: run=${args?.runId}`,
2419
+ lifecycle: {
2420
+ action: "start",
2421
+ runId: args?.runId,
2422
+ status: "started",
2423
+ bindingId: binding?.bindingId ?? null,
2424
+ },
2425
+ });
2426
+ return {
2427
+ content: [
2428
+ {
2429
+ type: "text",
2430
+ text: binding
2431
+ ? `${result}\nBinding attached: ${binding.bindingId}`
2432
+ : `${result}\nBinding attached: none`,
2433
+ },
2434
+ ],
2435
+ };
950
2436
  }
951
- catch { /* graceful degradation — HTTP result already persisted */ }
952
- // Bridge to JSONL outbox for orchestration
953
- appendJsonl(OUTBOX_PATH, {
954
- ts: Math.floor(Date.now() / 1000),
955
- from: "comet-browser",
956
- to: "orchestration",
957
- type: "update",
958
- task: args?.runId,
959
- thread: args?.taskThreadId,
960
- msg: `Lifecycle started: run=${args?.runId}`,
961
- lifecycle: { action: "start", runId: args?.runId, status: "started" },
962
- });
963
- return { content: [{ type: "text", text: result }] };
964
- }
965
- case "comet_lifecycle_complete": {
966
- const result = await callLifecycleEndpoint({
967
- action: "complete",
968
- runId: args?.runId,
969
- });
970
- try {
971
- const env = createMCPLifecycleEnvelope({
2437
+ case "comet_lifecycle_complete": {
2438
+ const result = await callLifecycleEndpoint({
2439
+ action: "complete",
972
2440
  runId: args?.runId,
973
- toolName: "comet_lifecycle_complete",
974
2441
  });
975
- emitLifecycleEvent("complete", env, { persist: true });
2442
+ const binding = await transitionBindingByRunId(args?.runId, "completed");
2443
+ try {
2444
+ const env = createMCPLifecycleEnvelope({
2445
+ runId: args?.runId,
2446
+ toolName: "comet_lifecycle_complete",
2447
+ });
2448
+ emitLifecycleEvent("complete", env, { persist: true });
2449
+ }
2450
+ catch {
2451
+ /* graceful degradation */
2452
+ }
2453
+ // Bridge to JSONL outbox
2454
+ appendJsonl(OUTBOX_PATH, {
2455
+ ts: Math.floor(Date.now() / 1000),
2456
+ from: "comet-browser",
2457
+ to: "orchestration",
2458
+ type: "complete",
2459
+ task: args?.runId,
2460
+ msg: `Lifecycle completed: run=${args?.runId}`,
2461
+ lifecycle: {
2462
+ action: "complete",
2463
+ runId: args?.runId,
2464
+ status: "completed",
2465
+ bindingId: binding?.bindingId ?? null,
2466
+ },
2467
+ });
2468
+ return {
2469
+ content: [
2470
+ {
2471
+ type: "text",
2472
+ text: binding
2473
+ ? `${result}\nBinding transitioned: ${binding.bindingId} -> completed`
2474
+ : `${result}\nBinding transitioned: none`,
2475
+ },
2476
+ ],
2477
+ };
976
2478
  }
977
- catch { /* graceful degradation */ }
978
- // Bridge to JSONL outbox
979
- appendJsonl(OUTBOX_PATH, {
980
- ts: Math.floor(Date.now() / 1000),
981
- from: "comet-browser",
982
- to: "orchestration",
983
- type: "complete",
984
- task: args?.runId,
985
- msg: `Lifecycle completed: run=${args?.runId}`,
986
- lifecycle: { action: "complete", runId: args?.runId, status: "completed" },
987
- });
988
- return { content: [{ type: "text", text: result }] };
989
- }
990
- case "comet_lifecycle_abort": {
991
- const result = await callLifecycleEndpoint({
992
- action: "abort",
993
- runId: args?.runId,
994
- reason: args?.reason,
995
- });
996
- try {
997
- const env = createMCPLifecycleEnvelope({
2479
+ case "comet_lifecycle_abort": {
2480
+ const result = await callLifecycleEndpoint({
2481
+ action: "abort",
998
2482
  runId: args?.runId,
999
- toolName: "comet_lifecycle_abort",
2483
+ reason: args?.reason,
2484
+ });
2485
+ const binding = await transitionBindingByRunId(args?.runId, "stale");
2486
+ try {
2487
+ const env = createMCPLifecycleEnvelope({
2488
+ runId: args?.runId,
2489
+ toolName: "comet_lifecycle_abort",
2490
+ });
2491
+ emitLifecycleEvent("abort", env, { persist: true });
2492
+ }
2493
+ catch {
2494
+ /* graceful degradation */
2495
+ }
2496
+ // Bridge to JSONL outbox
2497
+ appendJsonl(OUTBOX_PATH, {
2498
+ ts: Math.floor(Date.now() / 1000),
2499
+ from: "comet-browser",
2500
+ to: "orchestration",
2501
+ type: "blocked",
2502
+ task: args?.runId,
2503
+ msg: `Lifecycle aborted: run=${args?.runId}${args?.reason ? ` reason=${args.reason}` : ""}`,
2504
+ lifecycle: {
2505
+ action: "abort",
2506
+ runId: args?.runId,
2507
+ status: "aborted",
2508
+ reason: args?.reason,
2509
+ bindingId: binding?.bindingId ?? null,
2510
+ },
1000
2511
  });
1001
- emitLifecycleEvent("abort", env, { persist: true });
2512
+ return {
2513
+ content: [
2514
+ {
2515
+ type: "text",
2516
+ text: binding
2517
+ ? `${result}\nBinding transitioned: ${binding.bindingId} -> stale`
2518
+ : `${result}\nBinding transitioned: none`,
2519
+ },
2520
+ ],
2521
+ };
1002
2522
  }
1003
- catch { /* graceful degradation */ }
1004
- // Bridge to JSONL outbox
1005
- appendJsonl(OUTBOX_PATH, {
1006
- ts: Math.floor(Date.now() / 1000),
1007
- from: "comet-browser",
1008
- to: "orchestration",
1009
- type: "blocked",
1010
- task: args?.runId,
1011
- msg: `Lifecycle aborted: run=${args?.runId}${args?.reason ? ` reason=${args.reason}` : ""}`,
1012
- lifecycle: { action: "abort", runId: args?.runId, status: "aborted", reason: args?.reason },
1013
- });
1014
- return { content: [{ type: "text", text: result }] };
1015
- }
1016
- case "comet_lifecycle_update": {
1017
- const result = await callLifecycleEndpoint({
1018
- action: "update",
1019
- runId: args?.runId,
1020
- auditSessionId: args?.auditSessionId,
1021
- tabGroupId: args?.tabGroupId,
1022
- workflowId: args?.workflowId,
1023
- metadata: args?.metadata,
1024
- });
1025
- try {
1026
- const env = createMCPLifecycleEnvelope({
2523
+ case "comet_lifecycle_update": {
2524
+ const result = await callLifecycleEndpoint({
2525
+ action: "update",
1027
2526
  runId: args?.runId,
1028
- toolName: "comet_lifecycle_update",
2527
+ auditSessionId: args?.auditSessionId,
2528
+ tabGroupId: args?.tabGroupId,
2529
+ workflowId: args?.workflowId,
2530
+ metadata: args?.metadata,
1029
2531
  });
1030
- emitLifecycleEvent("update", env, { persist: true });
2532
+ try {
2533
+ const env = createMCPLifecycleEnvelope({
2534
+ runId: args?.runId,
2535
+ toolName: "comet_lifecycle_update",
2536
+ });
2537
+ emitLifecycleEvent("update", env, { persist: true });
2538
+ }
2539
+ catch {
2540
+ /* graceful degradation */
2541
+ }
2542
+ return { content: [{ type: "text", text: result }] };
1031
2543
  }
1032
- catch { /* graceful degradation */ }
1033
- return { content: [{ type: "text", text: result }] };
1034
- }
1035
- case "comet_task_status": {
1036
- const groupId = args?.groupId;
1037
- const threadId = args?.threadId;
1038
- if (!groupId && !threadId) {
1039
- return { content: [{ type: "text", text: "Error: provide groupId or threadId" }], isError: true };
2544
+ case "comet_task_status": {
2545
+ const bindingId = args?.bindingId;
2546
+ const sessionKey = args?.sessionKey;
2547
+ const projectThreadId = args?.projectThreadId;
2548
+ const runId = args?.runId;
2549
+ const sidecarResultId = args?.sidecarResultId;
2550
+ const sidecarContextKey = args?.sidecarContextKey;
2551
+ const groupId = args?.groupId;
2552
+ const threadId = args?.threadId;
2553
+ if (!bindingId &&
2554
+ !sessionKey &&
2555
+ !projectThreadId &&
2556
+ !runId &&
2557
+ !sidecarResultId &&
2558
+ !sidecarContextKey &&
2559
+ !groupId &&
2560
+ !threadId) {
2561
+ return {
2562
+ content: [
2563
+ {
2564
+ type: "text",
2565
+ text: "Error: provide bindingId, sessionKey, projectThreadId, runId, sidecarResultId, sidecarContextKey, groupId, or threadId",
2566
+ },
2567
+ ],
2568
+ isError: true,
2569
+ };
2570
+ }
2571
+ const sidecarArtifact = sidecarResultId
2572
+ ? await sidecarArtifactStore.get(sidecarResultId)
2573
+ : sidecarContextKey
2574
+ ? await sidecarArtifactStore.latestForContext(sidecarContextKey)
2575
+ : null;
2576
+ const authorizedBinding = sidecarArtifact
2577
+ ? await resolveBoundSession(sessionRegistry.getCurrent(), {
2578
+ bindingId: sidecarArtifact.bindingId,
2579
+ })
2580
+ : null;
2581
+ const manifest = readJsonSafe(MANIFEST_PATH);
2582
+ const sessions = manifest?.sessions || [];
2583
+ let liveTabCountsByGroupId = new Map();
2584
+ try {
2585
+ const { tabGroupsClient } = await import("./tab-groups.js");
2586
+ const liveTabs = await tabGroupsClient.listTabs();
2587
+ liveTabCountsByGroupId = liveTabs.reduce((counts, tab) => {
2588
+ if (typeof tab.groupId === "number" && tab.groupId !== -1) {
2589
+ counts.set(tab.groupId, (counts.get(tab.groupId) ?? 0) + 1);
2590
+ }
2591
+ return counts;
2592
+ }, new Map());
2593
+ }
2594
+ catch {
2595
+ /* Extension data unavailable; fall back to manifest-only status. */
2596
+ }
2597
+ // Filter sessions. Multiple query keys narrow the result; they do not broaden it.
2598
+ const matched = sessions.filter((s) => {
2599
+ const checks = [];
2600
+ if (bindingId)
2601
+ checks.push(s.codexBinding?.bindingId === bindingId);
2602
+ if (sessionKey)
2603
+ checks.push(s.sessionKey === sessionKey);
2604
+ if (projectThreadId)
2605
+ checks.push(s.codexBinding?.projectThreadId === projectThreadId);
2606
+ if (runId) {
2607
+ checks.push(Array.isArray(s.codexBinding?.runIds) && s.codexBinding.runIds.includes(runId));
2608
+ }
2609
+ if (groupId !== undefined)
2610
+ checks.push(s.tabGroupId === groupId);
2611
+ if (threadId)
2612
+ checks.push(s.taskThreadId === threadId);
2613
+ return checks.length > 0 && checks.every(Boolean);
2614
+ });
2615
+ if (matched.length === 0) {
2616
+ if (sidecarArtifact) {
2617
+ return {
2618
+ content: [
2619
+ {
2620
+ type: "text",
2621
+ text: JSON.stringify({
2622
+ bindingId: authorizedBinding?.binding.bindingId,
2623
+ sidecarArtifact,
2624
+ }, null, 2),
2625
+ },
2626
+ ],
2627
+ };
2628
+ }
2629
+ return {
2630
+ content: [
2631
+ {
2632
+ type: "text",
2633
+ text: `No sessions found for binding-scoped query ${JSON.stringify({ bindingId, sessionKey, projectThreadId, runId, sidecarResultId, sidecarContextKey, groupId, threadId })}`,
2634
+ },
2635
+ ],
2636
+ };
2637
+ }
2638
+ // Try to get recent ring buffer events via CDP
2639
+ let recentEvents = [];
2640
+ try {
2641
+ const { tabGroupsClient } = await import("./tab-groups.js");
2642
+ // Reuse the tab groups client's CDP path to find service worker
2643
+ const response = await fetch(`http://127.0.0.1:9222/json/list`);
2644
+ const targets = (await response.json());
2645
+ const sw = targets.find((t) => t.type === "service_worker" && t.webSocketDebuggerUrl);
2646
+ if (sw) {
2647
+ // We can't easily eval here without ws, so skip ring buffer in MCP context
2648
+ // Ring buffer events are aggregated by comet-event-aggregator.py instead
2649
+ }
2650
+ }
2651
+ catch {
2652
+ /* CDP not available */
2653
+ }
2654
+ const result = matched.map((s) => ({
2655
+ bindingId: s.codexBinding?.bindingId ?? null,
2656
+ sessionKey: s.sessionKey,
2657
+ groupId: s.tabGroupId,
2658
+ threadId: s.taskThreadId,
2659
+ status: s.agent?.status || "unknown",
2660
+ agentId: s.agentId || s.agent?.id || null,
2661
+ tabs: typeof s.tabGroupId === "number" && liveTabCountsByGroupId.has(s.tabGroupId)
2662
+ ? liveTabCountsByGroupId.get(s.tabGroupId)
2663
+ : s.tabs?.length || 0,
2664
+ metrics: s.metrics || {},
2665
+ links: s.links || {},
2666
+ sidecarArtifact: sidecarArtifact?.bindingId === s.codexBinding?.bindingId ? sidecarArtifact : null,
2667
+ lastActivity: s.metrics?.lastActivityAt || null,
2668
+ }));
2669
+ return {
2670
+ content: [
2671
+ {
2672
+ type: "text",
2673
+ text: JSON.stringify(result.length === 1 ? result[0] : result, null, 2),
2674
+ },
2675
+ ],
2676
+ };
1040
2677
  }
1041
- const manifest = readJsonSafe(MANIFEST_PATH);
1042
- const sessions = manifest?.sessions || [];
1043
- // Filter sessions
1044
- const matched = sessions.filter((s) => {
1045
- if (groupId !== undefined && s.groupId === groupId)
1046
- return true;
1047
- if (threadId && s.threadId === threadId)
1048
- return true;
1049
- return false;
1050
- });
1051
- if (matched.length === 0) {
1052
- return { content: [{ type: "text", text: `No sessions found for ${groupId ? `groupId=${groupId}` : `threadId=${threadId}`}` }] };
2678
+ case "comet_delegate": {
2679
+ const threadId = args?.threadId;
2680
+ const instruction = args?.instruction;
2681
+ const priority = args?.priority || "P3";
2682
+ const urls = args?.urls || [];
2683
+ const dependsOn = args?.dependsOn || [];
2684
+ const agentId = args?.agentId || "comet-mcp";
2685
+ if (!threadId || !instruction) {
2686
+ return {
2687
+ content: [{ type: "text", text: "Error: threadId and instruction are required" }],
2688
+ isError: true,
2689
+ };
2690
+ }
2691
+ const taskId = `comet-${Date.now()}`;
2692
+ const priorityColors = {
2693
+ P1: "red",
2694
+ P2: "yellow",
2695
+ P3: "blue",
2696
+ P4: "grey",
2697
+ };
2698
+ const color = priorityColors[priority] || "blue";
2699
+ // 1. Write to inbox-comet.jsonl
2700
+ appendJsonl(INBOX_PATH, {
2701
+ ts: Math.floor(Date.now() / 1000),
2702
+ from: "comet-delegate",
2703
+ to: "comet-browser",
2704
+ task: taskId,
2705
+ type: "instruction",
2706
+ thread: threadId,
2707
+ priority,
2708
+ msg: instruction,
2709
+ browser_task: {
2710
+ thread_id: threadId,
2711
+ group_name: threadId.slice(0, 50),
2712
+ color,
2713
+ priority,
2714
+ urls,
2715
+ depends_on: dependsOn,
2716
+ },
2717
+ });
2718
+ // 2. Start lifecycle tracking
2719
+ let lifecycleResult = "skipped";
2720
+ try {
2721
+ lifecycleResult = await callLifecycleEndpoint({
2722
+ action: "start",
2723
+ runId: taskId,
2724
+ taskThreadId: threadId,
2725
+ agentId,
2726
+ route: "mcp",
2727
+ });
2728
+ }
2729
+ catch {
2730
+ /* CC-SO may not be running */
2731
+ }
2732
+ // 3. Create/reuse the Codex window binding directly.
2733
+ const currentSession = sessionRegistry.getCurrent();
2734
+ const bindingDispatch = await createOrReuseDelegateBinding({
2735
+ taskId,
2736
+ threadId,
2737
+ agentId,
2738
+ currentBinding: currentSession?.codexBinding ?? null,
2739
+ bindingId: args?.bindingId,
2740
+ codexSessionId: args?.codexSessionId ??
2741
+ currentSession?.codexIdentity?.codexSessionId,
2742
+ projectThreadId: args?.projectThreadId ?? threadId,
2743
+ projectThreadFamily: args?.projectThreadFamily ??
2744
+ currentSession?.codexIdentity?.projectThreadFamily,
2745
+ worktreePath: args?.worktreePath ??
2746
+ currentSession?.codexIdentity?.worktreePath,
2747
+ repoSlug: args?.repoSlug ?? currentSession?.codexIdentity?.repoSlug,
2748
+ branchName: args?.branchName ?? currentSession?.codexIdentity?.branchName,
2749
+ sessionKey: args?.sessionKey ?? currentSession?.codexIdentity?.sessionKey,
2750
+ role: args?.codexSessionRole ?? currentSession?.codexIdentity?.role,
2751
+ windowId: args?.windowId,
2752
+ tabGroupId: args?.tabGroupId ?? args?.groupId,
2753
+ targetId: args?.targetId,
2754
+ });
2755
+ // 4. Bridge to outbox
2756
+ appendJsonl(OUTBOX_PATH, {
2757
+ ts: Math.floor(Date.now() / 1000),
2758
+ from: "comet-browser",
2759
+ to: "orchestration",
2760
+ type: "update",
2761
+ task: taskId,
2762
+ thread: threadId,
2763
+ msg: `Delegated: ${instruction.slice(0, 100)}`,
2764
+ lifecycle: { action: "start", runId: taskId, status: "dispatched" },
2765
+ });
2766
+ const summary = [
2767
+ `Task delegated successfully.`,
2768
+ ` Task ID: ${taskId}`,
2769
+ ` Thread: ${threadId}`,
2770
+ ` Priority: ${priority} (color: ${color})`,
2771
+ ` URLs: ${urls.length > 0 ? urls.join(", ") : "(from thread metadata)"}`,
2772
+ ` Lifecycle: ${lifecycleResult === "skipped" ? "skipped (CC-SO unavailable)" : "started"}`,
2773
+ ` Binding ID: ${bindingDispatch.bindingId ?? "none"}`,
2774
+ ` Window ID: ${bindingDispatch.windowId ?? "none"}`,
2775
+ ` Tab Group ID: ${bindingDispatch.tabGroupId ?? "none"}`,
2776
+ ` Dispatch: ${bindingDispatch.dispatchStatus}`,
2777
+ ].join("\n");
2778
+ return { content: [{ type: "text", text: summary }] };
1053
2779
  }
1054
- // Try to get recent ring buffer events via CDP
1055
- let recentEvents = [];
1056
- try {
1057
- const { tabGroupsClient } = await import("./tab-groups.js");
1058
- // Reuse the tab groups client's CDP path to find service worker
1059
- const response = await fetch(`http://127.0.0.1:9222/json/list`);
1060
- const targets = (await response.json());
1061
- const sw = targets.find((t) => t.type === "service_worker" && t.webSocketDebuggerUrl);
1062
- if (sw) {
1063
- // We can't easily eval here without ws, so skip ring buffer in MCP context
1064
- // Ring buffer events are aggregated by comet-event-aggregator.py instead
2780
+ case "comet_observe": {
2781
+ const { getHealth, getSnapshot, getStatus, getDetail, formatHealth, formatSnapshot } = await import("./observer.js");
2782
+ const observeAction = args?.action;
2783
+ const observeIdentity = sessionRegistry.getCurrent()?.codexIdentity;
2784
+ const observeFilters = {
2785
+ group: args?.group,
2786
+ agentId: args?.agentId,
2787
+ urlPattern: args?.urlPattern,
2788
+ thumbnails: args?.thumbnails || false,
2789
+ codexIdentity: observeIdentity,
2790
+ };
2791
+ switch (observeAction) {
2792
+ case "health": {
2793
+ const health = await getHealth();
2794
+ const isErr = !health.running;
2795
+ return {
2796
+ content: [{ type: "text", text: formatHealth(health) }],
2797
+ ...(isErr ? { isError: true } : {}),
2798
+ };
2799
+ }
2800
+ case "snapshot": {
2801
+ const snapshot = await getSnapshot(observeFilters);
2802
+ if (!snapshot.browser.cdpConnected) {
2803
+ return {
2804
+ content: [
2805
+ {
2806
+ type: "text",
2807
+ text: "Comet browser is not running. Start with: node scripts/session.mjs start --profile oe",
2808
+ },
2809
+ ],
2810
+ isError: true,
2811
+ };
2812
+ }
2813
+ return { content: [{ type: "text", text: formatSnapshot(snapshot) }] };
2814
+ }
2815
+ case "status": {
2816
+ const statusText = await getStatus(observeFilters);
2817
+ const isStatusErr = statusText.includes("not running");
2818
+ return {
2819
+ content: [{ type: "text", text: statusText }],
2820
+ ...(isStatusErr ? { isError: true } : {}),
2821
+ };
2822
+ }
2823
+ case "detail": {
2824
+ if (!observeFilters.group) {
2825
+ return {
2826
+ content: [
2827
+ {
2828
+ type: "text",
2829
+ text: "The 'detail' action requires a 'group' parameter (tab group name).",
2830
+ },
2831
+ ],
2832
+ isError: true,
2833
+ };
2834
+ }
2835
+ const detailText = await getDetail(observeFilters.group, observeFilters);
2836
+ const isDetailErr = detailText.includes("not found") || detailText.includes("not running");
2837
+ return {
2838
+ content: [{ type: "text", text: detailText }],
2839
+ ...(isDetailErr ? { isError: true } : {}),
2840
+ };
2841
+ }
2842
+ default:
2843
+ return {
2844
+ content: [
2845
+ {
2846
+ type: "text",
2847
+ text: `Unknown observe action: ${observeAction}. Use: snapshot, status, detail, or health.`,
2848
+ },
2849
+ ],
2850
+ isError: true,
2851
+ };
1065
2852
  }
1066
2853
  }
1067
- catch { /* CDP not available */ }
1068
- const result = matched.map((s) => ({
1069
- groupId: s.groupId,
1070
- groupName: s.groupName,
1071
- groupColor: s.groupColor,
1072
- threadId: s.threadId,
1073
- status: s.agent?.status || "unknown",
1074
- agentId: s.agent?.id || null,
1075
- tabs: s.tabs?.length || 0,
1076
- metrics: s.metrics || {},
1077
- links: s.links || {},
1078
- lastActivity: s.metrics?.lastActivityAt || null,
1079
- }));
1080
- return { content: [{ type: "text", text: JSON.stringify(result.length === 1 ? result[0] : result, null, 2) }] };
1081
- }
1082
- case "comet_delegate": {
1083
- const threadId = args?.threadId;
1084
- const instruction = args?.instruction;
1085
- const priority = args?.priority || "P3";
1086
- const urls = args?.urls || [];
1087
- const dependsOn = args?.dependsOn || [];
1088
- const agentId = args?.agentId || "comet-mcp";
1089
- if (!threadId || !instruction) {
1090
- return { content: [{ type: "text", text: "Error: threadId and instruction are required" }], isError: true };
2854
+ // ── Safe observation (no session required) ──
2855
+ case "comet_peek": {
2856
+ const peekTargetId = args?.targetId;
2857
+ const peekAction = args?.action;
2858
+ if (!peekTargetId || !peekAction) {
2859
+ return {
2860
+ content: [
2861
+ {
2862
+ type: "text",
2863
+ text: "Error: targetId and action are required. Use comet_observe(action='snapshot') to find target IDs.",
2864
+ },
2865
+ ],
2866
+ isError: true,
2867
+ };
2868
+ }
2869
+ // Resolve the WebSocket URL for this target — raw HTTP, no session needed
2870
+ const peekTargetsResp = await fetch(`http://127.0.0.1:9222/json/list`);
2871
+ if (!peekTargetsResp.ok) {
2872
+ return {
2873
+ content: [
2874
+ {
2875
+ type: "text",
2876
+ text: "Error: Cannot connect to CDP on port 9222. Is Comet browser running?",
2877
+ },
2878
+ ],
2879
+ isError: true,
2880
+ };
2881
+ }
2882
+ const peekTargets = (await peekTargetsResp.json());
2883
+ const peekTarget = peekTargets.find((t) => t.id === peekTargetId);
2884
+ if (!peekTarget) {
2885
+ return {
2886
+ content: [
2887
+ {
2888
+ type: "text",
2889
+ text: `Error: Target ID '${peekTargetId}' not found. Use comet_observe(action='snapshot') to list current targets.`,
2890
+ },
2891
+ ],
2892
+ isError: true,
2893
+ };
2894
+ }
2895
+ if (peekAction === "info") {
2896
+ return {
2897
+ content: [
2898
+ {
2899
+ type: "text",
2900
+ text: `Tab Info (read-only peek)\nTitle: ${peekTarget.title}\nURL: ${peekTarget.url}\nType: ${peekTarget.type}\nTarget ID: ${peekTarget.id}`,
2901
+ },
2902
+ ],
2903
+ };
2904
+ }
2905
+ // Temporarily attach to target for screenshot/read — then immediately disconnect
2906
+ const peekCDP = await (await import("chrome-remote-interface")).default({
2907
+ target: peekTarget.webSocketDebuggerUrl,
2908
+ });
2909
+ try {
2910
+ if (peekAction === "screenshot") {
2911
+ await peekCDP.Page.enable();
2912
+ const ssResult = (await Promise.race([
2913
+ peekCDP.Page.captureScreenshot({ format: "png" }),
2914
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Screenshot timeout (3s)")), 3000)),
2915
+ ]));
2916
+ const peekOutputDir = join(homedir(), ".claude", "comet-browser", "output");
2917
+ mkdirSync(peekOutputDir, { recursive: true });
2918
+ const peekPath = join(peekOutputDir, `peek-${Date.now()}.png`);
2919
+ writeFileSync(peekPath, Buffer.from(ssResult.data, "base64"));
2920
+ return {
2921
+ content: [
2922
+ {
2923
+ type: "text",
2924
+ text: `Screenshot saved: ${peekPath}\nTab: ${peekTarget.title}\nURL: ${peekTarget.url}`,
2925
+ },
2926
+ ],
2927
+ };
2928
+ }
2929
+ if (peekAction === "read") {
2930
+ await peekCDP.Runtime.enable();
2931
+ const readResult = await peekCDP.Runtime.evaluate({
2932
+ expression: `(() => {
2933
+ const title = document.title;
2934
+ const url = window.location.href;
2935
+ const text = document.body?.innerText?.substring(0, 10000) || '';
2936
+ const links = Array.from(document.querySelectorAll('a[href]')).slice(0, 20).map(a => ({ text: a.innerText.trim().substring(0, 50), href: a.href }));
2937
+ return JSON.stringify({ title, url, text, links });
2938
+ })()`,
2939
+ returnByValue: true,
2940
+ });
2941
+ const pageData = JSON.parse(readResult.result.value);
2942
+ return {
2943
+ content: [
2944
+ {
2945
+ type: "text",
2946
+ text: `Page Content (read-only peek)\nTitle: ${pageData.title}\nURL: ${pageData.url}\n\n${pageData.text.substring(0, 5000)}${pageData.text.length > 5000 ? "\n\n... (truncated)" : ""}`,
2947
+ },
2948
+ ],
2949
+ };
2950
+ }
2951
+ return {
2952
+ content: [
2953
+ {
2954
+ type: "text",
2955
+ text: `Unknown peek action: ${peekAction}. Use: screenshot, read, or info.`,
2956
+ },
2957
+ ],
2958
+ isError: true,
2959
+ };
2960
+ }
2961
+ finally {
2962
+ try {
2963
+ await peekCDP.close();
2964
+ }
2965
+ catch {
2966
+ /* ignore */
2967
+ }
2968
+ }
1091
2969
  }
1092
- const taskId = `comet-${Date.now()}`;
1093
- const priorityColors = { P1: "red", P2: "yellow", P3: "blue", P4: "grey" };
1094
- const color = priorityColors[priority] || "blue";
1095
- // 1. Write to inbox-comet.jsonl
1096
- appendJsonl(INBOX_PATH, {
1097
- ts: Math.floor(Date.now() / 1000),
1098
- from: "comet-delegate",
1099
- to: "comet-browser",
1100
- task: taskId,
1101
- type: "instruction",
1102
- thread: threadId,
1103
- priority,
1104
- msg: instruction,
1105
- browser_task: {
1106
- thread_id: threadId,
1107
- group_name: threadId.slice(0, 50),
1108
- color,
1109
- priority,
1110
- urls,
1111
- depends_on: dependsOn,
1112
- },
1113
- });
1114
- // 2. Start lifecycle tracking
1115
- let lifecycleResult = "skipped";
1116
- try {
1117
- lifecycleResult = await callLifecycleEndpoint({
1118
- action: "start",
1119
- runId: taskId,
1120
- taskThreadId: threadId,
1121
- agentId,
1122
- route: "mcp",
2970
+ // ── Parity tools ──
2971
+ case "comet_pdf": {
2972
+ const pdfUrl = args?.url;
2973
+ const pdfName = args?.name;
2974
+ const pdfFormat = args?.format || "Letter";
2975
+ const pdfLandscape = args?.landscape || false;
2976
+ const pdfMargin = args?.margin ?? 0.5;
2977
+ const pdfScale = args?.scale || 1;
2978
+ const pdfPrintBg = args?.printBackground !== false;
2979
+ const pdfHideSelectors = args?.hideSelectors;
2980
+ // Navigate if URL provided
2981
+ if (pdfUrl) {
2982
+ await cometClient.navigate(pdfUrl, true, true);
2983
+ }
2984
+ // Hide elements if requested
2985
+ if (pdfHideSelectors) {
2986
+ const selectors = pdfHideSelectors.split(",").map((s) => s.trim());
2987
+ for (const sel of selectors) {
2988
+ await cometClient.evaluate(`document.querySelectorAll(${safeSelector(sel)}).forEach(el => el.style.display = 'none')`);
2989
+ }
2990
+ }
2991
+ // Paper dimensions in inches
2992
+ const paperSizes = {
2993
+ Letter: { width: 8.5, height: 11 },
2994
+ Legal: { width: 8.5, height: 14 },
2995
+ A4: { width: 8.27, height: 11.69 },
2996
+ A3: { width: 11.69, height: 16.54 },
2997
+ Tabloid: { width: 11, height: 17 },
2998
+ };
2999
+ const paper = paperSizes[pdfFormat] || paperSizes.Letter;
3000
+ // Use CDP Page.printToPDF
3001
+ const cdp = cometClient.protocol;
3002
+ const pdfResult = await cdp.send("Page.printToPDF", {
3003
+ landscape: pdfLandscape,
3004
+ printBackground: pdfPrintBg,
3005
+ scale: pdfScale,
3006
+ paperWidth: paper.width,
3007
+ paperHeight: paper.height,
3008
+ marginTop: pdfMargin,
3009
+ marginBottom: pdfMargin,
3010
+ marginLeft: pdfMargin,
3011
+ marginRight: pdfMargin,
1123
3012
  });
3013
+ // Save to output directory
3014
+ const outputDir = join(homedir(), ".claude", "comet-browser", "output");
3015
+ mkdirSync(outputDir, { recursive: true });
3016
+ const titleResult = await cometClient.evaluate("document.title");
3017
+ const pageTitle = titleResult.result.value || "page";
3018
+ const safeName = pdfName || pageTitle.replace(/[^a-zA-Z0-9-_]/g, "_").substring(0, 60);
3019
+ const outputPath = join(outputDir, `${safeName}-${Date.now()}.pdf`);
3020
+ const pdfBuffer = Buffer.from(pdfResult.data, "base64");
3021
+ writeFileSync(outputPath, pdfBuffer);
3022
+ return {
3023
+ content: [
3024
+ {
3025
+ type: "text",
3026
+ text: `PDF saved: ${outputPath}\nFormat: ${pdfFormat}${pdfLandscape ? " (landscape)" : ""}\nSize: ${(pdfBuffer.length / 1024).toFixed(1)} KB`,
3027
+ },
3028
+ ],
3029
+ };
1124
3030
  }
1125
- catch { /* CC-SO may not be running */ }
1126
- // 3. Dispatch via session-controller.mjs
1127
- let dispatchResult = "skipped";
1128
- const controllerPath = join(homedir(), ".claude", "comet-browser", "session-controller.mjs");
1129
- try {
1130
- const dispatchArgs = ["dispatch", "--thread", threadId];
1131
- if (agentId)
1132
- dispatchArgs.push("--agent", agentId);
1133
- const { stdout } = await execFileAsync("node", [controllerPath, ...dispatchArgs], { timeout: 15000 });
1134
- dispatchResult = stdout.trim();
3031
+ case "comet_scrape": {
3032
+ const scrapeUrl = args?.url;
3033
+ const scrapeSelector = args?.selector;
3034
+ const scrapeMode = args?.mode || "text";
3035
+ const scrapeAttr = args?.attr;
3036
+ const scrapeScroll = args?.scroll || false;
3037
+ const scrapeWaitFor = args?.waitFor;
3038
+ // Navigate if URL provided
3039
+ if (scrapeUrl) {
3040
+ await cometClient.navigate(scrapeUrl, true, true);
3041
+ }
3042
+ // Wait for selector if specified
3043
+ if (scrapeWaitFor) {
3044
+ const waitStart = Date.now();
3045
+ while (Date.now() - waitStart < 10000) {
3046
+ const found = await cometClient.evaluate(`!!document.querySelector(${safeSelector(scrapeWaitFor)})`);
3047
+ if (found.result.value)
3048
+ break;
3049
+ await new Promise((r) => setTimeout(r, 300));
3050
+ }
3051
+ }
3052
+ // Auto-scroll for lazy loading
3053
+ if (scrapeScroll) {
3054
+ await cometClient.evaluate(`
3055
+ (async () => {
3056
+ let totalHeight = 0;
3057
+ const distance = 500;
3058
+ while (totalHeight < document.body.scrollHeight && totalHeight < 50000) {
3059
+ window.scrollBy(0, distance);
3060
+ totalHeight += distance;
3061
+ await new Promise(r => setTimeout(r, 200));
1135
3062
  }
1136
- catch (e) {
1137
- dispatchResult = `failed: ${e instanceof Error ? e.message : e}`;
3063
+ window.scrollTo(0, 0);
3064
+ })()
3065
+ `);
3066
+ await new Promise((r) => setTimeout(r, 500));
3067
+ }
3068
+ // Safe selector strings for browser evaluation
3069
+ const safeSel = safeSelector(scrapeSelector || "body");
3070
+ const safeAttrName = safeSelector(scrapeAttr || "href");
3071
+ let extractionScript;
3072
+ switch (scrapeMode) {
3073
+ case "table":
3074
+ extractionScript = `
3075
+ (() => {
3076
+ const table = document.querySelector(${safeSelector(scrapeSelector || "table")});
3077
+ if (!table) return { error: 'No table found' };
3078
+ const rows = [...table.querySelectorAll('tr')];
3079
+ const headers = [...rows[0]?.querySelectorAll('th, td')].map(c => c.innerText.trim());
3080
+ const data = rows.slice(1).map(row => {
3081
+ const cells = [...row.querySelectorAll('td, th')].map(c => c.innerText.trim());
3082
+ return headers.length ? Object.fromEntries(headers.map((h, i) => [h, cells[i] || ''])) : cells;
3083
+ });
3084
+ return { rows: data.length, headers, data };
3085
+ })()`;
3086
+ break;
3087
+ case "json-ld":
3088
+ extractionScript = `
3089
+ (() => {
3090
+ const scripts = [...document.querySelectorAll('script[type="application/ld+json"]')];
3091
+ const data = scripts.map(s => { try { return JSON.parse(s.textContent); } catch { return null; } }).filter(Boolean);
3092
+ return { count: data.length, data };
3093
+ })()`;
3094
+ break;
3095
+ case "list":
3096
+ extractionScript = `
3097
+ (() => {
3098
+ const sel = ${safeSelector(scrapeSelector || "ul li, ol li")};
3099
+ const items = [...document.querySelectorAll(sel)].map(el => el.innerText.trim());
3100
+ return { count: items.length, items };
3101
+ })()`;
3102
+ break;
3103
+ case "attr":
3104
+ extractionScript = `
3105
+ (() => {
3106
+ const sel = ${safeSel};
3107
+ const attr = ${safeAttrName};
3108
+ const els = [...document.querySelectorAll(sel)];
3109
+ const values = els.map(el => el.getAttribute(attr)).filter(Boolean);
3110
+ return { count: values.length, attribute: attr, values };
3111
+ })()`;
3112
+ break;
3113
+ case "multi":
3114
+ extractionScript = `
3115
+ (() => {
3116
+ const sel = ${safeSelector(scrapeSelector || "p")};
3117
+ const els = [...document.querySelectorAll(sel)];
3118
+ const items = els.map(el => ({ tag: el.tagName, text: el.innerText.trim().substring(0, 500) }));
3119
+ return { count: items.length, items };
3120
+ })()`;
3121
+ break;
3122
+ default: // text
3123
+ extractionScript = `
3124
+ (() => {
3125
+ const sel = ${safeSel};
3126
+ const el = document.querySelector(sel);
3127
+ if (!el) return { error: 'Selector not found: ' + sel };
3128
+ return { tag: el.tagName, text: el.innerText.trim().substring(0, 10000) };
3129
+ })()`;
3130
+ }
3131
+ const scrapeResult = await cometClient.evaluate(extractionScript);
3132
+ const scrapeData = scrapeResult.result.value;
3133
+ return {
3134
+ content: [
3135
+ {
3136
+ type: "text",
3137
+ text: typeof scrapeData === "string" ? scrapeData : JSON.stringify(scrapeData, null, 2),
3138
+ },
3139
+ ],
3140
+ };
1138
3141
  }
1139
- // 4. Bridge to outbox
1140
- appendJsonl(OUTBOX_PATH, {
1141
- ts: Math.floor(Date.now() / 1000),
1142
- from: "comet-browser",
1143
- to: "orchestration",
1144
- type: "update",
1145
- task: taskId,
1146
- thread: threadId,
1147
- msg: `Delegated: ${instruction.slice(0, 100)}`,
1148
- lifecycle: { action: "start", runId: taskId, status: "dispatched" },
1149
- });
1150
- const summary = [
1151
- `Task delegated successfully.`,
1152
- ` Task ID: ${taskId}`,
1153
- ` Thread: ${threadId}`,
1154
- ` Priority: ${priority} (color: ${color})`,
1155
- ` URLs: ${urls.length > 0 ? urls.join(", ") : "(from thread metadata)"}`,
1156
- ` Lifecycle: ${lifecycleResult === "skipped" ? "skipped (CC-SO unavailable)" : "started"}`,
1157
- ` Dispatch: ${dispatchResult}`,
1158
- ].join("\n");
1159
- return { content: [{ type: "text", text: summary }] };
3142
+ case "comet_network": {
3143
+ const netAction = args?.action || "capture";
3144
+ const netUrl = args?.url;
3145
+ const netDuration = args?.duration || 10000;
3146
+ const netFilter = args?.filter;
3147
+ const netResourceType = args?.resourceType;
3148
+ const netPattern = args?.pattern;
3149
+ const netMockResponse = args?.mockResponse;
3150
+ const netMockStatus = args?.mockStatus || 200;
3151
+ const netIncludeHeaders = args?.includeHeaders || false;
3152
+ // CDP.Client extends EventEmitter at runtime but types don't expose on/once/removeListener.
3153
+ // Scoped cast for event methods — only used in this network handler.
3154
+ const cdp = cometClient.protocol;
3155
+ if (netAction === "block") {
3156
+ if (!netPattern) {
3157
+ return {
3158
+ content: [{ type: "text", text: "Error: 'pattern' is required for block action." }],
3159
+ isError: true,
3160
+ };
3161
+ }
3162
+ await cdp.send("Network.enable");
3163
+ await cdp.send("Network.setBlockedURLs", { urls: [netPattern] });
3164
+ return {
3165
+ content: [
3166
+ {
3167
+ type: "text",
3168
+ text: `Blocking requests matching: ${netPattern}\nUse comet_network(action='block', pattern='') to clear.`,
3169
+ },
3170
+ ],
3171
+ };
3172
+ }
3173
+ if (netAction === "intercept") {
3174
+ if (!netPattern) {
3175
+ return {
3176
+ content: [
3177
+ { type: "text", text: "Error: 'pattern' is required for intercept action." },
3178
+ ],
3179
+ isError: true,
3180
+ };
3181
+ }
3182
+ await cdp.send("Fetch.enable", {
3183
+ patterns: [{ urlPattern: `*${netPattern}*`, requestStage: "Response" }],
3184
+ });
3185
+ // Set up a one-shot interceptor
3186
+ const interceptPromise = new Promise((resolve) => {
3187
+ const timeout = setTimeout(() => resolve("No matching request within 30s"), 30000);
3188
+ cdp.once("Fetch.requestPaused", async (params) => {
3189
+ clearTimeout(timeout);
3190
+ const body = netMockResponse || '{"mocked":true}';
3191
+ const headers = [
3192
+ { name: "Content-Type", value: "application/json" },
3193
+ { name: "Access-Control-Allow-Origin", value: "*" },
3194
+ ];
3195
+ await cdp.send("Fetch.fulfillRequest", {
3196
+ requestId: params.requestId,
3197
+ responseCode: netMockStatus,
3198
+ responseHeaders: headers,
3199
+ body: Buffer.from(body).toString("base64"),
3200
+ });
3201
+ resolve(`Intercepted: ${params.request.url}\nResponded with status ${netMockStatus}`);
3202
+ });
3203
+ });
3204
+ const interceptResult = await interceptPromise;
3205
+ await cdp.send("Fetch.disable");
3206
+ return { content: [{ type: "text", text: interceptResult }] };
3207
+ }
3208
+ // capture action
3209
+ if (netUrl) {
3210
+ await cometClient.navigate(netUrl, true, true);
3211
+ }
3212
+ await cdp.send("Network.enable");
3213
+ const captured = [];
3214
+ const responseMap = new Map();
3215
+ const onRequest = (params) => {
3216
+ if (netFilter && !params.request.url.includes(netFilter))
3217
+ return;
3218
+ if (netResourceType && params.type?.toLowerCase() !== netResourceType.toLowerCase())
3219
+ return;
3220
+ captured.push({
3221
+ requestId: params.requestId,
3222
+ method: params.request.method,
3223
+ url: params.request.url,
3224
+ type: params.type,
3225
+ ...(netIncludeHeaders ? { requestHeaders: params.request.headers } : {}),
3226
+ });
3227
+ };
3228
+ const onResponse = (params) => {
3229
+ responseMap.set(params.requestId, {
3230
+ status: params.response.status,
3231
+ mimeType: params.response.mimeType,
3232
+ ...(netIncludeHeaders ? { responseHeaders: params.response.headers } : {}),
3233
+ });
3234
+ };
3235
+ cdp.on("Network.requestWillBeSent", onRequest);
3236
+ cdp.on("Network.responseReceived", onResponse);
3237
+ await new Promise((r) => setTimeout(r, netDuration));
3238
+ cdp.removeListener("Network.requestWillBeSent", onRequest);
3239
+ cdp.removeListener("Network.responseReceived", onResponse);
3240
+ await cdp.send("Network.disable"); // Clean up CDP domain state after capture
3241
+ // Merge responses into captured requests
3242
+ for (const req of captured) {
3243
+ const resp = responseMap.get(req.requestId);
3244
+ if (resp) {
3245
+ req.status = resp.status;
3246
+ req.mimeType = resp.mimeType;
3247
+ if (resp.responseHeaders)
3248
+ req.responseHeaders = resp.responseHeaders;
3249
+ }
3250
+ delete req.requestId;
3251
+ }
3252
+ const summary = `Captured ${captured.length} requests over ${netDuration}ms${netFilter ? ` (filter: ${netFilter})` : ""}`;
3253
+ return {
3254
+ content: [
3255
+ {
3256
+ type: "text",
3257
+ text: `${summary}\n\n${JSON.stringify(captured.slice(0, 50), null, 2)}${captured.length > 50 ? `\n\n... and ${captured.length - 50} more` : ""}`,
3258
+ },
3259
+ ],
3260
+ };
3261
+ }
3262
+ case "comet_automate": {
3263
+ const autoSteps = args?.steps;
3264
+ const autoVerbose = args?.verbose || false;
3265
+ if (!autoSteps || autoSteps.length === 0) {
3266
+ return {
3267
+ content: [
3268
+ { type: "text", text: "Error: 'steps' array is required and must not be empty." },
3269
+ ],
3270
+ isError: true,
3271
+ };
3272
+ }
3273
+ const variables = {};
3274
+ const results = [];
3275
+ async function executeStep(step, index) {
3276
+ const prefix = `Step ${index + 1} [${step.tool}]`;
3277
+ try {
3278
+ switch (step.tool) {
3279
+ case "navigate":
3280
+ await cometClient.navigate(step.url, true, true);
3281
+ results.push(`✓ ${prefix}: navigated to ${step.url}`);
3282
+ break;
3283
+ case "click":
3284
+ await cometClient.evaluate(`
3285
+ (() => {
3286
+ const el = document.querySelector(${safeSelector(step.selector)});
3287
+ if (!el) throw new Error('Element not found: ' + ${safeSelector(step.selector)});
3288
+ el.scrollIntoView({ block: 'center' });
3289
+ el.click();
3290
+ return el.innerText?.substring(0, 50) || el.tagName;
3291
+ })()
3292
+ `);
3293
+ results.push(`✓ ${prefix}: clicked ${step.selector}`);
3294
+ break;
3295
+ case "fill": {
3296
+ const safeFillVal = JSON.stringify(step.value || "");
3297
+ await cometClient.evaluate(`
3298
+ (() => {
3299
+ const el = document.querySelector(${safeSelector(step.selector)});
3300
+ if (!el) throw new Error('Element not found: ' + ${safeSelector(step.selector)});
3301
+ const val = ${safeFillVal};
3302
+ const nativeSet = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
3303
+ || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
3304
+ if (nativeSet) nativeSet.call(el, val);
3305
+ else el.value = val;
3306
+ el.dispatchEvent(new Event('input', { bubbles: true }));
3307
+ el.dispatchEvent(new Event('change', { bubbles: true }));
3308
+ })()
3309
+ `);
3310
+ results.push(`✓ ${prefix}: filled ${step.selector}`);
3311
+ break;
3312
+ }
3313
+ case "type":
3314
+ for (const char of step.value || "") {
3315
+ const cdpInner = cometClient.protocol;
3316
+ await cdpInner.send("Input.dispatchKeyEvent", {
3317
+ type: "keyDown",
3318
+ text: char,
3319
+ });
3320
+ await cdpInner.send("Input.dispatchKeyEvent", {
3321
+ type: "keyUp",
3322
+ text: char,
3323
+ });
3324
+ }
3325
+ results.push(`✓ ${prefix}: typed ${(step.value || "").length} chars`);
3326
+ break;
3327
+ case "press": {
3328
+ const cdpPress = cometClient.protocol;
3329
+ await cdpPress.send("Input.dispatchKeyEvent", {
3330
+ type: "keyDown",
3331
+ key: step.value,
3332
+ code: `Key${step.value?.toUpperCase()}`,
3333
+ });
3334
+ await cdpPress.send("Input.dispatchKeyEvent", {
3335
+ type: "keyUp",
3336
+ key: step.value,
3337
+ code: `Key${step.value?.toUpperCase()}`,
3338
+ });
3339
+ results.push(`✓ ${prefix}: pressed ${step.value}`);
3340
+ break;
3341
+ }
3342
+ case "select":
3343
+ await cometClient.evaluate(`
3344
+ (() => {
3345
+ const el = document.querySelector(${safeSelector(step.selector)});
3346
+ if (!el) throw new Error('Element not found');
3347
+ el.value = ${JSON.stringify(step.value || "")};
3348
+ el.dispatchEvent(new Event('change', { bubbles: true }));
3349
+ })()
3350
+ `);
3351
+ results.push(`✓ ${prefix}: selected ${step.value}`);
3352
+ break;
3353
+ case "wait":
3354
+ if (step.selector) {
3355
+ const waitMs = Date.now();
3356
+ while (Date.now() - waitMs < 10000) {
3357
+ const found = await cometClient.evaluate(`!!document.querySelector(${safeSelector(step.selector)})`);
3358
+ if (found.result.value)
3359
+ break;
3360
+ await new Promise((r) => setTimeout(r, 300));
3361
+ }
3362
+ }
3363
+ else if (step.value) {
3364
+ await new Promise((r) => setTimeout(r, parseInt(step.value)));
3365
+ }
3366
+ results.push(`✓ ${prefix}: waited for ${step.selector || step.value + "ms"}`);
3367
+ break;
3368
+ case "screenshot": {
3369
+ const cdpScreenshot = cometClient.protocol;
3370
+ const ssResult = await cdpScreenshot.send("Page.captureScreenshot", {
3371
+ format: "png",
3372
+ });
3373
+ const ssDir = join(homedir(), ".claude", "comet-browser", "output");
3374
+ mkdirSync(ssDir, { recursive: true });
3375
+ const ssPath = join(ssDir, `${step.name || "step-" + (index + 1)}-${Date.now()}.png`);
3376
+ writeFileSync(ssPath, Buffer.from(ssResult.data, "base64"));
3377
+ results.push(`✓ ${prefix}: screenshot → ${ssPath}`);
3378
+ break;
3379
+ }
3380
+ case "extract": {
3381
+ const extractResult = await cometClient.evaluate(`
3382
+ (() => {
3383
+ const el = document.querySelector(${safeSelector(step.selector)});
3384
+ if (!el) return null;
3385
+ return el.innerText?.trim() || el.value || '';
3386
+ })()
3387
+ `);
3388
+ const extractedValue = extractResult.result.value;
3389
+ if (step.variable && isValidIdentifier(step.variable))
3390
+ variables[step.variable] = extractedValue;
3391
+ results.push(`✓ ${prefix}: extracted ${String(extractedValue).substring(0, 100)}${step.variable ? ` → $${step.variable}` : ""}`);
3392
+ break;
3393
+ }
3394
+ case "assert": {
3395
+ const assertResult = await cometClient.evaluate(`
3396
+ document.querySelector(${safeSelector(step.selector)})?.innerText || ''
3397
+ `);
3398
+ const assertText = assertResult.result.value;
3399
+ if (step.contains && !assertText.includes(step.contains)) {
3400
+ throw new Error(`Assertion failed: "${step.selector}" does not contain "${step.contains}". Got: "${assertText.substring(0, 200)}"`);
3401
+ }
3402
+ results.push(`✓ ${prefix}: assertion passed`);
3403
+ break;
3404
+ }
3405
+ case "evaluate": {
3406
+ const evalResult = await cometClient.evaluate(step.expression);
3407
+ const evalValue = evalResult.result.value;
3408
+ if (step.variable && isValidIdentifier(step.variable))
3409
+ variables[step.variable] = evalValue;
3410
+ results.push(`✓ ${prefix}: ${JSON.stringify(evalValue).substring(0, 200)}${step.variable ? ` → $${step.variable}` : ""}`);
3411
+ break;
3412
+ }
3413
+ case "if": {
3414
+ // Evaluate condition in browser context (sandboxed) with variable injection
3415
+ const varInjection = Object.entries(variables)
3416
+ .filter(([k]) => isValidIdentifier(k))
3417
+ .map(([k, v]) => `const ${k} = ${JSON.stringify(v)};`)
3418
+ .join(" ");
3419
+ const condEvalResult = await cometClient.evaluate(`(() => { ${varInjection} return !!(${step.condition}); })()`);
3420
+ const condResult = condEvalResult.result.value;
3421
+ if (condResult && step.then) {
3422
+ for (let i = 0; i < step.then.length; i++) {
3423
+ const ok = await executeStep(step.then[i], i);
3424
+ if (!ok)
3425
+ return false;
3426
+ }
3427
+ }
3428
+ results.push(`✓ ${prefix}: condition ${condResult ? "true" : "false"}`);
3429
+ break;
3430
+ }
3431
+ case "loop": {
3432
+ const loopItems = variables[step.items];
3433
+ if (!Array.isArray(loopItems)) {
3434
+ throw new Error(`Variable "${step.items}" is not an array`);
3435
+ }
3436
+ for (let li = 0; li < loopItems.length; li++) {
3437
+ variables["_item"] = loopItems[li];
3438
+ variables["_index"] = li;
3439
+ for (let si = 0; si < (step.each || []).length; si++) {
3440
+ const ok = await executeStep(step.each[si], si);
3441
+ if (!ok)
3442
+ return false;
3443
+ }
3444
+ }
3445
+ results.push(`✓ ${prefix}: looped ${loopItems.length} items`);
3446
+ break;
3447
+ }
3448
+ default:
3449
+ throw new Error(`Unknown step tool: ${step.tool}`);
3450
+ }
3451
+ // Small pause between steps for DOM settling
3452
+ await new Promise((r) => setTimeout(r, 100));
3453
+ return true;
3454
+ }
3455
+ catch (err) {
3456
+ const errMsg = err instanceof Error ? err.message : String(err);
3457
+ results.push(`✗ ${prefix}: ${errMsg}`);
3458
+ if (step.optional)
3459
+ return true;
3460
+ return false;
3461
+ }
3462
+ }
3463
+ for (let i = 0; i < autoSteps.length; i++) {
3464
+ const ok = await executeStep(autoSteps[i], i);
3465
+ if (!ok) {
3466
+ results.push(`\n⚠ Workflow aborted at step ${i + 1}`);
3467
+ break;
3468
+ }
3469
+ }
3470
+ const varSummary = Object.keys(variables).length > 0
3471
+ ? `\n\nVariables:\n${Object.entries(variables)
3472
+ .filter(([k]) => !k.startsWith("_"))
3473
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v).substring(0, 200)}`)
3474
+ .join("\n")}`
3475
+ : "";
3476
+ return {
3477
+ content: [
3478
+ {
3479
+ type: "text",
3480
+ text: `Workflow: ${autoSteps.length} steps\n\n${results.join("\n")}${varSummary}`,
3481
+ },
3482
+ ],
3483
+ };
3484
+ }
3485
+ case "comet_domain": {
3486
+ const domainName = args?.domain;
3487
+ const domainAction = args?.action || "check-auth";
3488
+ const domainPath = args?.path;
3489
+ const domainUrls = {
3490
+ qbo: {
3491
+ home: "https://app.qbo.intuit.com/app/homepage",
3492
+ authCheck: "app.qbo.intuit.com",
3493
+ name: "QuickBooks Online",
3494
+ },
3495
+ mercury: {
3496
+ home: "https://app.mercury.com/dashboard",
3497
+ authCheck: "app.mercury.com",
3498
+ name: "Mercury Banking",
3499
+ },
3500
+ github: {
3501
+ home: "https://github.com",
3502
+ authCheck: "github.com",
3503
+ name: "GitHub",
3504
+ },
3505
+ google: {
3506
+ home: "https://drive.google.com",
3507
+ authCheck: "accounts.google.com/SignOut",
3508
+ name: "Google Workspace",
3509
+ },
3510
+ salt: {
3511
+ home: "https://app.salt.dev",
3512
+ authCheck: "app.salt.dev",
3513
+ name: "SALT Tax",
3514
+ },
3515
+ };
3516
+ const domainConfig = domainUrls[domainName];
3517
+ if (!domainConfig) {
3518
+ return {
3519
+ content: [
3520
+ {
3521
+ type: "text",
3522
+ text: `Unknown domain: ${domainName}. Use: qbo, mercury, github, google, salt`,
3523
+ },
3524
+ ],
3525
+ isError: true,
3526
+ };
3527
+ }
3528
+ if (domainAction === "navigate" || domainAction === "status") {
3529
+ const targetUrl = domainPath
3530
+ ? new URL(domainPath, domainConfig.home).href
3531
+ : domainConfig.home;
3532
+ await cometClient.navigate(targetUrl, true, true);
3533
+ const finalUrlResult = await cometClient.evaluate("window.location.href");
3534
+ const finalUrl = finalUrlResult.result.value;
3535
+ const titleResult = await cometClient.evaluate("document.title");
3536
+ const title = titleResult.result.value;
3537
+ // Check if we got redirected to login
3538
+ const isLoginPage = finalUrl.includes("login") ||
3539
+ finalUrl.includes("signin") ||
3540
+ finalUrl.includes("auth") ||
3541
+ finalUrl.includes("accounts.google.com/v3/signin");
3542
+ return {
3543
+ content: [
3544
+ {
3545
+ type: "text",
3546
+ text: [
3547
+ `Domain: ${domainConfig.name}`,
3548
+ `URL: ${finalUrl}`,
3549
+ `Title: ${title}`,
3550
+ `Auth: ${isLoginPage ? "❌ NOT LOGGED IN — redirected to login page" : "✓ Authenticated"}`,
3551
+ isLoginPage
3552
+ ? `\nAction needed: Log in to ${domainConfig.name} in the Comet browser (profile: oe) before proceeding.`
3553
+ : "",
3554
+ ]
3555
+ .filter(Boolean)
3556
+ .join("\n"),
3557
+ },
3558
+ ],
3559
+ ...(isLoginPage ? { isError: true } : {}),
3560
+ };
3561
+ }
3562
+ // check-auth: just check current URL without navigating
3563
+ const currentUrlResult = await cometClient.evaluate("window.location.href");
3564
+ const currentUrl = currentUrlResult.result.value;
3565
+ const isOnDomain = currentUrl.includes(domainConfig.authCheck);
3566
+ if (!isOnDomain) {
3567
+ // Navigate to domain to check
3568
+ await cometClient.navigate(domainConfig.home, true, true);
3569
+ const checkUrlResult = await cometClient.evaluate("window.location.href");
3570
+ const checkUrl = checkUrlResult.result.value;
3571
+ const isLoginRedirect = checkUrl.includes("login") ||
3572
+ checkUrl.includes("signin") ||
3573
+ checkUrl.includes("auth");
3574
+ return {
3575
+ content: [
3576
+ {
3577
+ type: "text",
3578
+ text: `${domainConfig.name}: ${isLoginRedirect ? "❌ Not authenticated" : "✓ Authenticated"}\nURL: ${checkUrl}`,
3579
+ },
3580
+ ],
3581
+ ...(isLoginRedirect ? { isError: true } : {}),
3582
+ };
3583
+ }
3584
+ return {
3585
+ content: [
3586
+ {
3587
+ type: "text",
3588
+ text: `${domainConfig.name}: ✓ Currently on domain\nURL: ${currentUrl}`,
3589
+ },
3590
+ ],
3591
+ };
3592
+ }
3593
+ default:
3594
+ throw new Error(`Unknown tool: ${name}`);
1160
3595
  }
1161
- default:
1162
- throw new Error(`Unknown tool: ${name}`);
1163
3596
  }
3597
+ catch (error) {
3598
+ const errorMsg = error instanceof Error ? error.message : String(error);
3599
+ console.error(`[comet-bridge] Tool "${name}" failed: ${errorMsg}`);
3600
+ return {
3601
+ content: [{ type: "text", text: `Error in ${name}: ${errorMsg}` }],
3602
+ isError: true,
3603
+ };
3604
+ }
3605
+ })();
3606
+ // Drain queued alerts and append to response (Spec 016, FR-011, T004)
3607
+ const queuedAlerts = drainMcpAlertQueue();
3608
+ if (queuedAlerts.length > 0 && result?.content) {
3609
+ const alertText = formatAlertsForResponse(queuedAlerts);
3610
+ result.content.push({ type: "text", text: alertText });
1164
3611
  }
1165
- catch (error) {
1166
- return {
1167
- content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : error}` }],
1168
- isError: true,
1169
- };
3612
+ // Tab group warning — once per session (Spec 016, T011)
3613
+ if (result?.content && !result.isError) {
3614
+ const tabGroupWarn = getTabGroupWarning(name);
3615
+ if (tabGroupWarn) {
3616
+ const lastContent = result.content[result.content.length - 1];
3617
+ if (lastContent?.type === "text") {
3618
+ lastContent.text += tabGroupWarn;
3619
+ }
3620
+ }
1170
3621
  }
3622
+ await disconnectCdpClientsAfterTool();
3623
+ return result;
1171
3624
  });
1172
3625
  const transport = new StdioServerTransport();
1173
3626
  server.connect(transport);