@oh-my-pi/pi-coding-agent 17.2.4 → 17.2.5
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.
- package/CHANGELOG.md +51 -0
- package/dist/{CHANGELOG-bpmhv26t.md → CHANGELOG-q2w43aw3.md} +51 -0
- package/dist/cli.js +4148 -3717
- package/dist/{template-8vdv6xb4.js → template-c2hyaytt.js} +8 -26
- package/dist/types/cli/args.d.ts +4 -1
- package/dist/types/cli/browser-relay-cli.d.ts +15 -0
- package/dist/types/cli/command-help.d.ts +99 -0
- package/dist/types/cli/help-extra.d.ts +2 -0
- package/dist/types/cli/thinking-levels.d.ts +5 -0
- package/dist/types/cli/update-cli.d.ts +13 -0
- package/dist/types/commands/browser-relay.d.ts +38 -0
- package/dist/types/commands/commit.d.ts +3 -0
- package/dist/types/commands/complete.d.ts +1 -1
- package/dist/types/commands/completions.d.ts +6 -0
- package/dist/types/commands/grep.d.ts +3 -0
- package/dist/types/commands/join.d.ts +4 -0
- package/dist/types/commands/launch-help.d.ts +171 -0
- package/dist/types/commands/launch.d.ts +5 -1
- package/dist/types/commands/models.d.ts +3 -0
- package/dist/types/commands/say.d.ts +10 -0
- package/dist/types/commands/worktree.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/model-discovery.d.ts +2 -2
- package/dist/types/config/models-config-schema-bundle.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/models-config.d.ts +1 -0
- package/dist/types/config/service-tier.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +69 -21
- package/dist/types/cursor-bridge-tools.d.ts +1 -1
- package/dist/types/cursor.d.ts +7 -4
- package/dist/types/edit/index.d.ts +3 -3
- package/dist/types/edit/modes/replace.d.ts +22 -15
- package/dist/types/edit/renderer.d.ts +1 -1
- package/dist/types/eval/py/kernel.d.ts +3 -1
- package/dist/types/eval/py/spawn-options.d.ts +12 -16
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +8 -1
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/launch/broker.d.ts +1 -1
- package/dist/types/launch/client.d.ts +6 -3
- package/dist/types/launch/ensure.d.ts +8 -0
- package/dist/types/launch/paths.d.ts +1 -1
- package/dist/types/launch/protocol.d.ts +2 -2
- package/dist/types/lsp/client.d.ts +2 -0
- package/dist/types/lsp/mux/daemon.d.ts +15 -0
- package/dist/types/lsp/mux/protocol.d.ts +57 -0
- package/dist/types/lsp/mux/server.d.ts +18 -0
- package/dist/types/lsp/types.d.ts +22 -2
- package/dist/types/main.d.ts +2 -0
- package/dist/types/mnemopi/embed-client.d.ts +1 -1
- package/dist/types/mnemopi/state.d.ts +0 -23
- package/dist/types/modes/components/assistant-message.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +2 -1
- package/dist/types/modes/components/read-tool-group.d.ts +2 -0
- package/dist/types/modes/components/stripped-tool-calls-placeholder.d.ts +12 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/workflow.d.ts +2 -1
- package/dist/types/sdk.d.ts +3 -1
- package/dist/types/secrets/index.d.ts +1 -1
- package/dist/types/session/agent-session-types.d.ts +3 -1
- package/dist/types/session/indexed-session-storage.d.ts +1 -0
- package/dist/types/session/session-advisors.d.ts +1 -1
- package/dist/types/session/session-context.d.ts +2 -1
- package/dist/types/session/session-maintenance.d.ts +1 -0
- package/dist/types/session/session-manager.d.ts +16 -11
- package/dist/types/session/session-stats.d.ts +10 -0
- package/dist/types/session/session-storage.d.ts +15 -3
- package/dist/types/system-prompt.d.ts +4 -1
- package/dist/types/task/index.d.ts +2 -1
- package/dist/types/task/spawn-policy.d.ts +5 -0
- package/dist/types/thinking.d.ts +1 -7
- package/dist/types/tools/ast-grep.d.ts +1 -1
- package/dist/types/tools/browser/registry.d.ts +2 -1
- package/dist/types/tools/browser/relay/bridge.d.ts +35 -0
- package/dist/types/tools/browser/relay/daemon.d.ts +16 -0
- package/dist/types/tools/browser/relay/kind.d.ts +19 -0
- package/dist/types/tools/browser/relay/protocol.d.ts +97 -0
- package/dist/types/tools/browser/relay/server.d.ts +35 -0
- package/dist/types/tools/browser/render.d.ts +1 -0
- package/dist/types/tools/browser.d.ts +3 -0
- package/dist/types/tools/computer/exposure.d.ts +9 -7
- package/dist/types/tools/computer/protocol.d.ts +70 -17
- package/dist/types/tools/computer/supervisor.d.ts +23 -10
- package/dist/types/tools/computer/worker-entry.d.ts +1 -0
- package/dist/types/tools/computer/worker.d.ts +42 -7
- package/dist/types/tools/computer-renderer.d.ts +7 -5
- package/dist/types/tools/computer.d.ts +25 -44
- package/dist/types/tools/context.d.ts +3 -0
- package/dist/types/tools/glob.d.ts +1 -1
- package/dist/types/tools/grep.d.ts +1 -1
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/{browser/run-cancellation.d.ts → run-scope.d.ts} +8 -8
- package/dist/types/tools/tool-timeouts.d.ts +5 -0
- package/dist/types/tools/xdev.d.ts +8 -0
- package/dist/types/utils/clipboard.d.ts +2 -2
- package/dist/types/web/kagi.d.ts +1 -0
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/providers/anthropic.d.ts +1 -0
- package/dist/types/web/search/providers/base.d.ts +2 -0
- package/dist/types/web/search/providers/brave.d.ts +1 -0
- package/dist/types/web/search/providers/browser-page.d.ts +1 -0
- package/dist/types/web/search/providers/codex.d.ts +1 -0
- package/dist/types/web/search/providers/exa.d.ts +1 -0
- package/dist/types/web/search/providers/firecrawl.d.ts +1 -0
- package/dist/types/web/search/providers/gemini.d.ts +1 -0
- package/dist/types/web/search/providers/jina.d.ts +1 -0
- package/dist/types/web/search/providers/kagi.d.ts +1 -0
- package/dist/types/web/search/providers/kimi.d.ts +1 -0
- package/dist/types/web/search/providers/parallel.d.ts +1 -0
- package/dist/types/web/search/providers/perplexity.d.ts +1 -0
- package/dist/types/web/search/providers/searxng.d.ts +1 -0
- package/dist/types/web/search/providers/tavily.d.ts +1 -0
- package/dist/types/web/search/providers/tinyfish.d.ts +1 -0
- package/dist/types/web/search/providers/utils.d.ts +6 -9
- package/dist/types/web/search/providers/zai.d.ts +1 -0
- package/dist/types/web/search/types.d.ts +4 -0
- package/package.json +12 -12
- package/src/cli/args.ts +7 -87
- package/src/cli/browser-relay-cli.ts +119 -0
- package/src/cli/command-help.ts +103 -0
- package/src/cli/completion-gen.ts +5 -5
- package/src/cli/flag-tables.ts +9 -0
- package/src/cli/help-extra.ts +89 -0
- package/src/cli/setup-cli.ts +13 -21
- package/src/cli/thinking-levels.ts +7 -0
- package/src/cli/update-cli.ts +26 -7
- package/src/cli-commands.ts +170 -33
- package/src/cli.ts +17 -5
- package/src/commands/acp.ts +3 -1
- package/src/commands/agents.ts +3 -2
- package/src/commands/auth-broker.ts +3 -2
- package/src/commands/auth-gateway.ts +3 -2
- package/src/commands/bench.ts +2 -3
- package/src/commands/browser-relay.ts +53 -0
- package/src/commands/cleanse.ts +2 -2
- package/src/commands/commit.ts +3 -2
- package/src/commands/complete.ts +2 -1
- package/src/commands/completions.ts +3 -2
- package/src/commands/config.ts +3 -2
- package/src/commands/dry-balance.ts +2 -2
- package/src/commands/gallery.ts +3 -2
- package/src/commands/gc.ts +3 -2
- package/src/commands/grep.ts +3 -2
- package/src/commands/grievances.ts +3 -2
- package/src/commands/install.ts +2 -2
- package/src/commands/join.ts +3 -2
- package/src/commands/launch-help.ts +116 -0
- package/src/commands/launch.ts +7 -191
- package/src/commands/models.ts +3 -2
- package/src/commands/plugin.ts +3 -2
- package/src/commands/read.ts +3 -2
- package/src/commands/say.ts +3 -2
- package/src/commands/setup.ts +3 -2
- package/src/commands/shell.ts +3 -2
- package/src/commands/ssh.ts +3 -2
- package/src/commands/stats.ts +3 -2
- package/src/commands/tiny-models.ts +2 -2
- package/src/commands/token.ts +2 -2
- package/src/commands/ttsr.ts +2 -2
- package/src/commands/update.ts +3 -2
- package/src/commands/usage.ts +3 -2
- package/src/commands/web-search.ts +3 -2
- package/src/commands/worktree.ts +3 -2
- package/src/config/keybindings.ts +5 -0
- package/src/config/model-discovery.ts +30 -13
- package/src/config/model-registry.ts +9 -5
- package/src/config/models-config-schema-bundle.ts +9 -0
- package/src/config/resolve-config-value.ts +2 -1
- package/src/config/service-tier.ts +5 -0
- package/src/config/settings-schema.ts +72 -20
- package/src/cursor-bridge-tools.ts +1 -1
- package/src/cursor.ts +10 -14
- package/src/edit/diff.ts +17 -8
- package/src/edit/index.ts +20 -7
- package/src/edit/modes/replace.ts +93 -40
- package/src/edit/renderer.ts +1 -1
- package/src/edit/streaming.ts +14 -17
- package/src/eval/py/kernel.ts +2 -1
- package/src/eval/py/spawn-options.ts +24 -29
- package/src/export/html/template.js +8 -26
- package/src/extensibility/extensions/wrapper.ts +1 -0
- package/src/extensibility/legacy-pi-ai-shim.ts +22 -1
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -6
- package/src/extensibility/plugins/legacy-pi-compat.ts +124 -57
- package/src/extensibility/skills.ts +11 -0
- package/src/extensibility/utils.ts +10 -2
- package/src/launch/broker.ts +1 -1
- package/src/launch/client.ts +32 -13
- package/src/launch/ensure.ts +73 -0
- package/src/launch/paths.ts +1 -1
- package/src/launch/protocol.ts +2 -2
- package/src/lsp/client.ts +23 -2
- package/src/lsp/index.ts +8 -0
- package/src/lsp/mux/daemon.ts +348 -0
- package/src/lsp/mux/protocol.ts +96 -0
- package/src/lsp/mux/server.ts +797 -0
- package/src/lsp/types.ts +28 -2
- package/src/main.ts +7 -2
- package/src/mcp/oauth-flow.ts +11 -1
- package/src/mnemopi/embed-client.ts +49 -2
- package/src/mnemopi/state.ts +57 -23
- package/src/modes/components/assistant-message.ts +11 -0
- package/src/modes/components/chat-transcript-builder.ts +4 -0
- package/src/modes/components/custom-editor.ts +9 -0
- package/src/modes/components/read-tool-group.ts +10 -0
- package/src/modes/components/status-line/component.ts +1 -1
- package/src/modes/components/stripped-tool-calls-placeholder.ts +35 -0
- package/src/modes/components/tool-execution.ts +8 -0
- package/src/modes/components/tree-selector.ts +46 -48
- package/src/modes/controllers/event-controller.ts +17 -0
- package/src/modes/controllers/input-controller.ts +39 -1
- package/src/modes/controllers/mcp-command-controller.ts +11 -5
- package/src/modes/controllers/selector-controller.ts +20 -0
- package/src/modes/interactive-mode.ts +2 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/interactive-context-helpers.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +6 -10
- package/src/modes/workflow.ts +8 -2
- package/src/prompts/agents/init.md +1 -1
- package/src/prompts/system/plan-mode-active.md +2 -2
- package/src/prompts/system/system-prompt.md +26 -52
- package/src/prompts/system/workflow-notice.md +3 -3
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/computer.md +21 -21
- package/src/prompts/tools/glob.md +1 -1
- package/src/prompts/tools/grep.md +1 -1
- package/src/prompts/tools/replace.md +5 -4
- package/src/prompts/tools/task.md +4 -4
- package/src/sdk.ts +34 -6
- package/src/secrets/index.ts +1 -1
- package/src/session/agent-session-types.ts +3 -1
- package/src/session/agent-session.ts +13 -1
- package/src/session/indexed-session-storage.ts +9 -0
- package/src/session/prewalk.ts +24 -2
- package/src/session/session-advisors.ts +2 -2
- package/src/session/session-context.ts +16 -12
- package/src/session/session-dump-format.ts +1 -1
- package/src/session/session-maintenance.ts +38 -4
- package/src/session/session-manager.ts +102 -67
- package/src/session/session-paths.ts +118 -55
- package/src/session/session-stats.ts +48 -3
- package/src/session/session-storage.ts +30 -22
- package/src/session/stream-guards.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +27 -24
- package/src/system-prompt.ts +8 -3
- package/src/task/index.ts +23 -8
- package/src/task/spawn-policy.ts +14 -0
- package/src/thinking.ts +2 -8
- package/src/tools/ast-grep.ts +10 -4
- package/src/tools/browser/cmux/cmux-tab.ts +5 -10
- package/src/tools/browser/cmux/rpc.ts +2 -8
- package/src/tools/browser/registry.ts +53 -2
- package/src/tools/browser/relay/bridge.ts +945 -0
- package/src/tools/browser/relay/daemon.ts +117 -0
- package/src/tools/browser/relay/extension-assets/background.js.txt +242 -0
- package/src/tools/browser/relay/extension-assets/manifest.json.txt +14 -0
- package/src/tools/browser/relay/extension-assets/options.html.txt +53 -0
- package/src/tools/browser/relay/extension-assets/options.js.txt +23 -0
- package/src/tools/browser/relay/kind.ts +41 -0
- package/src/tools/browser/relay/protocol.ts +54 -0
- package/src/tools/browser/relay/server.ts +141 -0
- package/src/tools/browser/render.ts +4 -1
- package/src/tools/browser/shared-daemon.ts +7 -58
- package/src/tools/browser/tab-supervisor.ts +5 -4
- package/src/tools/browser/tab-worker.ts +33 -12
- package/src/tools/browser.ts +26 -1
- package/src/tools/computer/exposure.ts +11 -35
- package/src/tools/computer/protocol.ts +51 -10
- package/src/tools/computer/supervisor.ts +227 -124
- package/src/tools/computer/worker-entry.ts +11 -6
- package/src/tools/computer/worker.ts +695 -81
- package/src/tools/computer-renderer.ts +104 -65
- package/src/tools/computer.ts +134 -428
- package/src/tools/context.ts +3 -0
- package/src/tools/glob.ts +9 -2
- package/src/tools/grep.ts +12 -6
- package/src/tools/inspect-image.ts +32 -6
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/{browser/run-cancellation.ts → run-scope.ts} +11 -11
- package/src/tools/tool-timeouts.ts +1 -0
- package/src/tools/xdev.ts +29 -2
- package/src/utils/clipboard.ts +40 -14
- package/src/utils/command-args.ts +26 -28
- package/src/utils/tool-choice.ts +0 -13
- package/src/web/kagi.ts +2 -1
- package/src/web/search/index.ts +18 -2
- package/src/web/search/providers/anthropic.ts +4 -1
- package/src/web/search/providers/base.ts +2 -0
- package/src/web/search/providers/brave.ts +3 -1
- package/src/web/search/providers/browser-page.ts +6 -4
- package/src/web/search/providers/codex.ts +4 -1
- package/src/web/search/providers/duckduckgo.ts +2 -1
- package/src/web/search/providers/ecosia.ts +2 -1
- package/src/web/search/providers/exa.ts +4 -2
- package/src/web/search/providers/firecrawl.ts +3 -1
- package/src/web/search/providers/gemini.ts +8 -2
- package/src/web/search/providers/google.ts +2 -1
- package/src/web/search/providers/jina.ts +12 -2
- package/src/web/search/providers/kagi.ts +3 -0
- package/src/web/search/providers/kimi.ts +5 -1
- package/src/web/search/providers/mojeek.ts +2 -1
- package/src/web/search/providers/parallel.ts +5 -1
- package/src/web/search/providers/perplexity.ts +6 -3
- package/src/web/search/providers/public.ts +1 -1
- package/src/web/search/providers/searxng.ts +11 -5
- package/src/web/search/providers/startpage.ts +10 -4
- package/src/web/search/providers/synthetic.ts +11 -5
- package/src/web/search/providers/tavily.ts +3 -1
- package/src/web/search/providers/tinyfish.ts +3 -1
- package/src/web/search/providers/utils.ts +12 -10
- package/src/web/search/providers/xai.ts +2 -2
- package/src/web/search/providers/zai.ts +18 -3
- package/src/web/search/types.ts +6 -0
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP façade over `chrome.debugger`.
|
|
3
|
+
*
|
|
4
|
+
* Puppeteer clients (the omp browser tool: one supervisor connection plus one
|
|
5
|
+
* per tab worker) connect to this bridge as if it were Chrome's browser
|
|
6
|
+
* debugging endpoint. Chrome only allows a single debugger attachment per tab,
|
|
7
|
+
* so the bridge owns ONE `chrome.debugger` attachment per tab (via the
|
|
8
|
+
* extension) and multiplexes every downstream connection over it with minted
|
|
9
|
+
* per-connection session ids.
|
|
10
|
+
*
|
|
11
|
+
* Emulated surface (everything else is forwarded to `chrome.debugger`):
|
|
12
|
+
* - the browser target (`/json/version` handshake, `Browser.getVersion`)
|
|
13
|
+
* - the `Target.*` domain, including puppeteer's tab → page auto-attach
|
|
14
|
+
* hierarchy (see puppeteer-core `cdp/ExtensionTransport.ts`, the reference
|
|
15
|
+
* implementation for this emulation)
|
|
16
|
+
*
|
|
17
|
+
* Session id namespaces seen by a downstream connection:
|
|
18
|
+
* - minted tab pseudo-sessions (`ST<tab>.<conn>.<n>`) — Target emulation only
|
|
19
|
+
* - minted page pseudo-sessions (`SP<tab>.<conn>.<n>`) — forwarded to the
|
|
20
|
+
* tab's root debugger session
|
|
21
|
+
* - real child session ids (OOPIFs, workers) — created by Chrome under the
|
|
22
|
+
* shared root session and passed through verbatim
|
|
23
|
+
*/
|
|
24
|
+
import type { ExtToRelayMessage, RelayRpcRequest, RelayToExtMessage, TabSnapshot } from "./protocol";
|
|
25
|
+
|
|
26
|
+
/** Transport-agnostic websocket surface the bridge writes to. */
|
|
27
|
+
export interface RelaySocket {
|
|
28
|
+
send(text: string): void;
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CdpCommand {
|
|
33
|
+
id: number;
|
|
34
|
+
method: string;
|
|
35
|
+
params?: Record<string, unknown>;
|
|
36
|
+
sessionId?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SessionRef {
|
|
40
|
+
kind: "tab" | "page";
|
|
41
|
+
tabId: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface TargetInfo {
|
|
45
|
+
targetId: string;
|
|
46
|
+
type: "tab" | "page" | "browser";
|
|
47
|
+
title: string;
|
|
48
|
+
url: string;
|
|
49
|
+
attached: boolean;
|
|
50
|
+
canAccessOpener: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class CdpConnection {
|
|
54
|
+
discover = false;
|
|
55
|
+
autoAttach = false;
|
|
56
|
+
/** Minted pseudo-sessions owned by this connection. */
|
|
57
|
+
readonly sessions = new Map<string, SessionRef>();
|
|
58
|
+
/** Tabs this connection claimed as drive targets (`OMP.claimTarget` / `Target.createTarget`). */
|
|
59
|
+
readonly claims = new Set<number>();
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
readonly id: number,
|
|
63
|
+
readonly socket: RelaySocket,
|
|
64
|
+
) {}
|
|
65
|
+
|
|
66
|
+
sessionsForTab(tabId: number, kind?: "tab" | "page"): string[] {
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
for (const [sessionId, ref] of this.sessions) {
|
|
69
|
+
if (ref.tabId === tabId && (!kind || ref.kind === kind)) out.push(sessionId);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
class TabState {
|
|
76
|
+
url: string;
|
|
77
|
+
title: string;
|
|
78
|
+
active: boolean;
|
|
79
|
+
windowId: number;
|
|
80
|
+
pinned: boolean;
|
|
81
|
+
/** Chrome tab group id from the last snapshot; -1 when ungrouped. */
|
|
82
|
+
groupId: number;
|
|
83
|
+
/** Whether `chrome.debugger` is currently attached to this tab. */
|
|
84
|
+
attached = false;
|
|
85
|
+
/** Set when attach failed or the user cancelled the debugger; cleared on navigation. */
|
|
86
|
+
banned = false;
|
|
87
|
+
/** Whether targets for this tab were announced to discovering connections. */
|
|
88
|
+
announced = false;
|
|
89
|
+
attaching: Promise<boolean> | null = null;
|
|
90
|
+
/** True after the relay put this tab in the omp group; `ompGroupId` holds that group. */
|
|
91
|
+
grouped = false;
|
|
92
|
+
/** Group RPC in flight — suppresses duplicate requests from load-time tabUpdated bursts. */
|
|
93
|
+
grouping = false;
|
|
94
|
+
ompGroupId: number | undefined;
|
|
95
|
+
/** User pulled the tab out of the omp group — never re-group it. */
|
|
96
|
+
groupOptOut = false;
|
|
97
|
+
/** Real Chrome session ids (OOPIF/worker children) living under this tab's root session. */
|
|
98
|
+
readonly realSessions = new Set<string>();
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
readonly tabId: number,
|
|
102
|
+
snap: TabSnapshot,
|
|
103
|
+
) {
|
|
104
|
+
this.url = snap.url;
|
|
105
|
+
this.title = snap.title;
|
|
106
|
+
this.active = snap.active;
|
|
107
|
+
this.windowId = snap.windowId;
|
|
108
|
+
this.pinned = snap.pinned;
|
|
109
|
+
this.groupId = snap.groupId;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
update(snap: TabSnapshot): void {
|
|
113
|
+
this.url = snap.url;
|
|
114
|
+
this.title = snap.title;
|
|
115
|
+
this.active = snap.active;
|
|
116
|
+
this.windowId = snap.windowId;
|
|
117
|
+
this.pinned = snap.pinned;
|
|
118
|
+
this.groupId = snap.groupId;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** URLs `chrome.debugger` cannot attach to; hidden from downstream discovery entirely. */
|
|
123
|
+
const INELIGIBLE_URL = /^(chrome|devtools|edge|view-source|chrome-extension|chrome-untrusted|chrome-search):/i;
|
|
124
|
+
|
|
125
|
+
const RPC_TIMEOUT_MS = 20_000;
|
|
126
|
+
const CDP_ERROR_METHOD_NOT_FOUND = -32601;
|
|
127
|
+
const CDP_ERROR_SERVER = -32000;
|
|
128
|
+
|
|
129
|
+
function tabTargetId(tabId: number): string {
|
|
130
|
+
return `TAB${tabId}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pageTargetId(tabId: number): string {
|
|
134
|
+
return `PAGE${tabId}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Reverse of {@link tabTargetId}/{@link pageTargetId}; null for foreign ids. */
|
|
138
|
+
function parseTargetId(targetId: string): { kind: "tab" | "page"; tabId: number } | null {
|
|
139
|
+
const match = /^(TAB|PAGE)(\d+)$/.exec(targetId);
|
|
140
|
+
if (!match) return null;
|
|
141
|
+
return { kind: match[1] === "TAB" ? "tab" : "page", tabId: Number(match[2]) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Multiplexing CDP bridge between downstream puppeteer connections and the
|
|
146
|
+
* relay extension. One instance per relay server; all state lives here so an
|
|
147
|
+
* extension service-worker restart only has to re-handshake.
|
|
148
|
+
*/
|
|
149
|
+
export class RelayBridge {
|
|
150
|
+
#tabs = new Map<number, TabState>();
|
|
151
|
+
#conns = new Map<number, CdpConnection>();
|
|
152
|
+
#connSeq = 0;
|
|
153
|
+
#sessionSeq = 0;
|
|
154
|
+
#rpcSeq = 0;
|
|
155
|
+
#ext: RelaySocket | null = null;
|
|
156
|
+
#extInfo: { userAgent: string; browserVersion: string } | null = null;
|
|
157
|
+
#pendingRpc = new Map<
|
|
158
|
+
number,
|
|
159
|
+
{ resolve: (value: unknown) => void; reject: (err: Error) => void; timer: NodeJS.Timeout }
|
|
160
|
+
>();
|
|
161
|
+
/** Real child session id → owning tab, learned from `Target.attachedToTarget` events. */
|
|
162
|
+
#realSessionTabs = new Map<string, number>();
|
|
163
|
+
#log: (message: string, data?: Record<string, unknown>) => void;
|
|
164
|
+
/** Tab-group appearance for driven tabs; null disables grouping. */
|
|
165
|
+
#group: { title: string; color: string } | null;
|
|
166
|
+
/** Tabs awaiting the next group RPC; drained one batch at a time. */
|
|
167
|
+
#groupQueue: TabState[] = [];
|
|
168
|
+
/** True while {@link #drainGroupQueue} runs — group RPCs must never overlap. */
|
|
169
|
+
#groupDraining = false;
|
|
170
|
+
|
|
171
|
+
constructor(
|
|
172
|
+
opts: {
|
|
173
|
+
log?: (message: string, data?: Record<string, unknown>) => void;
|
|
174
|
+
/** Group tabs the agent actively drives under one per-window Chrome tab group. */
|
|
175
|
+
group?: { title: string; color: string } | null;
|
|
176
|
+
} = {},
|
|
177
|
+
) {
|
|
178
|
+
this.#log = opts.log ?? (() => {});
|
|
179
|
+
this.#group = opts.group ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** True once the extension has completed its hello handshake. */
|
|
183
|
+
get ready(): boolean {
|
|
184
|
+
return this.#ext !== null && this.#extInfo !== null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Payload for `GET /json/version`. */
|
|
188
|
+
versionInfo(wsUrl: string): Record<string, string> {
|
|
189
|
+
const ua = this.#extInfo?.userAgent ?? "";
|
|
190
|
+
return {
|
|
191
|
+
Browser: this.#extInfo?.browserVersion ?? "Chrome/unknown",
|
|
192
|
+
"Protocol-Version": "1.3",
|
|
193
|
+
"User-Agent": ua,
|
|
194
|
+
"V8-Version": "",
|
|
195
|
+
"WebKit-Version": "",
|
|
196
|
+
webSocketDebuggerUrl: wsUrl,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Payload for `GET /json/list` (debugging aid; per-target endpoints are not served). */
|
|
201
|
+
listTargets(): Array<Record<string, string>> {
|
|
202
|
+
const out: Array<Record<string, string>> = [];
|
|
203
|
+
for (const tab of this.#tabs.values()) {
|
|
204
|
+
if (!this.#eligible(tab)) continue;
|
|
205
|
+
out.push({ id: pageTargetId(tab.tabId), type: "page", title: tab.title, url: tab.url });
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- extension lifecycle -------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/** A new extension socket connected; replaces any previous one. */
|
|
213
|
+
extConnected(socket: RelaySocket): void {
|
|
214
|
+
if (this.#ext && this.#ext !== socket) {
|
|
215
|
+
this.#log("replacing extension socket");
|
|
216
|
+
this.#ext.close();
|
|
217
|
+
}
|
|
218
|
+
this.#ext = socket;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
extClosed(socket: RelaySocket): void {
|
|
222
|
+
if (this.#ext !== socket) return;
|
|
223
|
+
this.#ext = null;
|
|
224
|
+
this.#extInfo = null;
|
|
225
|
+
for (const pending of this.#pendingRpc.values()) {
|
|
226
|
+
clearTimeout(pending.timer);
|
|
227
|
+
pending.reject(new Error("relay extension disconnected"));
|
|
228
|
+
}
|
|
229
|
+
this.#pendingRpc.clear();
|
|
230
|
+
for (const tab of this.#tabs.values()) {
|
|
231
|
+
tab.attached = false;
|
|
232
|
+
tab.attaching = null;
|
|
233
|
+
// The extension dissolves omp groups on disconnect (or died along
|
|
234
|
+
// with them); grouping state is unknowable until the next hello.
|
|
235
|
+
// Without this reset, the next hello's groupId=-1 snapshots would
|
|
236
|
+
// read as the user dragging every tab out (permanent opt-out).
|
|
237
|
+
tab.grouped = false;
|
|
238
|
+
tab.grouping = false;
|
|
239
|
+
tab.ompGroupId = undefined;
|
|
240
|
+
}
|
|
241
|
+
this.#groupQueue.length = 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
extMessage(socket: RelaySocket, raw: string): void {
|
|
245
|
+
if (socket !== this.#ext) return;
|
|
246
|
+
let msg: ExtToRelayMessage;
|
|
247
|
+
try {
|
|
248
|
+
msg = JSON.parse(raw) as ExtToRelayMessage;
|
|
249
|
+
} catch {
|
|
250
|
+
this.#log("dropping malformed extension message");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
switch (msg.t) {
|
|
254
|
+
case "hello":
|
|
255
|
+
this.#onHello(msg);
|
|
256
|
+
return;
|
|
257
|
+
case "rpcResult": {
|
|
258
|
+
const pending = this.#pendingRpc.get(msg.id);
|
|
259
|
+
if (!pending) return;
|
|
260
|
+
this.#pendingRpc.delete(msg.id);
|
|
261
|
+
clearTimeout(pending.timer);
|
|
262
|
+
if (msg.ok) pending.resolve(msg.result);
|
|
263
|
+
else pending.reject(new Error(msg.error ?? "extension rpc failed"));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
case "cdpEvent":
|
|
267
|
+
this.#onCdpEvent(msg.tabId, msg.sessionId, msg.method, msg.params);
|
|
268
|
+
return;
|
|
269
|
+
case "detached":
|
|
270
|
+
this.#onTabDetached(msg.tabId, msg.reason);
|
|
271
|
+
return;
|
|
272
|
+
case "tabCreated":
|
|
273
|
+
this.#onTabUpsert(msg.tab);
|
|
274
|
+
return;
|
|
275
|
+
case "tabUpdated":
|
|
276
|
+
this.#onTabUpsert(msg.tab);
|
|
277
|
+
return;
|
|
278
|
+
case "tabRemoved":
|
|
279
|
+
this.#onTabRemoved(msg.tabId);
|
|
280
|
+
return;
|
|
281
|
+
case "ping":
|
|
282
|
+
socket.send(JSON.stringify({ t: "pong" } satisfies RelayToExtMessage));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#onHello(msg: Extract<ExtToRelayMessage, { t: "hello" }>): void {
|
|
288
|
+
this.#extInfo = { userAgent: msg.userAgent, browserVersion: msg.browserVersion };
|
|
289
|
+
const seen = new Set<number>();
|
|
290
|
+
const attachedNow = new Set(msg.attachedTabIds);
|
|
291
|
+
for (const snap of msg.tabs) {
|
|
292
|
+
seen.add(snap.tabId);
|
|
293
|
+
this.#onTabUpsert(snap, { silent: true });
|
|
294
|
+
}
|
|
295
|
+
for (const tabId of [...this.#tabs.keys()]) {
|
|
296
|
+
if (!seen.has(tabId)) this.#onTabRemoved(tabId);
|
|
297
|
+
}
|
|
298
|
+
for (const tab of this.#tabs.values()) {
|
|
299
|
+
const wasAttached = tab.attached;
|
|
300
|
+
tab.attached = attachedNow.has(tab.tabId);
|
|
301
|
+
tab.attaching = null;
|
|
302
|
+
// A service-worker restart can drop attachments while downstream
|
|
303
|
+
// connections still hold sessions: restore them best-effort.
|
|
304
|
+
if (wasAttached && !tab.attached && this.#sessionHolders(tab.tabId).length > 0) {
|
|
305
|
+
void this.#ensureAttached(tab).then(ok => {
|
|
306
|
+
if (!ok) this.#onTabDetached(tab.tabId, "reattach_failed");
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
this.#syncGrouping();
|
|
311
|
+
this.#log("extension connected", { tabs: this.#tabs.size, version: msg.browserVersion });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---- downstream (puppeteer) lifecycle -------------------------------------
|
|
315
|
+
|
|
316
|
+
/** Register a downstream CDP websocket; returns the connection id. */
|
|
317
|
+
cdpConnected(socket: RelaySocket): number {
|
|
318
|
+
const conn = new CdpConnection(++this.#connSeq, socket);
|
|
319
|
+
this.#conns.set(conn.id, conn);
|
|
320
|
+
this.#log("cdp client connected", { conn: conn.id });
|
|
321
|
+
return conn.id;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
cdpClosed(connId: number): void {
|
|
325
|
+
const conn = this.#conns.get(connId);
|
|
326
|
+
if (!conn) return;
|
|
327
|
+
this.#conns.delete(connId);
|
|
328
|
+
const touched = new Set<number>();
|
|
329
|
+
for (const ref of conn.sessions.values()) touched.add(ref.tabId);
|
|
330
|
+
conn.sessions.clear();
|
|
331
|
+
// Tabs this client claimed leave the omp group unless another claimant
|
|
332
|
+
// remains — session holders don't count: the long-lived registry
|
|
333
|
+
// connection holds sessions on every tab without driving any of them.
|
|
334
|
+
for (const tabId of conn.claims) {
|
|
335
|
+
const tab = this.#tabs.get(tabId);
|
|
336
|
+
if (tab) this.#syncTabGrouping(tab);
|
|
337
|
+
}
|
|
338
|
+
conn.claims.clear();
|
|
339
|
+
// Drop the debugger (and its infobar) from tabs nobody drives anymore.
|
|
340
|
+
for (const tabId of touched) {
|
|
341
|
+
if (this.#sessionHolders(tabId).length > 0) continue;
|
|
342
|
+
const tab = this.#tabs.get(tabId);
|
|
343
|
+
if (tab?.attached) {
|
|
344
|
+
tab.attached = false;
|
|
345
|
+
void this.#rpc({ op: "detach", tabId }).catch(() => {});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
this.#log("cdp client closed", { conn: connId });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
cdpMessage(connId: number, raw: string): void {
|
|
352
|
+
const conn = this.#conns.get(connId);
|
|
353
|
+
if (!conn) return;
|
|
354
|
+
let msg: CdpCommand;
|
|
355
|
+
try {
|
|
356
|
+
msg = JSON.parse(raw) as CdpCommand;
|
|
357
|
+
} catch {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (typeof msg.id !== "number" || typeof msg.method !== "string") return;
|
|
361
|
+
void this.#handleCdpCommand(conn, msg).catch(err => {
|
|
362
|
+
this.#replyError(conn, msg, err instanceof Error ? err.message : String(err));
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- command routing -------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
async #handleCdpCommand(conn: CdpConnection, msg: CdpCommand): Promise<void> {
|
|
369
|
+
const sessionId = msg.sessionId;
|
|
370
|
+
if (!sessionId) {
|
|
371
|
+
await this.#handleBrowserCommand(conn, msg);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const ref = conn.sessions.get(sessionId);
|
|
375
|
+
if (ref?.kind === "tab") {
|
|
376
|
+
this.#handleTabSessionCommand(conn, msg, ref);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (ref?.kind === "page") {
|
|
380
|
+
await this.#forwardToTab(conn, msg, ref.tabId, undefined);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const realTab = this.#realSessionTabs.get(sessionId);
|
|
384
|
+
if (realTab !== undefined) {
|
|
385
|
+
await this.#forwardToTab(conn, msg, realTab, sessionId);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.#replyError(conn, msg, `Unknown session id ${sessionId}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async #forwardToTab(
|
|
392
|
+
conn: CdpConnection,
|
|
393
|
+
msg: CdpCommand,
|
|
394
|
+
tabId: number,
|
|
395
|
+
realSessionId: string | undefined,
|
|
396
|
+
): Promise<void> {
|
|
397
|
+
// Guard rail: a page session must never take the whole browser down.
|
|
398
|
+
if (msg.method === "Browser.close") {
|
|
399
|
+
this.#reply(conn, msg, {});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// Relay-private claim: the omp tab worker marks the page it was spawned
|
|
403
|
+
// to drive. Never forwarded — real Chrome rejects the unknown method.
|
|
404
|
+
if (msg.method === "OMP.claimTarget") {
|
|
405
|
+
this.#claimTab(conn, tabId);
|
|
406
|
+
this.#reply(conn, msg, {});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
const result = await this.#rpc({
|
|
411
|
+
op: "send",
|
|
412
|
+
tabId,
|
|
413
|
+
sessionId: realSessionId,
|
|
414
|
+
method: msg.method,
|
|
415
|
+
params: msg.params,
|
|
416
|
+
});
|
|
417
|
+
this.#reply(conn, msg, (result as Record<string, unknown> | undefined) ?? {});
|
|
418
|
+
} catch (err) {
|
|
419
|
+
this.#replyError(conn, msg, err instanceof Error ? err.message : String(err));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Record `conn` as a driver of the tab and reconcile grouping. Claims are
|
|
425
|
+
* explicit (worker adoption or tab creation) rather than inferred from
|
|
426
|
+
* command traffic: target discovery scans every page with the same
|
|
427
|
+
* commands a driver sends, so inference would sweep all tabs.
|
|
428
|
+
*/
|
|
429
|
+
#claimTab(conn: CdpConnection, tabId: number): void {
|
|
430
|
+
const tab = this.#tabs.get(tabId);
|
|
431
|
+
if (!tab) return;
|
|
432
|
+
if (!conn.claims.has(tabId)) {
|
|
433
|
+
conn.claims.add(tabId);
|
|
434
|
+
this.#log("tab claimed", { conn: conn.id, tabId });
|
|
435
|
+
}
|
|
436
|
+
this.#syncTabGrouping(tab);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** True while any downstream connection claims the tab as its drive target. */
|
|
440
|
+
#claimed(tabId: number): boolean {
|
|
441
|
+
for (const conn of this.#conns.values()) {
|
|
442
|
+
if (conn.claims.has(tabId)) return true;
|
|
443
|
+
}
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Tab pseudo-sessions only exist to satisfy puppeteer's Target hierarchy. */
|
|
448
|
+
#handleTabSessionCommand(conn: CdpConnection, msg: CdpCommand, ref: SessionRef): void {
|
|
449
|
+
switch (msg.method) {
|
|
450
|
+
case "Target.setAutoAttach": {
|
|
451
|
+
const tab = this.#tabs.get(ref.tabId);
|
|
452
|
+
if (!tab) {
|
|
453
|
+
this.#replyError(conn, msg, `Tab ${ref.tabId} is gone`);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
// Emit before replying: puppeteer's TargetManager counts page
|
|
457
|
+
// children attached before the setAutoAttach response resolves.
|
|
458
|
+
const pageSession = this.#mintSession(conn, "page", tab.tabId);
|
|
459
|
+
this.#emit(
|
|
460
|
+
conn,
|
|
461
|
+
"Target.attachedToTarget",
|
|
462
|
+
{
|
|
463
|
+
sessionId: pageSession,
|
|
464
|
+
targetInfo: this.#pageInfo(tab, true),
|
|
465
|
+
waitingForDebugger: false,
|
|
466
|
+
},
|
|
467
|
+
msg.sessionId,
|
|
468
|
+
);
|
|
469
|
+
this.#reply(conn, msg, {});
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
case "Runtime.runIfWaitingForDebugger":
|
|
473
|
+
this.#reply(conn, msg, {});
|
|
474
|
+
return;
|
|
475
|
+
case "Target.detachFromTarget": {
|
|
476
|
+
const child = typeof msg.params?.sessionId === "string" ? msg.params.sessionId : undefined;
|
|
477
|
+
if (child) this.#releaseSession(conn, child, msg.sessionId);
|
|
478
|
+
this.#reply(conn, msg, {});
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
default:
|
|
482
|
+
this.#replyError(conn, msg, `'${msg.method}' is not supported on a tab target`, CDP_ERROR_METHOD_NOT_FOUND);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async #handleBrowserCommand(conn: CdpConnection, msg: CdpCommand): Promise<void> {
|
|
487
|
+
switch (msg.method) {
|
|
488
|
+
case "Browser.getVersion": {
|
|
489
|
+
this.#reply(conn, msg, {
|
|
490
|
+
protocolVersion: "1.3",
|
|
491
|
+
product: this.#extInfo?.browserVersion ?? "Chrome/unknown",
|
|
492
|
+
revision: "",
|
|
493
|
+
userAgent: this.#extInfo?.userAgent ?? "",
|
|
494
|
+
jsVersion: "",
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
case "Target.getBrowserContexts":
|
|
499
|
+
this.#reply(conn, msg, { browserContextIds: [] });
|
|
500
|
+
return;
|
|
501
|
+
case "Target.setDiscoverTargets": {
|
|
502
|
+
conn.discover = true;
|
|
503
|
+
for (const tab of this.#tabs.values()) {
|
|
504
|
+
if (!this.#eligible(tab)) continue;
|
|
505
|
+
tab.announced = true;
|
|
506
|
+
this.#emit(conn, "Target.targetCreated", { targetInfo: this.#tabInfo(tab, tab.attached) });
|
|
507
|
+
this.#emit(conn, "Target.targetCreated", { targetInfo: this.#pageInfo(tab, tab.attached) });
|
|
508
|
+
}
|
|
509
|
+
this.#reply(conn, msg, {});
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
case "Target.setAutoAttach": {
|
|
513
|
+
conn.autoAttach = true;
|
|
514
|
+
const tabs = [...this.#tabs.values()].filter(tab => this.#eligible(tab));
|
|
515
|
+
await Promise.all(tabs.map(tab => this.#ensureAttached(tab)));
|
|
516
|
+
for (const tab of tabs) {
|
|
517
|
+
if (!tab.attached) {
|
|
518
|
+
// Attach failed (DevTools open, another debugger, …): retract
|
|
519
|
+
// the target so puppeteer's init never waits on it.
|
|
520
|
+
this.#retractTab(tab);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
this.#emitTabAttached(conn, tab);
|
|
524
|
+
}
|
|
525
|
+
this.#reply(conn, msg, {});
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
case "Target.attachToTarget": {
|
|
529
|
+
const parsed = typeof msg.params?.targetId === "string" ? parseTargetId(msg.params.targetId) : null;
|
|
530
|
+
const tab = parsed ? this.#tabs.get(parsed.tabId) : undefined;
|
|
531
|
+
if (!parsed || !tab) {
|
|
532
|
+
this.#replyError(conn, msg, `No target with id ${String(msg.params?.targetId)}`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (!(await this.#ensureAttached(tab))) {
|
|
536
|
+
this.#replyError(conn, msg, `Cannot attach to tab ${tab.tabId} (${tab.url})`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const sessionId = this.#mintSession(conn, parsed.kind, tab.tabId);
|
|
540
|
+
const info = parsed.kind === "tab" ? this.#tabInfo(tab, true) : this.#pageInfo(tab, true);
|
|
541
|
+
this.#emit(conn, "Target.attachedToTarget", { sessionId, targetInfo: info, waitingForDebugger: false });
|
|
542
|
+
this.#reply(conn, msg, { sessionId });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
case "Target.detachFromTarget": {
|
|
546
|
+
const sessionId = typeof msg.params?.sessionId === "string" ? msg.params.sessionId : undefined;
|
|
547
|
+
if (sessionId) this.#releaseSession(conn, sessionId, undefined);
|
|
548
|
+
this.#reply(conn, msg, {});
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
case "Target.createTarget": {
|
|
552
|
+
const url =
|
|
553
|
+
typeof msg.params?.url === "string" && msg.params.url.length > 0 ? msg.params.url : "about:blank";
|
|
554
|
+
const result = (await this.#rpc({ op: "createTab", url })) as { tab: TabSnapshot };
|
|
555
|
+
this.#onTabUpsert(result.tab);
|
|
556
|
+
// Creating a tab is an explicit act of driving it.
|
|
557
|
+
this.#claimTab(conn, result.tab.tabId);
|
|
558
|
+
this.#reply(conn, msg, { targetId: pageTargetId(result.tab.tabId) });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
case "Target.closeTarget": {
|
|
562
|
+
const parsed = typeof msg.params?.targetId === "string" ? parseTargetId(msg.params.targetId) : null;
|
|
563
|
+
if (!parsed) {
|
|
564
|
+
this.#replyError(conn, msg, `No target with id ${String(msg.params?.targetId)}`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
await this.#rpc({ op: "removeTab", tabId: parsed.tabId });
|
|
568
|
+
this.#reply(conn, msg, { success: true });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
case "Target.activateTarget": {
|
|
572
|
+
const parsed = typeof msg.params?.targetId === "string" ? parseTargetId(msg.params.targetId) : null;
|
|
573
|
+
if (parsed) await this.#rpc({ op: "activateTab", tabId: parsed.tabId });
|
|
574
|
+
this.#reply(conn, msg, {});
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
case "Target.getTargetInfo": {
|
|
578
|
+
const raw = typeof msg.params?.targetId === "string" ? msg.params.targetId : undefined;
|
|
579
|
+
const parsed = raw ? parseTargetId(raw) : null;
|
|
580
|
+
const tab = parsed ? this.#tabs.get(parsed.tabId) : undefined;
|
|
581
|
+
if (parsed && tab) {
|
|
582
|
+
const info =
|
|
583
|
+
parsed.kind === "tab" ? this.#tabInfo(tab, tab.attached) : this.#pageInfo(tab, tab.attached);
|
|
584
|
+
this.#reply(conn, msg, { targetInfo: info });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
this.#reply(conn, msg, {
|
|
588
|
+
targetInfo: {
|
|
589
|
+
targetId: "relay-browser",
|
|
590
|
+
type: "browser",
|
|
591
|
+
title: "",
|
|
592
|
+
url: "",
|
|
593
|
+
attached: true,
|
|
594
|
+
canAccessOpener: false,
|
|
595
|
+
} satisfies TargetInfo,
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
case "Browser.close":
|
|
600
|
+
// Never close the user's browser; acknowledge and ignore.
|
|
601
|
+
this.#log("refusing Browser.close from downstream client", { conn: conn.id });
|
|
602
|
+
this.#reply(conn, msg, {});
|
|
603
|
+
return;
|
|
604
|
+
case "Browser.setDownloadBehavior":
|
|
605
|
+
this.#reply(conn, msg, {});
|
|
606
|
+
return;
|
|
607
|
+
case "Target.createBrowserContext":
|
|
608
|
+
this.#replyError(conn, msg, "Browser contexts are not supported by the omp browser relay");
|
|
609
|
+
return;
|
|
610
|
+
default:
|
|
611
|
+
this.#replyError(conn, msg, `'${msg.method}' wasn't found`, CDP_ERROR_METHOD_NOT_FOUND);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---- extension events -------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
#onCdpEvent(
|
|
618
|
+
tabId: number,
|
|
619
|
+
sourceSessionId: string | undefined,
|
|
620
|
+
method: string,
|
|
621
|
+
params?: Record<string, unknown>,
|
|
622
|
+
): void {
|
|
623
|
+
const tab = this.#tabs.get(tabId);
|
|
624
|
+
if (!tab) return;
|
|
625
|
+
// Track real child sessions so downstream commands can route back.
|
|
626
|
+
if (method === "Target.attachedToTarget") {
|
|
627
|
+
const child = params?.sessionId;
|
|
628
|
+
if (typeof child === "string") {
|
|
629
|
+
tab.realSessions.add(child);
|
|
630
|
+
this.#realSessionTabs.set(child, tabId);
|
|
631
|
+
}
|
|
632
|
+
} else if (method === "Target.detachedFromTarget") {
|
|
633
|
+
const child = params?.sessionId;
|
|
634
|
+
if (typeof child === "string") {
|
|
635
|
+
tab.realSessions.delete(child);
|
|
636
|
+
this.#realSessionTabs.delete(child);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (sourceSessionId) {
|
|
640
|
+
// Event from a real child session: pass through verbatim to every
|
|
641
|
+
// connection that observes this tab.
|
|
642
|
+
const payload = JSON.stringify({ sessionId: sourceSessionId, method, params });
|
|
643
|
+
for (const conn of this.#conns.values()) {
|
|
644
|
+
if (conn.sessionsForTab(tabId, "page").length > 0) conn.socket.send(payload);
|
|
645
|
+
}
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// Root-session event: fan out once per minted page session.
|
|
649
|
+
for (const conn of this.#conns.values()) {
|
|
650
|
+
for (const pageSession of conn.sessionsForTab(tabId, "page")) {
|
|
651
|
+
conn.socket.send(JSON.stringify({ sessionId: pageSession, method, params }));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
#onTabDetached(tabId: number, reason: string): void {
|
|
657
|
+
const tab = this.#tabs.get(tabId);
|
|
658
|
+
if (!tab) return;
|
|
659
|
+
this.#log("tab detached", { tabId, reason });
|
|
660
|
+
tab.attached = false;
|
|
661
|
+
tab.attaching = null;
|
|
662
|
+
tab.banned = true;
|
|
663
|
+
// The user dismissed the debugger infobar (or the attach was torn
|
|
664
|
+
// down): release the tab's omp-group membership too.
|
|
665
|
+
this.#syncTabGrouping(tab);
|
|
666
|
+
this.#retractTab(tab);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
#onTabRemoved(tabId: number): void {
|
|
670
|
+
const tab = this.#tabs.get(tabId);
|
|
671
|
+
if (!tab) return;
|
|
672
|
+
this.#retractTab(tab);
|
|
673
|
+
this.#tabs.delete(tabId);
|
|
674
|
+
for (const conn of this.#conns.values()) conn.claims.delete(tabId);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
#onTabUpsert(snap: TabSnapshot, opts: { silent?: boolean } = {}): void {
|
|
678
|
+
let tab = this.#tabs.get(snap.tabId);
|
|
679
|
+
if (!tab) {
|
|
680
|
+
tab = new TabState(snap.tabId, snap);
|
|
681
|
+
this.#tabs.set(snap.tabId, tab);
|
|
682
|
+
} else {
|
|
683
|
+
if (tab.url !== snap.url) tab.banned = false;
|
|
684
|
+
// The user dragging a tab out of the omp group is an opt-out; the
|
|
685
|
+
// relay never fights the user over grouping.
|
|
686
|
+
if (tab.grouped && tab.ompGroupId !== undefined && snap.groupId !== tab.ompGroupId) {
|
|
687
|
+
tab.grouped = false;
|
|
688
|
+
tab.groupOptOut = true;
|
|
689
|
+
}
|
|
690
|
+
tab.update(snap);
|
|
691
|
+
}
|
|
692
|
+
if (opts.silent) return;
|
|
693
|
+
const eligible = this.#eligible(tab);
|
|
694
|
+
this.#syncTabGrouping(tab);
|
|
695
|
+
if (eligible && !tab.announced) {
|
|
696
|
+
tab.announced = true;
|
|
697
|
+
for (const conn of this.#conns.values()) {
|
|
698
|
+
if (!conn.discover) continue;
|
|
699
|
+
this.#emit(conn, "Target.targetCreated", { targetInfo: this.#tabInfo(tab, tab.attached) });
|
|
700
|
+
this.#emit(conn, "Target.targetCreated", { targetInfo: this.#pageInfo(tab, tab.attached) });
|
|
701
|
+
}
|
|
702
|
+
for (const conn of this.#conns.values()) {
|
|
703
|
+
if (!conn.autoAttach) continue;
|
|
704
|
+
void this.#ensureAttached(tab).then(ok => {
|
|
705
|
+
if (ok) this.#emitTabAttached(conn, tab);
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (!eligible && tab.announced) {
|
|
711
|
+
this.#retractTab(tab);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (eligible && tab.announced) {
|
|
715
|
+
for (const conn of this.#conns.values()) {
|
|
716
|
+
if (!conn.discover) continue;
|
|
717
|
+
this.#emit(conn, "Target.targetInfoChanged", { targetInfo: this.#tabInfo(tab, tab.attached) });
|
|
718
|
+
this.#emit(conn, "Target.targetInfoChanged", { targetInfo: this.#pageInfo(tab, tab.attached) });
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ---- tab grouping -----------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
/** A tab belongs in the omp group when claimed by a client, controllable, unpinned, not user-opted-out, and not already in a user group. */
|
|
726
|
+
#groupWorthy(tab: TabState): boolean {
|
|
727
|
+
if (!this.#claimed(tab.tabId) || !this.#eligible(tab) || tab.pinned || tab.groupOptOut) return false;
|
|
728
|
+
return tab.grouped || tab.groupId === -1;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Re-group every claimed tab (extension hello / reconnect). */
|
|
732
|
+
#syncGrouping(): void {
|
|
733
|
+
if (!this.#group) return;
|
|
734
|
+
const worthy = [...this.#tabs.values()].filter(tab => this.#groupWorthy(tab) && !tab.grouped && !tab.grouping);
|
|
735
|
+
if (worthy.length > 0) this.#requestGroup(worthy);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Reconcile one tab's group membership after a lifecycle event. */
|
|
739
|
+
#syncTabGrouping(tab: TabState): void {
|
|
740
|
+
if (!this.#group) return;
|
|
741
|
+
if (this.#groupWorthy(tab)) {
|
|
742
|
+
if (!tab.grouped && !tab.grouping) this.#requestGroup([tab]);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (tab.grouped) {
|
|
746
|
+
tab.grouped = false;
|
|
747
|
+
tab.ompGroupId = undefined;
|
|
748
|
+
void this.#rpc({ op: "ungroup", tabIds: [tab.tabId] }).catch(() => {});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Queue tabs for grouping and drain serially. Overlapping group RPCs race
|
|
754
|
+
* the extension's non-atomic query→create→set-title sequence and mint
|
|
755
|
+
* duplicate omp groups, so at most one group RPC is ever in flight.
|
|
756
|
+
*/
|
|
757
|
+
#requestGroup(tabs: TabState[]): void {
|
|
758
|
+
if (!this.#group) return;
|
|
759
|
+
for (const tab of tabs) {
|
|
760
|
+
tab.grouping = true;
|
|
761
|
+
this.#groupQueue.push(tab);
|
|
762
|
+
}
|
|
763
|
+
if (!this.#groupDraining) void this.#drainGroupQueue();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async #drainGroupQueue(): Promise<void> {
|
|
767
|
+
const group = this.#group;
|
|
768
|
+
if (!group) return;
|
|
769
|
+
this.#groupDraining = true;
|
|
770
|
+
try {
|
|
771
|
+
while (this.#groupQueue.length > 0) {
|
|
772
|
+
const batch = this.#groupQueue.splice(0);
|
|
773
|
+
const tabIds = batch.map(tab => tab.tabId);
|
|
774
|
+
try {
|
|
775
|
+
const result = await this.#rpc({ op: "group", tabIds, title: group.title, color: group.color });
|
|
776
|
+
// Extension replies { grouped: { [tabId]: groupId } }; validate per entry.
|
|
777
|
+
const grouped: Record<string, unknown> =
|
|
778
|
+
result &&
|
|
779
|
+
typeof result === "object" &&
|
|
780
|
+
"grouped" in result &&
|
|
781
|
+
result.grouped &&
|
|
782
|
+
typeof result.grouped === "object"
|
|
783
|
+
? (result.grouped as Record<string, unknown>)
|
|
784
|
+
: {};
|
|
785
|
+
for (const tab of batch) {
|
|
786
|
+
const groupId = grouped[String(tab.tabId)];
|
|
787
|
+
if (typeof groupId !== "number") continue;
|
|
788
|
+
tab.grouped = true;
|
|
789
|
+
tab.ompGroupId = groupId;
|
|
790
|
+
}
|
|
791
|
+
this.#log("grouped tabs", { tabIds, grouped });
|
|
792
|
+
} catch (err) {
|
|
793
|
+
this.#log("tab grouping failed", { error: err instanceof Error ? err.message : String(err) });
|
|
794
|
+
} finally {
|
|
795
|
+
for (const tab of batch) tab.grouping = false;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
} finally {
|
|
799
|
+
this.#groupDraining = false;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Tear a tab out of every downstream connection (closed, detached, or now ineligible). */
|
|
804
|
+
#retractTab(tab: TabState): void {
|
|
805
|
+
for (const realSession of tab.realSessions) this.#realSessionTabs.delete(realSession);
|
|
806
|
+
tab.realSessions.clear();
|
|
807
|
+
for (const conn of this.#conns.values()) {
|
|
808
|
+
const tabSessions = conn.sessionsForTab(tab.tabId, "tab");
|
|
809
|
+
for (const pageSession of conn.sessionsForTab(tab.tabId, "page")) {
|
|
810
|
+
conn.sessions.delete(pageSession);
|
|
811
|
+
this.#emit(
|
|
812
|
+
conn,
|
|
813
|
+
"Target.detachedFromTarget",
|
|
814
|
+
{ sessionId: pageSession, targetId: pageTargetId(tab.tabId) },
|
|
815
|
+
tabSessions[0],
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
for (const tabSession of tabSessions) {
|
|
819
|
+
conn.sessions.delete(tabSession);
|
|
820
|
+
this.#emit(conn, "Target.detachedFromTarget", { sessionId: tabSession, targetId: tabTargetId(tab.tabId) });
|
|
821
|
+
}
|
|
822
|
+
if (conn.discover && tab.announced) {
|
|
823
|
+
this.#emit(conn, "Target.targetDestroyed", { targetId: pageTargetId(tab.tabId) });
|
|
824
|
+
this.#emit(conn, "Target.targetDestroyed", { targetId: tabTargetId(tab.tabId) });
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
tab.announced = false;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ---- session + attach bookkeeping --------------------------------------------
|
|
831
|
+
|
|
832
|
+
#mintSession(conn: CdpConnection, kind: "tab" | "page", tabId: number): string {
|
|
833
|
+
const sessionId = `S${kind === "tab" ? "T" : "P"}${tabId}.${conn.id}.${++this.#sessionSeq}`;
|
|
834
|
+
conn.sessions.set(sessionId, { kind, tabId });
|
|
835
|
+
return sessionId;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
#releaseSession(conn: CdpConnection, sessionId: string, parentSessionId: string | undefined): void {
|
|
839
|
+
const ref = conn.sessions.get(sessionId);
|
|
840
|
+
if (!ref) return;
|
|
841
|
+
conn.sessions.delete(sessionId);
|
|
842
|
+
const targetId = ref.kind === "tab" ? tabTargetId(ref.tabId) : pageTargetId(ref.tabId);
|
|
843
|
+
this.#emit(conn, "Target.detachedFromTarget", { sessionId, targetId }, parentSessionId);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Connections currently holding any session on a tab. */
|
|
847
|
+
#sessionHolders(tabId: number): CdpConnection[] {
|
|
848
|
+
const out: CdpConnection[] = [];
|
|
849
|
+
for (const conn of this.#conns.values()) {
|
|
850
|
+
if (conn.sessionsForTab(tabId).length > 0) out.push(conn);
|
|
851
|
+
}
|
|
852
|
+
return out;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
#emitTabAttached(conn: CdpConnection, tab: TabState): void {
|
|
856
|
+
if (conn.sessionsForTab(tab.tabId, "tab").length > 0) return;
|
|
857
|
+
const sessionId = this.#mintSession(conn, "tab", tab.tabId);
|
|
858
|
+
this.#emit(conn, "Target.attachedToTarget", {
|
|
859
|
+
sessionId,
|
|
860
|
+
targetInfo: this.#tabInfo(tab, true),
|
|
861
|
+
waitingForDebugger: false,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async #ensureAttached(tab: TabState): Promise<boolean> {
|
|
866
|
+
if (tab.attached) return true;
|
|
867
|
+
if (tab.banned || !this.#ext) return false;
|
|
868
|
+
if (tab.attaching) return await tab.attaching;
|
|
869
|
+
const attempt = this.#rpc({ op: "attach", tabId: tab.tabId })
|
|
870
|
+
.then(() => {
|
|
871
|
+
tab.attached = true;
|
|
872
|
+
return true;
|
|
873
|
+
})
|
|
874
|
+
.catch(err => {
|
|
875
|
+
this.#log("attach failed", {
|
|
876
|
+
tabId: tab.tabId,
|
|
877
|
+
url: tab.url,
|
|
878
|
+
error: err instanceof Error ? err.message : String(err),
|
|
879
|
+
});
|
|
880
|
+
tab.banned = true;
|
|
881
|
+
return false;
|
|
882
|
+
})
|
|
883
|
+
.finally(() => {
|
|
884
|
+
tab.attaching = null;
|
|
885
|
+
});
|
|
886
|
+
tab.attaching = attempt;
|
|
887
|
+
return await attempt;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
#eligible(tab: TabState): boolean {
|
|
891
|
+
if (tab.banned) return false;
|
|
892
|
+
if (!tab.url) return true;
|
|
893
|
+
return !INELIGIBLE_URL.test(tab.url);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
#tabInfo(tab: TabState, attached: boolean): TargetInfo {
|
|
897
|
+
return {
|
|
898
|
+
targetId: tabTargetId(tab.tabId),
|
|
899
|
+
type: "tab",
|
|
900
|
+
title: tab.title,
|
|
901
|
+
url: tab.url || "about:blank",
|
|
902
|
+
attached,
|
|
903
|
+
canAccessOpener: false,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
#pageInfo(tab: TabState, attached: boolean): TargetInfo {
|
|
908
|
+
return {
|
|
909
|
+
targetId: pageTargetId(tab.tabId),
|
|
910
|
+
type: "page",
|
|
911
|
+
title: tab.title,
|
|
912
|
+
url: tab.url || "about:blank",
|
|
913
|
+
attached,
|
|
914
|
+
canAccessOpener: false,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ---- plumbing ---------------------------------------------------------------
|
|
919
|
+
|
|
920
|
+
#reply(conn: CdpConnection, msg: CdpCommand, result: Record<string, unknown>): void {
|
|
921
|
+
conn.socket.send(JSON.stringify({ id: msg.id, sessionId: msg.sessionId, result }));
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
#replyError(conn: CdpConnection, msg: CdpCommand, message: string, code = CDP_ERROR_SERVER): void {
|
|
925
|
+
conn.socket.send(JSON.stringify({ id: msg.id, sessionId: msg.sessionId, error: { code, message } }));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
#emit(conn: CdpConnection, method: string, params: Record<string, unknown>, sessionId?: string): void {
|
|
929
|
+
conn.socket.send(JSON.stringify({ sessionId, method, params }));
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
#rpc(req: RelayRpcRequest, timeoutMs = RPC_TIMEOUT_MS): Promise<unknown> {
|
|
933
|
+
const ext = this.#ext;
|
|
934
|
+
if (!ext) return Promise.reject(new Error("relay extension is not connected"));
|
|
935
|
+
const id = ++this.#rpcSeq;
|
|
936
|
+
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
|
|
937
|
+
const timer = setTimeout(() => {
|
|
938
|
+
this.#pendingRpc.delete(id);
|
|
939
|
+
reject(new Error(`extension rpc '${req.op}' timed out after ${timeoutMs}ms`));
|
|
940
|
+
}, timeoutMs);
|
|
941
|
+
this.#pendingRpc.set(id, { resolve, reject, timer });
|
|
942
|
+
ext.send(JSON.stringify({ t: "rpc", id, ...req } satisfies RelayToExtMessage));
|
|
943
|
+
return promise;
|
|
944
|
+
}
|
|
945
|
+
}
|