@shawnowen/comet-mcp 2.3.1 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +86 -19
  2. package/dist/alert-dispatcher.d.ts +23 -0
  3. package/dist/alert-dispatcher.js +101 -0
  4. package/dist/bound-session.d.ts +23 -0
  5. package/dist/bound-session.js +119 -0
  6. package/dist/bridge-config.d.ts +6 -0
  7. package/dist/bridge-config.js +78 -0
  8. package/dist/cdp-client.d.ts +40 -4
  9. package/dist/cdp-client.js +502 -155
  10. package/dist/comet-ai.d.ts +15 -0
  11. package/dist/comet-ai.js +114 -38
  12. package/dist/delegate-binding.d.ts +19 -0
  13. package/dist/delegate-binding.js +73 -0
  14. package/dist/discovery/capability-entry.d.ts +215 -0
  15. package/dist/discovery/capability-entry.js +13 -0
  16. package/dist/discovery/description-template.d.ts +40 -0
  17. package/dist/discovery/description-template.js +61 -0
  18. package/dist/discovery/golden-queries.fixture.d.ts +22 -0
  19. package/dist/discovery/golden-queries.fixture.js +137 -0
  20. package/dist/discovery/mcp-source.d.ts +38 -0
  21. package/dist/discovery/mcp-source.js +70 -0
  22. package/dist/discovery/metadata-completeness.d.ts +48 -0
  23. package/dist/discovery/metadata-completeness.js +83 -0
  24. package/dist/discovery/registry.d.ts +35 -0
  25. package/dist/discovery/registry.js +35 -0
  26. package/dist/discovery/safety.d.ts +44 -0
  27. package/dist/discovery/safety.js +59 -0
  28. package/dist/discovery/schema-validator.d.ts +36 -0
  29. package/dist/discovery/schema-validator.js +257 -0
  30. package/dist/discovery/source-error.d.ts +47 -0
  31. package/dist/discovery/source-error.js +95 -0
  32. package/dist/discovery/tool-meta.d.ts +41 -0
  33. package/dist/discovery/tool-meta.js +229 -0
  34. package/dist/discovery/virtual-tools.d.ts +20 -0
  35. package/dist/discovery/virtual-tools.js +69 -0
  36. package/dist/http-server.js +2067 -47
  37. package/dist/index.js +3163 -710
  38. package/dist/observer.d.ts +47 -0
  39. package/dist/observer.js +516 -0
  40. package/dist/session-registry.d.ts +57 -0
  41. package/dist/session-registry.js +500 -0
  42. package/dist/sidecar-artifacts.d.ts +49 -0
  43. package/dist/sidecar-artifacts.js +146 -0
  44. package/dist/snapshot-capture.d.ts +3 -0
  45. package/dist/snapshot-capture.js +91 -0
  46. package/dist/tab-group-archive.js +3 -1
  47. package/dist/tab-groups.d.ts +7 -0
  48. package/dist/tab-groups.js +21 -3
  49. package/dist/task-thread-aggregator.d.ts +34 -0
  50. package/dist/task-thread-aggregator.js +480 -0
  51. package/dist/task-thread-canonical.d.ts +142 -0
  52. package/dist/task-thread-canonical.js +116 -0
  53. package/dist/types.d.ts +237 -0
  54. package/dist/window-bindings.d.ts +112 -0
  55. package/dist/window-bindings.js +476 -0
  56. package/extension/background.js +1556 -300
  57. package/extension/icons/icon.svg +9 -0
  58. package/extension/icons/icon128.png +0 -0
  59. package/extension/icons/icon16.png +0 -0
  60. package/extension/icons/icon48.png +0 -0
  61. package/extension/manifest.json +19 -4
  62. package/extension/session-logic.js +2383 -0
  63. package/extension/session-manager.html +299 -0
  64. package/extension/sidepanel.css +5323 -528
  65. package/extension/sidepanel.html +282 -2
  66. package/extension/sidepanel.js +10075 -951
  67. package/extension/window-policy.js +162 -0
  68. package/package.json +10 -7
  69. package/vendor/lifecycle-mcp-adapter.mjs +103 -0
  70. package/vendor/lifecycle-metadata.mjs +252 -0
  71. package/vendor/readiness-report.mjs +742 -0
  72. package/dist/cdp-client.d.ts.map +0 -1
  73. package/dist/cdp-client.js.map +0 -1
  74. package/dist/comet-ai.d.ts.map +0 -1
  75. package/dist/comet-ai.js.map +0 -1
  76. package/dist/http-server.d.ts.map +0 -1
  77. package/dist/http-server.js.map +0 -1
  78. package/dist/index.d.ts.map +0 -1
  79. package/dist/index.js.map +0 -1
  80. package/dist/tab-group-archive.d.ts.map +0 -1
  81. package/dist/tab-group-archive.js.map +0 -1
  82. package/dist/tab-groups.d.ts.map +0 -1
  83. package/dist/tab-groups.js.map +0 -1
  84. package/dist/types.d.ts.map +0 -1
  85. package/dist/types.js.map +0 -1
@@ -6,10 +6,173 @@
6
6
  // Architecture:
7
7
  // Cowork VM -> Claude-in-Chrome MCP -> Chrome fetch('localhost:3456') -> this server -> CDP -> Comet
8
8
  import { createServer } from "node:http";
9
+ import { readFileSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { homedir } from "node:os";
9
12
  import { cometClient } from "./cdp-client.js";
10
13
  import { cometAI } from "./comet-ai.js";
11
14
  import { tabGroupsClient } from "./tab-groups.js";
15
+ import { BoundSessionError, resolveHttpBoundSession } from "./bound-session.js";
16
+ import { createOrReuseDelegateBinding } from "./delegate-binding.js";
17
+ import { deriveCodexSessionIdentity, windowBindingStore, } from "./window-bindings.js";
18
+ import { readAgentRegistry, classifyAgentStatus, getHealth, getSnapshot, getStatus, getDetail, formatHealth, formatSnapshot, } from "./observer.js";
12
19
  const PORT = parseInt(process.env.COMET_HTTP_PORT || "3456", 10);
20
+ // Lifecycle + orchestration paths (mirrored from index.ts)
21
+ const CC_LIFECYCLE_URL = process.env.COMET_CC_LIFECYCLE_URL || "http://localhost:3001/command-center/api/comet/lifecycle";
22
+ const OUTBOX_PATH = join(homedir(), "equabot", "agent-chat", "outbox-comet.jsonl");
23
+ const INBOX_PATH = join(homedir(), "equabot", "agent-chat", "inbox-comet.jsonl");
24
+ const MANIFEST_PATH = join(homedir(), ".claude", "comet-browser", "session-manifest.json");
25
+ const EQUA_SERVER_URL = process.env.EQUA_SERVER_URL || "http://localhost:3000";
26
+ const EQUANAUT_GATEWAY_ASK_URL = process.env.EQUANAUT_GATEWAY_ASK_URL || "";
27
+ const EQUANAUT_GATEWAY_STATUS_URLS = [
28
+ process.env.EQUANAUT_GATEWAY_STATUS_URL,
29
+ "http://localhost:3001/api/v1/agent/gateway/status",
30
+ "http://localhost:3001/command-center/api/v1/agent/gateway/status",
31
+ "http://localhost:3000/api/v1/agent/gateway/status",
32
+ ].filter(Boolean);
33
+ const EQUANAUT_OLLAMA_BASE = process.env.EQUANAUT_OLLAMA_BASE || "http://localhost:11434";
34
+ // ---- Helper utilities ----
35
+ /** Safely quote a CSS selector for use in browser evaluate() expressions */
36
+ function safeSelector(sel) {
37
+ return JSON.stringify(sel);
38
+ }
39
+ /** Append a JSON line to a JSONL file (non-fatal if directory missing) */
40
+ function appendJsonl(filePath, obj) {
41
+ try {
42
+ mkdirSync(dirname(filePath), { recursive: true });
43
+ appendFileSync(filePath, JSON.stringify(obj) + "\n", "utf-8");
44
+ }
45
+ catch {
46
+ /* non-fatal — outbox may not be configured */
47
+ }
48
+ }
49
+ /** Read and parse a JSON file safely, returning null on any error */
50
+ function readJsonSafe(filePath) {
51
+ try {
52
+ return JSON.parse(readFileSync(filePath, "utf-8"));
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** POST to Command Center lifecycle API */
59
+ async function callLifecycleEndpoint(payload) {
60
+ const resp = await fetch(CC_LIFECYCLE_URL, {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify(payload),
64
+ signal: AbortSignal.timeout(5000),
65
+ });
66
+ const data = await resp.text();
67
+ if (!resp.ok) {
68
+ throw new Error(`CC-SO lifecycle ${resp.status}: ${data.substring(0, 200)}`);
69
+ }
70
+ return data;
71
+ }
72
+ /**
73
+ * Session guard for browsing endpoints.
74
+ * Returns true (and sends HTTP 409) if no active session; returns false if OK to proceed.
75
+ */
76
+ function requireSession(res) {
77
+ if (!cometClient.isConnected) {
78
+ errorJson(res, "No active Comet session. Call POST /api/connect first.", 409);
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+ function paramsToRecord(params) {
84
+ const args = {};
85
+ for (const [key, value] of params.entries()) {
86
+ if (["windowId", "tabGroupId", "groupId"].includes(key)) {
87
+ const parsed = Number(value);
88
+ args[key] = Number.isFinite(parsed) ? parsed : value;
89
+ }
90
+ else {
91
+ args[key] = value;
92
+ }
93
+ }
94
+ return args;
95
+ }
96
+ function identityInputFromArgs(args = {}) {
97
+ return {
98
+ codexSessionId: args.codexSessionId,
99
+ projectThreadId: args.projectThreadId,
100
+ projectThreadFamily: args.projectThreadFamily,
101
+ worktreePath: args.worktreePath,
102
+ repoSlug: args.repoSlug,
103
+ branchName: args.branchName,
104
+ sessionKey: args.sessionKey,
105
+ role: args.codexSessionRole,
106
+ strict: true,
107
+ fallbackAgentId: args.agentId,
108
+ fallbackTaskThreadId: args.taskThreadId,
109
+ };
110
+ }
111
+ function assertAgentRuntimeProfileArg(profile) {
112
+ if (profile === undefined ||
113
+ profile === null ||
114
+ profile === "" ||
115
+ profile === "agent" ||
116
+ profile === "oe") {
117
+ return profile === "agent" ? "oe" : profile;
118
+ }
119
+ throw new Error(`PROFILE_OWNERSHIP_VIOLATION: profile ${String(profile)} is not an agent-owned Comet runtime profile`);
120
+ }
121
+ async function getWindowIdForTarget(targetId) {
122
+ const CDP = (await import("chrome-remote-interface")).default;
123
+ const client = await CDP({ host: "127.0.0.1", port: 9222, target: targetId });
124
+ try {
125
+ const { windowId } = await client.Browser.getWindowForTarget();
126
+ return windowId;
127
+ }
128
+ finally {
129
+ try {
130
+ await client.close();
131
+ }
132
+ catch {
133
+ /* ignore */
134
+ }
135
+ }
136
+ }
137
+ function writeBoundError(res, err) {
138
+ if (err instanceof BoundSessionError) {
139
+ errorJson(res, `${err.code}: ${err.message}. ${err.repairAction}`, err.status);
140
+ return true;
141
+ }
142
+ return false;
143
+ }
144
+ async function requireHttpBinding(res, args) {
145
+ try {
146
+ const resolved = await resolveHttpBoundSession(identityInputFromArgs(args), args);
147
+ if (resolved.binding.targetId) {
148
+ await cometClient.connect(resolved.binding.targetId);
149
+ }
150
+ return resolved.binding;
151
+ }
152
+ catch (err) {
153
+ if (!writeBoundError(res, err))
154
+ throw err;
155
+ return null;
156
+ }
157
+ }
158
+ async function attachRunIdToHttpBinding(runId, args) {
159
+ if (!runId)
160
+ return null;
161
+ const bindingId = typeof args.bindingId === "string" ? args.bindingId : undefined;
162
+ if (bindingId) {
163
+ return windowBindingStore.addRunId(bindingId, runId);
164
+ }
165
+ try {
166
+ const identity = deriveCodexSessionIdentity(identityInputFromArgs(args));
167
+ const binding = await windowBindingStore.findActiveByIdentity(identity);
168
+ if (!binding)
169
+ return null;
170
+ return windowBindingStore.addRunId(binding.bindingId, runId);
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
13
176
  // Simple mutex to prevent concurrent CDP operations
14
177
  let busy = false;
15
178
  function json(res, data, status = 200) {
@@ -40,6 +203,138 @@ async function readBody(req) {
40
203
  });
41
204
  });
42
205
  }
206
+ async function fetchJsonWithTimeout(url, init = {}, timeoutMs = 1500) {
207
+ const resp = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
208
+ const contentType = resp.headers.get("content-type") || "";
209
+ const data = contentType.includes("application/json") ? await resp.json() : await resp.text();
210
+ return { resp, data };
211
+ }
212
+ function routerCapability(reachable, extra = {}) {
213
+ return { reachable, fresh: reachable, ...extra };
214
+ }
215
+ function routerCapabilityUsable(capability) {
216
+ return !!(capability &&
217
+ capability.reachable === true &&
218
+ capability.fresh !== false &&
219
+ capability.connected !== false &&
220
+ capability.usable !== false);
221
+ }
222
+ function selectRouterRoute(capabilities) {
223
+ if (routerCapabilityUsable(capabilities.equaGateway)) {
224
+ return { route: "gateway", degraded: false };
225
+ }
226
+ if (routerCapabilityUsable(capabilities.equaApi)) {
227
+ return {
228
+ route: "equa_api_enriched",
229
+ degraded: true,
230
+ degradedReason: "gateway unavailable; using Equa API context enrichment",
231
+ };
232
+ }
233
+ if (routerCapabilityUsable(capabilities.cometBridgeAsk)) {
234
+ return {
235
+ route: "comet_bridge",
236
+ degraded: true,
237
+ degradedReason: "gateway and Equa API unavailable; using Comet bridge fallback",
238
+ };
239
+ }
240
+ if (routerCapabilityUsable(capabilities.localModel)) {
241
+ return {
242
+ route: "local_model",
243
+ degraded: true,
244
+ degradedReason: "gateway, Equa API, and Comet bridge unavailable; using local model fallback",
245
+ };
246
+ }
247
+ return { route: "unavailable", degraded: true, degradedReason: "no router backend reachable" };
248
+ }
249
+ function mergeRouterCapability(probedCapability, envelopeCapability) {
250
+ if (!probedCapability)
251
+ return envelopeCapability || {};
252
+ if (!envelopeCapability)
253
+ return probedCapability;
254
+ const probedUsable = routerCapabilityUsable(probedCapability);
255
+ const envelopeUsable = routerCapabilityUsable(envelopeCapability);
256
+ const preferred = probedUsable || !envelopeUsable ? probedCapability : envelopeCapability;
257
+ return {
258
+ ...envelopeCapability,
259
+ ...probedCapability,
260
+ ...preferred,
261
+ };
262
+ }
263
+ function mergeRouterCapabilities(probedCapabilities, envelopeCapabilities) {
264
+ const merged = { ...envelopeCapabilities, ...probedCapabilities };
265
+ for (const key of new Set([
266
+ ...Object.keys(envelopeCapabilities || {}),
267
+ ...Object.keys(probedCapabilities || {}),
268
+ ])) {
269
+ merged[key] = mergeRouterCapability(probedCapabilities?.[key], envelopeCapabilities?.[key]);
270
+ }
271
+ return merged;
272
+ }
273
+ async function probeGatewayCapability() {
274
+ for (const url of EQUANAUT_GATEWAY_STATUS_URLS) {
275
+ try {
276
+ const { resp, data } = await fetchJsonWithTimeout(url, { method: "GET", headers: { Accept: "application/json" } }, 1200);
277
+ if (!resp.ok || typeof data !== "object" || data === null)
278
+ continue;
279
+ const payload = data;
280
+ if (payload.connected) {
281
+ return routerCapability(true, {
282
+ connected: true,
283
+ modelId: payload.modelId || payload.model?.id || payload.model || null,
284
+ threadId: payload.threadId || payload.sessionKey || null,
285
+ statusUrl: url,
286
+ });
287
+ }
288
+ }
289
+ catch {
290
+ /* try next status URL */
291
+ }
292
+ }
293
+ return routerCapability(false, { connected: false });
294
+ }
295
+ async function probeEquaApiCapability() {
296
+ try {
297
+ const { resp } = await fetchJsonWithTimeout(`${EQUA_SERVER_URL}/api/equanaut/threads`, { method: "GET", headers: { Accept: "application/json" } }, 1200);
298
+ // Auth failures still prove the API surface is reachable. The router can
299
+ // report reachability while leaving authenticated data access to configured
300
+ // local credentials.
301
+ if (resp.ok || resp.status === 401 || resp.status === 403) {
302
+ return routerCapability(true, { status: resp.status, baseUrl: EQUA_SERVER_URL });
303
+ }
304
+ }
305
+ catch {
306
+ /* unavailable */
307
+ }
308
+ return routerCapability(false, { baseUrl: EQUA_SERVER_URL });
309
+ }
310
+ async function probeLocalModelCapability() {
311
+ try {
312
+ const { resp, data } = await fetchJsonWithTimeout(`${EQUANAUT_OLLAMA_BASE}/api/tags`, { method: "GET", headers: { Accept: "application/json" } }, 1200);
313
+ if (resp.ok) {
314
+ const models = data?.models;
315
+ const firstModel = Array.isArray(models) && models[0] ? models[0].name || models[0].model : null;
316
+ return routerCapability(true, { modelId: firstModel || null });
317
+ }
318
+ }
319
+ catch {
320
+ /* unavailable */
321
+ }
322
+ return routerCapability(false);
323
+ }
324
+ async function getEquanautRouterCapabilities() {
325
+ const [gateway, equaApi, localModel] = await Promise.all([
326
+ probeGatewayCapability(),
327
+ probeEquaApiCapability(),
328
+ probeLocalModelCapability(),
329
+ ]);
330
+ return {
331
+ cometMcpRouter: routerCapability(true, { port: PORT }),
332
+ equaGateway: gateway,
333
+ equaApi,
334
+ cometBridgeAsk: routerCapability(true, { path: "/api/ask" }),
335
+ localModel,
336
+ };
337
+ }
43
338
  async function withMutex(res, fn) {
44
339
  if (busy) {
45
340
  errorJson(res, "Server busy — another operation is in progress. Try again shortly.", 429);
@@ -54,37 +349,58 @@ async function withMutex(res, fn) {
54
349
  }
55
350
  }
56
351
  // ---- Route handlers (mirrored from index.ts MCP tool handlers) ----
57
- async function handleConnect(res) {
352
+ async function handleConnect(res, body) {
353
+ const identity = deriveCodexSessionIdentity(identityInputFromArgs(body));
58
354
  const result = await withMutex(res, async () => {
355
+ const profileAlias = assertAgentRuntimeProfileArg(body.profile);
59
356
  const startResult = await cometClient.startComet(9222);
60
- const targets = await cometClient.listTargets();
61
- const pageTabs = targets.filter((t) => t.type === "page");
62
- if (pageTabs.length > 1) {
63
- for (let i = 1; i < pageTabs.length; i++) {
64
- try {
65
- await cometClient.closeTab(pageTabs[i].id);
66
- }
67
- catch { /* ignore */ }
68
- }
69
- }
70
- const freshTargets = await cometClient.listTargets();
71
- const anyPage = freshTargets.find((t) => t.type === "page");
72
- if (anyPage) {
73
- await cometClient.connect(anyPage.id);
74
- await cometClient.navigate("https://www.perplexity.ai/", true);
75
- await new Promise((r) => setTimeout(r, 1500));
76
- return { message: `${startResult}\nConnected to Perplexity (cleaned ${pageTabs.length - 1} old tabs)` };
77
- }
78
- const newTab = await cometClient.newTab("https://www.perplexity.ai/");
357
+ const initialUrl = `about:blank#comet-http-session-${Date.now()}`;
358
+ const windowTab = await tabGroupsClient.createTopDisplayFullscreenWindowWithTab(initialUrl);
359
+ const newTab = await cometClient.waitForTargetUrl(initialUrl);
79
360
  await new Promise((r) => setTimeout(r, 2000));
80
361
  await cometClient.connect(newTab.id);
81
- return { message: `${startResult}\nCreated new tab and navigated to Perplexity` };
362
+ await cometClient.navigate("https://www.perplexity.ai/", true);
363
+ await cometClient.positionOnTopDisplay(newTab.id);
364
+ const targetId = newTab.id;
365
+ let tabGroupId = null;
366
+ // Create tab group for the new agent session (FR-007, spec 010)
367
+ // Mirrors MCP tool path: session-registry.ts creates a dedicated window and group.
368
+ try {
369
+ const group = await tabGroupsClient.createGroup({
370
+ tabIds: [windowTab.tabId],
371
+ title: `session-${Date.now()}`,
372
+ color: "blue",
373
+ });
374
+ tabGroupId = group.groupId;
375
+ }
376
+ catch {
377
+ // Tab group creation is advisory — do not fail the connection
378
+ }
379
+ const windowId = windowTab.windowId || (await getWindowIdForTarget(targetId));
380
+ const bindingResult = await windowBindingStore.createOrReuse({
381
+ ...identity,
382
+ windowId,
383
+ tabGroupId,
384
+ targetId,
385
+ runIds: typeof body.runId === "string" ? [body.runId] : [],
386
+ profileId: "agent",
387
+ profileAlias: profileAlias || "oe",
388
+ profileOwner: "agent",
389
+ });
390
+ return {
391
+ message: `${startResult}\nCreated dedicated top-display full-screen bounds window and tab group`,
392
+ binding: bindingResult.binding,
393
+ bindingAction: bindingResult.action,
394
+ };
82
395
  });
83
396
  if (result)
84
397
  json(res, result);
85
398
  }
86
399
  async function handleAsk(res, body) {
87
400
  const result = await withMutex(res, async () => {
401
+ const binding = await requireHttpBinding(res, body);
402
+ if (!binding)
403
+ return null;
88
404
  let prompt = body.prompt;
89
405
  const timeout = body.timeout || 15000;
90
406
  const newChat = body.newChat || false;
@@ -97,20 +413,10 @@ async function handleAsk(res, body) {
97
413
  .replace(/\n+/g, " ")
98
414
  .replace(/\s+/g, " ")
99
415
  .trim();
100
- // newChat: full reset
416
+ // newChat: navigate to fresh Perplexity page (without closing other agents' tabs)
101
417
  if (newChat) {
102
418
  const targets = await cometClient.listTargets();
103
- const pageTabs = targets.filter((t) => t.type === "page");
104
- if (pageTabs.length > 1) {
105
- for (let i = 1; i < pageTabs.length; i++) {
106
- try {
107
- await cometClient.closeTab(pageTabs[i].id);
108
- }
109
- catch { /* ignore */ }
110
- }
111
- }
112
- const freshTargets = await cometClient.listTargets();
113
- const mainTab = freshTargets.find((t) => t.type === "page");
419
+ const mainTab = targets.find((t) => t.type === "page");
114
420
  if (mainTab)
115
421
  await cometClient.connect(mainTab.id);
116
422
  await cometClient.navigate("https://www.perplexity.ai/", true);
@@ -170,7 +476,10 @@ async function handleAsk(res, body) {
170
476
  stepsCollected.push(step);
171
477
  }
172
478
  if (status.status === "completed" && sawNewResponse) {
173
- return { status: "completed", response: status.response || "Task completed (no response text extracted)" };
479
+ return {
480
+ status: "completed",
481
+ response: status.response || "Task completed (no response text extracted)",
482
+ };
174
483
  }
175
484
  }
176
485
  // Timeout — return in-progress status
@@ -192,8 +501,79 @@ async function handleAsk(res, body) {
192
501
  }
193
502
  }
194
503
  }
195
- async function handlePoll(res) {
504
+ async function handleEquanautRouterStatus(res) {
505
+ const capabilities = await getEquanautRouterCapabilities();
506
+ json(res, {
507
+ capabilities,
508
+ decision: selectRouterRoute(capabilities),
509
+ timestamp: new Date().toISOString(),
510
+ });
511
+ }
512
+ async function handleEquanautRouterAsk(res, body) {
513
+ const prompt = body.prompt;
514
+ const envelope = body.envelope || {};
515
+ if (!prompt || !prompt.trim()) {
516
+ errorJson(res, "prompt is required", 400);
517
+ return;
518
+ }
519
+ const probedCapabilities = await getEquanautRouterCapabilities();
520
+ const envelopeCapabilities = envelope.capabilities && typeof envelope.capabilities === "object" ? envelope.capabilities : {};
521
+ const liveCapabilities = mergeRouterCapabilities(probedCapabilities, envelopeCapabilities);
522
+ const decision = selectRouterRoute(liveCapabilities);
523
+ if (decision.route === "gateway" && EQUANAUT_GATEWAY_ASK_URL) {
524
+ try {
525
+ const { resp, data } = await fetchJsonWithTimeout(EQUANAUT_GATEWAY_ASK_URL, {
526
+ method: "POST",
527
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
528
+ body: JSON.stringify({ prompt, envelope, timeout: body.timeout || 60000 }),
529
+ }, Number(body.timeout || 60000));
530
+ if (!resp.ok) {
531
+ json(res, {
532
+ ...decision,
533
+ error: `gateway ask ${resp.status}`,
534
+ details: typeof data === "string" ? data.slice(0, 200) : data,
535
+ }, 502);
536
+ return;
537
+ }
538
+ const payload = typeof data === "object" && data !== null ? data : {};
539
+ json(res, {
540
+ ...decision,
541
+ response: payload.response || payload.answer || payload.text || "",
542
+ telemetry: {
543
+ route: decision.route,
544
+ degraded: decision.degraded,
545
+ activeWindowId: envelope.telemetry?.activeWindowId || null,
546
+ completedAt: new Date().toISOString(),
547
+ },
548
+ });
549
+ return;
550
+ }
551
+ catch (err) {
552
+ json(res, {
553
+ ...decision,
554
+ error: `gateway ask unavailable: ${err instanceof Error ? err.message : String(err)}`,
555
+ }, 502);
556
+ return;
557
+ }
558
+ }
559
+ json(res, {
560
+ ...decision,
561
+ error: decision.route === "gateway"
562
+ ? "gateway route selected but EQUANAUT_GATEWAY_ASK_URL is not configured"
563
+ : "router has no stronger configured answer route; caller should use existing fallback",
564
+ telemetry: {
565
+ route: decision.route,
566
+ degraded: decision.degraded,
567
+ activeWindowId: envelope.telemetry?.activeWindowId || null,
568
+ completedAt: new Date().toISOString(),
569
+ },
570
+ }, 503);
571
+ }
572
+ async function handlePoll(res, args) {
196
573
  const result = await withMutex(res, async () => {
574
+ const binding = await requireHttpBinding(res, args);
575
+ if (!binding)
576
+ return null;
197
577
  const status = await cometAI.getAgentStatus();
198
578
  if (status.status === "completed" && status.response) {
199
579
  return { status: "completed", response: status.response };
@@ -216,8 +596,11 @@ async function handleStop(res) {
216
596
  if (result)
217
597
  json(res, result);
218
598
  }
219
- async function handleScreenshot(res) {
599
+ async function handleScreenshot(res, args) {
220
600
  const result = await withMutex(res, async () => {
601
+ const binding = await requireHttpBinding(res, args);
602
+ if (!binding)
603
+ return null;
221
604
  const screenshot = await cometClient.screenshot("png");
222
605
  return { data: screenshot.data, mimeType: "image/png" };
223
606
  });
@@ -249,7 +632,12 @@ async function handleMode(res, body) {
249
632
  `);
250
633
  return { currentMode: modeResult.result.value };
251
634
  }
252
- const modeMap = { search: "Search", research: "Research", labs: "Labs", learn: "Learn" };
635
+ const modeMap = {
636
+ search: "Search",
637
+ research: "Research",
638
+ labs: "Labs",
639
+ learn: "Learn",
640
+ };
253
641
  const ariaLabel = modeMap[mode];
254
642
  if (!ariaLabel) {
255
643
  return { error: `Invalid mode: ${mode}. Use: search, research, labs, learn` };
@@ -307,6 +695,1486 @@ async function handleMode(res, body) {
307
695
  }
308
696
  }
309
697
  }
698
+ // ---- Navigation & Interaction handlers (require session) ----
699
+ async function handleNavigate(res, body) {
700
+ const result = await withMutex(res, async () => {
701
+ const binding = await requireHttpBinding(res, body);
702
+ if (!binding)
703
+ return null;
704
+ const url = body.url;
705
+ if (!url || url.trim().length === 0) {
706
+ return { error: "url is required" };
707
+ }
708
+ const waitForIdle = body.waitForIdle !== false; // default true
709
+ const navResult = await cometClient.navigate(url, true, waitForIdle);
710
+ const finalUrlResult = await cometClient.safeEvaluate("window.location.href");
711
+ const finalUrl = finalUrlResult.result.value || url;
712
+ const titleResult = await cometClient.safeEvaluate("document.title");
713
+ const title = titleResult.result.value || "";
714
+ return {
715
+ url: finalUrl,
716
+ title,
717
+ networkIdle: navResult.networkIdle !== undefined ? navResult.networkIdle : null,
718
+ };
719
+ });
720
+ if (result !== null) {
721
+ if (result && "error" in result)
722
+ errorJson(res, result.error, 400);
723
+ else if (result)
724
+ json(res, result);
725
+ }
726
+ }
727
+ async function handleInteract(res, body) {
728
+ const result = await withMutex(res, async () => {
729
+ const binding = await requireHttpBinding(res, body);
730
+ if (!binding)
731
+ return null;
732
+ const actions = body.actions;
733
+ if (!actions || actions.length === 0) {
734
+ return { error: "actions array is required and cannot be empty" };
735
+ }
736
+ const results = [];
737
+ for (const act of actions) {
738
+ try {
739
+ switch (act.action) {
740
+ case "click": {
741
+ if (!act.selector)
742
+ throw new Error("click requires a selector");
743
+ const clicked = await cometClient.safeEvaluate(`
744
+ (() => {
745
+ const el = document.querySelector(${safeSelector(act.selector)});
746
+ if (!el) return JSON.stringify({ ok: false, error: 'Element not found: ${act.selector.replace(/'/g, "\\'")}' });
747
+ el.scrollIntoView({ block: 'center' });
748
+ el.click();
749
+ return JSON.stringify({ ok: true, tag: el.tagName, text: (el.textContent || '').trim().substring(0, 80) });
750
+ })()
751
+ `);
752
+ const clickRes = JSON.parse(clicked.result.value);
753
+ if (!clickRes.ok)
754
+ throw new Error(clickRes.error);
755
+ results.push({
756
+ action: "click",
757
+ success: true,
758
+ result: `Clicked <${clickRes.tag}> "${clickRes.text}"`,
759
+ });
760
+ break;
761
+ }
762
+ case "type": {
763
+ if (!act.selector)
764
+ throw new Error("type requires a selector");
765
+ if (!act.value)
766
+ throw new Error("type requires a value");
767
+ await cometClient.safeEvaluate(`
768
+ (() => {
769
+ const el = document.querySelector(${safeSelector(act.selector)});
770
+ if (!el) throw new Error('Element not found');
771
+ el.focus();
772
+ })()
773
+ `);
774
+ for (const char of act.value) {
775
+ await cometClient.pressKey(char);
776
+ await new Promise((r) => setTimeout(r, 50));
777
+ }
778
+ results.push({
779
+ action: "type",
780
+ success: true,
781
+ result: `Typed ${act.value.length} chars`,
782
+ });
783
+ break;
784
+ }
785
+ case "fill": {
786
+ if (!act.selector)
787
+ throw new Error("fill requires a selector");
788
+ const fillRes = await cometClient.safeEvaluate(`
789
+ (() => {
790
+ const el = document.querySelector(${safeSelector(act.selector)});
791
+ if (!el) return JSON.stringify({ ok: false, error: 'Element not found' });
792
+ el.focus();
793
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
794
+ const nativeSetter = Object.getOwnPropertyDescriptor(
795
+ el.tagName === 'INPUT' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype, 'value'
796
+ )?.set;
797
+ if (nativeSetter) nativeSetter.call(el, ${JSON.stringify(act.value || "")});
798
+ else el.value = ${JSON.stringify(act.value || "")};
799
+ el.dispatchEvent(new Event('input', { bubbles: true }));
800
+ el.dispatchEvent(new Event('change', { bubbles: true }));
801
+ } else if (el.contentEditable === 'true') {
802
+ el.focus();
803
+ document.execCommand('selectAll', false, null);
804
+ document.execCommand('insertText', false, ${JSON.stringify(act.value || "")});
805
+ }
806
+ return JSON.stringify({ ok: true });
807
+ })()
808
+ `);
809
+ const fRes = JSON.parse(fillRes.result.value);
810
+ if (!fRes.ok)
811
+ throw new Error(fRes.error);
812
+ results.push({
813
+ action: "fill",
814
+ success: true,
815
+ result: `Filled with "${(act.value || "").substring(0, 40)}"`,
816
+ });
817
+ break;
818
+ }
819
+ case "press": {
820
+ const key = act.key || act.value || "Enter";
821
+ if (act.selector) {
822
+ await cometClient.safeEvaluate(`
823
+ (() => {
824
+ const el = document.querySelector(${safeSelector(act.selector)});
825
+ if (el) el.focus();
826
+ })()
827
+ `);
828
+ }
829
+ await cometClient.pressKey(key);
830
+ results.push({ action: "press", success: true, result: `Pressed ${key}` });
831
+ break;
832
+ }
833
+ case "check":
834
+ case "uncheck": {
835
+ if (!act.selector)
836
+ throw new Error(`${act.action} requires a selector`);
837
+ const shouldCheck = act.action === "check";
838
+ const checkRes = await cometClient.safeEvaluate(`
839
+ (() => {
840
+ const el = document.querySelector(${safeSelector(act.selector)});
841
+ if (!el) return JSON.stringify({ ok: false, error: 'Element not found' });
842
+ const isCheckbox = el.type === 'checkbox' || el.getAttribute('role') === 'checkbox';
843
+ if (isCheckbox) {
844
+ if (el.checked !== ${shouldCheck}) el.click();
845
+ return JSON.stringify({ ok: true, checked: ${shouldCheck} });
846
+ }
847
+ el.scrollIntoView({ block: 'center' });
848
+ el.click();
849
+ return JSON.stringify({ ok: true, clicked: true });
850
+ })()
851
+ `);
852
+ const cRes = JSON.parse(checkRes.result.value);
853
+ if (!cRes.ok)
854
+ throw new Error(cRes.error);
855
+ results.push({
856
+ action: act.action,
857
+ success: true,
858
+ result: `${act.action}ed: ${act.selector}`,
859
+ });
860
+ break;
861
+ }
862
+ case "select": {
863
+ if (!act.selector)
864
+ throw new Error("select requires a selector");
865
+ if (!act.value)
866
+ throw new Error("select requires a value");
867
+ const selRes = await cometClient.safeEvaluate(`
868
+ (() => {
869
+ const el = document.querySelector(${safeSelector(act.selector)});
870
+ if (!el || el.tagName !== 'SELECT') return JSON.stringify({ ok: false, error: 'SELECT element not found' });
871
+ let found = false;
872
+ for (const opt of el.options) {
873
+ if (opt.value === ${JSON.stringify(act.value)} || opt.text === ${JSON.stringify(act.value)}) {
874
+ el.value = opt.value;
875
+ found = true;
876
+ break;
877
+ }
878
+ }
879
+ if (!found) return JSON.stringify({ ok: false, error: 'Option not found' });
880
+ el.dispatchEvent(new Event('change', { bubbles: true }));
881
+ return JSON.stringify({ ok: true, selected: el.value });
882
+ })()
883
+ `);
884
+ const sRes = JSON.parse(selRes.result.value);
885
+ if (!sRes.ok)
886
+ throw new Error(sRes.error);
887
+ results.push({ action: "select", success: true, result: `Selected: ${sRes.selected}` });
888
+ break;
889
+ }
890
+ case "scroll": {
891
+ const dir = act.direction || "down";
892
+ const amount = act.amount || 500;
893
+ const dx = dir === "right" ? amount : dir === "left" ? -amount : 0;
894
+ const dy = dir === "down" ? amount : dir === "up" ? -amount : 0;
895
+ if (act.selector) {
896
+ await cometClient.safeEvaluate(`
897
+ (() => {
898
+ const el = document.querySelector(${safeSelector(act.selector)});
899
+ if (el) el.scrollBy(${dx}, ${dy});
900
+ })()
901
+ `);
902
+ }
903
+ else {
904
+ await cometClient.safeEvaluate(`window.scrollBy(${dx}, ${dy})`);
905
+ }
906
+ results.push({
907
+ action: "scroll",
908
+ success: true,
909
+ result: `Scrolled ${dir} ${amount}px`,
910
+ });
911
+ break;
912
+ }
913
+ case "wait": {
914
+ if (act.selector) {
915
+ const waitTimeout = act.ms || 10000;
916
+ const start = Date.now();
917
+ let found = false;
918
+ while (Date.now() - start < waitTimeout) {
919
+ const exists = await cometClient.safeEvaluate(`document.querySelector(${safeSelector(act.selector)}) !== null`);
920
+ if (exists.result.value === true) {
921
+ found = true;
922
+ break;
923
+ }
924
+ await new Promise((r) => setTimeout(r, 300));
925
+ }
926
+ if (!found)
927
+ throw new Error(`Timeout waiting for ${act.selector}`);
928
+ results.push({ action: "wait", success: true, result: `Found: ${act.selector}` });
929
+ }
930
+ else {
931
+ const ms = act.ms || 1000;
932
+ await new Promise((r) => setTimeout(r, ms));
933
+ results.push({ action: "wait", success: true, result: `Waited ${ms}ms` });
934
+ }
935
+ break;
936
+ }
937
+ case "extract": {
938
+ if (!act.selector)
939
+ throw new Error("extract requires a selector");
940
+ const extRes = await cometClient.safeEvaluate(`
941
+ (() => {
942
+ const el = document.querySelector(${safeSelector(act.selector)});
943
+ if (!el) return JSON.stringify({ ok: false, error: 'Element not found' });
944
+ return JSON.stringify({
945
+ ok: true,
946
+ text: el.innerText?.trim() || '',
947
+ value: el.value || '',
948
+ tag: el.tagName,
949
+ });
950
+ })()
951
+ `);
952
+ const eRes = JSON.parse(extRes.result.value);
953
+ if (!eRes.ok)
954
+ throw new Error(eRes.error);
955
+ const extracted = eRes.text || eRes.value || `<${eRes.tag}>`;
956
+ results.push({
957
+ action: "extract",
958
+ success: true,
959
+ result: extracted.substring(0, 2000),
960
+ });
961
+ break;
962
+ }
963
+ case "evaluate": {
964
+ if (!act.script)
965
+ throw new Error("evaluate requires a script");
966
+ const evalRes = await cometClient.safeEvaluate(`
967
+ (() => {
968
+ try {
969
+ const result = (function() { ${act.script} })();
970
+ return JSON.stringify({ ok: true, result: String(result ?? 'undefined').substring(0, 4000) });
971
+ } catch (e) {
972
+ return JSON.stringify({ ok: false, error: e.message });
973
+ }
974
+ })()
975
+ `);
976
+ const evRes = JSON.parse(evalRes.result.value);
977
+ if (!evRes.ok)
978
+ throw new Error(evRes.error);
979
+ results.push({ action: "evaluate", success: true, result: evRes.result });
980
+ break;
981
+ }
982
+ default:
983
+ throw new Error(`Unknown action: ${act.action}`);
984
+ }
985
+ await new Promise((r) => setTimeout(r, 100));
986
+ }
987
+ catch (err) {
988
+ const errorMsg = err instanceof Error ? err.message : String(err);
989
+ results.push({ action: act.action, success: false, error: errorMsg });
990
+ if (!act.optional) {
991
+ results.push({
992
+ action: "ABORTED",
993
+ success: false,
994
+ error: `Stopped after ${act.action} failed`,
995
+ });
996
+ break;
997
+ }
998
+ }
999
+ }
1000
+ const allSucceeded = results.every((r) => r.success);
1001
+ return { results, allSucceeded };
1002
+ });
1003
+ if (result !== null) {
1004
+ if (result && "error" in result)
1005
+ errorJson(res, result.error, 400);
1006
+ else if (result)
1007
+ json(res, result);
1008
+ }
1009
+ }
1010
+ async function handleReadPage(res, params) {
1011
+ const result = await withMutex(res, async () => {
1012
+ const args = paramsToRecord(params);
1013
+ const binding = await requireHttpBinding(res, args);
1014
+ if (!binding)
1015
+ return null;
1016
+ const mode = params.get("mode") || "text";
1017
+ const maxDepth = parseInt(params.get("maxDepth") || "5", 10);
1018
+ const maxLength = parseInt(params.get("maxLength") || "12000", 10);
1019
+ const parts = [];
1020
+ if (mode === "tree" || mode === "both") {
1021
+ const tree = await cometClient.getAccessibilityTree(maxDepth, maxLength);
1022
+ parts.push("## Accessibility Tree\n" + tree);
1023
+ }
1024
+ if (mode === "text" || mode === "both") {
1025
+ const text = await cometClient.getPageText(maxLength);
1026
+ parts.push("## Page Text\n" + text);
1027
+ }
1028
+ if (parts.length === 0) {
1029
+ return { error: `Invalid mode: ${mode}. Use: text, tree, or both` };
1030
+ }
1031
+ return { mode, content: parts.join("\n\n") };
1032
+ });
1033
+ if (result !== null) {
1034
+ if (result && "error" in result)
1035
+ errorJson(res, result.error, 400);
1036
+ else if (result)
1037
+ json(res, result);
1038
+ }
1039
+ }
1040
+ async function handleShortcut(res, body) {
1041
+ const result = await withMutex(res, async () => {
1042
+ if (requireSession(res))
1043
+ return null;
1044
+ let shortcut = body.shortcut;
1045
+ if (!shortcut || shortcut.trim().length === 0) {
1046
+ return { error: "shortcut is required" };
1047
+ }
1048
+ shortcut = shortcut.replace(/^\//, "");
1049
+ const context = body.context;
1050
+ const timeout = body.timeout || 30000;
1051
+ // Ensure on Perplexity
1052
+ const tabs = await cometClient.listTabsCategorized();
1053
+ if (tabs.main)
1054
+ await cometClient.connect(tabs.main.id);
1055
+ const urlResult = await cometClient.evaluate("window.location.href");
1056
+ const currentUrl = urlResult.result.value;
1057
+ if (!currentUrl?.includes("perplexity.ai")) {
1058
+ await cometClient.navigate("https://www.perplexity.ai/", true);
1059
+ await new Promise((r) => setTimeout(r, 2000));
1060
+ }
1061
+ // Capture old state
1062
+ const oldStateResult = await cometClient.evaluate(`
1063
+ (() => {
1064
+ const proseEls = document.querySelectorAll('[class*="prose"]');
1065
+ const lastProse = proseEls[proseEls.length - 1];
1066
+ return { count: proseEls.length, lastText: lastProse ? lastProse.innerText.substring(0, 100) : '' };
1067
+ })()
1068
+ `);
1069
+ const oldState = oldStateResult.result.value;
1070
+ await cometAI.sendShortcut(shortcut, context);
1071
+ const startTime = Date.now();
1072
+ let sawNewResponse = false;
1073
+ while (Date.now() - startTime < timeout) {
1074
+ await new Promise((r) => setTimeout(r, 2000));
1075
+ const currentStateResult = await cometClient.evaluate(`
1076
+ (() => {
1077
+ const proseEls = document.querySelectorAll('[class*="prose"]');
1078
+ const lastProse = proseEls[proseEls.length - 1];
1079
+ return { count: proseEls.length, lastText: lastProse ? lastProse.innerText.substring(0, 100) : '' };
1080
+ })()
1081
+ `);
1082
+ const currentState = currentStateResult.result.value;
1083
+ if (!sawNewResponse) {
1084
+ if (currentState.count > oldState.count ||
1085
+ (currentState.lastText && currentState.lastText !== oldState.lastText)) {
1086
+ sawNewResponse = true;
1087
+ }
1088
+ }
1089
+ const status = await cometAI.getAgentStatus();
1090
+ if (status.status === "completed" && sawNewResponse) {
1091
+ return {
1092
+ status: "completed",
1093
+ response: status.response || "Shortcut completed (no response text extracted)",
1094
+ };
1095
+ }
1096
+ }
1097
+ const finalStatus = await cometAI.getAgentStatus();
1098
+ return {
1099
+ status: "in_progress",
1100
+ currentStep: finalStatus.currentStep || null,
1101
+ message: `Shortcut /${shortcut} in progress (timed out after ${timeout}ms). Use GET /api/poll to check progress.`,
1102
+ };
1103
+ });
1104
+ if (result !== null) {
1105
+ if (result && "error" in result)
1106
+ errorJson(res, result.error, 400);
1107
+ else if (result)
1108
+ json(res, result);
1109
+ }
1110
+ }
1111
+ async function handleWaitForIdle(res, body) {
1112
+ const result = await withMutex(res, async () => {
1113
+ if (requireSession(res))
1114
+ return null;
1115
+ const idleTime = body.idleTime || 1500;
1116
+ const timeout = body.timeout || 15000;
1117
+ const idleResult = await cometClient.waitForNetworkIdle({ idleTime, timeout });
1118
+ return {
1119
+ idle: idleResult.idle,
1120
+ waitedMs: idleResult.waitedMs,
1121
+ totalRequests: idleResult.totalRequests,
1122
+ totalCompleted: idleResult.totalCompleted,
1123
+ totalFailed: idleResult.totalFailed,
1124
+ pendingRequests: idleResult.pendingRequests,
1125
+ };
1126
+ });
1127
+ if (result !== null) {
1128
+ if (result)
1129
+ json(res, result);
1130
+ }
1131
+ }
1132
+ // ---- Safe Observation handlers (no session, no mutex) ----
1133
+ async function handleObserve(res, params) {
1134
+ const action = params.get("action") || "health";
1135
+ const filters = {
1136
+ group: params.get("group") || undefined,
1137
+ agentId: params.get("agentId") || undefined,
1138
+ urlPattern: params.get("urlPattern") || undefined,
1139
+ thumbnails: params.get("thumbnails") === "true",
1140
+ codexIdentity: (() => {
1141
+ try {
1142
+ return deriveCodexSessionIdentity(identityInputFromArgs(paramsToRecord(params)));
1143
+ }
1144
+ catch {
1145
+ return undefined;
1146
+ }
1147
+ })(),
1148
+ };
1149
+ try {
1150
+ switch (action) {
1151
+ case "health": {
1152
+ const health = await getHealth();
1153
+ json(res, { action: "health", formatted: formatHealth(health), data: health });
1154
+ break;
1155
+ }
1156
+ case "snapshot": {
1157
+ const snapshot = await getSnapshot(filters);
1158
+ json(res, { action: "snapshot", formatted: formatSnapshot(snapshot), data: snapshot });
1159
+ break;
1160
+ }
1161
+ case "status": {
1162
+ const statusText = await getStatus(filters);
1163
+ json(res, { action: "status", formatted: statusText });
1164
+ break;
1165
+ }
1166
+ case "detail": {
1167
+ if (!filters.group) {
1168
+ errorJson(res, "The 'detail' action requires a 'group' query parameter (tab group name).", 400);
1169
+ return;
1170
+ }
1171
+ const detailText = await getDetail(filters.group, filters);
1172
+ json(res, { action: "detail", group: filters.group, formatted: detailText });
1173
+ break;
1174
+ }
1175
+ default:
1176
+ errorJson(res, `Unknown observe action: ${action}. Use: health, snapshot, status, or detail.`, 400);
1177
+ }
1178
+ }
1179
+ catch (err) {
1180
+ const message = err instanceof Error ? err.message : String(err);
1181
+ errorJson(res, message);
1182
+ }
1183
+ }
1184
+ async function handlePeek(res, params) {
1185
+ const args = paramsToRecord(params);
1186
+ const targetId = params.get("targetId");
1187
+ const action = params.get("action") || "info";
1188
+ const binding = await requireHttpBinding(res, args);
1189
+ if (!binding)
1190
+ return;
1191
+ if (!targetId) {
1192
+ errorJson(res, "targetId query parameter is required. Use GET /api/observe?action=snapshot to find target IDs.", 400);
1193
+ return;
1194
+ }
1195
+ // Resolve CDP target list (no session, no mutex needed for info)
1196
+ let peekTargets;
1197
+ try {
1198
+ const resp = await fetch(`http://127.0.0.1:9222/json/list`);
1199
+ if (!resp.ok) {
1200
+ errorJson(res, "Cannot connect to CDP on port 9222. Is Comet browser running?", 503);
1201
+ return;
1202
+ }
1203
+ peekTargets = (await resp.json());
1204
+ }
1205
+ catch {
1206
+ errorJson(res, "Cannot reach CDP on port 9222. Is Comet browser running?", 503);
1207
+ return;
1208
+ }
1209
+ const peekTarget = peekTargets.find((t) => t.id === targetId);
1210
+ if (!peekTarget) {
1211
+ errorJson(res, `Target ID '${targetId}' not found. Use GET /api/observe?action=snapshot to list current targets.`, 404);
1212
+ return;
1213
+ }
1214
+ if (action === "info") {
1215
+ json(res, {
1216
+ targetId: peekTarget.id,
1217
+ url: peekTarget.url,
1218
+ title: peekTarget.title,
1219
+ type: peekTarget.type,
1220
+ });
1221
+ return;
1222
+ }
1223
+ // screenshot and read require temporary CDP attachment — use mutex
1224
+ const mutexResult = await withMutex(res, async () => {
1225
+ if (!peekTarget.webSocketDebuggerUrl) {
1226
+ return { error: "Target has no WebSocket debugger URL" };
1227
+ }
1228
+ const peekCDP = await (await import("chrome-remote-interface")).default({
1229
+ target: peekTarget.webSocketDebuggerUrl,
1230
+ });
1231
+ try {
1232
+ if (action === "screenshot") {
1233
+ await peekCDP.Page.enable();
1234
+ const ssResult = (await Promise.race([
1235
+ peekCDP.Page.captureScreenshot({ format: "png" }),
1236
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Screenshot timeout (3s)")), 3000)),
1237
+ ]));
1238
+ const outputDir = join(homedir(), ".claude", "comet-browser", "output");
1239
+ mkdirSync(outputDir, { recursive: true });
1240
+ const outputPath = join(outputDir, `peek-${Date.now()}.png`);
1241
+ writeFileSync(outputPath, Buffer.from(ssResult.data, "base64"));
1242
+ return { filePath: outputPath, url: peekTarget.url, title: peekTarget.title };
1243
+ }
1244
+ if (action === "read") {
1245
+ await peekCDP.Runtime.enable();
1246
+ const readResult = await peekCDP.Runtime.evaluate({
1247
+ expression: `(() => {
1248
+ const title = document.title;
1249
+ const url = window.location.href;
1250
+ const text = document.body?.innerText?.substring(0, 10000) || '';
1251
+ const links = Array.from(document.querySelectorAll('a[href]')).slice(0, 20).map(a => ({ text: a.innerText.trim().substring(0, 50), href: a.href }));
1252
+ return JSON.stringify({ title, url, text, links });
1253
+ })()`,
1254
+ returnByValue: true,
1255
+ });
1256
+ const pageData = JSON.parse(readResult.result.value);
1257
+ return {
1258
+ title: pageData.title,
1259
+ url: pageData.url,
1260
+ text: pageData.text.substring(0, 5000),
1261
+ links: pageData.links,
1262
+ };
1263
+ }
1264
+ return { error: `Unknown peek action: ${action}. Use: info, screenshot, or read.` };
1265
+ }
1266
+ finally {
1267
+ try {
1268
+ await peekCDP.close();
1269
+ }
1270
+ catch {
1271
+ /* ignore */
1272
+ }
1273
+ }
1274
+ });
1275
+ if (mutexResult !== null) {
1276
+ if (mutexResult && "error" in mutexResult)
1277
+ errorJson(res, mutexResult.error, 400);
1278
+ else if (mutexResult)
1279
+ json(res, mutexResult);
1280
+ }
1281
+ }
1282
+ // ---- Lifecycle handlers (no session, no mutex, forward to CC-SO) ----
1283
+ async function handleLifecycleStart(res, body) {
1284
+ const runId = body.runId;
1285
+ const taskThreadId = body.taskThreadId;
1286
+ if (!runId) {
1287
+ errorJson(res, "runId is required", 400);
1288
+ return;
1289
+ }
1290
+ if (!taskThreadId) {
1291
+ errorJson(res, "taskThreadId is required", 400);
1292
+ return;
1293
+ }
1294
+ let result = "skipped";
1295
+ let warning;
1296
+ try {
1297
+ result = await callLifecycleEndpoint({
1298
+ action: "start",
1299
+ runId,
1300
+ taskThreadId,
1301
+ agentId: body.agentId,
1302
+ route: body.route || "http",
1303
+ deferred: body.deferred,
1304
+ });
1305
+ }
1306
+ catch {
1307
+ warning = "CC-SO unavailable — lifecycle event not persisted";
1308
+ }
1309
+ const binding = await attachRunIdToHttpBinding(runId, body);
1310
+ appendJsonl(OUTBOX_PATH, {
1311
+ ts: Math.floor(Date.now() / 1000),
1312
+ from: "comet-http",
1313
+ to: "orchestration",
1314
+ type: "update",
1315
+ task: runId,
1316
+ thread: taskThreadId,
1317
+ msg: `Lifecycle started: run=${runId}`,
1318
+ lifecycle: { action: "start", runId, status: "started", bindingId: binding?.bindingId ?? null },
1319
+ });
1320
+ json(res, {
1321
+ success: true,
1322
+ runId,
1323
+ result,
1324
+ bindingId: binding?.bindingId ?? null,
1325
+ ...(warning ? { warning } : {}),
1326
+ });
1327
+ }
1328
+ async function handleLifecycleComplete(res, body) {
1329
+ const runId = body.runId;
1330
+ if (!runId) {
1331
+ errorJson(res, "runId is required", 400);
1332
+ return;
1333
+ }
1334
+ let result = "skipped";
1335
+ let warning;
1336
+ try {
1337
+ result = await callLifecycleEndpoint({ action: "complete", runId });
1338
+ }
1339
+ catch {
1340
+ warning = "CC-SO unavailable — lifecycle event not persisted";
1341
+ }
1342
+ const binding = await windowBindingStore.transitionByRunId(runId, "completed");
1343
+ appendJsonl(OUTBOX_PATH, {
1344
+ ts: Math.floor(Date.now() / 1000),
1345
+ from: "comet-http",
1346
+ to: "orchestration",
1347
+ type: "complete",
1348
+ task: runId,
1349
+ msg: `Lifecycle completed: run=${runId}`,
1350
+ lifecycle: {
1351
+ action: "complete",
1352
+ runId,
1353
+ status: "completed",
1354
+ bindingId: binding?.bindingId ?? null,
1355
+ },
1356
+ });
1357
+ json(res, {
1358
+ success: true,
1359
+ runId,
1360
+ result,
1361
+ bindingId: binding?.bindingId ?? null,
1362
+ ...(warning ? { warning } : {}),
1363
+ });
1364
+ }
1365
+ async function handleLifecycleAbort(res, body) {
1366
+ const runId = body.runId;
1367
+ if (!runId) {
1368
+ errorJson(res, "runId is required", 400);
1369
+ return;
1370
+ }
1371
+ let result = "skipped";
1372
+ let warning;
1373
+ try {
1374
+ result = await callLifecycleEndpoint({
1375
+ action: "abort",
1376
+ runId,
1377
+ reason: body.reason,
1378
+ });
1379
+ }
1380
+ catch {
1381
+ warning = "CC-SO unavailable — lifecycle event not persisted";
1382
+ }
1383
+ const binding = await windowBindingStore.transitionByRunId(runId, "stale");
1384
+ appendJsonl(OUTBOX_PATH, {
1385
+ ts: Math.floor(Date.now() / 1000),
1386
+ from: "comet-http",
1387
+ to: "orchestration",
1388
+ type: "blocked",
1389
+ task: runId,
1390
+ msg: `Lifecycle aborted: run=${runId}${body.reason ? ` reason=${body.reason}` : ""}`,
1391
+ lifecycle: {
1392
+ action: "abort",
1393
+ runId,
1394
+ status: "aborted",
1395
+ reason: body.reason,
1396
+ bindingId: binding?.bindingId ?? null,
1397
+ },
1398
+ });
1399
+ json(res, {
1400
+ success: true,
1401
+ runId,
1402
+ result,
1403
+ bindingId: binding?.bindingId ?? null,
1404
+ ...(warning ? { warning } : {}),
1405
+ });
1406
+ }
1407
+ async function handleLifecycleUpdate(res, body) {
1408
+ const runId = body.runId;
1409
+ if (!runId) {
1410
+ errorJson(res, "runId is required", 400);
1411
+ return;
1412
+ }
1413
+ let result = "skipped";
1414
+ let warning;
1415
+ try {
1416
+ result = await callLifecycleEndpoint({
1417
+ action: "update",
1418
+ runId,
1419
+ auditSessionId: body.auditSessionId,
1420
+ tabGroupId: body.tabGroupId,
1421
+ workflowId: body.workflowId,
1422
+ metadata: body.metadata,
1423
+ });
1424
+ }
1425
+ catch {
1426
+ warning = "CC-SO unavailable — lifecycle event not persisted";
1427
+ }
1428
+ json(res, { success: true, runId, result, ...(warning ? { warning } : {}) });
1429
+ }
1430
+ // ---- Orchestration handlers ----
1431
+ async function handleTaskStatus(res, params) {
1432
+ const args = paramsToRecord(params);
1433
+ const binding = await requireHttpBinding(res, args);
1434
+ if (!binding)
1435
+ return;
1436
+ const groupId = typeof args.groupId === "number" ? args.groupId : undefined;
1437
+ const threadId = args.projectThreadId ?? args.threadId;
1438
+ const bindingId = args.bindingId;
1439
+ const sessionKey = args.sessionKey;
1440
+ const runId = args.runId;
1441
+ const manifest = readJsonSafe(MANIFEST_PATH);
1442
+ const sessions = manifest?.sessions || [];
1443
+ const matched = sessions.filter((s) => {
1444
+ const codexBinding = s.codexBinding;
1445
+ if (codexBinding?.bindingId !== binding.bindingId)
1446
+ return false;
1447
+ const checks = [];
1448
+ if (bindingId)
1449
+ checks.push(codexBinding.bindingId === bindingId);
1450
+ if (sessionKey)
1451
+ checks.push(s.sessionKey === sessionKey);
1452
+ if (groupId !== undefined)
1453
+ checks.push(s.tabGroupId === groupId);
1454
+ if (threadId)
1455
+ checks.push(s.taskThreadId === threadId || codexBinding.projectThreadId === threadId);
1456
+ if (runId)
1457
+ checks.push(Array.isArray(codexBinding.runIds) && codexBinding.runIds.includes(runId));
1458
+ return checks.every(Boolean);
1459
+ });
1460
+ if (matched.length === 0) {
1461
+ errorJson(res, `No sessions found for binding-scoped query ${JSON.stringify({ bindingId, sessionKey, projectThreadId: threadId, runId, groupId })}`, 404);
1462
+ return;
1463
+ }
1464
+ const result = matched.map((s) => ({
1465
+ bindingId: s.codexBinding?.bindingId ?? null,
1466
+ sessionKey: s.sessionKey,
1467
+ groupId: s.tabGroupId,
1468
+ threadId: s.taskThreadId,
1469
+ status: s.agent?.status || "unknown",
1470
+ agentId: s.agentId ?? s.agent?.id ?? null,
1471
+ tabs: Array.isArray(s.tabs) ? s.tabs.length : 0,
1472
+ metrics: s.metrics || {},
1473
+ links: s.links || {},
1474
+ lastActivity: s.metrics?.lastActivityAt || null,
1475
+ }));
1476
+ json(res, { sessions: result });
1477
+ }
1478
+ async function handleDelegate(res, body) {
1479
+ const delegateResult = await withMutex(res, async () => {
1480
+ const threadId = body.threadId;
1481
+ const instruction = body.instruction;
1482
+ if (!threadId || !instruction) {
1483
+ return { error: "threadId and instruction are required" };
1484
+ }
1485
+ const priority = body.priority || "P3";
1486
+ const urls = body.urls || [];
1487
+ const dependsOn = body.dependsOn || [];
1488
+ const agentId = body.agentId || "comet-http";
1489
+ const taskId = `comet-${Date.now()}`;
1490
+ const priorityColors = {
1491
+ P1: "red",
1492
+ P2: "yellow",
1493
+ P3: "blue",
1494
+ P4: "grey",
1495
+ };
1496
+ const color = priorityColors[priority] || "blue";
1497
+ // 1. Write to inbox
1498
+ appendJsonl(INBOX_PATH, {
1499
+ ts: Math.floor(Date.now() / 1000),
1500
+ from: "comet-delegate",
1501
+ to: "comet-browser",
1502
+ task: taskId,
1503
+ type: "instruction",
1504
+ thread: threadId,
1505
+ priority,
1506
+ msg: instruction,
1507
+ browser_task: {
1508
+ thread_id: threadId,
1509
+ group_name: threadId.slice(0, 50),
1510
+ color,
1511
+ priority,
1512
+ urls,
1513
+ depends_on: dependsOn,
1514
+ },
1515
+ });
1516
+ // 2. Start lifecycle (graceful)
1517
+ let lifecycleResult = "skipped";
1518
+ try {
1519
+ lifecycleResult = await callLifecycleEndpoint({
1520
+ action: "start",
1521
+ runId: taskId,
1522
+ taskThreadId: threadId,
1523
+ agentId,
1524
+ route: "http",
1525
+ });
1526
+ }
1527
+ catch {
1528
+ /* CC-SO may not be running */
1529
+ }
1530
+ // 3. Create/reuse the Codex window binding directly (no legacy session-controller dispatch).
1531
+ let currentBinding = null;
1532
+ try {
1533
+ const identity = deriveCodexSessionIdentity(identityInputFromArgs(body));
1534
+ currentBinding = await windowBindingStore.findActiveByIdentity(identity);
1535
+ }
1536
+ catch {
1537
+ currentBinding = null;
1538
+ }
1539
+ const bindingDispatch = await createOrReuseDelegateBinding({
1540
+ ...identityInputFromArgs(body),
1541
+ strict: false,
1542
+ taskId,
1543
+ threadId,
1544
+ agentId,
1545
+ currentBinding,
1546
+ bindingId: body.bindingId,
1547
+ projectThreadId: body.projectThreadId ?? threadId,
1548
+ fallbackAgentId: agentId,
1549
+ fallbackTaskThreadId: threadId,
1550
+ windowId: body.windowId,
1551
+ tabGroupId: body.tabGroupId ?? body.groupId ?? null,
1552
+ targetId: body.targetId,
1553
+ });
1554
+ // 4. Write to outbox
1555
+ appendJsonl(OUTBOX_PATH, {
1556
+ ts: Math.floor(Date.now() / 1000),
1557
+ from: "comet-http",
1558
+ to: "orchestration",
1559
+ type: "update",
1560
+ task: taskId,
1561
+ thread: threadId,
1562
+ msg: `Delegated: ${instruction.slice(0, 100)}`,
1563
+ lifecycle: { action: "start", runId: taskId, status: "dispatched" },
1564
+ });
1565
+ return {
1566
+ taskId,
1567
+ threadId,
1568
+ priority,
1569
+ color,
1570
+ lifecycleResult,
1571
+ bindingId: bindingDispatch.bindingId,
1572
+ windowId: bindingDispatch.windowId,
1573
+ tabGroupId: bindingDispatch.tabGroupId,
1574
+ dispatchStatus: bindingDispatch.dispatchStatus,
1575
+ };
1576
+ });
1577
+ if (delegateResult !== null) {
1578
+ if (delegateResult && "error" in delegateResult)
1579
+ errorJson(res, delegateResult.error, 400);
1580
+ else if (delegateResult)
1581
+ json(res, delegateResult);
1582
+ }
1583
+ }
1584
+ // ---- Parity tool handlers (require session) ----
1585
+ async function handlePdf(res, body) {
1586
+ const result = await withMutex(res, async () => {
1587
+ if (requireSession(res))
1588
+ return null;
1589
+ const pdfUrl = body.url;
1590
+ const pdfName = body.name;
1591
+ const pdfFormat = body.format || "Letter";
1592
+ const pdfLandscape = body.landscape || false;
1593
+ const pdfMargin = body.margin ?? 0.5;
1594
+ const pdfScale = body.scale || 1;
1595
+ const pdfPrintBg = body.printBackground !== false;
1596
+ const pdfHideSelectors = body.hideSelectors;
1597
+ if (pdfUrl) {
1598
+ await cometClient.navigate(pdfUrl, true, true);
1599
+ }
1600
+ if (pdfHideSelectors) {
1601
+ const selectors = pdfHideSelectors.split(",").map((s) => s.trim());
1602
+ for (const sel of selectors) {
1603
+ await cometClient.evaluate(`document.querySelectorAll(${safeSelector(sel)}).forEach(el => el.style.display = 'none')`);
1604
+ }
1605
+ }
1606
+ const paperSizes = {
1607
+ Letter: { width: 8.5, height: 11 },
1608
+ Legal: { width: 8.5, height: 14 },
1609
+ A4: { width: 8.27, height: 11.69 },
1610
+ A3: { width: 11.69, height: 16.54 },
1611
+ Tabloid: { width: 11, height: 17 },
1612
+ };
1613
+ const paper = paperSizes[pdfFormat] || paperSizes.Letter;
1614
+ const cdp = cometClient.protocol;
1615
+ const pdfResult = await cdp.send("Page.printToPDF", {
1616
+ landscape: pdfLandscape,
1617
+ printBackground: pdfPrintBg,
1618
+ scale: pdfScale,
1619
+ paperWidth: paper.width,
1620
+ paperHeight: paper.height,
1621
+ marginTop: pdfMargin,
1622
+ marginBottom: pdfMargin,
1623
+ marginLeft: pdfMargin,
1624
+ marginRight: pdfMargin,
1625
+ });
1626
+ const outputDir = join(homedir(), ".claude", "comet-browser", "output");
1627
+ mkdirSync(outputDir, { recursive: true });
1628
+ const titleResult = await cometClient.evaluate("document.title");
1629
+ const pageTitle = titleResult.result.value || "page";
1630
+ const safeName = pdfName || pageTitle.replace(/[^a-zA-Z0-9-_]/g, "_").substring(0, 60);
1631
+ const outputPath = join(outputDir, `${safeName}-${Date.now()}.pdf`);
1632
+ const pdfBuffer = Buffer.from(pdfResult.data, "base64");
1633
+ writeFileSync(outputPath, pdfBuffer);
1634
+ return {
1635
+ filePath: outputPath,
1636
+ sizeKB: (pdfBuffer.length / 1024).toFixed(1),
1637
+ format: pdfFormat,
1638
+ landscape: pdfLandscape,
1639
+ };
1640
+ });
1641
+ if (result !== null) {
1642
+ if (result)
1643
+ json(res, result);
1644
+ }
1645
+ }
1646
+ async function handleScrape(res, body) {
1647
+ const result = await withMutex(res, async () => {
1648
+ if (requireSession(res))
1649
+ return null;
1650
+ const scrapeUrl = body.url;
1651
+ const scrapeSelector = body.selector;
1652
+ const scrapeMode = body.mode || "text";
1653
+ const scrapeAttr = body.attr;
1654
+ const scrapeScroll = body.scroll || false;
1655
+ const scrapeWaitFor = body.waitFor;
1656
+ if (scrapeUrl) {
1657
+ await cometClient.navigate(scrapeUrl, true, true);
1658
+ }
1659
+ if (scrapeWaitFor) {
1660
+ const waitStart = Date.now();
1661
+ while (Date.now() - waitStart < 10000) {
1662
+ const found = await cometClient.evaluate(`!!document.querySelector(${safeSelector(scrapeWaitFor)})`);
1663
+ if (found.result.value)
1664
+ break;
1665
+ await new Promise((r) => setTimeout(r, 300));
1666
+ }
1667
+ }
1668
+ if (scrapeScroll) {
1669
+ await cometClient.evaluate(`
1670
+ (async () => {
1671
+ let totalHeight = 0;
1672
+ const distance = 500;
1673
+ while (totalHeight < document.body.scrollHeight && totalHeight < 50000) {
1674
+ window.scrollBy(0, distance);
1675
+ totalHeight += distance;
1676
+ await new Promise(r => setTimeout(r, 200));
1677
+ }
1678
+ window.scrollTo(0, 0);
1679
+ })()
1680
+ `);
1681
+ await new Promise((r) => setTimeout(r, 500));
1682
+ }
1683
+ const safeSel = safeSelector(scrapeSelector || "body");
1684
+ const safeAttrName = safeSelector(scrapeAttr || "href");
1685
+ let extractionScript;
1686
+ switch (scrapeMode) {
1687
+ case "table":
1688
+ extractionScript = `
1689
+ (() => {
1690
+ const table = document.querySelector(${safeSelector(scrapeSelector || "table")});
1691
+ if (!table) return { error: 'No table found' };
1692
+ const rows = [...table.querySelectorAll('tr')];
1693
+ const headers = [...rows[0]?.querySelectorAll('th, td')].map(c => c.innerText.trim());
1694
+ const data = rows.slice(1).map(row => {
1695
+ const cells = [...row.querySelectorAll('td, th')].map(c => c.innerText.trim());
1696
+ return headers.length ? Object.fromEntries(headers.map((h, i) => [h, cells[i] || ''])) : cells;
1697
+ });
1698
+ return { rows: data.length, headers, data };
1699
+ })()`;
1700
+ break;
1701
+ case "json-ld":
1702
+ extractionScript = `
1703
+ (() => {
1704
+ const scripts = [...document.querySelectorAll('script[type="application/ld+json"]')];
1705
+ const data = scripts.map(s => { try { return JSON.parse(s.textContent); } catch { return null; } }).filter(Boolean);
1706
+ return { count: data.length, data };
1707
+ })()`;
1708
+ break;
1709
+ case "list":
1710
+ extractionScript = `
1711
+ (() => {
1712
+ const sel = ${safeSelector(scrapeSelector || "ul li, ol li")};
1713
+ const items = [...document.querySelectorAll(sel)].map(el => el.innerText.trim());
1714
+ return { count: items.length, items };
1715
+ })()`;
1716
+ break;
1717
+ case "attr":
1718
+ extractionScript = `
1719
+ (() => {
1720
+ const sel = ${safeSel};
1721
+ const attr = ${safeAttrName};
1722
+ const els = [...document.querySelectorAll(sel)];
1723
+ const values = els.map(el => el.getAttribute(attr)).filter(Boolean);
1724
+ return { count: values.length, attribute: attr, values };
1725
+ })()`;
1726
+ break;
1727
+ case "multi":
1728
+ extractionScript = `
1729
+ (() => {
1730
+ const sel = ${safeSelector(scrapeSelector || "p")};
1731
+ const els = [...document.querySelectorAll(sel)];
1732
+ const items = els.map(el => ({ tag: el.tagName, text: el.innerText.trim().substring(0, 500) }));
1733
+ return { count: items.length, items };
1734
+ })()`;
1735
+ break;
1736
+ default: // text
1737
+ extractionScript = `
1738
+ (() => {
1739
+ const sel = ${safeSel};
1740
+ const el = document.querySelector(sel);
1741
+ if (!el) return { error: 'Selector not found: ' + sel };
1742
+ return { tag: el.tagName, text: el.innerText.trim().substring(0, 10000) };
1743
+ })()`;
1744
+ }
1745
+ const scrapeResult = await cometClient.evaluate(extractionScript);
1746
+ return { data: scrapeResult.result.value };
1747
+ });
1748
+ if (result !== null) {
1749
+ if (result)
1750
+ json(res, result);
1751
+ }
1752
+ }
1753
+ async function handleNetwork(res, body) {
1754
+ const result = await withMutex(res, async () => {
1755
+ if (requireSession(res))
1756
+ return null;
1757
+ const netAction = body.action || "capture";
1758
+ const netUrl = body.url;
1759
+ const netDuration = body.duration || 10000;
1760
+ const netFilter = body.filter;
1761
+ const netResourceType = body.resourceType;
1762
+ const netPattern = body.pattern;
1763
+ const netMockResponse = body.mockResponse;
1764
+ const netMockStatus = body.mockStatus || 200;
1765
+ const netIncludeHeaders = body.includeHeaders || false;
1766
+ // CDP.Client extends EventEmitter at runtime but types don't expose on/once/removeListener
1767
+ const cdp = cometClient.protocol;
1768
+ if (netAction === "block") {
1769
+ if (!netPattern)
1770
+ return { error: "'pattern' is required for block action" };
1771
+ await cdp.send("Network.enable");
1772
+ await cdp.send("Network.setBlockedURLs", { urls: [netPattern] });
1773
+ return {
1774
+ action: "block",
1775
+ pattern: netPattern,
1776
+ message: `Blocking requests matching: ${netPattern}`,
1777
+ };
1778
+ }
1779
+ if (netAction === "intercept") {
1780
+ if (!netPattern)
1781
+ return { error: "'pattern' is required for intercept action" };
1782
+ await cdp.send("Fetch.enable", {
1783
+ patterns: [{ urlPattern: `*${netPattern}*`, requestStage: "Response" }],
1784
+ });
1785
+ const interceptResult = await new Promise((resolve) => {
1786
+ const timeout = setTimeout(() => resolve("No matching request within 30s"), 30000);
1787
+ cdp.once("Fetch.requestPaused", async (params) => {
1788
+ clearTimeout(timeout);
1789
+ const body = netMockResponse || '{"mocked":true}';
1790
+ const headers = [
1791
+ { name: "Content-Type", value: "application/json" },
1792
+ { name: "Access-Control-Allow-Origin", value: "*" },
1793
+ ];
1794
+ await cdp.send("Fetch.fulfillRequest", {
1795
+ requestId: params.requestId,
1796
+ responseCode: netMockStatus,
1797
+ responseHeaders: headers,
1798
+ body: Buffer.from(body).toString("base64"),
1799
+ });
1800
+ resolve(`Intercepted: ${params.request.url}\nResponded with status ${netMockStatus}`);
1801
+ });
1802
+ });
1803
+ await cdp.send("Fetch.disable");
1804
+ return { action: "intercept", pattern: netPattern, result: interceptResult };
1805
+ }
1806
+ // capture action
1807
+ if (netUrl)
1808
+ await cometClient.navigate(netUrl, true, true);
1809
+ await cdp.send("Network.enable");
1810
+ const captured = [];
1811
+ const responseMap = new Map();
1812
+ const onRequest = (params) => {
1813
+ if (netFilter && !params.request.url.includes(netFilter))
1814
+ return;
1815
+ if (netResourceType && params.type?.toLowerCase() !== netResourceType.toLowerCase())
1816
+ return;
1817
+ captured.push({
1818
+ requestId: params.requestId,
1819
+ method: params.request.method,
1820
+ url: params.request.url,
1821
+ type: params.type,
1822
+ ...(netIncludeHeaders ? { requestHeaders: params.request.headers } : {}),
1823
+ });
1824
+ };
1825
+ const onResponse = (params) => {
1826
+ responseMap.set(params.requestId, {
1827
+ status: params.response.status,
1828
+ mimeType: params.response.mimeType,
1829
+ ...(netIncludeHeaders ? { responseHeaders: params.response.headers } : {}),
1830
+ });
1831
+ };
1832
+ cdp.on("Network.requestWillBeSent", onRequest);
1833
+ cdp.on("Network.responseReceived", onResponse);
1834
+ await new Promise((r) => setTimeout(r, netDuration));
1835
+ cdp.removeListener("Network.requestWillBeSent", onRequest);
1836
+ cdp.removeListener("Network.responseReceived", onResponse);
1837
+ await cdp.send("Network.disable");
1838
+ for (const req of captured) {
1839
+ const resp = responseMap.get(req.requestId);
1840
+ if (resp) {
1841
+ req.status = resp.status;
1842
+ req.mimeType = resp.mimeType;
1843
+ if (resp.responseHeaders)
1844
+ req.responseHeaders = resp.responseHeaders;
1845
+ }
1846
+ delete req.requestId;
1847
+ }
1848
+ return {
1849
+ action: "capture",
1850
+ summary: `Captured ${captured.length} requests over ${netDuration}ms${netFilter ? ` (filter: ${netFilter})` : ""}`,
1851
+ captured: captured.slice(0, 50),
1852
+ overflow: captured.length > 50 ? captured.length - 50 : 0,
1853
+ };
1854
+ });
1855
+ if (result !== null) {
1856
+ if (result && "error" in result)
1857
+ errorJson(res, result.error, 400);
1858
+ else if (result)
1859
+ json(res, result);
1860
+ }
1861
+ }
1862
+ async function handleAutomate(res, body) {
1863
+ const result = await withMutex(res, async () => {
1864
+ if (requireSession(res))
1865
+ return null;
1866
+ const autoSteps = body.steps;
1867
+ if (!autoSteps || autoSteps.length === 0) {
1868
+ return { error: "'steps' array is required and must not be empty" };
1869
+ }
1870
+ const variables = {};
1871
+ const results = [];
1872
+ function isValidIdentifier(name) {
1873
+ return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
1874
+ }
1875
+ function isSafeConditionExpression(expression) {
1876
+ if (typeof expression !== "string" || expression.length === 0 || expression.length > 200)
1877
+ return false;
1878
+ if (/[;{}\\`]/.test(expression))
1879
+ return false;
1880
+ if (/\b(?:function|new|this|window|document|globalThis|constructor|__proto__|prototype|eval|import|return)\b/.test(expression)) {
1881
+ return false;
1882
+ }
1883
+ return /^[\w\s.$'"!?<>=&|()+\-*/%:[\],]+$/.test(expression);
1884
+ }
1885
+ async function executeStep(step, index) {
1886
+ const prefix = `Step ${index + 1} [${step.tool}]`;
1887
+ try {
1888
+ switch (step.tool) {
1889
+ case "navigate":
1890
+ await cometClient.navigate(step.url, true, true);
1891
+ results.push(`✓ ${prefix}: navigated to ${step.url}`);
1892
+ break;
1893
+ case "click":
1894
+ await cometClient.evaluate(`
1895
+ (() => {
1896
+ const el = document.querySelector(${safeSelector(step.selector)});
1897
+ if (!el) throw new Error('Element not found: ' + ${safeSelector(step.selector)});
1898
+ el.scrollIntoView({ block: 'center' });
1899
+ el.click();
1900
+ })()
1901
+ `);
1902
+ results.push(`✓ ${prefix}: clicked ${step.selector}`);
1903
+ break;
1904
+ case "fill": {
1905
+ const safeFillVal = JSON.stringify(step.value || "");
1906
+ await cometClient.evaluate(`
1907
+ (() => {
1908
+ const el = document.querySelector(${safeSelector(step.selector)});
1909
+ if (!el) throw new Error('Element not found: ' + ${safeSelector(step.selector)});
1910
+ const val = ${safeFillVal};
1911
+ const nativeSet = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
1912
+ || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
1913
+ if (nativeSet) nativeSet.call(el, val);
1914
+ else el.value = val;
1915
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1916
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1917
+ })()
1918
+ `);
1919
+ results.push(`✓ ${prefix}: filled ${step.selector}`);
1920
+ break;
1921
+ }
1922
+ case "type":
1923
+ for (const char of step.value || "") {
1924
+ await cometClient.protocol.send("Input.dispatchKeyEvent", {
1925
+ type: "keyDown",
1926
+ text: char,
1927
+ });
1928
+ await cometClient.protocol.send("Input.dispatchKeyEvent", {
1929
+ type: "keyUp",
1930
+ text: char,
1931
+ });
1932
+ }
1933
+ results.push(`✓ ${prefix}: typed ${(step.value || "").length} chars`);
1934
+ break;
1935
+ case "press":
1936
+ await cometClient.protocol.send("Input.dispatchKeyEvent", {
1937
+ type: "keyDown",
1938
+ key: step.value,
1939
+ code: `Key${step.value?.toUpperCase()}`,
1940
+ });
1941
+ await cometClient.protocol.send("Input.dispatchKeyEvent", {
1942
+ type: "keyUp",
1943
+ key: step.value,
1944
+ code: `Key${step.value?.toUpperCase()}`,
1945
+ });
1946
+ results.push(`✓ ${prefix}: pressed ${step.value}`);
1947
+ break;
1948
+ case "select":
1949
+ await cometClient.evaluate(`
1950
+ (() => {
1951
+ const el = document.querySelector(${safeSelector(step.selector)});
1952
+ if (!el) throw new Error('Element not found');
1953
+ el.value = ${JSON.stringify(step.value || "")};
1954
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1955
+ })()
1956
+ `);
1957
+ results.push(`✓ ${prefix}: selected ${step.value}`);
1958
+ break;
1959
+ case "wait":
1960
+ if (step.selector) {
1961
+ const waitMs = Date.now();
1962
+ while (Date.now() - waitMs < 10000) {
1963
+ const found = await cometClient.evaluate(`!!document.querySelector(${safeSelector(step.selector)})`);
1964
+ if (found.result.value)
1965
+ break;
1966
+ await new Promise((r) => setTimeout(r, 300));
1967
+ }
1968
+ }
1969
+ else if (step.value) {
1970
+ await new Promise((r) => setTimeout(r, parseInt(step.value)));
1971
+ }
1972
+ results.push(`✓ ${prefix}: waited for ${step.selector || step.value + "ms"}`);
1973
+ break;
1974
+ case "screenshot": {
1975
+ const ssResult = await cometClient.protocol.send("Page.captureScreenshot", {
1976
+ format: "png",
1977
+ });
1978
+ const ssDir = join(homedir(), ".claude", "comet-browser", "output");
1979
+ mkdirSync(ssDir, { recursive: true });
1980
+ const ssPath = join(ssDir, `${step.name || "step-" + (index + 1)}-${Date.now()}.png`);
1981
+ writeFileSync(ssPath, Buffer.from(ssResult.data, "base64"));
1982
+ results.push(`✓ ${prefix}: screenshot → ${ssPath}`);
1983
+ break;
1984
+ }
1985
+ case "extract": {
1986
+ const extractResult = await cometClient.evaluate(`
1987
+ (() => {
1988
+ const el = document.querySelector(${safeSelector(step.selector)});
1989
+ if (!el) return null;
1990
+ return el.innerText?.trim() || el.value || '';
1991
+ })()
1992
+ `);
1993
+ const extractedValue = extractResult.result.value;
1994
+ if (step.variable && isValidIdentifier(step.variable))
1995
+ variables[step.variable] = extractedValue;
1996
+ results.push(`✓ ${prefix}: extracted ${String(extractedValue).substring(0, 100)}${step.variable ? ` → $${step.variable}` : ""}`);
1997
+ break;
1998
+ }
1999
+ case "assert": {
2000
+ const assertResult = await cometClient.evaluate(`document.querySelector(${safeSelector(step.selector)})?.innerText || ''`);
2001
+ const assertText = assertResult.result.value;
2002
+ if (step.contains && !assertText.includes(step.contains)) {
2003
+ throw new Error(`Assertion failed: "${step.selector}" does not contain "${step.contains}". Got: "${assertText.substring(0, 200)}"`);
2004
+ }
2005
+ results.push(`✓ ${prefix}: assertion passed`);
2006
+ break;
2007
+ }
2008
+ case "evaluate": {
2009
+ const evalResult = await cometClient.evaluate(step.expression);
2010
+ const evalValue = evalResult.result.value;
2011
+ if (step.variable && isValidIdentifier(step.variable))
2012
+ variables[step.variable] = evalValue;
2013
+ results.push(`✓ ${prefix}: ${JSON.stringify(evalValue).substring(0, 200)}${step.variable ? ` → $${step.variable}` : ""}`);
2014
+ break;
2015
+ }
2016
+ case "if": {
2017
+ if (!isSafeConditionExpression(step.condition)) {
2018
+ throw new Error("Unsafe condition expression in if step");
2019
+ }
2020
+ const varInjection = Object.entries(variables)
2021
+ .filter(([k]) => isValidIdentifier(k))
2022
+ .map(([k, v]) => `const ${k} = ${JSON.stringify(v)};`)
2023
+ .join(" ");
2024
+ const condEvalResult = await cometClient.evaluate(`(() => { ${varInjection} return !!(${step.condition}); })()`);
2025
+ const condResult = condEvalResult.result.value;
2026
+ if (condResult && step.then) {
2027
+ for (let i = 0; i < step.then.length; i++) {
2028
+ const ok = await executeStep(step.then[i], i);
2029
+ if (!ok)
2030
+ return false;
2031
+ }
2032
+ }
2033
+ results.push(`✓ ${prefix}: condition ${condResult ? "true" : "false"}`);
2034
+ break;
2035
+ }
2036
+ case "loop": {
2037
+ const loopItems = variables[step.items];
2038
+ if (!Array.isArray(loopItems))
2039
+ throw new Error(`Variable "${step.items}" is not an array`);
2040
+ for (let li = 0; li < loopItems.length; li++) {
2041
+ variables["_item"] = loopItems[li];
2042
+ variables["_index"] = li;
2043
+ for (let si = 0; si < (step.each || []).length; si++) {
2044
+ const ok = await executeStep(step.each[si], si);
2045
+ if (!ok)
2046
+ return false;
2047
+ }
2048
+ }
2049
+ results.push(`✓ ${prefix}: looped ${loopItems.length} items`);
2050
+ break;
2051
+ }
2052
+ default:
2053
+ throw new Error(`Unknown step tool: ${step.tool}`);
2054
+ }
2055
+ await new Promise((r) => setTimeout(r, 100));
2056
+ return true;
2057
+ }
2058
+ catch (err) {
2059
+ const errMsg = err instanceof Error ? err.message : String(err);
2060
+ results.push(`✗ ${prefix}: ${errMsg}`);
2061
+ if (step.optional)
2062
+ return true;
2063
+ return false;
2064
+ }
2065
+ }
2066
+ let aborted = false;
2067
+ for (let i = 0; i < autoSteps.length; i++) {
2068
+ const ok = await executeStep(autoSteps[i], i);
2069
+ if (!ok) {
2070
+ results.push(`\n⚠ Workflow aborted at step ${i + 1}`);
2071
+ aborted = true;
2072
+ break;
2073
+ }
2074
+ }
2075
+ const publicVars = Object.fromEntries(Object.entries(variables).filter(([k]) => !k.startsWith("_")));
2076
+ return {
2077
+ stepsTotal: autoSteps.length,
2078
+ stepsCompleted: results.filter((r) => r.startsWith("✓")).length,
2079
+ results,
2080
+ variables: publicVars,
2081
+ aborted,
2082
+ };
2083
+ });
2084
+ if (result !== null) {
2085
+ if (result && "error" in result)
2086
+ errorJson(res, result.error, 400);
2087
+ else if (result)
2088
+ json(res, result);
2089
+ }
2090
+ }
2091
+ async function handleDomain(res, body) {
2092
+ const result = await withMutex(res, async () => {
2093
+ if (requireSession(res))
2094
+ return null;
2095
+ const domainName = body.domain;
2096
+ const domainAction = body.action || "check-auth";
2097
+ const domainPath = body.path;
2098
+ if (!domainName) {
2099
+ return { error: "domain is required. Use: qbo, mercury, github, google, salt" };
2100
+ }
2101
+ const domainConfigs = {
2102
+ qbo: {
2103
+ home: "https://app.qbo.intuit.com/app/homepage",
2104
+ authCheck: "app.qbo.intuit.com",
2105
+ name: "QuickBooks Online",
2106
+ },
2107
+ mercury: {
2108
+ home: "https://app.mercury.com/dashboard",
2109
+ authCheck: "app.mercury.com",
2110
+ name: "Mercury Banking",
2111
+ },
2112
+ github: { home: "https://github.com", authCheck: "github.com", name: "GitHub" },
2113
+ google: {
2114
+ home: "https://drive.google.com",
2115
+ authCheck: "accounts.google.com/SignOut",
2116
+ name: "Google Workspace",
2117
+ },
2118
+ salt: { home: "https://app.salt.dev", authCheck: "app.salt.dev", name: "SALT Tax" },
2119
+ };
2120
+ const domainConfig = domainConfigs[domainName];
2121
+ if (!domainConfig) {
2122
+ return { error: `Unknown domain: ${domainName}. Use: qbo, mercury, github, google, salt` };
2123
+ }
2124
+ if (domainAction === "navigate" || domainAction === "status") {
2125
+ const targetUrl = domainPath
2126
+ ? new URL(domainPath, domainConfig.home).href
2127
+ : domainConfig.home;
2128
+ await cometClient.navigate(targetUrl, true, true);
2129
+ const finalUrlResult = await cometClient.evaluate("window.location.href");
2130
+ const finalUrl = finalUrlResult.result.value;
2131
+ const titleResult = await cometClient.evaluate("document.title");
2132
+ const title = titleResult.result.value;
2133
+ const isLoginPage = finalUrl.includes("login") ||
2134
+ finalUrl.includes("signin") ||
2135
+ finalUrl.includes("auth") ||
2136
+ finalUrl.includes("accounts.google.com/v3/signin");
2137
+ return {
2138
+ domain: domainName,
2139
+ name: domainConfig.name,
2140
+ action: domainAction,
2141
+ url: finalUrl,
2142
+ title,
2143
+ authenticated: !isLoginPage,
2144
+ };
2145
+ }
2146
+ // check-auth: check current URL, navigate to verify if not on domain
2147
+ const currentUrlResult = await cometClient.evaluate("window.location.href");
2148
+ const currentUrl = currentUrlResult.result.value;
2149
+ const isOnDomain = currentUrl.includes(domainConfig.authCheck);
2150
+ if (!isOnDomain) {
2151
+ await cometClient.navigate(domainConfig.home, true, true);
2152
+ const checkUrlResult = await cometClient.evaluate("window.location.href");
2153
+ const checkUrl = checkUrlResult.result.value;
2154
+ const isLoginRedirect = checkUrl.includes("login") || checkUrl.includes("signin") || checkUrl.includes("auth");
2155
+ return {
2156
+ domain: domainName,
2157
+ name: domainConfig.name,
2158
+ action: "check-auth",
2159
+ url: checkUrl,
2160
+ authenticated: !isLoginRedirect,
2161
+ };
2162
+ }
2163
+ return {
2164
+ domain: domainName,
2165
+ name: domainConfig.name,
2166
+ action: "check-auth",
2167
+ url: currentUrl,
2168
+ authenticated: true,
2169
+ };
2170
+ });
2171
+ if (result !== null) {
2172
+ if (result && "error" in result)
2173
+ errorJson(res, result.error, 400);
2174
+ else if (result)
2175
+ json(res, result);
2176
+ }
2177
+ }
310
2178
  // ---- Tab Group route handlers ----
311
2179
  async function handleTabGroupsList(res) {
312
2180
  const result = await withMutex(res, async () => {
@@ -378,6 +2246,37 @@ async function handleTabGroupsDelete(res, body) {
378
2246
  json(res, result);
379
2247
  }
380
2248
  }
2249
+ /// Spec 037: Session lookup endpoint — returns manifest entry for a taskThreadId
2250
+ function handleSessionLookup(res, taskThreadId) {
2251
+ if (!taskThreadId) {
2252
+ errorJson(res, "taskThreadId is required", 400);
2253
+ return;
2254
+ }
2255
+ try {
2256
+ const manifestPath = join(homedir(), ".claude", "comet-browser", "session-manifest.json");
2257
+ const data = readFileSync(manifestPath, "utf-8");
2258
+ const manifest = JSON.parse(data);
2259
+ const entry = (manifest.sessions || []).find((e) => e.taskThreadId === taskThreadId);
2260
+ if (entry) {
2261
+ json(res, entry);
2262
+ }
2263
+ else {
2264
+ errorJson(res, `No session found for taskThreadId: ${taskThreadId}`, 404);
2265
+ }
2266
+ }
2267
+ catch {
2268
+ errorJson(res, "Session manifest not available", 404);
2269
+ }
2270
+ }
2271
+ // T110b: Agent registry endpoint — sync file read, no mutex needed
2272
+ function handleAgents(res) {
2273
+ const registry = readAgentRegistry();
2274
+ const agents = {};
2275
+ for (const [id, entry] of Object.entries(registry)) {
2276
+ agents[id] = classifyAgentStatus(entry);
2277
+ }
2278
+ json(res, { agents });
2279
+ }
381
2280
  // ---- HTTP Server ----
382
2281
  const server = createServer(async (req, res) => {
383
2282
  // CORS preflight
@@ -397,20 +2296,28 @@ const server = createServer(async (req, res) => {
397
2296
  json(res, { status: "ok", port: PORT, timestamp: new Date().toISOString() });
398
2297
  }
399
2298
  else if (path === "/api/connect" && req.method === "POST") {
400
- await handleConnect(res);
2299
+ const body = await readBody(req);
2300
+ await handleConnect(res, body);
401
2301
  }
402
2302
  else if (path === "/api/ask" && req.method === "POST") {
403
2303
  const body = await readBody(req);
404
2304
  await handleAsk(res, body);
405
2305
  }
2306
+ else if (path === "/api/equanaut/router/status" && req.method === "GET") {
2307
+ await handleEquanautRouterStatus(res);
2308
+ }
2309
+ else if (path === "/api/equanaut/router/ask" && req.method === "POST") {
2310
+ const body = await readBody(req);
2311
+ await handleEquanautRouterAsk(res, body);
2312
+ }
406
2313
  else if (path === "/api/poll" && req.method === "GET") {
407
- await handlePoll(res);
2314
+ await handlePoll(res, paramsToRecord(url.searchParams));
408
2315
  }
409
2316
  else if (path === "/api/stop" && req.method === "POST") {
410
2317
  await handleStop(res);
411
2318
  }
412
2319
  else if (path === "/api/screenshot" && req.method === "GET") {
413
- await handleScreenshot(res);
2320
+ await handleScreenshot(res, paramsToRecord(url.searchParams));
414
2321
  }
415
2322
  else if (path === "/api/mode" && req.method === "POST") {
416
2323
  const body = await readBody(req);
@@ -434,11 +2341,95 @@ const server = createServer(async (req, res) => {
434
2341
  const body = await readBody(req);
435
2342
  await handleTabGroupsDelete(res, body);
436
2343
  }
2344
+ else if (path === "/api/agents" && req.method === "GET") {
2345
+ handleAgents(res);
2346
+ }
2347
+ else if (path.startsWith("/api/session/") && req.method === "GET") {
2348
+ // Spec 037: Return session manifest entry by taskThreadId
2349
+ const taskThreadId = path.replace("/api/session/", "");
2350
+ handleSessionLookup(res, taskThreadId);
2351
+ // ---- Spec 042: New endpoints ----
2352
+ // Safe observation (no session required)
2353
+ }
2354
+ else if (path === "/api/observe" && req.method === "GET") {
2355
+ await handleObserve(res, url.searchParams);
2356
+ }
2357
+ else if (path === "/api/peek" && req.method === "GET") {
2358
+ await handlePeek(res, url.searchParams);
2359
+ // Lifecycle (no session required)
2360
+ }
2361
+ else if (path === "/api/lifecycle/start" && req.method === "POST") {
2362
+ const body = await readBody(req);
2363
+ await handleLifecycleStart(res, body);
2364
+ }
2365
+ else if (path === "/api/lifecycle/complete" && req.method === "POST") {
2366
+ const body = await readBody(req);
2367
+ await handleLifecycleComplete(res, body);
2368
+ }
2369
+ else if (path === "/api/lifecycle/abort" && req.method === "POST") {
2370
+ const body = await readBody(req);
2371
+ await handleLifecycleAbort(res, body);
2372
+ }
2373
+ else if (path === "/api/lifecycle/update" && req.method === "POST") {
2374
+ const body = await readBody(req);
2375
+ await handleLifecycleUpdate(res, body);
2376
+ // Orchestration
2377
+ }
2378
+ else if (path === "/api/task-status" && req.method === "GET") {
2379
+ await handleTaskStatus(res, url.searchParams);
2380
+ }
2381
+ else if (path === "/api/delegate" && req.method === "POST") {
2382
+ const body = await readBody(req);
2383
+ await handleDelegate(res, body);
2384
+ // Navigation & interaction (require session)
2385
+ }
2386
+ else if (path === "/api/navigate" && req.method === "POST") {
2387
+ const body = await readBody(req);
2388
+ await handleNavigate(res, body);
2389
+ }
2390
+ else if (path === "/api/interact" && req.method === "POST") {
2391
+ const body = await readBody(req);
2392
+ await handleInteract(res, body);
2393
+ }
2394
+ else if (path === "/api/read-page" && req.method === "GET") {
2395
+ await handleReadPage(res, url.searchParams);
2396
+ }
2397
+ else if (path === "/api/shortcut" && req.method === "POST") {
2398
+ const body = await readBody(req);
2399
+ await handleShortcut(res, body);
2400
+ }
2401
+ else if (path === "/api/wait-for-idle" && req.method === "POST") {
2402
+ const body = await readBody(req);
2403
+ await handleWaitForIdle(res, body);
2404
+ // Parity tools (require session)
2405
+ }
2406
+ else if (path === "/api/pdf" && req.method === "POST") {
2407
+ const body = await readBody(req);
2408
+ await handlePdf(res, body);
2409
+ }
2410
+ else if (path === "/api/scrape" && req.method === "POST") {
2411
+ const body = await readBody(req);
2412
+ await handleScrape(res, body);
2413
+ }
2414
+ else if (path === "/api/network" && req.method === "POST") {
2415
+ const body = await readBody(req);
2416
+ await handleNetwork(res, body);
2417
+ }
2418
+ else if (path === "/api/automate" && req.method === "POST") {
2419
+ const body = await readBody(req);
2420
+ await handleAutomate(res, body);
2421
+ }
2422
+ else if (path === "/api/domain" && req.method === "POST") {
2423
+ const body = await readBody(req);
2424
+ await handleDomain(res, body);
2425
+ }
437
2426
  else {
438
2427
  errorJson(res, `Not found: ${req.method} ${path}`, 404);
439
2428
  }
440
2429
  }
441
2430
  catch (err) {
2431
+ if (writeBoundError(res, err))
2432
+ return;
442
2433
  const message = err instanceof Error ? err.message : String(err);
443
2434
  console.error(`[${new Date().toISOString()}] Error on ${req.method} ${path}:`, message);
444
2435
  errorJson(res, message, 500);
@@ -447,13 +2438,42 @@ const server = createServer(async (req, res) => {
447
2438
  server.listen(PORT, () => {
448
2439
  console.log(`Comet Bridge HTTP API listening on port ${PORT}`);
449
2440
  console.log(`Health check: http://localhost:${PORT}/api/health`);
450
- console.log(`\nEndpoints:`);
451
- console.log(` POST /api/connect - Start Comet & connect`);
452
- console.log(` POST /api/ask - Send prompt {prompt, newChat?, timeout?}`);
453
- console.log(` GET /api/poll - Check agent status`);
454
- console.log(` POST /api/stop - Stop current agent`);
455
- console.log(` GET /api/screenshot - Capture page screenshot`);
456
- console.log(` POST /api/mode - Get/set Perplexity mode {mode?}`);
2441
+ console.log(`\nEndpoints (31 total):`);
2442
+ console.log(`\n --- Connection & Querying ---`);
2443
+ console.log(` POST /api/connect - Start Comet & connect {taskGoal?}`);
2444
+ console.log(` POST /api/ask - Send prompt {prompt, newChat?, timeout?}`);
2445
+ console.log(` GET /api/equanaut/router/status - Probe Equanaut router capabilities`);
2446
+ console.log(` POST /api/equanaut/router/ask - Route sidepanel envelope through gateway`);
2447
+ console.log(` GET /api/poll - Check agent status`);
2448
+ console.log(` POST /api/stop - Stop current agent`);
2449
+ console.log(` GET /api/screenshot - Capture page screenshot`);
2450
+ console.log(` POST /api/mode - Get/set Perplexity mode {mode?}`);
2451
+ console.log(` POST /api/shortcut - Trigger query shortcut {shortcut, context?, timeout?}`);
2452
+ console.log(`\n --- Navigation & Interaction (require session) ---`);
2453
+ console.log(` POST /api/navigate - Navigate to URL {url, waitForIdle?}`);
2454
+ console.log(` POST /api/interact - CDP interactions {actions: Action[]}`);
2455
+ console.log(` GET /api/read-page - Read page content ?mode=text|tree|both`);
2456
+ console.log(` POST /api/wait-for-idle - Wait for network idle {idleTime?, timeout?}`);
2457
+ console.log(`\n --- Safe Observation (no session required) ---`);
2458
+ console.log(` GET /api/observe - Browser state ?action=health|snapshot|status|detail`);
2459
+ console.log(` GET /api/peek - Read tab by target ID ?targetId=&action=info|screenshot|read`);
2460
+ console.log(` GET /api/agents - Agent registry snapshot`);
2461
+ console.log(`\n --- Lifecycle (no session required) ---`);
2462
+ console.log(` POST /api/lifecycle/start - Register run {runId, taskThreadId, agentId?, route?}`);
2463
+ console.log(` POST /api/lifecycle/complete - Complete run {runId}`);
2464
+ console.log(` POST /api/lifecycle/abort - Abort run {runId, reason?}`);
2465
+ console.log(` POST /api/lifecycle/update - Update run metadata {runId, tabGroupId?, workflowId?}`);
2466
+ console.log(`\n --- Orchestration ---`);
2467
+ console.log(` GET /api/task-status - Unified task status ?groupId=|threadId=`);
2468
+ console.log(` POST /api/delegate - Dispatch browser task {threadId, instruction, priority?}`);
2469
+ console.log(` GET /api/session/:id - Session manifest lookup by taskThreadId`);
2470
+ console.log(`\n --- Parity Tools (require session) ---`);
2471
+ console.log(` POST /api/pdf - Generate PDF {url?, format?, landscape?, ...}`);
2472
+ console.log(` POST /api/scrape - Extract structured data {mode?, selector?, ...}`);
2473
+ console.log(` POST /api/network - Network traffic {action?, duration?, filter?, ...}`);
2474
+ console.log(` POST /api/automate - Multi-step workflow {steps: Step[]}`);
2475
+ console.log(` POST /api/domain - Domain playbook {domain, action?, path?}`);
2476
+ console.log(`\n --- Tab Groups ---`);
457
2477
  console.log(` GET /api/tab-groups - List all tab groups`);
458
2478
  console.log(` GET /api/tab-groups/tabs - List all tabs with group info`);
459
2479
  console.log(` POST /api/tab-groups - Create group {tabIds, title?, color?}`);