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