AGENTS.md — Dual-LLM Bridge (v2, corrected)

⚠️ LANGUAGE NOTE: Headings and critical instructions are in English for maximum compatibility with coding agents. Explanations are in Russian. If you, the agent, do not understand a Russian comment — ask before coding.

🧠 MAIN RULE: This document is the source of truth. If your idea contradicts this document, stop and ask. Do not silently invent another architecture.


🎯 Project Overview

Dual-LLM Bridge is a Chrome Manifest V3 extension with a built-in Web UI.

The product goal:

  1. The user has two LLM web chats open in browser tabs, for example:

  2. The extension UI shows a split-screen control panel:

  3. Prefix routing:

  4. Auto-Relay:

  5. The system must work without official APIs as the primary mode. Official APIs are optional fallback adapters, not the main path.


🧱 Core Architectural Decision

The UI is not a random standalone website on localhost trying to talk to content scripts.

The UI is an extension page, opened via chrome-extension://<extension-id>/index.html.

Reason:

Therefore:

UI = extension page
Background Service Worker = message router and command dispatcher
Content Scripts = adapters inside LLM chat tabs

🏛 Canonical Architecture

Use this architecture exactly.

┌────────────────────────────┐        ┌────────────────────────────┐
│ Qwen tab                   │        │ ChatGPT tab                │
│ https://chat.qwen.ai       │        │ https://chatgpt.com        │
│                            │        │                            │
│ MAIN-world interceptor     │        │ MAIN-world interceptor     │
│ patches fetch/SSE/stream   │        │ patches fetch/SSE/stream   │
│         │                  │        │         │                  │
│         ▼                  │        │         ▼                  │
│ ISOLATED content bridge    │        │ ISOLATED content bridge    │
└───────────┬────────────────┘        └───────────┬────────────────┘
            │                                     │
            │ chrome.runtime.sendMessage           │ chrome.runtime.sendMessage
            ▼                                     ▼
        ┌───────────────────────────────────────────────┐
        │ Background Service Worker                     │
        │ - routes events                               │
        │ - knows tab/window assignment                 │
        │ - executes SEND_PROMPT commands               │
        │ - reports tab status                          │
        └───────────────────────┬───────────────────────┘
                                │ chrome.runtime.connect / sendMessage
                                ▼
                    ┌─────────────────────────┐
                    │ Extension Web UI        │
                    │ Split-screen control app│
                    │ Relay controller        │
                    │ Settings / Stop button  │
                    └─────────────────────────┘

Important consequences

  1. The UI does not embed official chat sites in iframes.
  2. The UI shows a mirrored transcript of intercepted messages.
  3. The actual chats remain open in normal browser tabs.
  4. Sending a message to a model means:
  5. Receiving a response means:

🚫 ANTI-PATTERNS — DO NOT

  1. DO NOT use plain <iframe src="https://chatgpt.com"> or similar. Major LLM chats block embedding using X-Frame-Options / CSP.

  2. DO NOT use BroadcastChannel as the main bridge between chat tabs and UI. It does not work across different origins. It may only be used optionally between multiple extension UI pages of the same origin.

  3. DO NOT rely on localStorage for real-time synchronization. Use extension messaging and in-memory state.

  4. DO NOT parse by random CSS class names. CSS classes change often. Prefer:

  5. DO NOT use MutationObserver polling as the main streaming mechanism. Streaming must be intercepted at network layer when possible. DOM observation is only a fallback.

  6. DO NOT use Puppeteer / Playwright as the main product architecture. They are testing/fallback tools only.

  7. DO NOT propose another tech stack. The stack is fixed below.

  8. DO NOT generate code for the next phase before the current phase is accepted.

  9. DO NOT try to bypass CAPTCHA, anti-bot, payment walls, or authentication. If the site requires human action, show a clear user-facing error.

  10. DO NOT send chat content, cookies, tokens, or API keys anywhere except:


⚡ KNOWN SOLUTIONS — READ BEFORE CODING

CRITICAL: Do not invent integration architecture from scratch. Study and adapt these patterns.

Task Reference What to adopt Notes
Intercepting chat requests/responses ChatALL Content script / bot adapter pattern, network interception Study adapter structure, not exact selectors
Chat UI adapters NextChat / ChatGPT-Next-Web Adapter abstraction, message normalization Do not copy whole app
MV3 content scripts Chrome Extensions docs content_scripts, world: MAIN, service worker Official docs are priority
Programmatic React input filling React controlled input pattern native value setter + input event See section “Programmatic Message Injection”
Extension build CRXJS Vite Plugin Build MV3 extension with Vite If unstable, use manual Vite build + static manifest
State management Zustand UI state, relay state, window stores No Redux
Local persistence IndexedDB Transcript/settings cache No sensitive data in localStorage

Reference URLs

Agent: verify URLs before relying on them. If moved, find the current repository.


🔧 TECH STACK — FIXED

Frontend:
  framework: React 18.3.x
  language: TypeScript 5.5.x
  strict_mode: true
  bundler: Vite 5.4.x

State:
  library: zustand 4.5.x
  pattern: UI stores + relay controller store

Styling:
  framework: tailwindcss 3.4.x
  note: Do not switch to Tailwind 4 without user approval.

Extension:
  manifest: V3
  minimum_chrome: "120"
  build_tool: >
    Prefer @crxjs/vite-plugin if a known-good version works.
    If CRXJS beta is unstable, stop and ask the user.
    Fallback: plain Vite build + static manifest.json.

IPC:
  content_to_background: chrome.runtime.sendMessage
  background_to_content: chrome.tabs.sendMessage or chrome.scripting.executeScript
  ui_to_background: chrome.runtime.connect / chrome.runtime.sendMessage
  main_world_to_isolated: window.postMessage with schema validation

Storage:
  settings: chrome.storage.local
  api_keys: chrome.storage.local or IndexedDB
  transcript_cache: IndexedDB
  forbidden_for_sensitive_data: localStorage

Testing:
  unit: vitest
  e2e: playwright
  e2e_policy: Use local mock pages, not live LLM sites.

Runtime:
  node: ">=20"
  package_manager: npm workspaces

Version policy


📁 PROJECT STRUCTURE — MANDATORY

Use this structure unless the user explicitly approves changes.

dual-llm-bridge/
├── packages/
│   ├── extension/
│   │   ├── src/
│   │   │   ├── background/
│   │   │   │   └── index.ts              # Service worker/router
│   │   │   │
│   │   │   ├── content/
│   │   │   │   ├── isolated/
│   │   │   │   │   └── index.ts          # Isolated-world bridge
│   │   │   │   ├── main-world/
│   │   │   │   │   └── injector.ts       # MAIN-world fetch/SSE interceptor
│   │   │   │   └── adapters/
│   │   │   │       ├── base.ts           # Shared adapter helpers
│   │   │   │       ├── qwen.ts           # Qwen adapter
│   │   │   │       └── chatgpt.ts        # ChatGPT adapter
│   │   │   │
│   │   │   ├── ui/
│   │   │   │   ├── components/
│   │   │   │   │   ├── SplitView.tsx
│   │   │   │   │   ├── ChatPanel.tsx
│   │   │   │   │   ├── MessageList.tsx
│   │   │   │   │   ├── UnifiedInput.tsx
│   │   │   │   │   ├── StatusBar.tsx
│   │   │   │   │   ├── RelayControls.tsx
│   │   │   │   │   └── WarningModal.tsx
│   │   │   │   ├── store/
│   │   │   │   │   ├── windows.ts
│   │   │   │   │   ├── relay.ts
│   │   │   │   │   └── settings.ts
│   │   │   │   ├── lib/
│   │   │   │   │   ├── parser.ts         # Prefix parsing: -1 / -2
│   │   │   │   │   ├── format.ts
│   │   │   │   │   └── ipc.ts            # chrome.runtime wrapper
│   │   │   │   ├── App.tsx
│   │   │   │   ├── main.tsx
│   │   │   │   └── index.html
│   │   │   │
│   │   │   └── shared-imports.ts         # Re-export shared package if needed
│   │   │
│   │   ├── manifest.json
│   │   ├── vite.config.ts
│   │   └── package.json
│   │
│   └── shared/
│       ├── src/
│       │   ├── types.ts                  # Protocol types
│       │   ├── guards.ts                 # Runtime validation guards
│       │   ├── protocol.ts               # Constants/topics
│       │   └── constants.ts              # Domains/timeouts/limits
│       └── package.json
│
├── tests/
│   ├── unit/
│   ├── e2e/
│   └── mocks/
│       ├── qwen-mock.html
│       └── chatgpt-mock.html
│
├── docs/
│   └── architecture.md
│
├── AGENTS.md
├── package.json
└── README.md

🌐 TARGET DOMAINS

Primary targets:

Window Model Primary URL Fallback URL
1 Qwen https://chat.qwen.ai/* https://qwen.ai/chat/* if applicable
2 ChatGPT https://chatgpt.com/* https://chat.openai.com/*

Manifest must register both ChatGPT patterns because chat.openai.com may redirect.


📨 MESSAGE PROTOCOL

All IPC messages must use typed structures from packages/shared/src/types.ts.

Envelope rules

Every message must have:

export const PROTOCOL_VERSION = 1;

export type WindowId = 1 | 2;

export type ModelId =
  | 'qwen'
  | 'chatgpt'
  | (string & {}); // allow future models, but constants must define known ones

export type MessageOrigin =
  | 'main-world'
  | 'content-isolated'
  | 'background'
  | 'ui';

export type ChatStatus =
  | 'unknown'
  | 'tab-not-found'
  | 'loading'
  | 'ready'
  | 'unauthenticated'
  | 'generating'
  | 'error';

export interface BridgeMeta {
  /** Unix timestamp in milliseconds */
  ts: number;

  /** Unique message id */
  messageId: string;

  /** Which UI window this belongs to */
  windowId: WindowId;

  /** Model id */
  model: ModelId;

  /** Who caused this message */
  source: 'user-ui' | 'user-chat-tab' | 'relay' | 'system';

  /** Auto-relay iteration number, 0 for user-initiated */
  iteration: number;

  /** true if this message was forwarded by relay */
  isAutoRelay: boolean;
}

Event types

export type BridgeEventType =
  | 'TAB_STATUS'
  | 'PROMPT_OBSERVED'
  | 'RESPONSE_CHUNK'
  | 'RESPONSE_DONE'
  | 'INJECTION_RESULT'
  | 'ERROR';

export interface BridgeEvent<T extends BridgeEventType = BridgeEventType> {
  kind: 'event';
  v: typeof PROTOCOL_VERSION;
  type: T;
  origin: MessageOrigin;
  meta: BridgeMeta;
  payload: BridgeEventPayload;
}

export interface BridgeEventPayload {
  status?: ChatStatus;
  text?: string;
  streamDelta?: string;
  fullText?: string;
  doneReason?: 'complete' | 'stopped' | 'error' | 'timeout';
  error?: {
    code: string;
    message: string;
    retryable: boolean;
  };
  url?: string;
}

Command types

export type BridgeCommandType =
  | 'SEND_PROMPT'
  | 'REQUEST_STATUS'
  | 'STOP_GENERATION'
  | 'ASSIGN_WINDOW';

export interface BridgeCommand<T extends BridgeCommandType = BridgeCommandType> {
  kind: 'command';
  v: typeof PROTOCOL_VERSION;
  type: T;
  origin: MessageOrigin;
  meta: BridgeMeta;
  payload: BridgeCommandPayload;
}

export interface BridgeCommandPayload {
  text?: string;
  windowId?: WindowId;
  tabId?: number;
  reason?: string;
}

Validation rules


🧩 CONTENT ADAPTER CONTRACT

Each supported model must have an adapter.

export interface ChatAdapterConfig {
  model: ModelId;
  windowId: WindowId;
  hostPatterns: string[];
  selectors: {
    input: string[];
    sendButton: string[];
    stopButton?: string[];
    loginIndicator?: string[];
    responseContainer?: string[];
  };
}

export interface ChatAdapter {
  config: ChatAdapterConfig;

  /** Return true if current URL belongs to this adapter */
  matches(url: string): boolean;

  /** Start network interception in MAIN world */
  initInterceptor(): void;

  /** Detect if user is logged in */
  detectAuth(): boolean;

  /** Extract last visible messages as fallback */
  extractVisibleHistory?(): Array<{
    role: 'user' | 'assistant' | 'system';
    text: string;
  }>;
}

Selector policy

Selectors must be arrays, tried in order.

Example:

selectors: {
  input: [
    '[data-testid="chat-input"]',
    'textarea[aria-label]',
    'textarea',
    '[contenteditable="true"]'
  ],
  sendButton: [
    '[data-testid="send-button"]',
    'button[aria-label*="send" i]',
    'button[type="submit"]'
  ]
}

Rules:


🧪 PROGRAMMATIC MESSAGE INJECTION

This is mandatory.

To send text into a React-based chat input, do not do only:

textarea.value = text;

That often does not work with React controlled components.

Use the native setter pattern:

function setNativeValue(element: HTMLElement, value: string) {
  const el = element as HTMLTextAreaElement | HTMLInputElement;

  const prototype =
    el instanceof HTMLTextAreaElement
      ? HTMLTextAreaElement.prototype
      : HTMLInputElement.prototype;

  const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
  const setter = descriptor?.set;

  if (!setter) {
    throw new Error('Native value setter is unavailable');
  }

  // React internal value tracker workaround
  const tracker = (el as any)._valueTracker;
  if (tracker) {
    tracker.setValue(el.value);
  }

  setter.call(el, value);

  el.dispatchEvent(new Event('input', { bubbles: true }));
  el.dispatchEvent(new Event('change', { bubbles: true }));
}

Then:

  1. Focus the input.
  2. Set value using the native setter.
  3. Dispatch input and change.
  4. Wait until send button becomes enabled, if detectable.
  5. Click send button.
  6. If send button not found, dispatch Enter key events on input.
  7. Confirm that a request started or response stream started.
  8. If no confirmation within timeout, return injection error.

Preferred execution method:

chrome.scripting.executeScript({
  target: { tabId },
  world: 'MAIN',
  func: injectPromptIntoChat,
  args: [adapterId, text, messageId]
});

Fallback if chrome.scripting MAIN world is unavailable:


📡 STREAM INTERCEPTION

Primary method:

Secondary method:

Tertiary fallback:

Rules:


🔁 AUTO-RELAY RULES

Relay controller lives in the UI store, but all actual tab operations go through background.

Relay enablement

Auto-relay is disabled by default.

Before first enablement, show a warning modal:

«Авто-релей будет отправлять ответы одной модели в качестве ввода другой модели. Содержимое чата может передаваться между разными провайдерами. Продолжить?»

User must explicitly confirm.

Relay loop

When relay is enabled:

  1. Wait for RESPONSE_DONE from Window A.
  2. Apply debounce: default 800ms.
  3. Check:
  4. Send completed response text as SEND_PROMPT to Window B.
  5. Mark command meta:
    isAutoRelay: true;
    iteration: previousIteration + 1;
    source: 'relay';
    
  6. Wait for Window B RESPONSE_DONE.
  7. Repeat in reverse direction if relay still enabled.

Queue rules

Limits

export const DEFAULT_DEBOUNCE_MS = 800;
export const DEFAULT_MAX_ITERATIONS = 10;
export const MAX_HARD_ITERATIONS = 100;
export const CONNECTION_TIMEOUT_MS = 30_000;
export const GENERATION_TIMEOUT_MS = 300_000;
export const INJECTION_TIMEOUT_MS = 15_000;

Duplicate protection

Do not forward a message if:


🖥 UI REQUIREMENTS

The UI is an extension page.

Layout

┌────────────────────────────────────────────────────┐
│ StatusBar: window statuses, relay iteration, Stop  │
├────────────────────────┬───────────────────────────┤
│ Window 1 transcript    │ Window 2 transcript       │
│ Qwen mirror            │ ChatGPT mirror            │
│                        │                           │
├────────────────────────┴───────────────────────────┤
│ Unified input: -1 / -2 / no prefix                 │
└────────────────────────────────────────────────────┘

Panels

Each panel must show:

Message rendering

Each message must show:

Input behavior

Input parsing:

"-1 hello" -> window 1 only
"-2 hello" -> window 2 only
"hello"    -> both windows

Rules:

Stop button

Stop button must:


🔐 SECURITY AND PRIVACY RULES

  1. No data exfiltration. Chat content must not be sent anywhere except:

  2. API keys local only. Store API keys in chrome.storage.local or IndexedDB. Never store secrets in localStorage.

  3. No remote code execution. No eval() of remote code. No injecting remote scripts.

  4. Minimal permissions. Required manifest permissions:

    Do not request <all_urls> unless user explicitly approves.

  5. Untrusted MAIN-world data. Data from page context must be schema-validated. It must never directly invoke privileged extension commands.

  6. Relay privacy warning. Auto-relay must require explicit user confirmation.

  7. No anti-bot bypass. If login, CAPTCHA, or consent screen is required, stop and notify user.


🧯 ERROR HANDLING STRATEGY

Every async operation must have error handling. Silent failures are forbidden.

Scenario Required behavior
Extension UI cannot reach background Show fatal error, suggest reloading extension
Target tab not found Show tab-not-found, suggest opening the chat URL
User not authenticated Show unauthenticated, provide button to open chat tab
Site layout changed Show site structure changed, log selector failure
Injection failed Retry once after 2 seconds, then show error
Stream interrupted Retry once after 2 seconds, then show error
Rate limit / 429 Pause relay, show cooldown warning
Generation timeout Stop waiting, mark error, pause relay
Invalid protocol message Log warning, discard, do not crash
Service worker restarted UI should re-request status and reset transient state

Logging prefixes:

[bridge]
[bridge:error]
[bridge:relay]
[bridge:inject]
[bridge:stream]

🧪 TESTING STRATEGY

Unit tests

Test at least:

Integration tests

Use mock pages:

Mock pages should simulate:

E2E tests

Playwright may test:

Do not run E2E against live LLM sites by default.

Manual acceptance may use live sites.


🚀 BUILD AND INSTALL

# install dependencies from repo root
npm install

# development build/watch
npm run dev

# production build
npm run build

# unit tests
npm run test

# e2e tests
npm run test:e2e

Load extension manually:

  1. Run npm run build.
  2. Open chrome://extensions.
  3. Enable Developer mode.
  4. Click “Load unpacked extension”.
  5. Select dist/extension or the build output folder.
  6. Open extension UI from toolbar action.

Firefox is not supported in the current scope.


📋 TASK BREAKDOWN — SEQUENTIAL

Do not skip phases. After each phase, present results and wait for user approval.


Phase 1 — Extension Skeleton and Stream Interception

Goal: prove that the extension can observe prompts and responses from real chat tabs.

Tasks:

Acceptance:

  1. Load extension in Chrome.
  2. Open Qwen chat tab.
  3. Open ChatGPT chat tab.
  4. Type a message in each chat.
  5. In each tab console, see structured events:

No UI is required yet.


Phase 2 — Extension UI, Handshake, and Message Injection

Goal: UI can see tab statuses and send messages into chat tabs.

Tasks:

Acceptance:

  1. Open Qwen tab and ChatGPT tab.
  2. Open extension UI.
  3. Both windows show 🟢 ready.
  4. Type -1 hello → message is sent only to Qwen tab.
  5. Type -2 hello → message is sent only to ChatGPT tab.
  6. Type hello → message is sent to both tabs.
  7. Responses appear in matching UI panels.

Phase 3 — Auto-Relay and Loop Control

Goal: models can talk to each other safely.

Tasks:

Acceptance:

  1. Enable relay.
  2. Send a prompt to Window 1.
  3. Window 1 responds.
  4. Response is automatically sent to Window 2.
  5. Window 2 responds.
  6. Response is automatically sent to Window 1.
  7. At least 5 autonomous exchanges happen.
  8. Iteration counter updates.
  9. Stop button halts relay immediately.
  10. After max iterations, relay stops automatically.

Phase 4 — API Fallback Adapter

Goal: allow official API mode without changing UI.

Tasks:

Acceptance:

  1. Enter API key.
  2. Switch window to API mode.
  3. Send message.
  4. Receive response.
  5. Switch back to extension mode.
  6. UI behavior remains consistent.
  7. Network tab shows no key leakage to unrelated domains.

💬 COMMUNICATION PROTOCOL WITH USER

These rules are mandatory.

  1. Before starting each phase, confirm the plan in 1–2 sentences.

    Example:

    «Начинаю Phase 1: создаю монорепо, shared-типы и MAIN-world interceptor для fetch/SSE. Приступаю.»

  2. If anything in this document is ambiguous, ask before coding.

  3. After each phase, present:

  4. Do not proceed to the next phase without explicit user approval.

  5. If a site changed DOM or network format, report it and propose adapter update.

  6. If a dependency is broken or abandoned, report it before replacing.

  7. Default communication language with the user: Russian.


🧭 DEFINITION OF DONE

The project is considered functionally complete when:


⚠️ FINAL AGENT INSTRUCTION

If you are about to do any of the following, stop immediately and ask:

This project values predictable architecture over clever hacks.