Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/superpowers/specs/2026-08-18-multi-session-pairing-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,22 @@ closure.
Ownership is **advisory**. Nothing is blocked. An agent that sees a tab is taken
can open its own, and two agents may share a tab on purpose.

### MCP release version visibility

The pairing payload carries an optional `mcpVersion` sourced from the published
MCP package. The extension keeps it on the corresponding connection-pool entry
and shows it in the MCP settings session list. The field remains optional under
pairing payload `v: 1`, so older MCP clients still connect and are displayed as
"MCP 版本未知".

On each pairing request, including an already-trusted install, the extension
compares `mcpVersion` with `chrome.runtime.getManifest().version`. A mismatch is
advisory: the pair page remains open with a recommendation to update the older
side, and the settings page keeps the warning visible beside the affected
session. Matching versions preserve the existing auto-close flow. Version
comparison never changes trust, websocket connectivity, or the separate
`protocol_version` compatibility gate.

### Tab freshness

A new C→S `TABS_UPDATE` fires on tab changes and ownership changes, reusing the
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "atwebpilot-monorepo",
"private": true,
"version": "0.0.75",
"version": "0.0.76",
"description": "AtWebPilot — AI 网页助手(侧边面板)monorepo root",
"type": "module",
"scripts": {
Expand Down
7 changes: 6 additions & 1 deletion packages/extension/src/background/coordinator-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { CoordinatorClient, type CoordinatorClientOptions } from "./coordinator-
export type PoolEntryStatus = "connected" | "connecting" | "disconnected" | "dormant";

export type PoolEntry = {
source: "mcp" | "manual";
endpoint: string;
installId: string;
sessionId: string;
Expand All @@ -21,6 +22,7 @@ export type PoolEntry = {
port: number;
status: PoolEntryStatus;
failures: number;
mcpVersion?: string;
};

type Managed = PoolEntry & { client: CoordinatorClient };
Expand Down Expand Up @@ -51,17 +53,20 @@ export class CoordinatorPool {
async addFromPairing(payload: PairPayload): Promise<void> {
const existing = this.entries.get(payload.sessionId);
if (existing) {
existing.mcpVersion = payload.mcpVersion;
existing.client.wakeUp();
this.changed();
return;
}
await this.add({
source: "mcp",
endpoint: endpointFor(payload.port),
installId: payload.installId,
sessionId: payload.sessionId,
label: payload.label,
pid: payload.pid,
port: payload.port
port: payload.port,
mcpVersion: payload.mcpVersion
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/extension/src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export async function startCoordinatorClient(): Promise<void> {
const p = ensurePool();
if (p.list().some((e) => e.sessionId === "legacy")) return;
await p.add({
source: "manual",
endpoint: config.ws_url,
installId: "legacy",
sessionId: "legacy",
Expand Down
43 changes: 36 additions & 7 deletions packages/extension/src/content/pairing-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
PAIR_PAGE_SOURCE,
PAIR_READY_SOURCE,
PAIR_RESULT_SOURCE,
comparePairingVersions,
type PairPayload,
type PairingDecision
} from "@atwebpilot/shared/pairing";
Expand Down Expand Up @@ -43,6 +44,11 @@ export function installPairingRelay(): () => void {
}

async function handle(payload: PairPayload): Promise<void> {
const extensionVersion = chrome.runtime.getManifest().version;
const { warning: versionWarning } = comparePairingVersions(
payload.mcpVersion,
extensionVersion
);
const reply = (await chrome.runtime.sendMessage({
type: "pairing.request",
payload
Expand All @@ -54,21 +60,33 @@ async function handle(payload: PairPayload): Promise<void> {
}

if (reply.decision === "trusted") {
post({ ok: true, trusted: true });
post({ ok: true, trusted: true, extensionVersion, versionWarning });
return;
}

const approved = await askUser(payload);
const approved = await askUser(payload, extensionVersion, versionWarning);
await chrome.runtime.sendMessage({
type: "pairing.decision",
sessionId: payload.sessionId,
approved,
payload
});
post({ ok: approved, trusted: false, reason: approved ? undefined : "denied" });
post({
ok: approved,
trusted: false,
reason: approved ? undefined : "denied",
extensionVersion,
versionWarning
});
}

function post(result: { ok: boolean; trusted?: boolean; reason?: "denied" | "connection_error" }): void {
function post(result: {
ok: boolean;
trusted?: boolean;
reason?: "denied" | "connection_error";
extensionVersion?: string;
versionWarning?: string | null;
}): void {
window.postMessage({ source: PAIR_RESULT_SOURCE, ...result }, "*");
}

Expand All @@ -80,22 +98,30 @@ function parsePayload(raw: unknown): PairPayload | null {
const strings = ["installId", "secret", "sessionId", "label"] as const;
for (const k of strings) if (typeof p[k] !== "string" || !p[k]) return null;
if (typeof p.pid !== "number" || typeof p.port !== "number") return null;
if (p.mcpVersion !== undefined && (typeof p.mcpVersion !== "string" || !p.mcpVersion)) {
return null;
}
return {
v: 1,
installId: p.installId as string,
secret: p.secret as string,
sessionId: p.sessionId as string,
label: p.label as string,
pid: p.pid,
port: p.port
port: p.port,
...(p.mcpVersion ? { mcpVersion: p.mcpVersion as string } : {})
};
}

/**
* Rendered into a closed shadow root so the page cannot restyle it into
* something misleading or read its contents.
*/
export function askUser(payload: PairPayload): Promise<boolean> {
export function askUser(
payload: PairPayload,
extensionVersion = chrome.runtime.getManifest().version,
versionWarning = comparePairingVersions(payload.mcpVersion, extensionVersion).warning
): Promise<boolean> {
return new Promise((resolve) => {
const host = document.createElement("div");
host.setAttribute("data-atwebpilot-pairing", "");
Expand All @@ -111,6 +137,7 @@ export function askUser(payload: PairPayload): Promise<boolean> {
max-width:26rem; box-shadow:0 10px 40px rgba(0,0,0,.3); }
h2 { font-size:15px; margin:0 0 8px; }
.meta { color:#666; font-size:13px; margin:0 0 14px; word-break:break-all; }
.warning { color:#b45309; font-size:13px; margin:0 0 14px; }
.row { display:flex; gap:8px; justify-content:flex-end; }
button { font:inherit; padding:6px 14px; border-radius:6px; cursor:pointer;
border:1px solid #ccc; background:#f6f6f6; }
Expand All @@ -121,8 +148,10 @@ export function askUser(payload: PairPayload): Promise<boolean> {
<h2>允许该会话控制此浏览器?</h2>
<p class="meta">
会话目录:${escapeHtml(payload.label)}<br>
pid ${payload.pid} · 端口 ${payload.port}
pid ${payload.pid} · 端口 ${payload.port}<br>
${payload.mcpVersion ? `MCP v${escapeHtml(payload.mcpVersion)}` : "MCP 版本未知"} · 扩展 v${escapeHtml(extensionVersion)}
</p>
${versionWarning ? `<p class="warning">${escapeHtml(versionWarning)}</p>` : ""}
<p class="meta">允许后,本机上的 AtWebPilot MCP 会话都可以直接连接,无需再次确认。可在扩展设置里撤销。</p>
<div class="row">
<button class="deny">拒绝</button>
Expand Down
104 changes: 64 additions & 40 deletions packages/extension/src/sidepanel/pages/coordinator-settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
setCdpRecorderEnabled
} from "@/background/recorder/cdp-permission";
import { listTrusted, revokeTrust } from "@/background/pairing-host";
import type { TrustRecord } from "@atwebpilot/shared/pairing";
import { comparePairingVersions, type TrustRecord } from "@atwebpilot/shared/pairing";
import type { PoolEntry } from "@/background/coordinator-pool";

const DEFAULT_WS_URL = "ws://localhost:8787/worker";
Expand Down Expand Up @@ -161,6 +161,12 @@ export function CoordinatorSettingsPage() {
if (!loaded) return <div className="p-4">载入中…</div>;

const liveStatus = formatConnectionStatus(enabled, wsUrl, connectionStatus, now);
const extensionVersion = chrome.runtime.getManifest().version;
const versionWarnings = sessions.flatMap((session) => {
if (session.source !== "mcp") return [];
const warning = comparePairingVersions(session.mcpVersion, extensionVersion).warning;
return warning ? [{ sessionId: session.sessionId, label: session.label, warning }] : [];
});

return (
<div className="p-4 space-y-4">
Expand Down Expand Up @@ -321,45 +327,63 @@ export function CoordinatorSettingsPage() {
还没有会话接入。在 Claude Code 里让 AI 操作网页时,会自动打开一个配对页请你确认。
</p>
) : (
<ul className="text-xs space-y-1">
{sessions.map((s) => (
<li key={s.sessionId} className="flex items-center gap-2">
<span className="flex-1 truncate" title={s.endpoint}>
{s.label}
{s.pid > 0 ? ` · pid ${s.pid}` : ""}
{s.port > 0 ? ` · :${s.port}` : ""}
</span>
<span className={s.status === "connected" ? "text-green-600" : "text-gray-500"}>
{s.status}
</span>
{s.status === "dormant" ? (
<button
className="underline"
onClick={() => {
void chrome.runtime.sendMessage({
type: "pairing.wake",
sessionId: s.sessionId
});
}}
>
重连
</button>
) : (
<button
className="underline"
onClick={() => {
void chrome.runtime.sendMessage({
type: "pairing.disconnect",
sessionId: s.sessionId
});
}}
>
断开
</button>
)}
</li>
))}
</ul>
<>
{versionWarnings.length > 0 && (
<div className="mb-2 space-y-1 text-xs text-amber-700" role="status">
{versionWarnings.map(({ sessionId, label, warning }) => (
<p key={sessionId} className="break-words">
{label}:{warning}
</p>
))}
</div>
)}
<ul className="space-y-2 text-xs">
{sessions.map((s) => (
<li key={s.sessionId} className="flex items-start gap-2">
<span className="min-w-0 flex-1" title={s.endpoint}>
<span className="block truncate">
{s.label}
{s.pid > 0 ? ` · pid ${s.pid}` : ""}
{s.port > 0 ? ` · :${s.port}` : ""}
</span>
{s.source === "mcp" && (
<span className="block text-gray-500">
{s.mcpVersion ? `MCP v${s.mcpVersion}` : "MCP 版本未知"}
</span>
)}
</span>
<span className={s.status === "connected" ? "text-green-600" : "text-gray-500"}>
{s.status}
</span>
{s.status === "dormant" ? (
<button
className="underline"
onClick={() => {
void chrome.runtime.sendMessage({
type: "pairing.wake",
sessionId: s.sessionId
});
}}
>
重连
</button>
) : (
<button
className="underline"
onClick={() => {
void chrome.runtime.sendMessage({
type: "pairing.disconnect",
sessionId: s.sessionId
});
}}
>
断开
</button>
)}
</li>
))}
</ul>
</>
)}
</section>

Expand Down
5 changes: 4 additions & 1 deletion packages/extension/tests/background/coordinator-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const payload = (over: Partial<PairPayload> = {}): PairPayload => ({
label: "~/code/atwebpilot2",
pid: 1,
port: 51234,
mcpVersion: "0.0.75",
...over
});

Expand Down Expand Up @@ -100,7 +101,8 @@ describe("CoordinatorPool", () => {
label: "~/code/atwebpilot2",
pid: 1,
port: 51234,
installId: "inst_abc"
installId: "inst_abc",
mcpVersion: "0.0.75"
});
});

Expand Down Expand Up @@ -177,6 +179,7 @@ describe("CoordinatorPool", () => {
it("the legacy single-URL config becomes exactly one entry", async () => {
const pool = makePool();
await pool.add({
source: "manual",
endpoint: "ws://127.0.0.1:8787/worker",
installId: "legacy",
sessionId: "legacy",
Expand Down
31 changes: 29 additions & 2 deletions packages/extension/tests/content/pairing-relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ const payload = {
sessionId: "sess_1",
label: "~/code/atwebpilot2",
pid: 1234,
port: 51234
port: 51234,
mcpVersion: "0.0.74"
};

let sent: unknown[];
Expand All @@ -37,6 +38,7 @@ beforeEach(() => {
reply = { decision: "trusted" };
globalThis.chrome = {
runtime: {
getManifest: () => ({ version: "0.0.75" }),
sendMessage: vi.fn(async (m: unknown) => {
sent.push(m);
return reply;
Expand Down Expand Up @@ -112,7 +114,32 @@ describe("pairing relay", () => {
pairMessage();
await settle();
expect(document.querySelector("[data-atwebpilot-pairing]")).toBeNull();
expect(results()[0]).toMatchObject({ ok: true, trusted: true });
expect(results()[0]).toMatchObject({
ok: true,
trusted: true,
extensionVersion: "0.0.75",
versionWarning: "MCP v0.0.74 低于扩展 v0.0.75,请更新 MCP"
});
});

it("accepts a legacy payload and reports that its MCP version is unknown", async () => {
const { mcpVersion: _mcpVersion, ...legacy } = payload;
pairMessage(legacy);
await settle();
expect(results()[0]).toMatchObject({
ok: true,
versionWarning: "MCP 版本未知,客户端可能过旧,请更新 MCP"
});
});

it("does not warn when MCP and extension versions match", async () => {
pairMessage({ ...payload, mcpVersion: "0.0.75" });
await settle();
expect(results()[0]).toMatchObject({
ok: true,
extensionVersion: "0.0.75",
versionWarning: null
});
});

it("renders an overlay when the worker asks", async () => {
Expand Down
Loading
Loading