@shawnowen/comet-mcp 2.3.1 → 2.4.2

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