/** * ChatGPT Web Bridge — drive an already-logged-in chatgpt.com conversation * from pi, so the "plan in the browser / execute in pi" round trip stops being * a manual copy-paste loop. * * Commands: * /brb status * /brb launch start the automation browser (needs login once) * /brb open [url] open a URL in the automation browser * /brb bind [url] bind this session to a conversation * /brb doctor deep diagnostic of the bound tab * /brb ask send text, wait, show the reply * /brb copy click the one-click copy button, show the plan * /brb plan ask + copy, then inject the plan into pi * /brb start start the automatic relay loop * /brb stop stop the loop * /brb config show/set configuration * * Tool: brb (status | doctor | ask | read | plan | copy | bind) */ import { execFileSync } from "node:child_process"; import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { ChatGptPage, COPY_MODES, conversationIdOf, sleep, type ChatGptPageStatus, type CopyMode, type InputMode } from "../../browser/chatgpt.ts"; import { classifyConversationRoute, conversationOwnershipKey, isDraftLocatorRoute } from "../../core/identity.ts"; import { describeCopyReceipt } from "../../browser/copySources.ts"; import { DEFAULT_DELIVERY_TIMEOUT_MS } from "../../core/delivery.ts"; import { BindingObserver, type DriftObservation } from "../../runtime/drift.ts"; import { RecoveryEpisode, type DeliveryState } from "../../core/recovery.ts"; import type { StallKind } from "../../core/cycle.ts"; import { appendShadowSample, buildShadowSample, shadowSamplingEnabled, } from "../../runtime/shadow.ts"; import { RecoveryEventBus, RunContext, buildGiveUpEvent, classifyFailure, } from "../../runtime/run.ts"; import { redactForDiagnostics } from "../../runtime/diagnostics.ts"; import { affinityState, type BindingAffinity } from "../../browser/identity-principal.ts"; import { BRB_COMMANDS, brbArgumentCompletions } from "./commands.ts"; import { createIdleBadge, formatIdleStatus, type IdleBadge } from "./idleStatus.ts"; import { advance, back, classifyOnboarding, emptyDraft, gitProjectFacts, onboardingAutoEnabled, onboardingFirstRunHint, withOnboardingHint, type SetupDraft } from "../../runtime/onboarding.ts"; import { explicitLegacyMigrationRead, readLegacyConversationUrlFromFile, resolveFreshSendTarget, validateStillCurrent, } from "../../core/freshness.ts"; import { advanceStall, checkBoundaries, DEFAULT_BOUNDARIES, describeVerdict, planKey, resetStall, type BoundaryConfig, type RoundRecord, type StallAction, } from "../../core/cycle.ts"; import { buildTurnPrompt, parsePlan, FULL_CONTRACT, summarizePlan, type Plan, } from "../../core/protocol.ts"; import { ACTIONS_REQUIRING_CONFIRMATION, canonicalCommand, confirmationStillValid, controlHelp, describePending, matchControlCommand, payloadPreview, specFor, type ControlCommand, type PendingAction, } from "./control.ts"; import { buildRepoDigest } from "../../runtime/digest.ts"; import { establish as driverEstablish, runRound as driverRunRound, settle as driverSettle, type DriverTransport, } from "../../core/driver.ts"; import { ConsentStore, canCreate, describeGates, isProvablyNotCreated, mintHumanIntent, } from "../../core/consent.ts"; import { OrphanStore } from "../../runtime/orphans.ts"; import { decideAutoCreate, normalizeAutoCreateOnUnbound, runAutoCreateTransaction, validateCandidateIdentity, validateCreatedTarget, validatePinnedTarget, reconcileProvisionalTarget, isDeterministicAutoCreateFailure, PROVISIONAL_READ_MESSAGE, type AutoCreateOnUnbound, type BoundOperation, } from "../../runtime/autobind.ts"; import { requestCapability, retryDecision } from "../../core/sideEffects.ts"; import { ConversationLock, SharedStore, defaultStatePath } from "../../runtime/store.ts"; import { BindingStore, describeBinding, isProvisionalBinding, isDurableConversationBinding, isReleasedBinding, ownershipConflictMessage, statePathFor, type Binding, } from "../../core/binding.ts"; import { advanceSession, describeSession, planRecovery, type Session, } from "../../core/handshake.ts"; import { browserStatus, defaultProfileDir, ensureBrowser, killBrowser, realUserDataDir, type BrowserChoice, type ProfileMode, } from "../../browser/launcher.ts"; import { discoverBrowser, type BrowserKind } from "../../browser/discovery.ts"; import { probePort, writePortMarker, browserStringKind } from "../../browser/portPolicy.ts"; import { probeProxy } from "../../browser/proxyProbe.ts"; import { effectiveProfileDir, resolveSelection } from "../../browser/discovery.ts"; import { closeTarget, listTargets, type CdpTarget } from "../../browser/cdp.ts"; /** Compact, diagnostic-only rendering for a legacy conversation URL. */ export function legacyConversationDiagnostic(url: string): string { const id = conversationIdOf(url); return id ? `/c/${id.slice(0, 8)}-…` : url; } /** A manual binding may only name a durable ChatGPT conversation route. */ export function validateChatgptConversationUrl(value: string): { ok: true; url: string } | { ok: false; reason: string } { const url = value.trim(); try { const parsed = new URL(url); const isConversation = parsed.protocol === "https:" && parsed.hostname === "chatgpt.com" && (/^\/c\/[^/?#]+$/.test(parsed.pathname) || /^\/g\/g-p-[^/]+\/c\/[^/?#]+$/.test(parsed.pathname)); if (isConversation) return { ok: true, url }; } catch { // Fall through to the selector-mismatch vocabulary below. } return { ok: false, reason: "TARGET_SELECTOR_MISMATCH:/brb bind 只接受 https://chatgpt.com/c/ 或 " + "https://chatgpt.com/g/g-p-…/c/ 会话 URL;拒绝猜测或写入非会话目标。", }; } /** Slash help is derived from the command table; input controls are intentionally separate. */ export function brbSlashUsageText(): string { const names = [...BRB_COMMANDS].sort((a, b) => a.order - b.order).map((command) => command.name); return [ `用法:/brb [${names.join("|")}](kill ≠ unbind)`, "控制命令:状态/会话/停轮/回执/取消/确认/交接/一轮/执行/新会话(及 English aliases)走 input hook:brb <动作>。", ].join("\n"); } /** * The binding truth shown by /brb status. * * The first line is always the registry-backed state. A legacy config value can * only appear as the second, explicitly downgraded diagnostic line; it is never * allowed to become the current thread's binding again. */ export function buildBindingStatusLines( binding: Binding | null, legacyUrl: string | null, legacyOwner: Pick | null, sessionId: string, autoCreateOnUnbound: AutoCreateOnUnbound = "ask", ): string[] { const lines: string[] = []; if (binding) { lines.push( isProvisionalBinding(binding) ? `本线程绑定: PROVISIONAL(${binding.source === "manual" || binding.origin === "explicit" ? "手动绑定" : "自动创建"};等待首次消息建立会话 ID)` : `本线程绑定: BOUND(active) ${describeBinding(binding)} source=${binding.source ?? "legacy/unknown"}`, ); const sameConversation = legacyUrl !== null && ((Boolean(binding.conversationId) && conversationIdOf(legacyUrl) === binding.conversationId) || legacyUrl === binding.conversationUrl); if (legacyUrl && !sameConversation) { const owner = legacyOwner && legacyOwner.localSessionId !== sessionId ? `(属于线程 ${legacyOwner.localSessionId};仅供诊断,不是本线程目标)` : "(未注册为本线程绑定;仅供诊断)"; lines.push(`遗留 config: ${legacyConversationDiagnostic(legacyUrl)}${owner}`); } return lines; } lines.push("本线程绑定: UNBOUND(未注册)"); if (legacyUrl) { const owner = legacyOwner && legacyOwner.localSessionId !== sessionId ? `(属于线程 ${legacyOwner.localSessionId};仅供诊断,不是本线程目标)` : "(未注册为本线程绑定;仅供诊断)"; lines.push(`遗留 config: ${legacyConversationDiagnostic(legacyUrl)}${owner}`); } lines.push( autoCreateOnUnbound === "off" ? "下一步: 自动创建策略为 off;使用 /brb bind 显式绑定" : autoCreateOnUnbound === "on" ? "下一步: 继续执行需要会话的 BRB 操作将按自动创建策略尝试创建;或使用 /brb bind 显式绑定" : "下一步: 继续执行需要会话的 BRB 操作可触发自动创建确认;或使用 /brb bind 显式绑定", ); return lines; } /* ------------------------------------------------------------------ */ /* Configuration */ /* ------------------------------------------------------------------ */ interface BridgeConfig { port: number; browser: BrowserChoice; /** * Sticky browser resolution (Chrome/Edge only). Written by the launch path * after a successful resolution; deliberately not settable through * `/brb config`, because it records an observed fact rather than a preference. */ resolvedBrowser?: BrowserKind; resolvedExecutable?: string; /** * When the user last chose a browser policy explicitly. The sticky resolution * below is only honoured when it was observed AFTER this moment. * * Why a timestamp and not a deletion: `saveConfig` merges with the file on disk * and drops `undefined` values -- deliberately, so a stale snapshot cannot erase * fields it never knew about. There is no field-deletion protocol, and inventing * one here would be a decision of its own. An invalidation mark achieves the same * user-visible result ("the old resolution no longer applies") using only writes. */ browserPolicyEpoch?: string; /** When the sticky resolution was last observed; compared against the epoch. */ resolvedBrowserAt?: string; browserExecutable?: string; /** "dedicated" = bridge-owned profile (log in once). "existing" = reuse your real browser profile. */ profileMode: ProfileMode; /** dedicated mode: profile directory owned by the bridge. */ profileDir: string; /** existing mode: which profile inside the real User Data dir, e.g. "Default" or "Profile 3". */ profileDirectory?: string; conversationUrl?: string; copyMode: CopyMode; /** * Optional regex forcing a specific copy source by button label, e.g. * "^复制回复" for the whole reply or "^复制$" for a code block. Takes * precedence over copyMode. Use /brb sources to see what is available. */ copyLabel?: string; /** * How many conversations the bridge may create on its own per rolling day. * * Every thread that runs `start` once without being bound leaves a conversation * behind forever. That is fine once and corrosive at scale: the account fills * with one-line conversations, and cleaning them up is tedious by hand while * doing it in bulk is what ChatGPT's abuse protection reads as suspicious. * The budget exists so accumulation is loud while it is still small. */ maxNewConversationsPerDay: number; /** What an unbound thread should do when a bound operation starts. */ autoCreateOnUnbound: AutoCreateOnUnbound; /** * Proxy for the automation browser, e.g. `http://127.0.0.1:7890`. * * Set this when the automation profile has no route to chatgpt.com. The system * proxy being off is common and does not mean the machine has no proxy -- it * means it is not in system-wide mode. */ proxy?: string; /** * "trusted" (default) puts text in with a real Delete key + CDP * Input.insertText, so every DOM event is isTrusted=true. "paste" uses a * synthetic ClipboardEvent (isTrusted=false) and is slightly faster. */ inputMode?: InputMode; maxIterations: number; /** Token budget for one ROUND prompt (delegated to buildTurnPrompt). */ promptTokens: number; /** Token budget for the repo digest inside each ROUND. */ digestTokens: number; waitTimeoutMs: number; submitDelayMs: number; donePattern: string; resultTemplate: string; includeToolNames: boolean; /** Onboarding version (verdict #17). Absent = pre-onboarding config (legacy migration). */ onboardingVersion?: 1; /** Onboarding state, orthogonal to version (verdict #18). */ onboardingState?: "new" | "in_progress" | "completed" | "dismissed"; /** Git push policy recorded as a PREFERENCE; this version declares no auto-push consumer. */ pushPolicy?: "manual" | "auto" | "never"; /** * T0 shadow sampling (shadow.ts): append one redacted JSONL record per settled * loop round for a future offline advisory evaluation. Observation-only; off by * default; WEBGPT_SHADOW_SAMPLES=1 enables it for one process without persisting. */ shadowSampling?: boolean; } const DEFAULT_CONFIG: BridgeConfig = { port: 9222, browser: "auto", profileMode: "dedicated", browserExecutable: undefined, resolvedBrowser: undefined, resolvedExecutable: undefined, browserPolicyEpoch: undefined, resolvedBrowserAt: undefined, profileDir: defaultProfileDir("chrome"), profileDirectory: "Default", conversationUrl: undefined, copyMode: "auto", copyLabel: undefined, maxNewConversationsPerDay: 3, autoCreateOnUnbound: "ask", proxy: undefined, inputMode: "trusted", maxIterations: 8, promptTokens: 6000, digestTokens: 2000, waitTimeoutMs: 15 * 60 * 1000, submitDelayMs: 1500, donePattern: "^\\s*(DONE|ALL DONE|完成|全部完成|无需继续|结束)\\s*$", resultTemplate: "[PI 执行回执 · 第 {{iteration}} 轮]\n" + "使用工具: {{tools}}\n" + "执行结果:\n{{result}}", includeToolNames: true, pushPolicy: "manual", }; // Resolved per call through paths.ts: runtime reads are hermetic under an isolated state // root (missing copy means defaults), writes under isolation land in that copy, and the // explicit legacy migration path is the only opt-in read of the real config. A module-level // constant would freeze the isolation decision before a harness has necessarily set it. import { configWritePath, legacyMigrationSourcePath, realConfigPath, runtimeConfigReadPath, stateRoot } from "../../runtime/paths.ts"; /** * Fields whose value came from the environment rather than the config file. * * `loadConfig` returns the *effective* configuration, with the environment veto applied, * because that is what the rest of the code should act on. But `saveConfig` writes that * same object back, so the veto was being persisted: running a tool once with the guard on * -- which is every tool, by default -- silently wrote `maxNewConversationsPerDay: 0` into * the user's file, and the guard outlived the run that was supposed to be protected. * * A safety override that is meant to be scoped to one process must not become a setting. */ const envOverridden = new Set(); /** Environment variables that deliberately take precedence over file configuration. */ const ENV_OVERRIDE_VARIABLES: Partial> = { maxNewConversationsPerDay: "PI_WEBGPT_MAX_NEW_CONVERSATIONS", }; /** * Config problems found while reading, kept so they can be said out loud. * * A silent fallback is indistinguishable from "the file says auto", and that is how a * legacy `copyMode: "latest"` stayed in place for months while every round failed at the * gate (BUG-015). The value is still normalized -- refusing to run over a stale enum would * be worse -- but the process now remembers WHY, and the entries with a UI say so. */ const configWarnings: string[] = []; const configWarningsAnnounced = new Set(); function noteConfigWarning(warning: string): void { if (!configWarnings.includes(warning)) configWarnings.push(warning); } /** * One bounded rendering of an offending value. * * The message names the field, the value and the fallback; it must not print the config, * because a warning about one field is not a licence to log the user's settings. */ function describeConfigValue(value: unknown): string { if (typeof value === "string") return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value); if (value === null) return "null"; if (typeof value === "number" || typeof value === "boolean") return JSON.stringify(value); return typeof value; } /** * Normalize `copyMode` to a legal value (BUG-015). * * The union `CopyMode` has no runtime effect: `"latest"` reached `pickCopySource`, fell * through every mode branch, and was executed as `auto` -- which prefers a code-block copy * source, whose payload carries no fenced block. The gate then reported `E_BLOCKS: remote` * on every round. The value is corrected in memory (never left illegal for a caller to trip * over) and the fallback is stated. */ function normalizeCopyMode(value: unknown): { mode: CopyMode; warning: string | null } { if (typeof value === "string" && (COPY_MODES as readonly string[]).includes(value)) { return { mode: value as CopyMode, warning: null }; } return { mode: "auto", warning: `copyMode 的取值 ${describeConfigValue(value)} 不合法(合法值 ${COPY_MODES.join(" | ")}):` + `已按 fallback=auto 生效,内存中的值已是 auto。`, }; } function loadConfig(): BridgeConfig { let loaded: BridgeConfig; try { const raw = JSON.parse(readFileSync(runtimeConfigReadPath(), "utf8")) as Partial; loaded = { ...DEFAULT_CONFIG, ...raw }; } catch { loaded = { ...DEFAULT_CONFIG }; } const copyMode = normalizeCopyMode((loaded as { copyMode?: unknown }).copyMode); if (copyMode.warning) noteConfigWarning(copyMode.warning); loaded.copyMode = copyMode.mode; const autoCreate = normalizeAutoCreateOnUnbound((loaded as { autoCreateOnUnbound?: unknown }).autoCreateOnUnbound); if (autoCreate.warning) noteConfigWarning(autoCreate.warning); loaded.autoCreateOnUnbound = autoCreate.value; // An environment veto, so a harness cannot create conversations by forgetting. // Creating one leaves a permanent line in someone's account, and cleaning those // up in bulk is what ChatGPT's abuse protection reacts to, so "remember not to" // is the wrong kind of guarantee: tools that must not create anything say so // once, and the bridge cannot be talked out of it afterwards. const veto = process.env.PI_WEBGPT_MAX_NEW_CONVERSATIONS; if (veto !== undefined && veto.trim() !== "") { const parsed = Number(veto); loaded.maxNewConversationsPerDay = Number.isFinite(parsed) ? parsed : 0; envOverridden.add("maxNewConversationsPerDay"); } return loaded; } const CONFIG_PATCH_KEYS = new Set([ "port", "browser", "browserExecutable", "profileMode", "profileDir", "profileDirectory", "conversationUrl", "copyMode", "copyLabel", "maxNewConversationsPerDay", "autoCreateOnUnbound", "proxy", "inputMode", "maxIterations", "promptTokens", "digestTokens", "waitTimeoutMs", "submitDelayMs", "donePattern", "resultTemplate", "includeToolNames", "onboardingVersion", "onboardingState", "pushPolicy", "shadowSampling", ]); function validConfigPatchValue(key: keyof BridgeConfig, value: unknown): boolean { switch (key) { case "port": case "maxNewConversationsPerDay": case "maxIterations": case "promptTokens": case "digestTokens": case "waitTimeoutMs": case "submitDelayMs": return typeof value === "number" && Number.isFinite(value); case "browser": return value === "auto" || value === "chrome" || value === "edge"; case "profileMode": return value === "dedicated" || value === "existing"; case "copyMode": return typeof value === "string" && (COPY_MODES as readonly string[]).includes(value); case "autoCreateOnUnbound": return value === "ask" || value === "on" || value === "off"; case "inputMode": return value === "trusted" || value === "paste"; case "onboardingVersion": return value === 1; case "onboardingState": return value === "new" || value === "in_progress" || value === "completed" || value === "dismissed"; case "pushPolicy": return value === "manual" || value === "auto" || value === "never"; case "includeToolNames": case "shadowSampling": return typeof value === "boolean"; default: return typeof value === "string"; } } interface ConfigPatchResult { applied: Partial; ignored: Array<{ key: keyof BridgeConfig; variable: string }>; unknown: string[]; invalid: string[]; } function assessConfigPatch(value: unknown): ConfigPatchResult { if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("/brb config 的 JSON patch 必须是对象。"); const result: ConfigPatchResult = { applied: {}, ignored: [], unknown: [], invalid: [] }; for (const [rawKey, patchValue] of Object.entries(value)) { if (!CONFIG_PATCH_KEYS.has(rawKey as keyof BridgeConfig)) { result.unknown.push(rawKey); continue; } const key = rawKey as keyof BridgeConfig; if (!validConfigPatchValue(key, patchValue)) { result.invalid.push(key); continue; } const variable = ENV_OVERRIDE_VARIABLES[key]; if (variable && envOverridden.has(key)) { result.ignored.push({ key, variable }); continue; } (result.applied as Record)[key] = patchValue; } return result; } function configPatchResultText(result: ConfigPatchResult): string { const appliedKeys = Object.keys(result.applied); const lines = appliedKeys.length ? [`配置已更新:${JSON.stringify(result.applied)}`, `已应用:${appliedKeys.join("、")}。`] : ["配置未更新:没有可应用的字段。"]; if (result.ignored.length) lines.push(`已忽略(由环境变量管理):${result.ignored.map(({ key, variable }) => `${key}(${variable})`).join("、")}。`); if (result.unknown.length) lines.push(`未知字段:${result.unknown.join("、")}。`); if (result.invalid.length) lines.push(`无效取值:${result.invalid.join("、")}。`); return lines.join("\n"); } function configEchoText(config: BridgeConfig): string { const envLines = [...envOverridden] .map((key) => { const variable = ENV_OVERRIDE_VARIABLES[key]; return variable ? ` ${key}: ${JSON.stringify(config[key])}(来自环境变量 ${variable};文件中的该键未生效)` : null; }) .filter((line): line is string => line !== null); return `${runtimeConfigReadPath()}\n${JSON.stringify(config, null, 2)}` + (envLines.length ? `\n环境变量管理的值:\n${envLines.join("\n")}` : ""); } /** * Write the configuration back, merging instead of overwriting. * * One shared file, several long-lived processes, mixed code versions: that is the normal * case here, and it is what produced BUG-015. A session that loaded the file hours ago still * writes whenever the user runs a command, and serializing its frozen snapshot erases every * field another process added in the meantime -- the forensic signature was a write shape * missing exactly the fields a newer writer had introduced. * * So the disk is read again at write time and becomes the base; this call's own values are * applied on top of it. `undefined` counts as "this call does not carry that field": every * optional field of DEFAULT_CONFIG reads as undefined until someone sets it, so treating it * as a value would make "I never touched it" indistinguishable from "erase it" -- the clobber * itself. There is deliberately no field-deletion protocol here; adding one is a decision, * not a side effect of fixing this bug. * * The environment veto (BUG-006) keeps its meaning on top of the merge: the field keeps * whatever the file says, and is not created if the file does not have it. * * What this is NOT: a transaction. Two processes that each read the file and then write * overlapping fields can still lose one write (last writer wins), and this change does not * pretend otherwise. It closes the observed failure -- a stale *snapshot* erasing fields it * never knew about -- without inventing a revision/CAS protocol, which is a decision of its * own and not a side effect of a bug fix. */ function saveConfig(config: BridgeConfig): void { // Corrupt or absent: the existing posture is to treat it as empty and rebuild from what // this call provides. No new recovery semantics are invented for this bug. let onDisk: Record = {}; try { const parsed: unknown = JSON.parse(readFileSync(runtimeConfigReadPath(), "utf8")); if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { onDisk = parsed as Record; } } catch { onDisk = {}; } const supplied = Object.entries(config as unknown as Record).filter( ([, value]) => value !== undefined, ); const toWrite: Record = { ...onDisk, ...Object.fromEntries(supplied) }; for (const key of envOverridden) { if (key in onDisk) toWrite[key] = onDisk[key]; else delete toWrite[key]; } const target = configWritePath(); mkdirSync(dirname(target), { recursive: true }); writeConfigAtomically(target, `${JSON.stringify(toWrite, null, 2)}\n`); } /** * Temp file plus rename, like every other durable write in this repository. * * A reader that catches a half-written file sees invalid JSON, and invalid JSON is treated as * "no config" -- so a torn write here is not a cosmetic problem, it silently resets settings * for whoever reads next. */ function writeConfigAtomically(target: string, text: string): void { const temp = `${target}.tmp-${process.pid}`; try { writeFileSync(temp, text, "utf8"); renameSync(temp, target); } catch (error) { try { if (existsSync(temp)) unlinkSync(temp); } catch { // Best effort: the write failure below is the one worth reporting. } throw error; } } /** * Say config problems once per process, at the entries that have a UI. * * `console` is not a channel this extension has, and a transient notify can be lost under a * command's own output; the persistent half of the message lives in `statusText`. */ function surfaceConfigWarnings(ctx: ExtensionContext): void { for (const warning of configWarnings) { if (configWarningsAnnounced.has(warning)) continue; configWarningsAnnounced.add(warning); ctx.ui.notify?.(`[brb 配置] ${warning}`, "warning"); } } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function textOf(message: any): string { if (!message) return ""; const content = message.content; if (typeof content === "string") return content; if (Array.isArray(content)) { return content .filter((block: any) => block?.type === "text") .map((block: any) => block.text ?? "") .join("\n"); } return ""; } interface Round { lastAssistant: string; toolNames: string[]; } /** Collect what pi did since `sinceLeafId`. */ function collectRound(ctx: ExtensionContext, sinceLeafId: string | null): Round { const branch = ctx.sessionManager.getBranch() as any[]; let startIndex = 0; if (sinceLeafId) { const index = branch.findIndex((entry) => entry?.id === sinceLeafId); if (index >= 0) startIndex = index + 1; } const toolNames: string[] = []; let lastAssistant = ""; for (const entry of branch.slice(startIndex)) { if (entry?.type !== "message") continue; const message = entry.message; if (!message) continue; if (message.role === "assistant") { const text = textOf(message).trim(); if (text) lastAssistant = text; } else if (message.role === "toolResult" && message.toolName) { toolNames.push(message.toolName); } } return { lastAssistant, toolNames: [...new Set(toolNames)] }; } function renderTemplate(template: string, vars: Record): string { return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => vars[key] ?? ""); } function clip(text: string, max = 24_000): string { if (text.length <= max) return text; return `${text.slice(0, max)}\n\n…[已截断 ${text.length - max} 字]`; } function isDone(reply: string, plan: string, pattern: string): boolean { if (!pattern) return false; let regex: RegExp; try { regex = new RegExp(pattern, "im"); } catch { return false; } if (plan && plan.trim().length <= 200 && regex.test(plan)) return true; return reply.trim().length <= 800 && regex.test(reply); } export interface AttachSelectorRequest { url?: string; targetId?: string; allowCurrentTab?: boolean; } export interface AttachSelectorConfig { conversationUrl?: string; /** Optional only for pure resolution/tests; runtime config has no target pin. */ targetId?: string; /** Optional target snapshot for pure tests; attachPage passes its own fresh snapshot. */ targets?: readonly AttachSelectorTarget[]; } export interface AttachSelectorTarget { id: string; url: string; } export type AttachSelector = | { ok: true; kind: "targetId"; targetId: string; url?: string } | { ok: true; kind: "url"; url: string } | { ok: true; kind: "currentTab"; allowCurrentTab: boolean } | { ok: false; code: "TARGET_NOT_FOUND" | "TARGET_SELECTOR_MISMATCH" | "TARGET_REQUIRED"; reason: string }; /** * Compare the optional URL corroboration for a pinned target. * * Frozen rule: compute identity on both sides with conversationIdOf(). Two identities * must agree; a request URL without identity (bare `/` or draft `/c/WEB:...`) cannot * contradict the authoritative targetId; but an identified request URL cannot be * accepted when the pinned target has no identified URL to corroborate it. Attachment * remains by targetId in every successful branch -- the URL is never a fallback. */ function corroboratesPinnedTarget(pinnedUrl: string | undefined, requestUrl: string): boolean { const pinnedId = conversationIdOf(pinnedUrl ?? ""); const requestId = conversationIdOf(requestUrl); if (requestId === null) return true; return pinnedId !== null && pinnedId === requestId; } /** * Resolve an attach selector without touching CDP. * * `targets` is optional for unit callers; attachPage always supplies the current page * snapshot, which makes a targetId's existence and any targetId+URL pairing provable. */ export function resolveAttachSelector( request: AttachSelectorRequest = {}, config: AttachSelectorConfig = {}, targets?: readonly AttachSelectorTarget[], ): AttachSelector { const targetId = request.targetId?.trim(); const requestUrl = request.url?.trim(); const knownTargets = targets ?? config.targets; if (targetId) { const observed = knownTargets?.find((target) => target.id === targetId); if (knownTargets && !observed) { return { ok: false, code: "TARGET_NOT_FOUND", reason: `TARGET_NOT_FOUND:显式 targetId=${targetId} 不存在;拒绝回退到 URL、配置或当前标签页。`, }; } if (requestUrl) { const knownUrl = observed?.url ?? (config.targetId === targetId ? config.conversationUrl : undefined); if (!corroboratesPinnedTarget(knownUrl, requestUrl)) { return { ok: false, code: "TARGET_SELECTOR_MISMATCH", reason: `TARGET_SELECTOR_MISMATCH:targetId=${targetId} 与 url=${requestUrl} ` + "没有证明是同一目标;拒绝猜测,不尝试 URL 回退。", }; } } return { ok: true, kind: "targetId", targetId, ...(requestUrl ? { url: requestUrl } : {}) }; } if (requestUrl) return { ok: true, kind: "url", url: requestUrl }; const configuredUrl = config.conversationUrl?.trim(); if (configuredUrl && conversationIdOf(configuredUrl) !== null) { return { ok: true, kind: "url", url: configuredUrl }; } if (request.allowCurrentTab === true) return { ok: true, kind: "currentTab", allowCurrentTab: request.allowCurrentTab === true }; return { ok: false, code: "TARGET_REQUIRED", reason: "执行路径必须显式指定目标(targetId 或会话 URL),不允许猜「当前标签页」。", }; } /* ------------------------------------------------------------------ */ /* Extension */ /* ------------------------------------------------------------------ */ export default function (pi: ExtensionAPI) { let config = loadConfig(); // Drift observation lives for the whole process (it IS the remembered baseline); // the VALUES it returns are always fresh reads. const bindingObserver = new BindingObserver(); // A3 Canary seam: recovery events are CONSUMED here; nothing here can start/resume a run (run.ts). const recoveryEvents = new RecoveryEventBus(); const loop = { active: false, busy: false, iterations: 0, lastLeafId: null as string | null, lastPlanHash: "", lastError: "" as string, /** When the round currently in flight started; used by the boundary rule. */ roundStartedAt: Date.now(), // Cycle policy state. index.ts only *collects facts* here; every decision // is made by cycle.ts, so the rules stay in one place and are testable // without a browser. rounds: [] as RoundRecord[], stall: resetStall(), // A3: the explicit-run authorization envelope for the auto-relay loop. run: null as RunContext | null, /** The plan the round currently in flight is executing. */ currentPlan: null as Plan | null, startedAt: Date.now(), // Handshake session. Until it is established, no task may be sent. session: null as Session | null, /** Repair attempts used for the current round. */ repairs: 0, objective: "", rootRequest: null as string | null, /** * The conversation this loop drives, resolved once at start and then used * explicitly. Never re-derived from "the current tab". */ conversationUrl: null as string | null, /** * qid of the request currently in effect. * * Tracked separately from the session because a payload can arrive before a * session exists. Reading it from `loop.session` alone made the confirmation * compute an empty qid at that point, so it could never match the pending * that had just been created with the payload's qid -- the confirmation * would have been permanently unconfirmable. */ qid: "", /** A high-risk action awaiting a `brb confirm`, bound to plan/round/HEAD. */ pending: null as PendingAction | null, }; const boundaries: BoundaryConfig = { ...DEFAULT_BOUNDARIES, maxRounds: config.maxIterations }; /** * The shared state file and its writer lock. * * "Current progress" is defined by this file plus the conversation FRAME * chain -- never by this extension's private memory, because the external * cycle driver reads the same session. Only one of the two may drive at a * time; read-only diagnostics (brb 状态, ask-file --copy-only) do not take * the lock. */ /** * Bindings, not a global conversation. * * `store` used to be one file for everything, and routing read a single * `config.conversationUrl`. That is what let one pi thread push its handoff * into another thread's conversation: whoever bound last owned the binding for * everyone. Runtime state is now keyed by bindingId, so a thread keeps its own * rounds, checkpoint and lock even as pi sessions come and go. */ const bindingStore = new BindingStore({}); /** * Authorisations to create a conversation, and nothing else. * * Only a control command a person typed can write here. Recovery, retries, * probes, tests and concurrent starts all want a conversation and none of them * is a person, so none of them can manufacture the two gates that matter. */ const consentStore = new ConsentStore({}); const orphanStore = new OrphanStore({}); /** Single-flight map for auto onboarding. The promise covers CREATE only; no second tab * can be opened while the first attempt is unresolved. */ const autoCreateFlights = new Map>(); /** Per-session answer for the current UNBOUND phase; never persisted. */ const autoCreatePhaseDecisions = new Map(); /** Observable write/send/create operations; explicit unbind fails closed while one is active. */ const bindingOperations = new Map(); let loopSessionId = ""; /** Operations that have no explicit URL and may legitimately need a binding. */ const autoBoundOperations = new Set(["ask", "plan", "read", "copy", "sources", "start"]); /** * The candidate currently being verified, if any. * * Needed because there is a second writer: `syncBindingUrl` backfills the conversation * id from the live page, and it runs in relay's finally whether or not the handshake * succeeded. Left alone it committed the candidate itself, so a failed rotation moved * `current` anyway -- exactly what the transaction exists to prevent. Found by * verifying the failure path on a real machine instead of only the happy one. */ let pendingCandidate: RotationCandidate | null = null; /** String.fromCharCode rather than an escape: it survives any editor or heredoc. */ const NL = String.fromCharCode(10); /** The fuse window. Rolling, so "per day" cannot be gamed by waiting for midnight. */ const AUTO_CREATE_WINDOW_MS = 24 * 60 * 60 * 1000; function autoCreateUsage(): number { return bindingStore.autoCreatedSince(AUTO_CREATE_WINDOW_MS).length + orphanStore.countSince(AUTO_CREATE_WINDOW_MS); } /** * Round B3: which browser this machine would actually use. * * Discovery is pure and reads the sticky resolution, so `/brb launch` and the * operation preflight agree on one answer instead of each guessing from the * configured value. */ function browserStickyValid(): boolean { const chosen = config.browserPolicyEpoch; if (!chosen) return true; const observed = config.resolvedBrowserAt; if (!observed) return false; // ISO-8601 timestamps compare correctly as strings. return observed > chosen; } function browserDiscovery() { const sticky = browserStickyValid(); return discoverBrowser({ browser: config.browser, browserExecutable: config.browserExecutable, resolvedBrowser: sticky ? config.resolvedBrowser : undefined, resolvedExecutable: sticky ? config.resolvedExecutable : undefined, }); } /** * Advice for the other supported browser, but only when there is evidence it * would work: recommending an alternative that is itself blocked would teach * users to distrust the diagnosis. */ function browserAlternativeAdvice(current: string): string { if (current === "auto") return ""; const other: "chrome" | "edge" = current === "chrome" ? "edge" : "chrome"; const found = discoverBrowser({ browser: other }); if (found.capability !== "USABLE" || !found.candidate) return ""; const label = other === "chrome" ? "Google Chrome" : "Microsoft Edge"; return ( ` 已检测到可用的 ${label}。如需切换:/brb browser ${other}` + ` —— 每个浏览器使用 BRB 自己的配置目录(相互独立),切换后可能需要在该配置目录里手动登录 ChatGPT。` ); } /** Fail-closed wording for a machine with no usable Chrome or Edge. */ function browserCapabilityMessage(found: ReturnType): string { const notes = found.notes.length ? ` ${found.notes.join(" ")}` : ""; const advice = browserAlternativeAdvice(config.browser); switch (found.capability) { case "NOT_FOUND": return `未找到可用的自动化浏览器:BRB 只支持 Google Chrome 与 Microsoft Edge,系统默认浏览器不会自动替代。${notes}${advice}`.trim(); case "UNSUPPORTED": return `当前配置指定的浏览器不受支持:${found.rejected?.executablePath ?? "未知路径"}。${notes}`.trim(); case "BLOCKED": return `浏览器已安装,但当前无法启用自动化:请检查浏览器策略、配置目录占用或调试端口状态。${notes}${advice}`.trim(); default: return `浏览器环境不可用。${notes}`.trim(); } } /** The one launch shape shared by /brb launch and operation preflight only. */ function configuredBrowserOptions() { const found = browserDiscovery(); const kind = found.candidate?.kind ?? (config.browser === "auto" ? "chrome" : config.browser); return { port: config.port, browser: kind, // An explicitly configured path stays authoritative even when discovery // rejected it, so a wrong path is reported instead of silently replaced. browserExecutable: config.browserExecutable ?? found.candidate?.executablePath, profileMode: config.profileMode, // Chrome keeps .pi-chrome-profile where it always was; Edge gets its own. profileDir: effectiveProfileDir(config.profileDir, kind), profileDirectory: config.profileDirectory, proxy: config.proxy, url: "https://chatgpt.com/", }; } /** User-facing mapping only; all classification stays in chatgpt.ts. */ function pageStateGuidance(status: ChatGptPageStatus): string { switch (status) { case "UNREACHABLE": return "无法访问 ChatGPT 页面。请先检查网络或代理连接,然后重试。可以运行 /brb doctor 查看当前环境状态。" + (config.proxy ? ` 已配置代理:${config.proxy}。` : ""); case "AUTH_REQUIRED": return ( "ChatGPT 尚未登录。请在 BRB 使用的浏览器窗口中完成登录,然后重试。" + " 该窗口使用 BRB 自己的配置目录,与你的日常浏览器登录状态相互独立。" + " 登录完成后请回到 Pi 重新执行刚才的操作——BRB 不会在你登录后自动补发之前的任务。" ); case "AUTHENTICATED_NOT_READY": return "ChatGPT 已登录,但页面还没有准备好。请等待页面加载完成后重试;如果持续出现,可运行 /brb doctor 查看状态。"; case "UNKNOWN": return "BRB 无法确认当前 ChatGPT 页面状态,本次操作已停止。请运行 /brb doctor 查看详细状态。"; case "READY": return "ChatGPT 页面已准备好。"; } } async function launchPageStateGuidance(): Promise { let page: ChatGptPage | null = null; try { page = await ChatGptPage.attach({ port: config.port }); return pageStateGuidance((await page.observePageState()).status); } catch { return pageStateGuidance("UNKNOWN"); } finally { if (page) await page.close().catch(() => undefined); } } /** * Which browser the current policy actually resolves to. * * Used for anything that must name the browser-kind-dependent path (the two * browsers have separate profile directories), so display and launch never * disagree about which one is in play (BUG-050). */ function currentBrowserKind(): "chrome" | "edge" { const resolved = browserStickyValid() ? config.resolvedBrowser : undefined; const kind = resolved ?? (config.browser === "auto" ? undefined : config.browser); return kind === "edge" ? "edge" : "chrome"; } /** * Record what actually happened: the sticky resolution and the port-ownership * marker. Both are observations, written only after a launch or a successful * adoption of the instance already listening on the port. * * Extracted so `/brb launch` and the operation preflight cannot drift: the * launch command used to skip this entirely, which made an immediately * following `doctor` call its own browser FOREIGN_CDP and stop operating * (BUG-048). */ function persistBrowserObservation( found: ReturnType, browserOptions: { profileDir?: string; port: number }, ): void { const selection = resolveSelection( { resolvedBrowser: config.resolvedBrowser, resolvedExecutable: config.resolvedExecutable }, found, ); const observedAt = new Date().toISOString(); if ( selection.resolvedBrowser !== config.resolvedBrowser || selection.resolvedExecutable !== config.resolvedExecutable || config.resolvedBrowserAt === undefined ) { config.resolvedBrowser = selection.resolvedBrowser; config.resolvedExecutable = selection.resolvedExecutable; config.resolvedBrowserAt = observedAt; saveConfig(config); } if (found.candidate) { writePortMarker(stateRoot(), { port: browserOptions.port, kind: found.candidate.kind, executablePath: found.candidate.executablePath, profileDir: browserOptions.profileDir, }); } } async function ensureOperationBrowser(options: { openChatGptHome?: boolean } = {}) { const found = browserDiscovery(); if (found.capability !== "USABLE") throw new Error(browserCapabilityMessage(found)); // A proxy that is already known to be unreachable would only produce an error // page that reads like a login problem. Stop before the browser opens, and say // which layer is broken. Read-only: no browser is started by this probe. const proxied = await probeProxy(config.proxy); if (proxied.state === "UNREACHABLE") { throw new Error( `代理 ${proxied.target ?? ""} 不可连接,自动化浏览器无法访问 ChatGPT,本次操作未执行。请先启动代理再重试。`, ); } const browserOptions = configuredBrowserOptions(); const result = await ensureBrowser({ ...browserOptions, // Auto-create verifies an already-open ChatGPT page before it is allowed to // open a new one. Do not let its launcher preflight create that page first. url: options.openChatGptHome === false ? undefined : browserOptions.url, }); // Record what actually happened: the sticky choice and the ownership marker. // Both are observations, written only after a launch or a successful attach. persistBrowserObservation(found, browserOptions); return result; } /** * Read-only view of the browser policy for `/brb browser`. * * Pure by construction: it resolves and reports, it never launches, attaches, * sends, or writes. The internal field names stay internal. */ function browserChoiceText(options: { cleared?: boolean } = {}): string { const found = browserDiscovery(); const describe = (kind: "chrome" | "edge"): string => { const one = discoverBrowser({ browser: kind }); const where = one.candidate ? ` (${one.candidate.executablePath})` : ""; return `${kind === "chrome" ? "Chrome" : "Edge"}: ${one.capability}${where}`; }; const resolved = found.candidate ? `当前解析: ${found.candidate.kind}` : "当前解析: 尚未确定"; return [ `浏览器策略: ${config.browser}`, resolved, describe("chrome"), describe("edge"), "说明: Chrome 与 Edge 使用 BRB 各自的配置目录(相互独立);切换浏览器后可能需要在新的配置目录里手动登录 ChatGPT。", options.cleared ? "此前的浏览器解析结果已作废;下一次操作会按上述策略重新解析。" : null, ] .filter((line): line is string => line !== null) .join(NL); } const OPERATION_BROWSER_FAILURE = "BRB 无法连接或自动启动浏览器,本次操作未执行。下一步:运行 `/brb doctor` 查看原因;必要时使用 `/brb launch`。"; /** One attempt only. Both bound and unbound operations degrade through this guard. */ async function guardOperationBrowser(options: { openChatGptHome?: boolean } = {}): Promise<{ ok: true } | { ok: false; detail: string }> { try { const result = await ensureOperationBrowser(options); if (!result.status.running) return { ok: false, detail: OPERATION_BROWSER_FAILURE }; return { ok: true }; } catch (error) { return { ok: false, detail: `${OPERATION_BROWSER_FAILURE}${NL}${error instanceof Error ? error.message : String(error)}`, }; } } /** * BUG-043 uses verify-first when an existing ChatGPT page is available: the * account principal is origin-wide, so this read-only CDP attachment can prove * (or fail to prove) it without opening a new chat. With no existing ChatGPT * page, creation remains the only page that can be observed; its post-create * identity failure path closes that page and retains the orphan record. */ async function observePreCreatePrincipal(targets: readonly CdpTarget[]): Promise<{ attempted: boolean; principal: string | null }> { const existing = targets.find( (target) => target.type === "page" && target.url.startsWith("https://chatgpt.com/"), ); if (!existing) return { attempted: false, principal: null }; let probe: ChatGptPage | null = null; try { probe = await ChatGptPage.attach({ port: config.port, targetId: existing.id }); return { attempted: true, principal: await probe.observePrincipal().catch(() => null) }; } catch { return { attempted: true, principal: null }; } finally { await probe?.close().catch(() => undefined); } } /** `ChatGptPage.close()` releases CDP sessions; this additionally closes the browser tab created by this attempt. */ async function closeCreatedCandidateTab(page: ChatGptPage): Promise { await closeTarget(page.createdTargetId || page.target.id, config.port); await page.close().catch(() => undefined); } /** What to do about a closed gate, phrased without deciding for the person. */ function gateHelp(): string { return [ "要新建会话:先执行 " + canonicalCommand("new") + "(这是一次性授权,30 分钟内有效)。", "要复用已有会话:/brb bind <会话URL> —— 不消耗任何额度。", "要看已经建了哪些:" + canonicalCommand("conversations") + "(只读,不会删)。", ].join(NL); } const NO_BINDING_PRIMARY = "NO_BINDING:本线程没有绑定 ChatGPT 会话;没有可证明的投递目标,不猜测当前标签页。仍可用 /brb bind 手动绑定。"; function implicitNoBindingError(detail?: string): Error { return new Error(detail ? `${NO_BINDING_PRIMARY}${NL}自动创建暂不可用:${detail}` : NO_BINDING_PRIMARY); } let activeBinding: Binding | null = null; /** * The page lazy binding opened, kept open for the first round. * * Opening a tab, remembering its id, and re-attaching by that id later does not * hold up under concurrency, and the re-attach is avoidable: the page we created * *is* the endpoint. */ let carriedPage: ChatGptPage | null = null; let store = new SharedStore({}); /** Point runtime state at this binding's own file. */ function useBinding(next: Binding | null): void { activeBinding = next; store = next ? new SharedStore({ path: statePathFor(next.bindingId) }) : new SharedStore({}); } function beginBindingOperation(sessionId: string): void { if (sessionId) bindingOperations.set(sessionId, (bindingOperations.get(sessionId) ?? 0) + 1); } function endBindingOperation(sessionId: string): void { if (!sessionId) return; const count = bindingOperations.get(sessionId) ?? 0; if (count <= 1) bindingOperations.delete(sessionId); else bindingOperations.set(sessionId, count - 1); } function bindingIsBusy(sessionId: string, binding?: Binding | null): boolean { return Boolean( bindingOperations.has(sessionId) || autoCreateFlights.has(sessionId) || pendingCandidate?.binding.localSessionId === sessionId || binding?.materializationPending || (loop.active && loopSessionId === sessionId), ); } async function withBindingOperation(sessionId: string, operation: () => Promise): Promise { beginBindingOperation(sessionId); try { return await operation(); } finally { endBindingOperation(sessionId); } } /** * The local pi endpoint identity. * * Verified stable across `--session-id`, `-c` and `--session `. `cwd` is * deliberately NOT used: session lookup is cwd-scoped, so routing by cwd would * cross-wire exactly the way the reported bug did. */ function localSessionId(ctx: ExtensionContext): string { try { return String(ctx.sessionManager.getSessionId?.() ?? ""); } catch { return ""; } } /** * Driving rights are exclusive per *conversation*, not per state file. * * The state lock alone is not enough once bindings are per-session: two * sessions pointing at the same conversation would each hold their own state * file and each believe they had exclusive rights, turning the cross-wiring * problem from "always" into "an occasional race". */ const conversationLock = new ConversationLock({}); const lockOwner = `extension:${process.pid}`; /** Key the current loop holds, so release targets the same endpoint. */ let lockedConversation = ""; /** * The identity used for driving exclusivity. * * A conversation id when the URL has one; otherwise the tab URL itself, because * a brand-new chat only gains its `/c/` after the first message. Either way * it names one transport endpoint, which is what needs one writer. */ function conversationKeyOf(url: string, targetId?: string): string { // Before a new chat has an id, the TAB is the endpoint that needs one writer. // Using the bare URL here made two freshly opened chats share a lock. return conversationOwnershipKey(url, targetId); } /** Real working-tree changes, used by the no-progress boundary rule. */ function changedFilesNow(cwd: string): string[] { try { const out = execFileSync("git", ["status", "--porcelain=v1"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); return out.split("\n").map((line) => line.slice(3).trim()).filter(Boolean); } catch { return []; } } /** * Attach to a page. * * A request targetId or durable URL names the conversation explicitly. The * `allowCurrentTab` escape hatch permits the old "whatever chatgpt.com tab happens * to be open" behaviour and exists only for read-only diagnostics. * * That fallback used to be the default everywhere. In a single-binding world it * was merely convenient; the moment more than one binding exists it becomes * routing nondeterminism -- the bridge would drive whichever tab was in front, * which is exactly the cross-wiring the binding model is meant to remove. */ async function attachPage( request: { url?: string; targetId?: string; allowCurrentTab?: boolean; signal?: AbortSignal } = {}, ): Promise { const status = await browserStatus(config.port, request.signal); if (!status.running) { throw new Error( `调试端口 ${config.port} 上没找到浏览器。先运行 /brb launch 启动自动化浏览器。`, ); } const selectorTargets = await listTargets(config.port, request.signal).catch(() => []); const selector = resolveAttachSelector( request, { conversationUrl: config.conversationUrl }, selectorTargets?.filter((target) => target.type === "page").map((target) => ({ id: target.id, url: target.url })), ); if (!selector.ok) throw new Error(selector.reason); // A bound conversation has a durable identity; a tab is only the transport // target carrying it at the moment. If the user closed the tab, reopen it // rather than failing -- otherwise a binding becomes unusable whenever a tab // is dismissed, and the durable identity buys nothing. const wantedUrl = selector.kind === "url" ? selector.url : undefined; let connected: ChatGptPage; if (selector.kind === "targetId") { // A conversation with no id yet is identified by its tab. Attaching by URL // would pick an arbitrary new chat, which may belong to another thread. connected = await ChatGptPage.attach({ port: config.port, targetId: selector.targetId, signal: request.signal }); } else if (wantedUrl) { const wantedId = conversationIdOf(wantedUrl); if (wantedId === null) { // Every brand-new chat reports the same address, so a URL with no // conversation id does not name one conversation. Matching on it picks // whichever new-chat tab happens to come first -- which is how two // threads ended up driving one conversation. Refuse rather than guess: // the caller either holds the page, or holds a target id. throw new Error( `会话 ${wantedUrl} 还没有 id —— 新会话要等第一条消息之后才有 /c/。` + "按 URL 匹配会挑到任意一个新会话标签页(可能是别的线程的)," + "所以这里拒绝猜测。请直接用创建时那个页面,或用 targetId 指定标签页。", ); } const open = status.tabs.some((tab) => tab.url === wantedUrl || tab.url.includes(wantedId)); connected = open ? await ChatGptPage.attach({ port: config.port, url: wantedUrl, signal: request.signal }) : await ChatGptPage.open(wantedUrl, { port: config.port }); } else { connected = await ChatGptPage.attach({ port: config.port, url: undefined, signal: request.signal }); } connected.setInputMode(config.inputMode ?? "trusted"); // A hidden tab renders slowly: long replies lag behind the stream and the DOM // can sit on the "正在思考" placeholder for minutes. await connected.ensureVisible().catch(() => undefined); return connected; } /** * BUG-024 (verdict #38): reading commands (sources / copy / read / resume) resolve their page * from THIS thread's binding — never from tab order. * * `attachPage({allowCurrentTab:true})` means "whatever chatgpt.com tab comes first in * /json/list". Observed live: a thread bound to 6aaf3e86 ran /brb copy and got 14 characters * out of the codex-cli conversation (6aafab9d), then pasted them into the user's composer — * a cross-conversation read. `allowCurrentTab` stays for doctor, which is a browser * diagnostic and does not claim product routing identity. */ async function attachBoundPage(c: ExtensionContext, options: { requireReadable?: boolean } = {}): Promise { const sessionId = localSessionId(c); const binding = sessionId ? bindingStore.findBySession(sessionId) : null; if (!binding?.conversationUrl) { throw new Error( "本线程没有绑定 ChatGPT 会话:拒绝读取其他标签页( NO_BINDING )。" + "先 /brb bind <会话URL> 或 /brb setup 绑定,再用读取类命令。", ); } const page = await attachPage({ url: binding.conversationUrl, targetId: binding.targetId }); const readable = await waitForReadable(page); if (options.requireReadable && !readable && !isProvisionalBinding(binding)) { // PASS #4 of the ruling: a page that has not rendered yet must never be reported as an // empty history ("0 复制源" / "(未识别)" were exactly that false negative). await page.close().catch(() => undefined); throw new Error( "绑定会话的页面在 12s 内没有定型(消息/轮次未挂载):拒绝把未定型的页面当空历史报告。稍后重试。", ); } return page; } /** * Bounded readiness probe for reads: wait until the page has mounted messages/turns. * A genuinely empty conversation just waits its bound and is then read as empty — the caller * decides whether that is acceptable (requireReadable) or a legitimate state (resume). */ async function waitForReadable(page: ChatGptPage, timeoutMs = 12_000): Promise { const deadline = Date.now() + timeoutMs; for (;;) { const state = await page.state().catch(() => null); if (state && (state.messages.length > 0 || state.turnNumber > 0)) return true; if (Date.now() > deadline) return false; await new Promise((resolve) => setTimeout(resolve, 400)); } } /** Bottom-status text for the wait phase (shared by relay and tool ask/plan). */ function formatWaitingStatus(elapsedMs: number, textLength: number): string { return `等待 ChatGPT 方案… ${Math.round(elapsedMs / 1000)}s / ${textLength} 字`; } /* ---------------------------------------------------------------- */ /* Idle badge — WEBGPT-IDLE-BADGE-v1 (verdict #26) */ /* ---------------------------------------------------------------- */ /** * Per-session two-layer footer: IDLE badge (browser/binding/tab) with * TRANSIENT task-progress override. All dynamic status paths call * setTransientStatus; every clear(undefined) became restoreIdleStatus so * the line shows the badge again after a task instead of going blank. */ const idleBadges = new Map(); const idleSessionOf = (c: ExtensionContext): string => localSessionId(c) ?? "?"; const idleBadgeFor = (c: ExtensionContext): IdleBadge => { const id = idleSessionOf(c); let badge = idleBadges.get(id); if (!badge) { const ui = c.ui; // capture this session's status line badge = createIdleBadge((text) => ui.setStatus?.("brb", text)); idleBadges.set(id, badge); } return badge; }; const setTransientStatus = (c: ExtensionContext, text: string): void => { idleBadgeFor(c).transient(text); }; const restoreIdleStatus = (c: ExtensionContext): void => { idleBadgeFor(c).restore(); }; /** * Re-observe browser/binding/tab and cache (publish only when idle). * Fire-and-forget from lifecycle hooks; awaited where an operation just * changed state and the badge should reflect it promptly. */ async function refreshIdleStatus(c: ExtensionContext): Promise { try { const sessionId = localSessionId(c) ?? null; const binding = sessionId ? bindingStore.findBySession(sessionId) : null; // BUG-025: no binding means this thread has no conversation of its own, so it must // NOT borrow the shared legacy config's URL. Observed live: a fresh unregistered Pi // thread showed 6ab0db7b in its footer — a conversation the registry had bound to a // DIFFERENT thread — which reads as "this thread is bound to that conversation". // Verdict #26's badge answers "which conversation is THIS thread bound to", so an // unregistered thread renders the frozen 未绑定 case instead of a borrowed identity. const convUrl = binding?.conversationUrl ?? null; const conversationId = convUrl ? conversationIdOf(convUrl) : null; const status = await browserStatus(config.port); const pageOpen = conversationId !== null ? status.tabs.some((tab) => tab.url.includes(conversationId)) : false; idleBadgeFor(c).refresh( formatIdleStatus({ browserRunning: status.running, conversationId, pageOpen, // BUG-061 ruling: a binding that exists but has no durable id yet is its own // state, not "未绑定". Derived from the registry record, never from the URL alone. provisional: binding !== null && conversationId === null && isProvisionalBinding(binding), }), ); } catch { // Observation failed: fail closed to 状态? (never claim a health we // did not measure). Without a binding there is nothing to name. idleBadgeFor(c).refresh(formatIdleStatus({ browserRunning: false, conversationId: null, pageOpen: null })); } } /** Send `payload` to the bound thread, wait, then grab the copied plan. */ async function relay( ctx: ExtensionContext, payload: string, ): Promise<{ reply: string; plan: string | null; via: string | null; promoted: boolean; parsed: Plan | null }> { const operationSessionId = localSessionId(ctx); beginBindingOperation(operationSessionId); setTransientStatus(ctx, "正在握手:连接浏览器与目标会话…"); // Publish before attach so failures also restore the idle badge. const page = await attachForExecution(ctx).catch((error: unknown) => { endBindingOperation(operationSessionId); restoreIdleStatus(ctx); throw error; }); try { await page.installClipboardHook(); setTransientStatus(ctx, "已连接,正在投递消息…"); // Level 2, external, account-visible; the registry decides whether it is allowed. const sendDecision = requestCapability({ operation: "message.send", context: `relay session=${localSessionId(ctx) ?? "?"}`, }); if (!sendDecision.ok) throw new Error(sendDecision.reason); // A17: observe the current principal immediately before the side effect. { const sessionId = localSessionId(ctx); const binding = sessionId ? bindingStore.findBySession(sessionId) : null; const principal = await page.observePrincipal().catch(() => null); const candidateAffinity = pendingCandidate && pendingCandidate.binding.localSessionId === sessionId ? pendingCandidate.binding.affinity ?? null : null; if (pendingCandidate && !principal) { throw new Error("身份 UNKNOWN:无法验证自动创建候选;远端发送与注册表提交均拒绝。"); } if (pendingCandidate && principal && !candidateAffinity) { pendingCandidate.binding.affinity = { observedIdentity: principal, source: "localstorage-user-prefix", verifiedAt: Date.now(), verificationVersion: 1, }; } const state = affinityState(principal, binding?.affinity ?? null); if (state !== "MATCH") { // Pending candidates use their freshly observed affinity. const candidateMatches = !binding && candidateAffinity && affinityState(principal, candidateAffinity) === "MATCH"; if (!candidateMatches) { throw new Error( `账号身份未通过:当前 principal ${principal ?? "(取不到)"} 与绑定记录 ` + `${JSON.stringify(binding?.affinity ?? candidateAffinity)} 不一致(${state})。` + "远端副作用 fail-closed;不自动解绑。请确认绑定会话属于当前登录账号。", ); } } } // Resolve the destination again immediately before ask(); ask() checks live location. const freshNow = resolveFreshSendTarget( freshSendDeps(ctx, isProvisionalBinding(bindingStore.findBySession(localSessionId(ctx)))), ); if (!freshNow.ok) throw new Error(freshNow.reason); const provisionalAtSend = pendingCandidate?.binding.targetId === page.target.id ? pendingCandidate.binding : bindingStore.findBySession(localSessionId(ctx)); // Filled by onSubmitConfirmed (BUG-060: evidence first) and consumed by onSubmitted. let materializationId: string | null = null; const { reply } = await page.ask(sendDecision.capability, payload, { timeoutMs: config.waitTimeoutMs, submitDelayMs: config.submitDelayMs, expectConversationId: freshNow.target.conversationId, expectedTargetId: freshNow.target.targetId, onSubmitConfirmed: async () => { // BUG-060 ruling: the lineage lands the moment the send is confirmed. if (!provisionalAtSend || !isProvisionalBinding(provisionalAtSend)) return; materializationId = markMaterializationPending(ctx, page.target.id); }, onSubmitted: async () => { if (!materializationId) return; await waitForMaterialization(ctx, page, materializationId); }, onTick: ({ elapsedMs, textLength }) => { setTransientStatus(ctx, formatWaitingStatus(elapsedMs, textLength)); }, }); setTransientStatus(ctx, "回复完成,提取方案…"); const copied = await page .copyLastPlan(config.copyMode, { label: config.copyLabel }) .catch(() => null); // Parse so the boundary rules can use ChatGPT's own DECISION rather // than this code inventing one. const parsed = copied?.text ? parsePlan(copied.text) : null; return { reply, plan: copied?.text ?? null, via: copied?.via ?? null, promoted: copied?.promoted ?? false, parsed }; } finally { try { restoreIdleStatus(ctx); // Capture the real conversation id before the page goes away, and read it // from the live DOM: `page.target.url` is a snapshot taken at attach time, // so after a new chat navigates to /c/ it still reports the old URL. // That staleness is why the id was never backfilled. const liveUrl = await page .state() .then((state) => state.url) .catch(() => null); try { if (liveUrl) { syncBindingUrl(ctx, liveUrl, page.target.id); // The binding drops its target id once a real conversation id exists, // so this has to follow: a later round still holding the id-less URL // and no target id would have nothing left to attach to. if (conversationIdOf(liveUrl) && localSessionId(ctx) && bindingStore.findBySession(localSessionId(ctx))) { loop.conversationUrl = liveUrl; } } } finally { await page.close().catch(() => undefined); } // Binding may have just been backfilled — refresh the idle badge to match. void refreshIdleStatus(ctx); } finally { endBindingOperation(operationSessionId); } } } /** * The driver's view of the browser. * * This is the whole coupling between the protocol core and CDP: send a message * and get back the reply plus the copied plan text, with `copyVia` preserved * because a degraded copy changes the *attribution* of a failure without * changing the decision to block execution. */ function createTransport(ctx: ExtensionContext): DriverTransport { return { async send(prompt: string) { const result = await relayWithRecovery(ctx, prompt); return { reply: result.reply, planText: result.plan, copyVia: result.via }; }, async discover() { const page = await attachForExecution(ctx); try { // An ACK is required at establish, so wait for it instead of reading // once: a background tab renders slowly, and one read that misses the // ACK looks like a contract mismatch rather than a timing problem. const discovery = await page.discoverProtocolFrames(12, { requireAck: true }); return { session: discovery.session, helloSent: discovery.helloSent, ackSeen: discovery.ackSeen, }; } finally { await page.close().catch(() => undefined); } }, }; } /** * Attach for driving. Requires a resolved binding: no guessing. */ async function attachForExecution(ctx: ExtensionContext): Promise { // The page lazy creation opened is the endpoint; do not go looking for it // again. Re-finding it by a URL that has no conversation id yet picks an // arbitrary new-chat tab, and keeping it also saves a round trip. if (carriedPage) { const page = carriedPage; carriedPage = null; return page; } // DECISION-009: loop.conversationUrl is a routing snapshot taken at start and can // be stale in a long-lived process (BUG-012). The destination is re-resolved from // the registry on every attach; a missing or degraded target refuses closed. const fresh = resolveFreshSendTarget( freshSendDeps(ctx, isProvisionalBinding(bindingStore.findBySession(localSessionId(ctx)))), ); if (!fresh.ok) throw new Error(fresh.reason); return attachPage({ url: fresh.target.conversationUrl, targetId: fresh.target.targetId }); } /** * The barrier's dependencies, assembled once per call site so the resolution reads * disk every time: BindingStore.load() is a fresh read, and the legacy fallback reads * the config FILE -- never the in-memory `config` object, which is the stale snapshot * BUG-012 is about. */ function freshSendDeps(ctx: ExtensionContext, allowProvisionalTarget = false) { const candidate = pendingCandidate?.binding; return { sessionId: localSessionId(ctx), registry: { findBySession: (sessionId: string) => candidate?.localSessionId === sessionId ? candidate : bindingStore.findBySession(sessionId), findByConversation: (conversationUrl: string, conversationId?: string) => bindingStore.findByConversation(conversationUrl, conversationId), }, readLegacyConversationUrl: () => readLegacyConversationUrlFromFile(runtimeConfigReadPath()), allowProvisionalTarget, }; } const MATERIALIZATION_TIMEOUT_MESSAGE = "消息已提交,但新会话身份尚未完成 materialize。BRB 将保持 PROVISIONAL,并在下一次操作前重新检查。不会自动重发。"; function materializationOperationId(targetId: string): string { return `materialize-${Date.now()}-${targetId.slice(0, 12)}`; } /** Read the pinned target and reconcile a PROVISIONAL binding before judging the operation. */ async function reconcilePinnedBinding(ctx: ExtensionContext, binding: Binding): Promise { if (!isProvisionalBinding(binding) || !binding.targetId) return binding; const targets = await listTargets(config.port).catch((error) => { throw new Error(`TARGET_SNAPSHOT_FAILED:无法读取 pinned target:${error instanceof Error ? error.message : String(error)}`); }); const observed = targets.find((target) => target.type === "page" && target.id === binding.targetId); if (!observed) throw new Error("TARGET_GONE:PROVISIONAL 绑定的 pinned target 已消失;不回退到其他标签页,也不自动重试。"); let affinityValid: boolean | undefined; if (classifyConversationRoute(observed.url) === "DURABLE" && binding.affinity) { const probe = await attachPage({ targetId: binding.targetId }); try { const principal = await probe.observePrincipal().catch(() => null); affinityValid = affinityState(principal, binding.affinity) === "MATCH"; } finally { await probe.close().catch(() => undefined); } } const id = conversationIdOf(observed.url); const observedDurableId = id ?? ""; const owner = id ? bindingStore.findByConversation(observed.url, id) : null; const verdict = reconcileProvisionalTarget( binding, { targetId: observed.id, url: observed.url, present: true, ownerSessionId: owner?.localSessionId ?? null, ownerLabel: owner?.label ?? null, ownerBindingId: owner?.bindingId ?? null, affinityValid, }, binding.localSessionId, ); if (!verdict.ok) throw new Error(verdict.reason); if (verdict.kind === "STILL_PROVISIONAL") { const updated = bindingStore.bind({ localSessionId: binding.localSessionId, conversationUrl: observed.url, targetId: binding.targetId, origin: binding.origin, bindingSource: binding.source, bindingState: "PROVISIONAL", label: binding.label, cwd: binding.cwd, affinity: binding.affinity, draftRoute: verdict.draftRoute ?? binding.draftRoute, materializationPending: binding.materializationPending, materializationOperationId: binding.materializationOperationId, submittedAt: binding.submittedAt, materializationError: binding.materializationError, }); if (!updated.ok) throw new Error(`PROVISIONAL reconciliation 写入失败:${updated.reason}`); return updated.binding; } let upgraded: ReturnType; try { upgraded = bindingStore.bind({ localSessionId: binding.localSessionId, conversationUrl: observed.url, targetId: binding.targetId, origin: binding.origin, bindingSource: binding.source, bindingState: "BOUND", label: binding.label, cwd: binding.cwd, affinity: binding.affinity, draftRoute: binding.draftRoute, materializationPending: false, materializationOperationId: binding.materializationOperationId, submittedAt: binding.submittedAt, materializationObservedId: null, materializationError: null, }); } catch (error) { const reason = `身份已 materialize,但注册表升级失败;保持 PROVISIONAL,不会自动重发:${error instanceof Error ? error.message : String(error)}`; try { bindingStore.bind({ localSessionId: binding.localSessionId, conversationUrl: binding.conversationUrl, targetId: binding.targetId, origin: binding.origin, bindingSource: binding.source, bindingState: "PROVISIONAL", label: binding.label, cwd: binding.cwd, affinity: binding.affinity, draftRoute: binding.draftRoute, materializationPending: true, materializationOperationId: binding.materializationOperationId, submittedAt: binding.submittedAt, materializationObservedId: observedDurableId, materializationError: reason, }); } catch { // The original failure is the useful fact; the next operation can retry the // same reconciliation if even the diagnostic write was unavailable. } throw new Error(reason); } if (!upgraded.ok) { // Do not claim BOUND. The provisional record remains the recovery checkpoint; // surface the registry failure and let the next operation reconcile again. const reason = `身份已 materialize,但注册表升级失败;保持 PROVISIONAL,不会自动重发:${upgraded.reason}`; try { bindingStore.bind({ localSessionId: binding.localSessionId, conversationUrl: binding.conversationUrl, targetId: binding.targetId, origin: binding.origin, bindingSource: binding.source, bindingState: "PROVISIONAL", label: binding.label, cwd: binding.cwd, affinity: binding.affinity, draftRoute: binding.draftRoute, materializationPending: true, materializationOperationId: binding.materializationOperationId, submittedAt: binding.submittedAt, materializationObservedId: observedDurableId, materializationError: reason, }); } catch { // Preserve the primary registry conflict in the thrown error. } throw new Error(reason); } useBinding(upgraded.binding); return upgraded.binding; } function markMaterializationPending(ctx: ExtensionContext, targetId: string): string { const operationId = materializationOperationId(targetId); const candidate = pendingCandidate?.binding.targetId === targetId ? pendingCandidate.binding : null; if (candidate) { candidate.materializationPending = true; candidate.materializationOperationId = operationId; candidate.submittedAt = Date.now(); candidate.draftRoute = candidate.conversationUrl; return operationId; } const sessionId = localSessionId(ctx); const current = sessionId ? bindingStore.findBySession(sessionId) : null; if (!current || !isProvisionalBinding(current) || current.targetId !== targetId) return operationId; const marked = bindingStore.bind({ localSessionId: sessionId, conversationUrl: current.conversationUrl, targetId, origin: current.origin, bindingSource: current.source, bindingState: "PROVISIONAL", label: current.label, cwd: current.cwd, affinity: current.affinity, draftRoute: current.draftRoute, materializationPending: true, materializationOperationId: operationId, submittedAt: Date.now(), materializationError: null, }); if (!marked.ok) throw new Error(`消息已提交,但 materialization lineage 无法落盘:${marked.reason}`); return operationId; } /** Wait on the same target only; a timeout is a delivery-success-preserving state. */ async function waitForMaterialization(ctx: ExtensionContext, page: ChatGptPage, operationId: string): Promise { const deadline = Date.now() + DEFAULT_DELIVERY_TIMEOUT_MS; let lastUrl = page.target.url; for (;;) { const state = await page.state().catch(() => null); lastUrl = state?.url ?? lastUrl; const route = classifyConversationRoute(lastUrl); if (route === "DURABLE") { const sessionId = localSessionId(ctx); const current = pendingCandidate?.binding.targetId === page.target.id ? pendingCandidate.binding : sessionId ? bindingStore.findBySession(sessionId) : null; if (!current) return; const owner = conversationIdOf(lastUrl) ? bindingStore.findByConversation(lastUrl, conversationIdOf(lastUrl) ?? undefined) : null; const verdict = reconcileProvisionalTarget( { ...current, materializationOperationId: operationId, materializationPending: true, submittedAt: current.submittedAt ?? Date.now() }, { targetId: page.target.id, url: lastUrl, present: true, ownerSessionId: owner?.localSessionId ?? null, ownerLabel: owner?.label ?? null, ownerBindingId: owner?.bindingId ?? null, }, current.localSessionId, ); if (!verdict.ok) throw new Error(verdict.reason); if (verdict.kind !== "PROMOTE") return; if (pendingCandidate?.binding.targetId === page.target.id) { pendingCandidate.binding.conversationUrl = lastUrl; pendingCandidate.binding.conversationId = verdict.conversationId; pendingCandidate.binding.materializationPending = false; } else if (sessionId) { await reconcilePinnedBinding(ctx, current); } return; } if (Date.now() >= deadline) break; await sleep(500); } const sessionId = localSessionId(ctx); const current = pendingCandidate?.binding.targetId === page.target.id ? pendingCandidate.binding : sessionId ? bindingStore.findBySession(sessionId) : null; if (current && pendingCandidate?.binding.targetId === page.target.id) { current.conversationUrl = lastUrl; current.draftRoute = isDraftLocatorRoute(lastUrl) ? lastUrl : current.draftRoute; } if (current && !pendingCandidate) { const marked = bindingStore.bind({ localSessionId: current.localSessionId, conversationUrl: lastUrl, targetId: page.target.id, origin: current.origin, bindingSource: current.source, bindingState: "PROVISIONAL", label: current.label, cwd: current.cwd, affinity: current.affinity, draftRoute: isDraftLocatorRoute(lastUrl) ? lastUrl : current.draftRoute, materializationPending: true, materializationOperationId: operationId, submittedAt: current.submittedAt ?? Date.now(), materializationError: null, }); if (!marked.ok) throw new Error(`消息已提交,但 materializationPending 无法保留:${marked.reason}`); } setTransientStatus(ctx, MATERIALIZATION_TIMEOUT_MESSAGE); } /** * A conversation that exists but has not been proved able to carry the protocol. * * It is a complete Binding value so the rest of the flow can use it unchanged, and * it is deliberately **not written to the registry**. That is the difference between * a transaction and a compensating action: the old binding is not overwritten and * then restored on failure, it is simply never touched until the new one has been * verified. See BUG-004. */ interface RotationCandidate { binding: Binding; page: ChatGptPage; /** The grant spent at the external creation point. Never returned. */ grantId: string; /** The binding this one replaces, when it replaces one. */ replaces: Binding | null; /** Prevent a second caller from handshaking the shared candidate twice. */ verificationInFlight?: boolean; /** Pinned create lineage; no later tab scan may substitute another target. */ targetsBefore: readonly string[]; createdTargetId: string; lineageValidated: boolean; } /** Result of resolving what this thread should drive. */ interface ResolvedTarget { binding: Binding; page: ChatGptPage | null; candidate: RotationCandidate | null; } /** * Resolve the conversation for this thread, preparing a candidate when one is asked for. * * A candidate is only prepared when a person has asked for a new conversation: either * there is no binding at all and a one-shot grant exists, or a binding exists and a * grant says to rotate. A grant that has been spent here is never given back -- * see the comment at the consume below. */ function issueCreationIntent(sessionId: string, reason: string) { const proof = mintHumanIntent("control-command"); return consentStore.issueIntent(proof, { localSessionId: sessionId, kind: "new-workflow", reason, }); } async function confirmAutoCreate(ctx: ExtensionContext, operation: BoundOperation): Promise { if (config.autoCreateOnUnbound !== "ask") return config.autoCreateOnUnbound === "on"; const sessionId = localSessionId(ctx); const recorded = autoCreatePhaseDecisions.get(sessionId); if (recorded) return recorded === "allow"; if (!onboardingAutoEnabled()) return false; const confirm = ctx.ui.confirm; const accepted = typeof confirm === "function" ? await confirm( "启用未绑定线程的自动建会话?", `当前操作 /brb ${operation} 需要一个 ChatGPT 会话,但本线程尚未绑定。` + "是否启用自动创建并绑定?每次创建仍受 conversation.create 授权、三闸门和每日额度约束。" + "选择否将保持 NO_BINDING;以后仍可用 /brb bind 手动绑定。", ) : false; autoCreatePhaseDecisions.set(sessionId, accepted ? "allow" : "deny"); return accepted; } async function ensureBindingInternal(ctx: ExtensionContext, operation: BoundOperation): Promise { const sessionId = localSessionId(ctx); if (!sessionId) throw new Error("当前 Pi 会话取不到会话标识,无法建立绑定。请在新开的 Pi 会话里重试。"); const existing = bindingStore.findBySession(sessionId); // A grant is the only thing that makes rotation happen; without one an existing // binding is reused exactly as before. let rotateRequested = consentStore.grantFor(sessionId) !== null; let operationBrowserPrepared = false; if (existing) { const browser = await guardOperationBrowser(); if (!browser.ok) throw new Error(browser.detail); operationBrowserPrepared = true; } if (existing && !rotateRequested) { // PROVISIONAL is not a generic "bound enough" state: after the operation // preflight has made the CDP endpoint available, re-read its pinned target. const reconciledExisting = isProvisionalBinding(existing) ? await reconcilePinnedBinding(ctx, existing) : existing; return { binding: reconciledExisting, page: null, candidate: null }; } const reconciledExisting = existing && isProvisionalBinding(existing) ? await reconcilePinnedBinding(ctx, existing) : existing; const implicitNoBindingFlow = !reconciledExisting && autoBoundOperations.has(operation); const autoFlow = implicitNoBindingFlow && !rotateRequested; if (autoFlow) { // The persistent policy is separate from the per-operation grant. `on` never // skips accounting, while `ask` records only this UNBOUND phase in memory. const accepted = await confirmAutoCreate(ctx, operation); if (!accepted) { const detail = config.autoCreateOnUnbound === "off" ? "autoCreateOnUnbound=off。" : !onboardingAutoEnabled() ? "BRB_ONBOARDING_AUTO=0 且设置仍为 ask。" : "用户未启用自动新建。"; throw implicitNoBindingError(detail); } const decision = decideAutoCreate({ bound: false, explicitUrl: null, setting: config.autoCreateOnUnbound, onboardingAuto: onboardingAutoEnabled(), operation, pendingOrphan: orphanStore.hasPendingForSession(sessionId), // The single-flight wrapper owns this attempt; a second caller is // returned the existing promise before reaching this decision. inFlight: false, maxNewConversationsPerDay: config.maxNewConversationsPerDay, usedInWindow: autoCreateUsage(), }); if (!decision.ok) { const pending = orphanStore.pendingForSession(sessionId); throw implicitNoBindingError( `${decision.reason}${pending ? ` 候选引用:${pending.conversationUrl};不自动重试。` : ""}`, ); } issueCreationIntent(sessionId, `未绑定线程自动创建(/brb ${operation})`); rotateRequested = true; } // Three gates, and the code that wants the conversation cannot produce two of // them. Refuse before opening a tab rather than after: a tab that exists briefly // and is closed still counts against whatever ChatGPT watches. const usedInWindow = autoCreateUsage(); const gateInput = { consent: consentStore, localSessionId: sessionId, cap: config.maxNewConversationsPerDay, usedInWindow, }; const decision = canCreate(gateInput); if (!decision.ok) { const detail = `${decision.reason}${NL}${NL}${describeGates(gateInput)}${NL}${NL}${gateHelp()}`; throw implicitNoBindingFlow ? implicitNoBindingError(detail) : new Error(detail); } if (!operationBrowserPrepared) { const browser = await guardOperationBrowser({ openChatGptHome: false }); if (!browser.ok) { throw implicitNoBindingFlow ? implicitNoBindingError(browser.detail) : new Error(browser.detail); } } // Spent before creating, so two concurrent starts cannot both spend one grant. // // And NOT returned if the handshake later fails. The grant authorises one external // side effect, not "retry until it works": the conversation now exists in the // account whatever happens next, so returning the grant would let one approval // expand into several creations across network failures. `one grant <= one // external create attempt`, as the external ruling puts it. // Read the intent id before spending the grant, because spending also fulfils the intent // and clears it -- so asking afterwards yields null and the capability would be refused // for the wrong reason. const intentId = consentStore.intentFor(sessionId)?.id; const spent = consentStore.consume(sessionId, "正在创建"); if (!spent) { const detail = `授权在消费时消失了(并发?)。${NL}${describeGates(gateInput)}${NL}${NL}${gateHelp()}`; throw implicitNoBindingFlow ? implicitNoBindingError(detail) : new Error(detail); } // PREPARE / CREATE. // // A create request has three outcomes, not two: it did not happen, it happened, or // it is not known whether it happened. Only the first may return the grant. // // opening provably fails (nothing listening, name not resolved) // -- the authorisation is given back. Charging someone a conversation because // their browser is not running is not what they approved. // anything else, including a lost reply // -- the tab may already exist, so the grant stays spent and nothing retries. // `one grant <= one external create attempt`: an ambiguous outcome must not // be expanded into several creations by a network fault. let page: ChatGptPage; // The registry is consulted here, not somewhere upstream, and the capability is what // makes the creation possible at all: `ChatGptPage.open` refuses an id-less URL, so this // is the only door. The evidence is the intent and grant ids rather than a boolean, // because "someone said yes" is not checkable. const capabilityDecision = requestCapability({ operation: "conversation.create", context: `session=${sessionId} rotate=${rotateRequested}`, evidence: { intentId, grantId: spent.id }, }); if (!capabilityDecision.ok) { consentStore.restore(spent.id); throw new Error( `${capabilityDecision.reason}${NL}${NL}${describeGates(gateInput)}${NL}${NL}${gateHelp()}`, ); } // Snapshot before the external create. This is an identity fence, not a // candidate-selection scan: later listings may only prove that the pinned // target still exists. let targetSnapshot: CdpTarget[]; let targetsBefore: string[]; try { targetSnapshot = await listTargets(config.port); targetsBefore = targetSnapshot .filter((target) => target.type === "page") .map((target) => target.id); } catch (error) { consentStore.restore(spent.id); throw new Error( `TARGET_SNAPSHOT_FAILED:创建前无法取得浏览器目标快照,未发起创建:${error instanceof Error ? error.message : String(error)}`, ); } setTransientStatus(ctx, "正在准备新会话…"); const preCreatePrincipal = await observePreCreatePrincipal(targetSnapshot); if (preCreatePrincipal.attempted) { const preCreateIdentity = validateCandidateIdentity(null, preCreatePrincipal.principal); if (!preCreateIdentity.ok) { throw new Error("身份 UNKNOWN:创建前无法验证当前登录账号;不打开新会话、不提交绑定,也不自动重试。"); } } try { setTransientStatus(ctx, "正在创建 ChatGPT 会话…"); page = await ChatGptPage.createConversation(capabilityDecision.capability, { port: config.port }); } catch (error) { if (isProvablyNotCreated(error)) { consentStore.restore(spent.id); throw new Error( "创建会话失败,授权已归还(可证明没有越过外部创建点):" + (error instanceof Error ? error.message : String(error)), ); } const createdTargetId = error && typeof error === "object" ? (error as { createdTargetId?: string }).createdTargetId : undefined; if (createdTargetId) { const listed = await listTargets(config.port).catch(() => null); const stillPresent = listed?.some((target) => target.type === "page" && target.id === createdTargetId); if (listed && !stillPresent) { throw new Error("TARGET_GONE:自动创建目标已关闭或消失;不登记 orphan,也不自动重试。"); } } // The request may already have created a tab. Keep a durable orphan marker // and burn the grant; a later command must not retry blindly. orphanStore.record({ conversationUrl: "https://chatgpt.com/", targetId: "", grantId: spent.id, localSessionId: sessionId, reason: "创建结果不确定:" + (error instanceof Error ? error.message : String(error)), at: Date.now(), }); throw new Error( "创建会话的结果不确定(请求已发出但没有收到明确答复),因此按失败关闭处理:" + "不自动重试,也不归还授权 —— 那次创建可能已经发生。" + "请先用 brb 会话 看清账号里有没有多出一条,再决定。" + `原始错误:${error instanceof Error ? error.message : String(error)}`, ); } page.setInputMode(config.inputMode ?? "trusted"); const createdTargetId = page.createdTargetId || page.target.id; const currentTarget = (await listTargets(config.port).catch(() => [])) .find((target) => target.type === "page" && target.id === createdTargetId); const createdUrl = currentTarget?.url ?? page.target.url; const lineage = validateCreatedTarget({ targetId: createdTargetId, targetsBefore, url: createdUrl, present: Boolean(currentTarget), }); if (!lineage.ok) { await page.close().catch(() => undefined); const owner = lineage.conversationId ? bindingStore.findByConversation(`https://chatgpt.com/c/${lineage.conversationId}`, lineage.conversationId) : null; const invalidMessage = lineage.conversationId ? `BRB 未能得到一个全新的会话页。新创建的页面落到了已有会话 ${lineage.conversationId.slice(0, 12)}…。为避免串绑,本次没有绑定该会话,也不会自动重试或借用它。` + `你可以:/brb bind 显式绑定一个允许当前线程使用的会话;或检查浏览器的标签页恢复/重定向状态后,重新执行原操作。` + (owner && owner.localSessionId !== sessionId ? `该会话当前属于 Pi 线程 ${owner.localSessionId.slice(0, 12)}` : "") : lineage.reason; throw new Error(`${lineage.code}:${invalidMessage}`); } // Bounded wait, not a one-shot read: a fresh conversation page writes its // localStorage identity keys asynchronously, and the one-shot probe raced that // window into a false "身份 UNKNOWN" for every create (BUG-064). const createdProbe = await page .waitForPrincipal() .catch(() => ({ principal: null, sawAnyKeys: false, attempts: 0, elapsedMs: 0 })); const createdPrincipal = createdProbe.principal; const createdIdentity = validateCandidateIdentity(null, createdPrincipal); if (!createdIdentity.ok) { await closeCreatedCandidateTab(page); orphanStore.record({ conversationUrl: page.target.url, targetId: page.target.id, grantId: spent.id, localSessionId: sessionId, // Name the fact we actually established. Waiting ~20s with an empty // localStorage is a page that never initialized; keys present without a // matching principal is a different problem entirely. reason: createdProbe.sawAnyKeys ? `身份 UNKNOWN:等待 ${createdProbe.elapsedMs}ms / ${createdProbe.attempts} 次后仍无法从 localStorage 建立主体(有键但无匹配)` : `身份 UNKNOWN:等待 ${createdProbe.elapsedMs}ms / ${createdProbe.attempts} 次后页面仍未初始化(localStorage 无键)`, at: Date.now(), }); throw new Error("身份 UNKNOWN:创建后无法验证当前登录账号;不提交绑定,也不自动重试。"); } const verifiedPrincipal = createdIdentity.principal; const now = Date.now(); const provisional: Binding = { bindingId: reconciledExisting?.bindingId ?? `pending-${spent.id}`, localSessionId: sessionId, origin: "auto", source: autoFlow ? "auto" : "start", affinity: { observedIdentity: verifiedPrincipal, source: "localstorage-user-prefix", verifiedAt: Date.now(), verificationVersion: 1, }, // A brand-new chat has no /c/ yet; it gains one after the first message, // which is what syncBindingUrl() picks up. conversationUrl: createdUrl, conversationId: "", state: "PROVISIONAL", // The only identity this conversation has until the first message. targetId: createdTargetId, label: ctx.sessionManager.getSessionName?.() ?? "", cwd: ctx.cwd, createdAt: now, lastUsedAt: now, }; const candidate: RotationCandidate = { binding: provisional, page, grantId: spent.id, replaces: reconciledExisting ?? null, targetsBefore, createdTargetId, lineageValidated: true, }; // Visible to `syncBindingUrl`, which runs inside the handshake and must hand the live // conversation id to this candidate rather than committing it. pendingCandidate = candidate; return { binding: provisional, page, candidate, }; } /** * Single-flight wrapper. Repeated `/brb ask`/`plan`/`start` calls while CREATE is * awaiting CDP share the same promise; only the first call can reach * ChatGptPage.createConversation. */ async function ensureBinding(ctx: ExtensionContext, operation: BoundOperation = "start"): Promise { const sessionId = localSessionId(ctx); if (!sessionId) return ensureBindingInternal(ctx, operation); beginBindingOperation(sessionId); try { const existing = bindingStore.findBySession(sessionId); const explicitGrant = consentStore.grantFor(sessionId) !== null; const autoFlow = !existing && !explicitGrant && autoBoundOperations.has(operation); if (!autoFlow) return await ensureBindingInternal(ctx, operation); const running = autoCreateFlights.get(sessionId); if (running) return await running; const promise = ensureBindingInternal(ctx, operation); autoCreateFlights.set(sessionId, promise); try { return await promise; } catch (error) { if (autoCreateFlights.get(sessionId) === promise) autoCreateFlights.delete(sessionId); throw error; } } finally { endBindingOperation(sessionId); } } /** * COMMIT. Called only after the new conversation has passed the handshake. * * Everything the ruling requires before this point -- HELLO actually delivered, the * page stopped generating, the ACK passed the contract check -- has already happened * inside `driverEstablish`. Nothing else may stand in for it: a conversation id, a * successful send, or any reply are all weaker than a validated handshake. */ function commitCandidate(candidate: RotationCandidate): { ok: boolean; reason: string; binding?: Binding } { // A page opened for a new conversation has a real CDP target before ChatGPT // assigns its durable `/c/`. The target id is the candidate identity in // that interval; the live-location backfill below replaces it once the page // navigates. Do not manufacture an id or send a probe just to make the record // look complete. const hasConversationId = Boolean(conversationIdOf(candidate.binding.conversationUrl)); if (!hasConversationId && !candidate.binding.targetId) { return { ok: false, reason: "新会话候选没有 conversation id 或 targetId,拒绝提交绑定。" }; } if (candidate.createdTargetId !== candidate.binding.targetId) { return { ok: false, reason: "TARGET_DRIFT:候选 target identity 已变化,拒绝提交绑定。" }; } let committed: ReturnType; try { committed = bindingStore.bind({ localSessionId: candidate.binding.localSessionId, conversationUrl: candidate.binding.conversationUrl, targetId: hasConversationId ? undefined : candidate.binding.targetId, origin: "auto", bindingSource: candidate.binding.source ?? "start", bindingState: hasConversationId ? "BOUND" : "PROVISIONAL", affinity: candidate.binding.affinity, draftRoute: candidate.binding.draftRoute, materializationPending: candidate.binding.materializationPending, materializationOperationId: candidate.binding.materializationOperationId, submittedAt: candidate.binding.submittedAt, materializationError: candidate.binding.materializationError ?? null, label: candidate.binding.label, cwd: candidate.binding.cwd, }); } catch (error) { return { ok: false, reason: `registry 写入失败:${error instanceof Error ? error.message : String(error)}`, }; } if (!committed.ok) return { ok: false, reason: `OWNERSHIP_CONFLICT:${committed.reason} 自动路径不接管。`, }; pendingCandidate = null; autoCreateFlights.delete(candidate.binding.localSessionId); autoCreatePhaseDecisions.delete(candidate.binding.localSessionId); useBinding(committed.binding); return { ok: true, reason: "", binding: committed.binding }; } async function validateCandidateLineage(candidate: RotationCandidate): Promise<{ ok: true } | { ok: false; reason: string }> { const pinned = validatePinnedTarget(candidate.createdTargetId, candidate.page.target.id); if (!pinned.ok) return pinned; const current = (await listTargets(config.port).catch(() => [])) .find((target) => target.type === "page" && target.id === candidate.createdTargetId); const verdict = validateCreatedTarget({ targetId: candidate.createdTargetId, targetsBefore: candidate.targetsBefore, url: current?.url ?? candidate.page.target.url, present: Boolean(current), }); if (!verdict.ok) return { ok: false, reason: `${verdict.code}:${verdict.reason}` }; return { ok: true }; } /** * Complete the canonical no-message handshake for an auto-created candidate * used by a non-`start` operation. A target id identifies the new tab before * ChatGPT assigns a durable `/c/`; syncBindingUrl backfills that id later. */ async function verifyAndCommitCandidate( ctx: ExtensionContext, resolved: ResolvedTarget, operation: BoundOperation, ): Promise { if (!resolved.candidate) return; const candidate = resolved.candidate; if (candidate.verificationInFlight) { throw new Error("AUTO_CREATE_BLOCKED_IN_FLIGHT:候选会话正在握手;不重复发送、不再创建。"); } candidate.verificationInFlight = true; setTransientStatus(ctx, "正在验证会话身份…"); try { const transaction = await runAutoCreateTransaction({ source: candidate.binding.source === "start" ? "start" : "auto", prepare: () => candidate, create: (prepared) => prepared, handshake: async (prepared) => { const lineage = await validateCandidateLineage(prepared); if (!lineage.ok) return lineage; const firstPrincipal = await prepared.page.observePrincipal().catch(() => null); const firstIdentity = validateCandidateIdentity(null, firstPrincipal); if (!firstIdentity.ok) return { ok: false, reason: `${firstIdentity.reason}不提交绑定。` }; prepared.binding.affinity = { observedIdentity: firstIdentity.principal, source: "localstorage-user-prefix", verifiedAt: Date.now(), verificationVersion: 1, }; const finalPrincipal = await prepared.page.observePrincipal().catch(() => null); const finalIdentity = validateCandidateIdentity(firstIdentity.principal, finalPrincipal); return finalIdentity.ok ? { ok: true } : { ok: false, reason: `${finalIdentity.reason}不提交绑定。` }; }, commit: (prepared) => { setTransientStatus(ctx, "正在绑定当前线程…"); const committed = commitCandidate(prepared); return committed.ok ? { ok: true } : { ok: false, reason: `握手已通过但绑定落盘失败:${committed.reason}。` }; }, }); if (!transaction.committed) { throw new Error( `${transaction.reason ?? "自动创建握手未完成"}候选会话仍可能存在:` + `${candidate.binding.conversationUrl};不自动重试。`, ); } setTransientStatus(ctx, "已连接"); } catch (error) { const reason = error instanceof Error ? error.message : String(error); pendingCandidate = null; autoCreateFlights.delete(candidate.binding.localSessionId); useBinding(candidate.replaces); if (!isDeterministicAutoCreateFailure(reason)) { orphanStore.record({ conversationUrl: candidate.binding.conversationUrl, targetId: candidate.binding.targetId ?? "", grantId: candidate.grantId, localSessionId: candidate.binding.localSessionId, reason, at: Date.now(), }); } throw new Error( isDeterministicAutoCreateFailure(reason) ? `${reason}。当前线程仍未绑定;本次确定性失败没有登记 orphan,也不会自动重试。` : `${reason}。当前线程仍未绑定;候选会话已登记为 orphan(${candidate.binding.conversationUrl}),未知部分不会自动重试。`, ); } finally { await candidate.page.close().catch(() => undefined); } } /** * Pick up the conversation id once a newly created chat has one. * * It only exists after the first message, so the binding is stored with a bare * URL at creation and upgraded here. Without this the binding would stay * URL-based, and a URL is not a durable remote identity. */ function syncBindingUrl(ctx: ExtensionContext, url: string, targetId?: string): void { const id = conversationIdOf(url); if (!id) return; const sessionId = localSessionId(ctx); if (!sessionId) return; loop.conversationUrl = url; const auditCurrent = bindingStore.findBySessionIncludingReleased(sessionId); if (isReleasedBinding(auditCurrent)) { // A late page callback must never promote a tombstoned binding back into // the active registry or keep a ghost runtime owner alive. if (pendingCandidate?.binding.localSessionId === sessionId) pendingCandidate = null; autoCreateFlights.delete(sessionId); loop.conversationUrl = null; useBinding(null); return; } // A candidate is not yet the current conversation, so it must not be written to the // registry from here. The id is handed to the candidate instead, and only a // validated handshake commits it. Without this guard the backfill in relay's // finally was a second commit path that ran regardless of the handshake's outcome. if (pendingCandidate?.binding.localSessionId === sessionId) { if (!pendingCandidate.binding.materializationPending && pendingCandidate.binding.conversationId !== id) { throw new Error("INVALID_TARGET:候选 target 出现 durable id,但 SEND lineage 尚未成立。拒绝升级。"); } pendingCandidate.binding.conversationUrl = url; pendingCandidate.binding.conversationId = id; return; } const current = bindingStore.findBySession(sessionId); if (!current) return; if (isProvisionalBinding(current) && current.targetId && targetId && current.targetId !== targetId) { throw new Error("TARGET_DRIFT:首次消息来自不同 target,拒绝升级 provisional 绑定。"); } if (current.conversationId === id) return; if (isProvisionalBinding(current) && !current.materializationPending) { throw new Error("INVALID_TARGET:pinned target 出现 durable id,但没有本次 SEND 的 materialization lineage。拒绝串绑。"); } const owner = bindingStore.findByConversation(url, id); if (owner && owner.localSessionId !== sessionId && isDurableConversationBinding(owner)) { throw new Error(`OWNERSHIP_CONFLICT:${ownershipConflictMessage(owner)}`); } let upgraded: ReturnType; try { upgraded = bindingStore.bind({ localSessionId: sessionId, conversationUrl: url, label: current.label, cwd: ctx.cwd, origin: current.origin, bindingSource: current.source, bindingState: "BOUND", affinity: current.affinity, targetId: current.targetId, materializationPending: false, materializationOperationId: current.materializationOperationId, submittedAt: current.submittedAt, materializationObservedId: null, draftRoute: current.draftRoute, materializationError: null, }); } catch (error) { const reason = `身份已 materialize,但注册表升级失败;保持 PROVISIONAL,不会自动重发:${error instanceof Error ? error.message : String(error)}`; try { bindingStore.bind({ localSessionId: sessionId, conversationUrl: current.conversationUrl, targetId: current.targetId, origin: current.origin, bindingSource: current.source, bindingState: "PROVISIONAL", label: current.label, cwd: current.cwd, affinity: current.affinity, draftRoute: current.draftRoute, materializationPending: true, materializationOperationId: current.materializationOperationId, submittedAt: current.submittedAt, materializationObservedId: id, materializationError: reason, }); } catch { // Preserve the primary registry failure in the thrown error. } throw new Error(reason); } if (!upgraded.ok) { const reason = `身份已 materialize,但注册表升级失败;保持 PROVISIONAL,不会自动重发:${upgraded.reason}`; try { bindingStore.bind({ localSessionId: sessionId, conversationUrl: current.conversationUrl, targetId: current.targetId, origin: current.origin, bindingSource: current.source, bindingState: "PROVISIONAL", label: current.label, cwd: current.cwd, affinity: current.affinity, draftRoute: current.draftRoute, materializationPending: true, materializationOperationId: current.materializationOperationId, submittedAt: current.submittedAt, materializationObservedId: id, materializationError: reason, }); } catch { // Preserve the primary registry failure in the thrown error. } throw new Error(reason); } } /** * Bind this pi session to a conversation. The single implementation. * * There were two, under one name, and they did different things. `/brb bind ` * wrote a registry binding; the `bind` tool action wrote the legacy global config field * and reported success. So a caller using the tool was told the conversation was bound * while `status` did not reflect the registry and the registry file was never created -- * BUG-002, reported from another lane as "prints success but nothing lands". * Two things sharing a name and disagreeing about what the name means is the same * failure shape as the rest of this registry. * * The write is read back before success is reported. The complaint was precisely that * the success message was not backed by anything, so the message now has to be earned: * a write that is not confirmed is a claim, not a fact. */ async function bindSession( ctx: ExtensionContext, url: string, ): Promise<{ ok: boolean; reason: string; binding?: Binding; rebound?: boolean }> { const target = validateChatgptConversationUrl(url); if (!target.ok) return { ok: false, reason: target.reason }; url = target.url; const sessionId = localSessionId(ctx); if (!sessionId) { return { ok: false, reason: "拿不到本地 pi session id,无法绑定。它是绑定模型的主键。" }; } beginBindingOperation(sessionId); try { // A failed write must surface as a failed bind with a reason, not as a raw fs // exception -- and above all not as silence. BUG-002's acceptance exercises this // against an unwritable registry path. // A17: verify the account principal when a page is reachable and record it as // a verified fact. Absent data stays absent (UNKNOWN upstream), never guessed. let affinity: BindingAffinity | undefined; try { const probe = await ChatGptPage.attach({ url }).catch(() => null); if (probe) { // BUG-027 (BUG-020/023's class): an A17 observation opens a CDP session like // any other attach, and discarding it leaked one session per rotation. Close it // in a finally so the observation can fail without taking the cleanup with it. try { const principal = await probe.observePrincipal().catch(() => null); if (principal) { affinity = { observedIdentity: principal, source: "localstorage-user-prefix", verifiedAt: Date.now(), verificationVersion: 1 }; } } finally { await probe.close().catch(() => undefined); } } } catch { // best effort — affinity absence is a recorded UNKNOWN, never a guess } let bound: ReturnType; try { bound = bindingStore.bind({ localSessionId: sessionId, conversationUrl: url, bindingSource: "manual", label: ctx.sessionManager.getSessionName?.() ?? "", cwd: ctx.cwd, affinity, }); } catch (error) { return { ok: false, reason: `注册表写入失败(${bindingStore.path}):` + `${error instanceof Error ? error.message : String(error)}。` + "绑定未落盘,也没有报告成功。", }; } if (!bound.ok) return { ok: false, reason: bound.reason }; // Read it back. A registry that cannot be re-read after a successful write is the // exact defect being fixed, and reporting success anyway is what made it invisible. const persisted = bindingStore.findBySession(sessionId); if (!persisted) { return { ok: false, reason: "绑定写入后读不回来:注册表没有落盘。检查 " + `${bindingStore.path} 所在目录是否可写(权限、只读挂载、磁盘满)。`, }; } useBinding(persisted); // Keep the legacy field in step so a migration has something to compare against, but // routing no longer reads it. config = { ...config, conversationUrl: url }; saveConfig(config); autoCreatePhaseDecisions.delete(sessionId); return { ok: true, reason: "", binding: persisted, rebound: bound.rebound }; } finally { endBindingOperation(sessionId); } } /** The current repository context for one round. */ function digestNow(ctx: ExtensionContext): string { try { return buildRepoDigest({ cwd: ctx.cwd, budgetTokens: config.digestTokens }).markdown; } catch { return "(仓库摘要不可用)"; } } /** The established session, or a clear error. */ function requireSession(): Session { if (!loop.session?.established) { throw new Error("握手尚未建立,禁止发送任务。先执行 /brb start。"); } return loop.session; } /** * The protocol gate for one plan. * * Fatal means pi must not execute. A local capture fault is also fatal to * execution, but it is NOT attributed to the other side, so it must not* send a * REPAIR for a message that was never malformed. */ type ProtocolGate = | { ok: true; warnings: string[] } | { ok: false; kind: "local-capture" | "remote" | "version"; codes: string[]; errors: string[] }; /** Execute one rung of the stall ladder. */ async function executeStallAction(ctx: ExtensionContext, action: StallAction): Promise { try { switch (action) { case "relaunch-browser": await ensureBrowser({ port: config.port, browser: config.browser, browserExecutable: config.browserExecutable, profileMode: config.profileMode, profileDir: config.profileDir, profileDirectory: config.profileDirectory, proxy: config.proxy, // BUG-017: relaunch without the proxy goes direct and cannot reach chatgpt.com url: "https://chatgpt.com/", }); return; case "press-stop": { const page = await attachForExecution(ctx); try { await page.pressStop(); } finally { await page.close().catch(() => undefined); } return; } case "reload-tab": { const page = await attachForExecution(ctx); try { await page.reload(); } finally { await page.close().catch(() => undefined); } return; } case "reopen-conversation": // No fallback to a new chat. This is a recovery action, and recovery must not create // conversations: that is precisely how `rotate-conversation` used to bypass the // creation gates, and the external ruling names it as the example. If there is no // conversation to reopen, say so and let the ladder give up. if (!config.conversationUrl) { ctx.ui.notify?.( "[brb] 没有可重新打开的会话(conversationUrl 为空)。" + "恢复动作不会新建会话 —— 那需要人的显式意图与一次性授权。", "warning", ); return; } // BUG-027: keep the handle and drop only the CDP session (the tab stays open). // The old shape discarded it, so every recovery attempt leaked a session — and a // short-lived driver of this action hung instead of exiting. const reopened = await ChatGptPage.open(config.conversationUrl, { port: config.port }); await reopened.close().catch(() => undefined); return; default: // retry-read / activate-tab / nudge need no extra work here: // attachPage() already brings the tab to the front, and the caller // retries the relay after the backoff. return; } } catch (error) { ctx.ui.notify?.( `[brb] 处置动作 ${action} 本身失败了:${redactForDiagnostics(error instanceof Error ? error.message : String(error))}`, "warning", ); } } /** * Relay with the stall ladder. * * Only errors the policy layer recognises as page problems enter the ladder. * Anything else (protocol error, code bug, blocked account) is rethrown * untouched, because retrying those would make things worse. */ async function relayWithRecovery( ctx: ExtensionContext, payload: string, ): Promise<{ reply: string; plan: string | null; via: string | null; promoted: boolean; parsed: Plan | null }> { // A13 (verdict #13): one RecoveryEpisode per top-level operation. Recovery may // add EFFORT (kind-local staircase resets), never PERMISSION (episode-global // budgets never reset with a kind change; observation failure grants nothing). const episode = new RecoveryEpisode({ operation: `relay:${payload.slice(0, 24)}`, originalOperation: "message.send", deadlineMs: 30 * 60_000, }); for (;;) { try { const result = await relay(ctx, payload); loop.stall = resetStall(); return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); // A3: every failure is classified before it may enter recovery. Only // RECOVERABLE runtime failures reach the episode ladder; TERMINAL and // NOT_APPLICABLE pass through untouched (no recovery for protocol // errors, missing conversation ids, or unrecognized conditions). const disposition = classifyFailure(error, { operation: "relay" }); if (disposition.kind !== "RECOVERABLE") throw error; const kind = disposition.stallKind as StallKind; // Resend is budgeted 1 per ORIGINAL send operation, never per kind and // never refreshed by a kind change (verdict #13 DoD 7/10). The ladder // for not-delivered leads the relay to re-send, so the first // not-delivered pass consumes the budget; anything after that is // observation-only until give-up. if (kind === "not-delivered" && episode.budgetLeft("resend") === 0) { episode.markTerminal("resend 预算已用尽(1 次/原发送,kind 改变不恢复)"); throw new Error( `不再重发:resend 预算已达上限。请人工检查页面与网络后重新发起。(原始错误:${message})`, ); } // The retry policy comes from the operation's own registry entry rather than from // this function's judgement. `not-delivered` means the delivery check proved the // send did not arrive, so the reconciliation `message.send` requires has been // done -- and it is passed as a claim, which is the only thing the policy accepts. const retryVerdict = kind === "not-delivered" ? retryDecision("message.send", { reconciled: true }) : undefined; const { decision, state } = advanceStall(loop.stall, kind, { hasConversationUrl: Boolean(config.conversationUrl), allowRelaunch: true, retryVerdict, }); loop.stall = state; if (decision.action === "give-up") { episode.markTerminal(`${decision.reason}|${decision.note}`); // A3 give-up event contract: structured diagnostic only — no // capability/grant/token/payload/cookies/DOM. Canary consumes it; // the run transitions to STOPPED_GIVE_UP (absorbing). const giveUpEvent = buildGiveUpEvent({ episode, run: loop.run, roundNumber: loop.run?.currentRound ?? null, originalOperation: "message.send", conversationId: conversationIdOf(loop.conversationUrl ?? "") ?? null, stallKind: kind, failureClass: disposition.detail, reason: `${decision.reason}|${decision.note}`, }); recoveryEvents.emit(giveUpEvent); if (loop.run) loop.run.transition("STOPPED_GIVE_UP", giveUpEvent.reason); throw new Error(`${decision.reason}|${decision.note}(原始错误:${message})`); } // A13 gate: every ladder action must pass the registry predicates before // it runs (reconcile requirement, conversation-id requirement, episode // budgets, kind applicability). A denial is terminal for this episode. const deliveryState: DeliveryState = kind === "not-delivered" ? "delivery-reconciled" : "delivery-reconciled"; const gate = episode.canRun(decision.action, { kind, hasConversationUrl: Boolean(config.conversationUrl), deliveryState, }); if (!gate.ok) { episode.markTerminal(gate.reason); throw new Error(`${gate.reason}|原始错误:${message}`); } if (kind === "not-delivered") episode.consume("resend"); episode.recordAttempt(kind); episode.record(decision.action, decision.note); ctx.ui.notify?.( `[brb] 网页异常处置 #${state.attempt + 1}:${decision.action} — ${decision.note}` + `(episode ${episode.id},resend 预算 ${(episode.budgetLeft("resend") ?? "—")}/1)`, "warning", ); await sleep(decision.backoffMs); await executeStallAction(ctx, decision.action); } } } function stopLoop(ctx: ExtensionContext, reason: string): void { loop.active = false; loop.busy = false; loopSessionId = ""; // BUG-027: a page carried for round 1 must not outlive an aborted start. Closing here // covers every stop path (boundary, protocol violation, user command, give-up) at one // choke point instead of at each early return. if (carriedPage) { void carriedPage.close().catch(() => undefined); carriedPage = null; } // A3: terminal run state. give-up already transitioned to STOPPED_GIVE_UP // (absorbing); everything else is mapped here and never resurrected — a new // run requires an explicit start/resume/handoff. if (loop.run) { if (loop.run.state === "RUNNING") { if (/用户手动/.test(reason)) loop.run.transition("STOPPED_BY_USER", reason); else if (/上限|分钟|边界/.test(reason)) loop.run.transition("STOPPED_LIMIT", reason); else loop.run.transition("STOPPED_FATAL", reason); } loop.run = null; } // Releasing here rather than at every call site means a stop from any path // (boundary, protocol violation, user command, shutdown) frees the lock. store.release(lockOwner); if (lockedConversation) { conversationLock.release(lockedConversation, lockOwner); lockedConversation = ""; } restoreIdleStatus(ctx); ctx.ui.notify?.(`[brb] 自动接力已停止:${reason}(共 ${loop.iterations} 轮)`, "info"); } /* ---------------- automatic relay ---------------- */ pi.on("agent_settled", async (_event, ctx) => { if (!loop.active || loop.busy) return; const leafId = (ctx.sessionManager.getLeafId?.() as string | null) ?? null; if (leafId && leafId === loop.lastLeafId) return; loop.busy = true; try { // Settle the round that just finished. This is the only place prev and // checkpoint may advance, and it is what makes the next ROUND's `prev=` // the remote confirmation of this one. if (loop.session && loop.currentPlan) { const settled = driverSettle({ session: loop.session, plan: loop.currentPlan, facts: { changedFiles: changedFilesNow(ctx.cwd), ok: true, pushed: null }, index: loop.rounds.length + 1, startedAt: loop.roundStartedAt, }); loop.session = settled.session; loop.rounds.push(settled.record); // Shadow sampling is observation-only (A15): it writes a redacted record // to its own JSONL file and can never influence the loop (shadow.ts). if (shadowSamplingEnabled(config)) { appendShadowSample( buildShadowSample({ record: settled.record, objective: loop.objective, rootRequest: loop.rootRequest ?? "", plan: loop.currentPlan, }), ); } // The shared file defines "current progress", and the external cycle // driver reads it, so it is written here rather than kept in memory. store.save( { ...store.load(), session: loop.session, currentPlan: loop.currentPlan, rounds: loop.rounds, pending: loop.pending, repairs: loop.repairs, objective: loop.objective, rootRequest: loop.rootRequest, lastPlanHash: loop.lastPlanHash, }, lockOwner, ); } // Boundary gate. Every stop condition lives in cycle.ts. const verdict = checkBoundaries(loop.rounds, boundaries); if (verdict.stop) { stopLoop(ctx, describeVerdict(verdict)); return; } const round = collectRound(ctx, loop.lastLeafId); loop.lastLeafId = leafId; loop.iterations += 1; loop.roundStartedAt = Date.now(); // A3: a protocol round dispatch begins here; recovery actions inside this // round must keep the SAME round identity and must not consume maxRounds. loop.run?.beginRound(loop.iterations); if (!round.lastAssistant.trim()) { stopLoop(ctx, "pi 本轮没有产生文本输出,无法回传"); return; } // One call owns the whole round: build the ROUND, gate the reply, repair // when the fault is the other side's, and stop when it is not. const run = await driverRunRound({ transport: createTransport(ctx), session: requireSession(), repairs: loop.repairs, contract: FULL_CONTRACT, promptTokens: config.promptTokens, facts: { rootRequest: loop.rootRequest ?? "", objective: loop.objective, digest: digestNow(ctx), receipt: renderTemplate(config.resultTemplate, { iteration: String(loop.iterations), tools: config.includeToolNames ? round.toolNames.join(", ") || "(无)" : "", result: clip(round.lastAssistant), }), }, onEvent: (event) => { if (event.kind === "gate-rejected" || event.kind === "repair-sent") { ctx.ui.notify?.(`[brb] ${event.kind}:${event.detail}`, "warning"); } }, }); loop.repairs = run.repairs; if (run.outcome.kind !== "plan") { // `stopped` and `failed` both end the loop; the reason already says // whether it was a protocol problem, a capture problem or a budget. stopLoop(ctx, run.outcome.reason); return; } for (const warning of run.outcome.warnings) { ctx.ui.notify?.(`[brb] 提示(不阻断):${warning}`, "warning"); } const next = run.outcome.raw.trim(); if (!next) { stopLoop(ctx, "ChatGPT 返回了空方案"); return; } if (isDone(next, next, config.donePattern)) { stopLoop(ctx, "ChatGPT 报告任务完成"); return; } const hash = `${next.length}:${next.slice(0, 400)}`; if (hash === loop.lastPlanHash) { stopLoop(ctx, "ChatGPT 返回了完全相同的方案,可能是死循环"); return; } loop.lastPlanHash = hash; // The round now in flight is executing this plan; its DECISION is what // the next settle reads. loop.currentPlan = run.outcome.plan; ctx.ui.notify?.(`[brb] 第 ${loop.iterations} 轮方案已注入 pi`, "info"); loop.lastLeafId = (ctx.sessionManager.getLeafId?.() as string | null) ?? loop.lastLeafId; deliver(ctx, next); } catch (error) { loop.lastError = error instanceof Error ? error.message : String(error); stopLoop(ctx, loop.lastError); } finally { loop.busy = false; } }); function deliver(ctx: ExtensionContext, text: string): void { try { if (ctx.isIdle()) { pi.sendUserMessage(text); } else { pi.sendUserMessage(text, { deliverAs: "followUp" }); } } catch { pi.sendUserMessage(text, { deliverAs: "followUp" }); } } /* ---------------- control commands (>> namespace) ---------------- */ /** Current git HEAD, used to bind a pending action to the repo state. */ function headNow(cwd: string): string { try { return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return "no-git"; } } /** The canonical instruction handed to the model for a control command. */ /** * The instruction handed to the model for a control command. * * The quoted command comes from `raw` -- what the user actually typed -- not * from a canonical string assembled here. The earlier version printed * `>>交接` because it hardcoded the prefix, and the user had typed * `brb 交接`; describing a thing from a second source of truth is how a * display string silently goes stale. */ function controlEnvelope(command: ControlCommand, extra?: string): string { const definitions: Record = { handoff: "把当前进度、待决问题与必要上下文规范化发给 ChatGPT(走握手:HELLO 已建立则发 ROUND),取回方案,然后**只报告,不修改任何代码**。", report: "把最近一轮的执行结果整理成回执发给 ChatGPT,取下一步方案,**不执行**。", execute: "按最近已确认的方案执行:改代码 → 跑 npm test → 提交推送 → 报告。不向 ChatGPT 请求新方案。", round: "执行完整一轮:交接取回方案 → 按方案执行 → 跑 npm test → 提交推送 → 形成回执。", }; // Output-shape request for the free-form commands (handoff/report). The relay // extraction falls back to a whole-turn copy when a reply carries several code // blocks, which is lossless but returns prose + many small blocks. A fresh // conversation has no "single block" habit, so ask for the shape explicitly // instead of relying on inherited chat context. Soft wording: it is a request, // not a contract (the deterministic loop's RESPONSE_CONTRACT stays the only // hard shape requirement). const OUTPUT_SHAPE_HINT = "输出格式:如需给出方案/计划/回执,请用**单个 Markdown 代码块**(一个 ``` 围栏)输出完整内容,不要拆成多个小代码块。"; const lines = [ `[CONTROL:${command.action}] 控制命令(用户原文)`, "```text", command.raw, "```", "", "这是已解析的控制命令;按动作定义执行,不重新解释用户意图。", "", `动作定义:${definitions[command.action] ?? command.action}`, ]; if (command.action === "handoff" || command.action === "report") { lines.push("", OUTPUT_SHAPE_HINT); } if (command.payload) { lines.push( "", `用户附带的任务正文(qid=${command.qid}):`, "```text", command.payload, "```", ); } else { lines.push("", "用户没有附带任务正文。"); } if (extra) lines.push("", extra); return lines.join("\n"); } pi.on("input", async (event, ctx) => { // Never re-process what an extension injected; only user input is matched. if (event.source === "extension") return { action: "continue" as const }; const match = matchControlCommand(event.text ?? ""); if (match.kind === "none") return { action: "continue" as const }; // An abandoned spelling gets one read-only explanation. It is NOT an alias: // a second working spelling for a surface with real side effects is how the // ambiguity this design removed would come back. if (match.kind === "deprecated") { ctx.ui.notify?.( `[控制] 「${match.word}」的旧写法已废弃,请改用:${match.replacement}`, "warning", ); return { action: "handled" as const }; } // Oversized payloads are CONSUMED, never passed through. Letting one fall // through would send it to the agent as prose and bypass the confirmation // that guards the high-risk actions -- a fail-open hole exactly where // fail-closed matters most. No truncation either: the request the user // confirmed and the request ChatGPT receives must be the same text. if (match.kind === "oversized") { ctx.ui.notify?.( `[控制] 正文过长:${match.actual} 个字符,上限 ${match.limit}。` + `不截断、不摘要、不转发——请把正文精简,或改用文件与 webgpt ${specFor(match.action).zh} 分开处理。`, "error", ); return { action: "handled" as const }; } const command = match.command; const { action, word, payload, qid } = command; // A payload starts a new request, so the previous plan no longer describes // what is being worked on. Letting `brb 执行` follow a new payload would // execute the old task; this is the invariant that makes payloads safe. if (payload) { if (loop.currentPlan) { ctx.ui.notify?.( `[控制] 新任务正文已到达(qid=${qid}),旧的方案立即失效,不再可执行。`, "warning", ); loop.currentPlan = null; } loop.objective = payload; loop.rootRequest = payload; loop.qid = qid; // The FRAME's qid must name the request ChatGPT is actually answering. // Leaving the old qid while the body says something else is precisely the // request-correlation lie the spec forbids. if (loop.session) { loop.session = { ...loop.session, qid }; } } switch (action) { case "status": { // One status surface. The hook used to assemble its own summary while the // /brb command used statusText(), so the two drifted -- the hook was // missing the lock holder and the command vocabulary. ctx.ui.notify?.(await statusText(ctx), "info"); return { action: "handled" as const }; } case "new": { // The only place a creation authorisation can come from, and that is the // point: a person typed this. Recovery code, retries, probes, tests and // concurrent starts all want a conversation and none of them is a person, // so the gate stays a gate rather than a formality. const sessionId = localSessionId(ctx); if (!sessionId) { ctx.ui.notify?.("[控制] 拿不到本地 pi session id,无法记录新建意图。", "error"); return { action: "handled" as const }; } // The production writer, and the only one: this is a control command the person // typed, matched by a deterministic parser rather than inferred from prose. // Recovery, retries, probes and tests cannot reach this line. const { intent } = issueCreationIntent( sessionId, // `match` is a union; the payload hangs off the command, not the match. // Reading the wrong one yields undefined without complaint, so the reason // was silently always the default. (match.command.payload ?? "").trim() || "用户显式新建", ); const gateInput = { consent: consentStore, localSessionId: sessionId, cap: config.maxNewConversationsPerDay, usedInWindow: autoCreateUsage(), }; const decision = canCreate(gateInput); ctx.ui.notify?.( [ `[控制] 已记录新建意图:${intent.reason}`, "", describeGates(gateInput), "", decision.ok ? `三闸门都开。下一步 /brb start <任务> 时会新建一条会话,并消耗掉这次授权。` : `注意:${decision.reason}${NL}${NL}${gateHelp()}`, ].join(NL), decision.ok ? "info" : "warning", ); return { action: "handled" as const }; } case "conversations": { // Read-only, and deliberately so. Counting is the bridge's business; // deleting is not. Clearing conversations in bulk is what ChatGPT's // abuse protection reads as suspicious, so the bridge shows what exists // and leaves the pace to the person doing it. ctx.ui.notify?.(conversationsText(), "info"); return { action: "handled" as const }; } case "stop": { loop.active = false; loop.pending = null; ctx.ui.notify?.("[控制] 已停止自动接力,并清除待确认动作。可重复调用。", "info"); return { action: "handled" as const }; } case "cancel": { const had = loop.pending; loop.pending = null; // "Cancel" should also withdraw a pending authorisation to create a // conversation. Leaving it armed would mean a later `start` creating one // after the person believed they had called it off -- and that is exactly // the class of surprise the consent gates exist to remove. const sessionId = localSessionId(ctx); const revoked = sessionId ? consentStore.revoke(sessionId) : false; const parts = [had ? `已取消:${describePending(had)}` : "没有待确认动作。"]; if (revoked) parts.push("同时撤回了新建会话的授权。"); ctx.ui.notify?.(`[控制] ${parts.join("")}`, "info"); return { action: "handled" as const }; } case "execute": case "round": { // Two different situations share this branch, and conflating them was a bug: // without a payload the command *continues* the current task, so there must // be something to continue; with a payload it *starts* one, and requiring an // existing plan would be backwards -- the plan is exactly what this round is // about to fetch. if (!payload) { // One control message stays one determinate action: a bare round with // nothing to continue is refused locally rather than opening an // interactive "please enter a task" sub-state. if (!loop.objective) { ctx.ui.notify?.( `[控制] 当前没有可沿用的任务,请使用:${canonicalCommand("round")} <任务描述>`, "warning", ); return { action: "handled" as const }; } if (!loop.session || !loop.currentPlan) { ctx.ui.notify?.( `[控制] 没有可执行的当前方案。先 ${canonicalCommand("handoff")} 取回一份方案。`, "warning", ); return { action: "handled" as const }; } } const pending: PendingAction = { action, // A payload starts a task, and that can happen before any session // exists. Empty values are honest here: they mean "not established // yet", and the confirmation compares them like any other field, so a // session appearing in between invalidates the pending. sid: loop.session?.sid ?? "", round: loop.session?.round ?? 0, planKey: loop.currentPlan ? planKey(loop.currentPlan) : "", // The request this confirmation applies to. During a payload // change the qid changes, so the old pending expires instead of // silently applying to the new request. qid: loop.qid, head: headNow(ctx.cwd), createdAt: Date.now(), }; loop.pending = pending; const previewLines = payload ? [ "", `本轮任务正文(${[...payload].length} 字符,显示前 ${Math.min([...payload].length, 120)}):`, payloadPreview(payload), ] : ["", "本轮沿用当前任务。"]; ctx.ui.notify?.( [ `[控制] ${canonicalCommand(action)} 是有副作用的动作,需要确认。`, `待确认:${describePending(pending)}`, ...previewLines, "", `确认:${canonicalCommand("confirm")} | ${canonicalCommand("confirm", "en")}`, `取消:${canonicalCommand("cancel")} | ${canonicalCommand("cancel", "en")}`, ].join("\n"), "warning", ); return { action: "handled" as const }; } case "confirm": { const pending = loop.pending; const current = { sid: loop.session?.sid ?? "", round: loop.session?.round ?? 0, planKey: loop.currentPlan ? planKey(loop.currentPlan) : "", qid: loop.qid, head: headNow(ctx.cwd), now: Date.now(), }; const verdict = confirmationStillValid(pending, current); if (!verdict.ok || !pending) { loop.pending = null; ctx.ui.notify?.(`[控制] 确认无效:${verdict.reason}。未执行任何操作。`, "error"); return { action: "handled" as const }; } loop.pending = null; ctx.ui.notify?.(`[控制] 确认通过,开始 ${pending.action}。`, "info"); // Rebuild the command from the registry and the stored pending, so the // confirmed instruction carries the same qid the user saw. return { action: "transform" as const, text: controlEnvelope( { action: pending.action, word: specFor(pending.action).zh, payload: loop.rootRequest ?? "", qid: pending.qid, raw: canonicalCommand(pending.action), }, `已通过 ${canonicalCommand("confirm")} 校验(${describePending(pending)})。`, ), }; } case "handoff": case "report": return { action: "transform" as const, text: controlEnvelope(command) }; default: return { action: "handled" as const }; } }); /* ---------------- tool ---------------- */ /** * What the `webgpt` tool accepts. * * Declared here rather than inferred from the schema because the schema is plain JSON, * and because the tool is the one place where every field arrives untyped. Two defects * in this file -- a `payload` read off the wrong union member and a `nearCodeBlock` * read off a shape that never had it -- were both this: a field name that looked right * and evaluated to undefined. */ interface WebgptToolParams { action: "ask" | "plan" | "read" | "copy" | "sources" | "status" | "doctor" | "bind"; text?: string; url?: string; index?: number; label?: string; copyMode?: CopyMode; } pi.registerTool({ name: "brb", label: "ChatGPT Web", description: "Drive the bound chatgpt.com conversation tab in the automation browser. " + "'ask' sends text and waits for the full reply; 'plan' additionally clicks ChatGPT's " + "one-click copy button and returns the clean Markdown plan; 'read' returns the last reply; " + "'copy' only performs the copy-button extraction; 'status'/'doctor' report bridge health.", promptSnippet: "Ask the bound ChatGPT web thread for a plan and read the copied Markdown back", promptGuidelines: [ "Use webgpt when the user asks to consult, continue, or hand a result back to the bound ChatGPT web conversation.", "Use webgpt with action \"plan\" to fetch the next plan from the ChatGPT web thread, then execute that plan with the normal tools.", ], // Plain JSON Schema instead of TypeBox helpers: keeps this extension // dependency-free, so it loads no matter how pi resolves its own packages. parameters: { type: "object", properties: { action: { type: "string", enum: ["ask", "plan", "read", "copy", "sources", "status", "doctor", "bind"], description: "What to do with the bound ChatGPT web thread.", }, text: { type: "string", description: "Text to send (required for 'ask' and 'plan')." }, url: { type: "string", description: "Conversation URL (for 'bind')." }, index: { type: "number", description: "Exact copy source index inside the newest turn (see action 'sources').", }, label: { type: "string", description: "Regex matched against the copy button label, e.g. '^复制回复'. Overrides copyMode.", }, copyMode: { type: "string", // Spread from the one list (BUG-015): a hand-written mirror of the legal values here // is the same drift that let a legacy value act as `auto` without anything saying so. enum: [...COPY_MODES], description: "Which copy affordance to prefer: 'code' = the code block's copy button, 'turn' = copy the whole reply, 'dom' = read the DOM text, 'auto' = code then turn then dom.", }, }, required: ["action"], additionalProperties: false, }, async execute(_toolCallId, rawParams, signal, onUpdate, ctx) { // `parameters` above is a plain JSON Schema so the extension loads without any // package resolution, which leaves the SDK only able to infer `{}` here. Naming // the shape is what turns `params.url` back into a string -- without it every // field read is an empty object, and `params.text?.trim()` does not compile. // Two casts, not one, and the reason is worth stating: the SDK's inference from a // plain JSON Schema yields a type whose fields are all optional-unknown, which // does not overlap with the real shape. The double cast is the honest way to say // "this cannot be checked mechanically, here is the shape it actually has", // rather than widening the interface until the fake agreement type-checks. const params = rawParams as unknown as WebgptToolParams; // A5+A16 config lifecycle: this held `config` is operation-scoped, refreshed // at the start of every public operation. What it must never be again is the // activation-time snapshot that outlived the world it described. config = loadConfig(); surfaceConfigWarnings(ctx); const mode = params.copyMode ?? config.copyMode; if (params.action === "status") { return { content: [{ type: "text", text: await statusText(ctx) }], details: { config, loop: { ...loop } }, }; } if (params.action === "bind") { if (!params.url) throw new Error("bind 需要 url 参数"); // The same implementation the command uses. This branch used to write the legacy // config field alone and report success, so the registry was never created. const bound = await bindSession(ctx, params.url); if (!bound.ok || !bound.binding) throw new Error(bound.reason); return { content: [{ type: "text", text: `已绑定:${describeBinding(bound.binding)}` }], details: { binding: bound.binding, conversationUrl: bound.binding.conversationUrl }, }; } if (params.action === "doctor") { const report = await doctorText(ctx); return { content: [{ type: "text", text: report.text }], details: { degraded: report.warning } }; } if (params.action === "sources") { return withBindingOperation(localSessionId(ctx), async () => { const prepared = await ensureBinding(ctx, "sources"); await verifyAndCommitCandidate(ctx, prepared, "sources"); const page = await attachBoundPage(ctx, { requireReadable: true }); try { const state = await page.state(); if (!state.messages.length && !state.turnNumber) { return { content: [{ type: "text", text: PROVISIONAL_READ_MESSAGE }], details: {} }; } const report = await page.resolveCopySources(); const lines = [ `最新轮: ${report.turnId ?? "(未识别)"} 锚点: ${report.anchor}`, `回复预览: ${report.replyPreview.slice(0, 80) || "(空)"}`, `可用复制源 (${report.sources.length}):`, ...report.sources.map( (source) => ` [${source.index}] ${source.kind}${source.isReply ? "/reply" : ""}` + `${source.nearCodeBlock ? "/codeblock" : ""} :: ${source.label}`, ), ]; return { content: [{ type: "text", text: lines.join("\n") }], details: report, }; } finally { await page.close().catch(() => undefined); } }); } if (params.action === "read") { return withBindingOperation(localSessionId(ctx), async () => { const prepared = await ensureBinding(ctx, "read"); await verifyAndCommitCandidate(ctx, prepared, "read"); const page = await attachBoundPage(ctx, { requireReadable: true }); try { const state = await page.state(); if (!state.messages.length && !state.turnNumber) { return { content: [{ type: "text", text: PROVISIONAL_READ_MESSAGE }], details: {} }; } const reply = await page.readLastReply(); return { content: [{ type: "text", text: reply || "(没有读到任何回复)" }], details: {} }; } finally { await page.close().catch(() => undefined); } }); } if (params.action === "copy") { return withBindingOperation(localSessionId(ctx), async () => { const prepared = await ensureBinding(ctx, "copy"); await verifyAndCommitCandidate(ctx, prepared, "copy"); const page = await attachBoundPage(ctx, { requireReadable: true }); try { const state = await page.state(); if (!state.messages.length && !state.turnNumber) { return { content: [{ type: "text", text: PROVISIONAL_READ_MESSAGE }], details: {} }; } await page.installClipboardHook(); const copied = await page.copyLastPlan(mode, { index: params.index as number | undefined, label: (params.label as string | undefined) ?? config.copyLabel, }); return { content: [{ type: "text", text: copied.text }], details: { via: copied.via, source: copied.source, label: copied.label, turnId: copied.turnId, candidates: copied.candidates, }, }; } finally { await page.close().catch(() => undefined); } }); } // ask | plan if (!params.text?.trim()) throw new Error(`${params.action} 需要 text 参数`); if (signal?.aborted) throw new Error("已取消"); const requestText = params.text; return withBindingOperation(localSessionId(ctx), async () => { const prepared = await ensureBinding(ctx, params.action === "plan" ? "plan" : "ask"); await verifyAndCommitCandidate(ctx, prepared, params.action === "plan" ? "plan" : "ask"); // DECISION-009 send-boundary freshness barrier: the destination is resolved from // durable state NOW -- never from the in-memory config snapshot, which BUG-012 // showed can name a predecessor conversation in a long-lived process. No durable // routing, no send: "current tab" guessing ends here. const fresh = resolveFreshSendTarget( freshSendDeps(ctx, isProvisionalBinding(bindingStore.findBySession(localSessionId(ctx)))), ); if (!fresh.ok) throw new Error(fresh.reason); onUpdate?.({ content: [{ type: "text", text: "已发送到 ChatGPT,等待方案…" }], details: {} }); // UX parity with the slash-command path (verdict #25): the tool card gets the // coarse onUpdate, the Pi TUI bottom bar gets the fine-grained live status // (elapsed seconds + reply character count) via setStatus, exactly like relay(). // Name the phase in the footer. The pre-send steps used to be silent, so a // cold conversation tab (seconds of navigation + SPA render) looked like // "nothing is happening" — and the ruling asked for exactly this: state // truth, not a performance change. const tabAlreadyOpen = await listTargets(config.port) .then((ts) => ts.some((t) => t.type === "page" && conversationIdOf(t.url) === fresh.target.conversationId)) .catch(() => true); setTransientStatus(ctx, tabAlreadyOpen ? "正在握手:连接浏览器与目标会话…" : "正在打开会话页(冷加载较慢)…"); // BUG-029 (same shape as relay's): the first phase is published before the try, so an // attach failure must restore the badge itself instead of leaving the line stuck. const page = await attachPage({ url: fresh.target.conversationUrl, targetId: fresh.target.targetId }).catch( (error: unknown) => { restoreIdleStatus(ctx); throw error; }, ); try { setTransientStatus(ctx, "已连接,正在投递消息…"); // The attached page must BE the fresh target; a stale cached page object must // never override a fresh authoritative resolution (DECISION-009's anti-fake-fix). const attachedId = conversationIdOf(page.target.url) ?? ""; if ( attachedId !== fresh.target.conversationId || (fresh.target.targetId && page.target.id !== fresh.target.targetId) ) { throw new Error( `附着到的页面(${attachedId || page.target.url})不是新鲜解析出的目标` + `(${fresh.target.conversationId.slice(0, 8)}…)。拒绝发送。`, ); } await page.installClipboardHook(); const sendDecision = requestCapability({ operation: "message.send", context: `tool:${params.action} session=${localSessionId(ctx) ?? "?"}`, }); if (!sendDecision.ok) throw new Error(sendDecision.reason); // The smallest validation against the read→send window: registry writes are // atomic, so a disagreement here means a rotation landed in between. const still = validateStillCurrent( freshSendDeps(ctx, isProvisionalBinding(bindingStore.findBySession(localSessionId(ctx)))), fresh.target, ); if (!still.ok) throw new Error(still.reason); const provisionalAtSend = bindingStore.findBySession(localSessionId(ctx)); // Filled by onSubmitConfirmed (BUG-060: evidence first) and consumed by onSubmitted. let materializationId: string | null = null; const { reply } = await page.ask(sendDecision.capability, requestText, { expectConversationId: fresh.target.conversationId, expectedTargetId: fresh.target.targetId, onSubmitConfirmed: async () => { if (!provisionalAtSend || !isProvisionalBinding(provisionalAtSend)) return; materializationId = markMaterializationPending(ctx, page.target.id); }, onSubmitted: async () => { if (!materializationId) return; await waitForMaterialization(ctx, page, materializationId); }, timeoutMs: config.waitTimeoutMs, submitDelayMs: config.submitDelayMs, onTick: ({ elapsedMs, textLength }) => { setTransientStatus(ctx, formatWaitingStatus(elapsedMs, textLength)); }, }); if (params.action === "ask") { setTransientStatus(ctx, "回复完成…"); return { content: [{ type: "text", text: `=== ChatGPT 回复 ===\n${reply}` }], details: { chars: reply.length }, }; } setTransientStatus(ctx, "回复完成,提取方案…"); const copied = await page.copyLastPlan(mode, { index: params.index as number | undefined, label: (params.label as string | undefined) ?? config.copyLabel, }); return { content: [ { type: "text", text: `=== ChatGPT 一键复制的方案(${copied.via}|${copied.turnId ?? "?"}|${copied.label})=== ` + `${copied.text} ` + `=== 完整回复 === ${clip(reply, 8000)}`, }, ], details: { via: copied.via, source: copied.source, label: copied.label, turnId: copied.turnId, candidates: copied.candidates, }, }; } finally { // Hard requirement (verdict #25/#26): restore the idle badge on EVERY exit // path (success, timeout, abort, stale binding, copy failure) before the // page goes — a task ending must never leave the line blank. restoreIdleStatus(ctx); const liveUrl = await page.state().then((state) => state.url).catch(() => null); try { if (liveUrl) syncBindingUrl(ctx, liveUrl, page.target.id); } finally { void refreshIdleStatus(ctx); await page.close().catch(() => undefined); } } }); }, }); /** Shared, read-only auto-create facts for inventory and diagnostics. */ function autoCreateInventory() { const created = bindingStore.autoCreated(); const day = bindingStore.autoCreatedSince(24 * 60 * 60 * 1000); const quotaOrphans = orphanStore.countSince(24 * 60 * 60 * 1000); return { created, day, quotaOrphans, cap: config.maxNewConversationsPerDay }; } /** A read-only inventory of the conversations this bridge created. */ function conversationsText(): string { const { created, day, quotaOrphans, cap } = autoCreateInventory(); const lines: string[] = []; if (created.length === 0) { lines.push("[brb] 这个桥没有自动新建过会话(只读清单,不会删除任何东西)。"); lines.push(""); lines.push(`自动新建额度:过去 24 小时用了 ${quotaOrphans}(含未解决残留) / ${cap}。计入 PENDING 残留及带会话 id 的已解决残留。`); return lines.join("\n"); } lines.push(`[brb] 这个桥自动新建过 ${created.length} 条会话(只读清单,不会删除任何东西):`); lines.push(""); for (const entry of created.slice(0, 20)) { const age = Math.round((Date.now() - entry.createdAt) / 60000); const when = age < 60 ? `${age} 分钟前` : `${Math.round(age / 60)} 小时前`; const id = entry.conversationId || "(还没有 id)"; lines.push(` - ${id} ${when} ${entry.label || entry.localSessionId}`); } if (created.length > 20) lines.push(` …还有 ${created.length - 20} 条`); lines.push(""); lines.push(`自动新建额度:过去 24 小时用了 ${day.length + quotaOrphans}(含未解决残留) / ${cap}。计入 PENDING 残留及带会话 id 的已解决残留。`); lines.push(""); lines.push("删除请在 ChatGPT 里逐条进行 —— 批量删除会被它的安全机制盯上。"); return lines.join("\n"); } function localDoctorText(ctx: ExtensionContext): string { const sessionId = localSessionId(ctx); const binding = sessionId ? bindingStore.findBySession(sessionId) : null; const legacyUrl = config.conversationUrl || null; const legacyOwner = legacyUrl ? bindingStore.findByConversation(legacyUrl) : null; const inventory = autoCreateInventory(); const pending = sessionId ? orphanStore.pendingForSession(sessionId) : null; let configReadable = "可读"; try { JSON.parse(readFileSync(runtimeConfigReadPath(), "utf8")); } catch { configReadable = "不可读(当前以默认/有效配置诊断)"; } return [ "LOCAL:", `配置: ${configReadable} (${runtimeConfigReadPath()})`, ...buildBindingStatusLines(binding, legacyUrl, legacyOwner, sessionId, config.autoCreateOnUnbound), `自动创建策略: ${config.autoCreateOnUnbound}`, `自动新建额度: 过去 24 小时用了 ${inventory.day.length + inventory.quotaOrphans}(含未解决残留) / ${inventory.cap}。计入 PENDING 残留及带会话 id 的已解决残留。`, `未解决创建残留: ${pending ? pending.id : "无"}`, `注册表: ${bindingStore.corruptedRecently() ? "不可读/损坏(已按空注册表处理)" : "可读"}`, `CDP endpoint: http://127.0.0.1:${config.port}`, ...browserCapabilityLines(), ].join(NL); } function pageDoctorSummary(report: Awaited>): string { return [ "PAGE:", `页面: ${report.pageTitle ?? "-"}`, `URL: ${report.pageUrl ?? "-"}`, `页面状态: ${report.pageState ?? "-"}`, `编辑器: ${report.composerFound ? "找到" : "未找到"}`, `发送按钮: ${report.sendButtonFound ? "找到" : "未找到"}`, `助手消息数: ${report.assistantCount ?? "-"}`, `一键复制按钮: ${report.copyButtons.length} 个` + (report.copyButtons.length ? ` [${report.copyButtons.map((b) => `${b.label}${b.nearCodeBlock ? "/代码块" : ""}`).join(", ")}]` : ""), `剪贴板钩子: ${report.clipboardHook ? "已装" : "未装"}`, report.problems.length ? `问题:\n - ${report.problems.join("\n - ")}` : "问题: 无", report.notes?.length ? `提示:\n - ${report.notes.join("\n - ")}` : null, "", "标签页:", ...report.targets.map((t) => ` - ${t.url}`), ].filter((line): line is string => line !== null).join(NL); } // Round B2: report which browser BRB would use and what holds the debug port. // `doctor` stays read-only: it probes, it never launches a browser, and it never // ends a process that belongs to someone else. function browserCapabilityLines(): string[] { const found = discoverBrowser({ browser: config.browser, browserExecutable: config.browserExecutable, resolvedBrowser: config.resolvedBrowser, resolvedExecutable: config.resolvedExecutable, }); const detail = found.candidate ? ` (${found.candidate.kind})` : found.rejected ? ` (${found.rejected.family})` : ""; return [`浏览器能力: ${found.capability}${detail}`, ...found.notes.map((note) => ` - ${note}`)]; } // CDP can answer the HTTP probes while its WebSocket handshake never completes; doctor must // degrade instead of leaving its read-only diagnostics command and its process stuck forever. /** * Port ownership and proxy reachability, appended to the doctor report. * * Kept out of `doctorText` itself so the command's own shape stays compact and * both facts are still available on every path -- including the paths that * return early because CDP is unreachable, which is exactly where "the port is * held by something that is not ours" needs to be said out loud. */ async function appendPortProxyLines(lines: string[]): Promise { try { const portState = await probePort(config.port, { stateRoot: stateRoot() }); lines.push(`端口归属: ${portState.state}${portState.browser ? ` (${portState.browser})` : ""}`); lines.push(` - ${portState.detail}`); } catch (error) { lines.push(`端口归属: 无法判定(${error instanceof Error ? error.message : String(error)})`); } try { const proxyState = await probeProxy(config.proxy); lines.push(`代理: ${proxyState.state}${proxyState.target ? ` (${proxyState.target})` : ""}`); lines.push(` - ${proxyState.detail}`); } catch (error) { lines.push(`代理: 无法判定(${error instanceof Error ? error.message : String(error)})`); } } // CDP can answer the HTTP probes while its WebSocket handshake never completes; doctor must // degrade instead of leaving its read-only diagnostics command and its process stuck forever. const DOCTOR_ATTACH_TIMEOUT_MS = 12_000; async function doctorText(ctx: ExtensionContext): Promise<{ text: string; warning: boolean }> { const lines = [localDoctorText(ctx)]; await appendPortProxyLines(lines); // This covers every read-only CDP operation below, not just the WebSocket. // Node fetch has no application-level deadline, so a half-open HTTP endpoint // could otherwise prevent doctor from ever reaching its local report. const doctorAbort = new AbortController(); const doctorTimer = setTimeout(() => doctorAbort.abort(), DOCTOR_ATTACH_TIMEOUT_MS); let status; try { status = await browserStatus(config.port, doctorAbort.signal); } catch (error) { lines.push(`BROWSER/CDP: 不可达(${error instanceof Error ? error.message : String(error)})`); lines.push("页面诊断: CDP 不可达,已跳过。"); clearTimeout(doctorTimer); return { text: lines.join(NL), warning: true }; } if (!status.running) { lines.push(`BROWSER/CDP: 不可达(端口 ${config.port} 未运行;未启动浏览器)`); lines.push("页面诊断: CDP 不可达,已跳过。"); clearTimeout(doctorTimer); return { text: lines.join(NL), warning: true }; } lines.push(`BROWSER/CDP: OK (${status.browser ?? "unknown"})`); let page: ChatGptPage; try { page = await attachPage({ allowCurrentTab: true, signal: doctorAbort.signal }); } catch { lines.push("页面诊断: 无法附着目标页面,已跳过。"); return { text: lines.join(NL), warning: true }; } finally { clearTimeout(doctorTimer); } try { const report = await page.doctor(); lines.push(pageDoctorSummary(report)); return { text: lines.join(NL), warning: report.problems.length > 0 }; } catch (error) { lines.push(`页面诊断: 无法读取目标页面,已跳过(${error instanceof Error ? error.message : String(error)})。`); return { text: lines.join(NL), warning: true }; } finally { await page.close().catch(() => undefined); } } /** * Onboarding wizard (verdict #17): a re-entrant, recoverable 7-step setup. * The wizard edits a SetupDraft; only Review/Confirm commits config-level * choices (pushPolicy, onboardingVersion). Real operations (launch, bind) * execute immediately as themselves, never as onboarding privileges. */ async function setupWizard(ctx: ExtensionContext): Promise { const draft: SetupDraft = emptyDraft(); const gitRunner: (args: string[]) => { ok: boolean; stdout: string; stderr: string } = (args) => { try { const out = execFileSync("git", args, { cwd: ctx.cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); return { ok: true, stdout: out, stderr: "" }; } catch (error) { const e = error as { stdout?: Buffer; stderr?: Buffer; status?: number }; return { ok: false, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? String(error)) }; } }; // A3/A4 合规提示(两屏):第三方服务 + 数据流。取消 = dismissed,不触发任何远端副作用。 const noticeOk = await ctx.ui.confirm( "第三方服务提示", "BRB 会操作你本人已登录的 ChatGPT Web 页面,并代表你发送内容、读取页面返回的结果。" + String.fromCharCode(10) + "ChatGPT 是第三方服务,BRB 不是 OpenAI 官方产品。" + String.fromCharCode(10) + "BRB 不会尝试绕过验证码、使用限额、安全挑战或其他平台保护措施;遇到这些情况会停止自动操作并交还给你处理。" + String.fromCharCode(10) + "继续即表示你确认自己有权提交相关内容,并自行确认这种使用方式符合相应服务条款和组织政策。" + String.fromCharCode(10) + "(下一屏:数据流提示;之后进入 7 步配置向导,可随时取消。)", ); if (!noticeOk) { config = { ...config, onboardingVersion: 1, onboardingState: "dismissed" }; saveConfig(config); ctx.ui.notify?.("已取消第三方服务提示(dismissed)。未执行任何远端操作。之后随时可运行 /brb setup 重新进入。", "info"); return; } const dataFlowOk = await ctx.ui.confirm( "数据流提示", "你交给 BRB 的任务会发送到 ChatGPT;返回内容由 BRB 读取并交回 Pi," + String.fromCharCode(10) + "随后可能按你的 Pi 工作流发送给你配置的 AI provider。" + String.fromCharCode(10) + "不要提交你无权提供给这些服务的密码、凭据、个人数据、公司机密或其他受限制内容。", ); if (!dataFlowOk) { config = { ...config, onboardingVersion: 1, onboardingState: "dismissed" }; saveConfig(config); ctx.ui.notify?.("已取消数据流提示(dismissed)。未执行任何远端操作。之后随时可运行 /brb setup 重新进入。", "info"); return; } const start = await ctx.ui.confirm( "BRB 首次配置向导", "本向导分 7 步配置:浏览器/登录 → ChatGPT 会话 → 项目 Git 推送策略。" + String.fromCharCode(10) + "全程只读检测 + 你确认后执行;创建新会话会走每日创建额度,绝不绕过。", ); if (!start) { config = { ...config, onboardingVersion: 1, onboardingState: "dismissed" }; saveConfig(config); ctx.ui.notify?.("已跳过(dismissed)。之后随时可运行 /brb setup 重新进入。", "info"); return; } // Step 1: Environment(轻量,复用既有检查,不写第二套 checker) const envLines: string[] = []; try { const version = await browserStatus(config.port); envLines.push(`CDP 端口 ${config.port}: ${version.running ? `运行中 (${version.browser})` : "未运行(setup 下一步会启动)"}`); } catch { envLines.push(`CDP 端口 ${config.port}: 不可达`); } envLines.push(`浏览器: ${config.browser} / 模式: ${config.profileMode}`); envLines.push(`配置: ${runtimeConfigReadPath()}`); draft.browserReady = true; // launch 在下一步做真实检查 ctx.ui.notify?.("[setup 1/7 环境]" + String.fromCharCode(10) + envLines.join(String.fromCharCode(10)), "info"); if (!(await ctx.ui.confirm("继续", "环境检查完毕,下一步:浏览器与登录"))) { config = { ...config, onboardingVersion: 1, onboardingState: "in_progress" }; saveConfig(config); ctx.ui.notify?.("向导已暂停(可随时 /brb setup 恢复)。", "info"); return; } // Step 2: Browser / Login await ensureBrowser({ port: config.port, browser: config.browser, browserExecutable: config.browserExecutable, profileMode: config.profileMode, profileDir: config.profileDir, profileDirectory: config.profileDirectory, proxy: config.proxy, url: "https://chatgpt.com/", }); let loggedIn = false; for (;;) { const auth = await probeLoginState(config.port); if (auth === "logged-in") { loggedIn = true; break; } const retry = await ctx.ui.confirm( "请先登录 ChatGPT", "自动化浏览器已打开 chatgpt.com。请在该窗口登录你的账号,登录完成后选择“重新检查”。" + String.fromCharCode(10) + "(向导绝不代填密码、不保存凭据、不处理验证码。)", ); if (!retry) { ctx.ui.notify?.("未登录,向导暂停于登录步。可稍后 /brb setup 继续。", "warning"); return; } } draft.loginReady = true; ctx.ui.notify?.("[setup 2/7 登录] 已确认登录态 ✓", "info"); // Step 3: Conversation // pi 的 ctx.ui.select(title, string[]):选项是字符串数组,返回值是选中的选项文本。 const modeChoice = await ctx.ui.select("ChatGPT 会话", [ "bind — 绑定已有会话(推荐)", "create — 创建专用会话(计入每日创建额度)", "skip — 暂时跳过(向导完成但接力不可用)", ]); const mode = modeChoice ? (modeChoice.startsWith("bind") ? "bind" : modeChoice.startsWith("create") ? "create" : "skip") : "skip"; if (mode === "bind") { const url = await ctx.ui.input("会话 URL", "粘贴要绑定的 https://chatgpt.com/c/(留空=只提示用法)"); if (url && url.includes("/c/")) { const bound = await bindSession(ctx, url.trim()); if (bound.ok) { draft.conversationMode = "bind"; draft.conversationUrl = url.trim(); ctx.ui.notify?.(`已绑定:${bound.binding ? describeBinding(bound.binding) : url}`, "info"); } else { ctx.ui.notify?.(`绑定失败:${bound.reason}`, "warning"); } } else { ctx.ui.notify?.("可稍后运行 /brb bind 完成绑定;向导继续。", "info"); } } else if (mode === "create") { // 创建走既有闸门:consent/budget/capability 全在 /brb new 路径里,setup 不复制特权 const ok = await ctx.ui.confirm( "创建专用会话", "将创建一个新的 ChatGPT 会话并计入每日创建额度(maxNewConversationsPerDay)。" + "确认后请在新会话标签页中开始使用,并在完成后运行 /brb bind <新会话URL> 绑定。", ); if (ok) { ctx.ui.notify?.("请运行 /brb new 创建会话(走既有额度与授权闸门),随后 /brb bind 绑定。", "info"); } draft.conversationMode = "skip"; } else { draft.conversationMode = "skip"; ctx.ui.notify?.("已跳过会话配置:向导可完成,但自动接力需要先绑定会话。", "warning"); } // Step 4: Git(只读检测) const facts = gitProjectFacts(gitRunner, ctx.cwd); draft.gitFacts = facts; if (!facts.isRepository) { ctx.ui.notify?.("[setup 4/7 Git] 当前目录不是 Git 仓库——GitHub 交接推送暂不可用。可跳过。", "info"); draft.skippedGit = true; } else { const lines = [ `仓库: ${facts.branch ?? "(detached)"}${facts.dirty ? "(工作区有改动)" : ""}`, `remote: ${facts.remotes.length > 0 ? facts.remotes.join(", ") : "未配置"}`, `upstream: ${facts.upstream ?? "无"} ahead: ${facts.ahead ?? "?"} behind: ${facts.behind ?? "?"}`, `未推送提交: ${facts.unpushedCommits}`, ]; ctx.ui.notify?.("[setup 4/7 Git]" + String.fromCharCode(10) + lines.join(String.fromCharCode(10)), "info"); const policyChoice = await ctx.ui.select("推送策略", [ "manual — 检测未推送但不自动 push(推荐默认)", "auto — 允许支持该策略的执行工作流自动 push(当前版本无 auto-push consumer,仅记录偏好)", "never — 桥自动化不得触发 Git push", ]); draft.pushPolicy = policyChoice ? (policyChoice.startsWith("manual") ? "manual" : policyChoice.startsWith("auto") ? "auto" : "never") : "manual"; } // Step 5: Review + Commit(draft → config) const review = [ "ChatGPT", ` 浏览器: ${draft.browserReady ? "Ready" : "?"} 登录: ${draft.loginReady ? "Ready" : "?"}`, ` 会话: ${draft.conversationMode === "bind" ? `已绑定 ${draft.conversationUrl}` : draft.conversationMode === "skip" ? "跳过" : "未配置"}`, "Git", facts.isRepository ? ` 仓库: ${facts.branch} remote: ${facts.remotes.join(",") || "无"} 策略: ${draft.pushPolicy ?? "未设置"}` : " 跳过(非 Git 仓库)", ]; ctx.ui.notify?.("[setup 5/7 确认]" + String.fromCharCode(10) + review.join(String.fromCharCode(10)), "info"); if (!(await ctx.ui.confirm("完成设置", "以上配置将写入桥配置(onboardingVersion=1)。确认?"))) { ctx.ui.notify?.("未提交,配置保持原样。可随时 /brb setup 重来。", "info"); return; } config = { ...config, onboardingVersion: 1, onboardingState: "completed", pushPolicy: draft.pushPolicy ?? config.pushPolicy ?? "manual", conversationUrl: draft.conversationUrl ?? config.conversationUrl, }; saveConfig(config); ctx.ui.notify?.("[setup 完成] BRB 首次配置完成。运行 /brb status 查看全貌,/brb start 开始接力。", "info"); } /** probeLoginState: read-only auth check on any chatgpt.com tab. */ async function probeLoginState(port: number): Promise<"logged-in" | "logged-out" | "unknown"> { try { const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()) as Array<{ type: string; url: string; webSocketDebuggerUrl?: string }>; const tab = targets.find((t) => t.type === "page" && t.url.startsWith("https://chatgpt.com")); if (!tab || !tab.webSocketDebuggerUrl) return "unknown"; const ws = new WebSocket(tab.webSocketDebuggerUrl); await new Promise((r) => ws.addEventListener("open", r, { once: true })); const result = await new Promise<{ result?: { value?: unknown } }>((resolve) => { ws.addEventListener("message", (ev) => { const m = JSON.parse(ev.data as string); if (m.id === 1) resolve(m.result); }); ws.send( JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: "JSON.stringify({ loginBtn: !!document.querySelector('[data-testid=\"login-button\"]'), hasNav: !!document.querySelector('nav'), hasEditor: !!document.querySelector('#prompt-textarea') })", returnByValue: true, }, }), ); setTimeout(() => resolve({ result: { value: '{"loginBtn":true,"hasNav":false,"hasEditor":false}' } }), 10_000); }); ws.close(); const parsed = JSON.parse(String(result?.result?.value ?? "{}")) as { loginBtn?: boolean; hasNav?: boolean; hasEditor?: boolean }; if (parsed.loginBtn) return "logged-out"; if (parsed.hasNav || parsed.hasEditor) return "logged-in"; return "unknown"; } catch { return "unknown"; } } function onboardingStatusLine(cfg: BridgeConfig): string { const auto = onboardingAutoEnabled() ? "" : " · auto prompt disabled (BRB_ONBOARDING_AUTO=0)"; if (cfg.onboardingState === "dismissed") return `Onboarding: dismissed${auto}`; if (cfg.onboardingState === "in_progress") return `Onboarding: in progress${auto}`; if (cfg.onboardingVersion === 1 && (cfg.onboardingState === "completed" || cfg.onboardingState === undefined)) return `Onboarding: completed (v1)${auto}`; return `Onboarding: not configured${auto}`; } /** * Which UI surface this session runs under, as a fact (BUG-030 investigation). * * `ctx.mode` is "tui" | "rpc" | "json" | "print"; `ctx.hasUI` is true for TUI and RPC modes. * A real TUI renders every setStatus immediately, while an RPC/bridge surface may only snapshot * extension status between turns — so "the status line never changed" has two very different * meanings, and this line tells them apart without guessing. */ function uiSurface(ctx: ExtensionContext): string { const mode = typeof ctx.mode === "string" ? ctx.mode : "?"; const setStatus = typeof ctx.ui?.setStatus === "function" ? "有" : "无"; return `mode=${mode} hasUI=${ctx.hasUI === true} setStatus=${setStatus}`; } async function statusText(ctx: ExtensionContext): Promise { // Resolve read-only here rather than trusting `activeBinding`: a fresh // process asking for status has not started or bound anything, so relying on // module state made every thread report "not registered" -- exactly the // confusion this feature exists to remove. const sessionId = localSessionId(ctx); // Fresh read + drift statement (A5+A16). `bindingObserver` remembers the // revision this process saw last time ONLY to compare; the value displayed // is the current one. If the durable registry changed under this process, // status says so with from/to revisions instead of showing a stale value // while claiming it is current (the BUG-012 symptom, closed here). const observation = sessionId ? bindingObserver.observe(bindingStore, sessionId) : null; const binding = observation?.current ?? null; const drift = observation === null ? "" : observation.changedSinceObserved ? ` · ⚠ 自上次观察后已变化(revision ${observation.fromRevision ?? "?"}→${observation.toRevision ?? "?"}),以上为当前值` : ""; const observedNote = observation && !observation.changedSinceObserved && observation.lastObservedRevision !== null ? `(与上次观察一致:revision ${observation.toRevision})` : ""; const pendingOrphan = sessionId ? orphanStore.pendingForSession(sessionId) : null; const activeBindings = bindingStore.list().filter((entry) => !isReleasedBinding(entry)); const legacyUrl = config.conversationUrl || null; const legacyOwner = legacyUrl ? bindingStore.findByConversation(legacyUrl) : null; const bindingLines = buildBindingStatusLines(binding, legacyUrl, legacyOwner, sessionId, config.autoCreateOnUnbound); if (drift && bindingLines.length > 0) bindingLines[0] += drift; // Read-only CDP health observation; status never opens, attaches, navigates, or sends. const status = await browserStatus(config.port); const lines = [ ...bindingLines, `自动创建策略: ${config.autoCreateOnUnbound === "ask" ? "ask(进入新的未绑定阶段时询问一次)" : config.autoCreateOnUnbound === "on" ? "on(未绑定时直接创建)" : "off(不自动创建)"}`, ...(config.autoCreateOnUnbound === "ask" ? [`本次未绑定阶段: ${autoCreatePhaseDecisions.get(sessionId) === "allow" ? "已允许" : autoCreatePhaseDecisions.get(sessionId) === "deny" ? "已拒绝" : "待询问"}`] : []), `自动化浏览器: ${status.running ? `运行中 (${status.browser})` : `未运行(端口 ${config.port})`}`, // Diagnostics must not lie about the surface they are drawn on: whether the footer // phases can be seen at all depends on the UI this session runs under. Reported here so // "the status line never changed" is answerable with a fact instead of a theory. `UI 面: ${uiSurface(ctx)}`, `启动模式: ${config.profileMode}` + (config.profileMode === "existing" ? ` (真实 profile: ${realUserDataDir(currentBrowserKind())})` : ` (profile: ${effectiveProfileDir(config.profileDir, currentBrowserKind())})`), `本线程 session: ${sessionId || "(拿不到)"}`, (bindingStore.corruptedRecently() ? "⚠ 注册表文件损坏(已按空注册表处理,可经会话帧链重建)——不静默视为正常" : ""), ...(pendingOrphan ? [ `未解决创建残留: ${pendingOrphan.id}`, "自动创建已暂停,避免重复建立会话。", "继续方式:", "- 已有要使用的会话:/brb bind ", `- 确认该残留无需恢复:/brb orphan clear ${pendingOrphan.id}`, "清除仅解除本地阻断,不会删除远端可能存在的 ChatGPT 会话。", ] : [`未解决创建残留: ${sessionId ? "无" : "未知"}`]), `全部绑定: ${ activeBindings.length === 0 ? "无" : activeBindings.map((b) => `${b.bindingId}=${b.label || b.localSessionId.slice(0, 8)}`).join(" ") }`, `复制策略: ${config.copyMode}${config.copyLabel ? ` (label=${config.copyLabel})` : ""} | 迭代上限: ${config.maxIterations}`, ...(configWarnings.length > 0 ? [`⚠ 配置告警: ${configWarnings.join(" / ")}`] : []), `自动接力: ${loop.active ? `运行中(第 ${loop.iterations} 轮)` : "已停止"}`, onboardingStatusLine(config), // Git state belongs here too: the old hook built its own status that showed // it, and unifying the two surfaces must not lose information. `仓库: HEAD ${headNow(process.cwd()).slice(0, 8)} · 改动 ${changedFilesNow(process.cwd()).length} 个文件`, `当前轮驱动权: ${lockedConversation ? `${lockedConversation} → ${lockOwner}` : "空闲"}`, `全部会话锁: ${conversationLock.list().length === 0 ? "无" : conversationLock.list().map((l) => `${l.conversationId.slice(0, 8)}=${l.owner}`).join(" ")}`, `方案新鲜度: ${store.describeFreshness(store.load())}`, ``, `可用控制命令:`, controlHelp(), ``, `循环边界: 上限 ${boundaries.maxRounds} 轮 / ${Math.round(boundaries.maxWallClockMs / 60000)} 分钟 · ` + `已记录 ${loop.rounds.length} 轮 · 卡住处置 ${loop.stall.kind ?? "无"}${loop.stall.attempt ? `(第 ${loop.stall.attempt + 1} 阶)` : ""}`, `配置文件: ${runtimeConfigReadPath()}${runtimeConfigReadPath() === realConfigPath() ? "" : "(隔离副本,真实配置未动)"}`, ]; if (loop.lastError) lines.push(`上次错误: ${loop.lastError}`); if (status.running) { const chats = status.tabs.filter((tab) => tab.url.startsWith("https://chatgpt.com")); lines.push(`chatgpt.com 标签页: ${chats.length} 个`); for (const tab of chats.slice(0, 5)) lines.push(` - ${tab.title} :: ${tab.url}`); } return lines.join("\n"); } /* ---------------- commands ---------------- */ pi.registerCommand("brb", { description: "Drive the web ChatGPT thread: status | doctor | launch | bind | unbind | ask | plan | start | stop | … (" + BRB_COMMANDS.length + " subcommands)", getArgumentCompletions: brbArgumentCompletions, handler: async (rawArgs, ctx) => { config = loadConfig(); // operation-scoped snapshot; see the tool entry surfaceConfigWarnings(ctx); const args = (rawArgs ?? "").trim(); const sub = args.length > 0 ? args.split(/\s+/)[0] : "status"; const tail = args.slice(sub.length).trim(); // Onboarding (verdict #17): first webgpt interaction classifies once. // Legacy established users are silently migrated (no prompt); genuine new // users get ONE lightweight hint -- and BUG-016 is where it is delivered: // attached to THIS command's own output, not sent as a separate notify that // the very next output line replaces. The classification below therefore only // DECIDES; whether the hint was actually delivered is decided in `withHint`. let onboardingHint: string | null = null; { const sessionId = localSessionId(ctx); const binding = sessionId ? bindingStore.findBySession(sessionId) : null; const cls = classifyOnboarding( config.onboardingVersion === undefined ? undefined : { version: 1, status: config.onboardingState ?? "completed" }, { hasBinding: binding !== null, hasConversationUrl: Boolean(config.conversationUrl), hasRegistryUsage: bindingStore.list().length > 0, }, ); if (cls.migratedLegacy) { saveConfig({ ...config, onboardingVersion: 1, onboardingState: "completed" }); } else if (sub !== "setup") { // The kill switch (BRB_ONBOARDING_AUTO=0) is inside this helper, first. onboardingHint = onboardingFirstRunHint(cls.status); if (cls.status === "in_progress") { // Verdict #18: never auto-reopen the full wizard; one light line. // Unchanged: this is the reminder for a user already past the first run, // not the first-run hint BUG-016 is about, and it is deliberately not // attached to command output (that would re-print onboarding guidance on // every command until setup finishes). ctx.ui.notify?.("BRB 设置尚未完成,可运行 /brb setup 继续。", "info"); } } } /** * Attach the first-run hint to a durable output, and consume it exactly once. * * Consumption happens HERE, not when the classification ran: only a command that * actually produced its output has shown the hint. A run that failed before that -- * or a command whose output is not one of the informational surfaces -- leaves the * state alone, so the next `/brb status` can still deliver it (BUG-016). * * `in_progress` is the existing vocabulary for "the first-run moment has passed and * setup is not finished"; recording it here avoids a second state source (a * `hintShown` field) for a fact the onboarding state already expresses. */ const withHint = (output: string): string => { if (!onboardingHint) return output; const hint = onboardingHint; onboardingHint = null; config = { ...config, onboardingVersion: 1, onboardingState: "in_progress" }; saveConfig(config); return withOnboardingHint(output, hint); }; try { switch (sub) { case "setup": await setupWizard(ctx); return; case "status": ctx.ui.notify?.(withHint(await statusText(ctx)), "info"); return; case "orphan": { const match = /^clear\s+(\S+)$/.exec(tail.trim()); if (!match) throw new Error("用法:/brb orphan clear "); const cleared = orphanStore.clear(match[1]); if (!cleared.ok) { ctx.ui.notify?.(`[brb] ${cleared.reason}`, "warning"); return; } ctx.ui.notify?.( `[brb] 已清除本地创建阻断:${cleared.orphan.id}。保留审计记录;不会删除远端可能存在的 ChatGPT 会话。`, "info", ); return; } case "launch": { const browserOptions = configuredBrowserOptions(); const found = browserDiscovery(); const result = await ensureBrowser(browserOptions); const guidance = await launchPageStateGuidance(); if (result.launched) { // BUG-048: the launch command records the observation too, so the doctor // call that follows does not call BRB's own browser foreign. persistBrowserObservation(found, browserOptions); ctx.ui.notify?.( config.profileMode === "existing" ? `已用你的真实 profile 启动:${result.userDataDir}\n${guidance}` : `已启动独立 automation profile:${result.userDataDir}\n${guidance}`, "info", ); return; } // The port is already served. Whether that instance is *this* policy's browser // decides what we may claim: a different brand is reported, never adopted, and // the process is never ended here (it may be the user's own window) — BUG-049. const runningKind = browserStringKind(result.status.browser); const policyKind = found.candidate?.kind ?? (config.browser === "auto" ? undefined : config.browser); const mismatch = policyKind !== undefined && runningKind !== "unknown" && runningKind !== policyKind; if (mismatch) { const runningLabel = runningKind === "edge" ? "Microsoft Edge" : "Google Chrome"; ctx.ui.notify?.( `调试端口 ${config.port} 上运行的是 ${runningLabel},与当前策略(${config.browser})不一致。` + ` BRB 不会结束这个进程:请先完全退出它,或运行 /brb browser ${runningKind} 把策略改为它。\n${guidance}`, "warning", ); return; } persistBrowserObservation(found, browserOptions); ctx.ui.notify?.(`调试端口已在运行(${result.status.browser})。\n${guidance}`, "info"); return; } case "auto-create": { if (!tail) { ctx.ui.notify?.(`自动创建策略: ${config.autoCreateOnUnbound}(只读回显,未改配置)`, "info"); return; } const wanted = tail as AutoCreateOnUnbound; if (wanted !== "ask" && wanted !== "on" && wanted !== "off") { throw new Error("用法:/brb auto-create ask|on|off"); } config = { ...config, autoCreateOnUnbound: wanted }; saveConfig(config); autoCreatePhaseDecisions.clear(); ctx.ui.notify?.(`自动创建策略已设为: ${wanted}`, "info"); return; } case "mode": { // O2 (verdict #38): no argument is a READ-ONLY echo of the current mode — it writes // no config and does not touch the browser. Only an explicit dedicated|existing // changes anything (which is also the ordinary CLI shape). if (!tail) { ctx.ui.notify?.( `当前模式: ${config.profileMode ?? "dedicated"}(只读回显,未改配置)\n` + ` profile: ${config.profileDir ?? "(default)"}\n` + "切换请用:/brb mode dedicated|existing", "info", ); return; } const wanted = tail.trim() as ProfileMode; if (wanted !== "dedicated" && wanted !== "existing") { throw new Error( "用法:/brb mode dedicated|existing\n" + " dedicated = 独立 profile,隔离但需登录一次\n" + " existing = 复用你真实的浏览器登录态(需先完全退出该浏览器)", ); } const browserForPath = config.browser === "edge" ? "edge" : "chrome"; config = { ...config, profileMode: wanted, profileDir: wanted === "dedicated" ? defaultProfileDir(browserForPath) : config.profileDir, }; saveConfig(config); ctx.ui.notify?.( wanted === "existing" ? `模式已切换为 existing(复用真实 profile):\n${realUserDataDir(browserForPath)}\n请先完全退出该浏览器,再 /brb launch。` : `模式已切换为 dedicated(独立 profile):\n${config.profileDir}`, "info", ); return; } case "unbind": { if (tail) throw new Error("用法:/brb unbind"); const sessionId = localSessionId(ctx); if (!sessionId) { ctx.ui.notify?.("当前线程没有绑定,无需释放", "info"); return; } const current = bindingStore.findBySession(sessionId); if (!current) { if (bindingIsBusy(sessionId)) { throw new Error("BINDING_BUSY:当前绑定正在进行可观察操作,拒绝释放;未修改注册表。"); } if (activeBinding?.localSessionId === sessionId) useBinding(null); if (loopSessionId === sessionId) loop.conversationUrl = null; autoCreatePhaseDecisions.delete(sessionId); ctx.ui.notify?.("当前线程没有绑定,无需释放", "info"); return; } if (bindingIsBusy(sessionId, current)) { throw new Error("BINDING_BUSY:当前绑定正在进行可观察操作,拒绝释放;未修改注册表。"); } const released = bindingStore.unbind(sessionId, Date.now(), { releasedBySessionId: sessionId, source: "control-command", }); if (!released) { if (activeBinding?.localSessionId === sessionId) useBinding(null); if (loopSessionId === sessionId) loop.conversationUrl = null; autoCreatePhaseDecisions.delete(sessionId); ctx.ui.notify?.("当前线程没有绑定,无需释放", "info"); return; } if (pendingCandidate?.binding.localSessionId === sessionId) pendingCandidate = null; autoCreateFlights.delete(sessionId); autoCreatePhaseDecisions.delete(sessionId); if (activeBinding?.localSessionId === sessionId) useBinding(null); if (loopSessionId === sessionId) loop.conversationUrl = null; ctx.ui.notify?.( "已释放当前线程的 BRB 绑定。\n远端 ChatGPT 会话和浏览器标签页未被删除。", "info", ); void refreshIdleStatus(ctx); return; } case "open": { // Verdict #27: with no argument, open resolves from THIS thread's binding, // not from the legacy global config — the badge shows the registry binding, // and open must open what the badge shows. Deterministic, fail-closed: let url: string | null = tail ?? null; if (!url) { const sessionId = localSessionId(ctx); const binding = sessionId ? bindingStore.findBySession(sessionId) : null; url = binding?.conversationUrl ?? null; } if (!url) { ctx.ui.notify?.( `[brb] 当前 Pi 线程尚未绑定 ChatGPT 会话(也没配遗留 conversationUrl)。\n` + `先 /brb bind <会话URL> 或 /brb setup 绑定。`, "warning", ); return; } // `/brb open` opens a tab for an existing conversation. Handing it a URL with no // id would create one, which needs the three gates -- so it refuses and points at // the path that has them, rather than quietly being a second creation entry. if (conversationIdOf(url) === null) { ctx.ui.notify?.( `[brb] ${url} 没有会话 id,打开它就等于新建会话。\n` + `新建请走显式路径:先 ${canonicalCommand("new")},再 /brb start <任务>。`, "warning", ); return; } await ensureBrowser({ port: config.port, browser: config.browser, browserExecutable: config.browserExecutable, profileMode: config.profileMode, profileDir: config.profileDir, profileDirectory: config.profileDirectory, proxy: config.proxy, // BUG-017: without the proxy the automation browser goes direct and cannot reach chatgpt.com }); // BUG-023 (BUG-020's class, missed in this host): `open` establishes a CDP // WebSocket session to the tab. The pi host is long-lived so discarding it // looked harmless, but the socket keeps the event loop alive: any // short-lived driver of this command (the verification harness, a script) // printed its result and then hung forever. Closing only drops the CDP // session -- the tab stays open. Every other browser-touching command // here already closes in a finally; this one did not. // BUG-024 (verdict #38): an already-open bound conversation is ACTIVATED, not // duplicated. ChatGptPage.open always openTab()s, so repeating /brb open used to // pile up same-conversation tabs — and those duplicates are what made the read // commands' tab-order selection ambiguous in the first place. const openId = conversationIdOf(url); const already = openId ? (await listTargets(config.port).catch(() => [])).find( (t) => t.type === "page" && (t.url ?? "").includes(openId), ) : undefined; if (already) { const activated = await ChatGptPage.attach({ port: config.port, targetId: already.id }); try { await activated.ensureVisible().catch(() => undefined); } finally { await activated.close().catch(() => undefined); } ctx.ui.notify?.(`已激活(未新建标签页):${url}`, "info"); return; } const opened = await ChatGptPage.open(url, { port: config.port }); await opened.close().catch(() => undefined); ctx.ui.notify?.(`已打开:${url}`, "info"); return; } case "bind": { // Explicit, one-time claim of the legacy global binding. Never // automatic: assigning it to whichever thread happens to start first // would reproduce the very bug this migration exists to fix. if (tail === "legacy") { const sessionId = localSessionId(ctx); if (!sessionId) throw new Error("当前 Pi 会话取不到会话标识,无法迁移。请在新开的 Pi 会话里重试。"); const already = bindingStore.findBySession(sessionId); if (already) { ctx.ui.notify?.( `[brb] 本线程已经绑定过了,无需迁移:${describeBinding(already)}`, "info", ); return; } // Explicit migration is the one permitted path to the owner's legacy config. // Read only the historical conversation URL; never import modern runtime fields. const legacyUrl = explicitLegacyMigrationRead(legacyMigrationSourcePath()).conversationUrl; if (!legacyUrl) throw new Error("没有可迁移的遗留 conversationUrl。"); const legacyTarget = validateChatgptConversationUrl(legacyUrl); if (!legacyTarget.ok) throw new Error(legacyTarget.reason); // Step 5 of the migration: check for a conflict before writing // anything, so a half-done migration cannot claim a conversation twice. const holder = bindingStore.findByConversation(legacyTarget.url); if (holder && holder.localSessionId !== sessionId) { ctx.ui.notify?.( `[brb] ${ownershipConflictMessage(holder)}`, "error", ); return; } // Attach and read the conversation back before committing, so a migration // against an unreachable or wrong conversation fails loudly instead of // silently registering a dead binding. const page = await attachPage({ url: legacyTarget.url }); let discovery: { session: unknown; helloSent: boolean } | null = null; try { discovery = await page.discoverProtocolFrames(12); } finally { await page.close().catch(() => undefined); } const bound = bindingStore.bind({ localSessionId: sessionId, conversationUrl: legacyTarget.url, bindingSource: "manual", label: ctx.sessionManager.getSessionName?.() ?? "", cwd: ctx.cwd, }); if (!bound.ok) { ctx.ui.notify?.(`[brb] ${bound.reason}`, "error"); return; } useBinding(bound.binding); autoCreatePhaseDecisions.delete(sessionId); // Move the legacy runtime state into the binding's own file, keeping a // backup rather than deleting: if this crashed halfway, the next run // must still be able to tell whether the migration committed. const legacyState = defaultStatePath(); const targetState = statePathFor(bound.binding.bindingId); let migrated = "无遗留状态可迁移"; if (existsSync(legacyState)) { mkdirSync(dirname(targetState), { recursive: true }); copyFileSync(legacyState, targetState); copyFileSync(legacyState, `${legacyState}.migrated-backup`); migrated = `已迁入 ${targetState}(遗留文件保留为 .migrated-backup)`; } bindingStore.markLegacyMigrated(); ctx.ui.notify?.( [ "[brb] 遗留绑定已由本线程认领。", ` ${describeBinding(bound.binding)}`, ` 历史 FRAME: ${discovery?.session ? "可读,已交叉验证" : "不可读(HELLO 可能已被虚拟化)"}`, ` ${migrated}`, " 后续 routing 不再读全局 config。", ].join("\n"), "info", ); return; } let url = tail; if (url) { const target = validateChatgptConversationUrl(url); if (!target.ok) { ctx.ui.notify?.(`[brb] ${target.reason}`, "error"); return; } url = target.url; } if (!url) { const status = await browserStatus(config.port); if (!status.running) throw new Error("浏览器未运行,先 /brb launch"); const tabs = status.tabs.filter((tab) => tab.url.startsWith("https://chatgpt.com")); if (tabs.length === 0) throw new Error("没有找到 chatgpt.com 标签页。下一步:打开 ChatGPT 页面后重试。"); if (tabs.length === 1) { url = tabs[0].url; } else { const choice = await ctx.ui.select( "选择要绑定的 ChatGPT 会话:", tabs.map((tab) => `${tab.title} :: ${tab.url}`), ); if (!choice) return; url = choice.split(" :: ").pop() ?? tabs[0].url; } } // The binding lives in the registry keyed by this pi session, not in a // global config value. A global value is what let one thread overwrite // the binding for every other thread. One implementation, shared with the // tool action, so the two cannot drift apart again (BUG-002). const bound = await bindSession(ctx, url); if (!bound.ok || !bound.binding) { ctx.ui.notify?.(`[brb] ${bound.reason}`, "error"); return; } ctx.ui.notify?.( `${bound.rebound ? "已改绑" : "已绑定"}:${url}` + `\n ${describeBinding(bound.binding)}` + (bound.binding.conversationId ? "" : `\n (新会话还没有 /c/,首轮之后会补上)`), "info", ); return; } case "doctor": { const report = await doctorText(ctx); ctx.ui.notify?.(withHint(report.text), report.warning ? "warning" : "info"); return; } case "ask": { if (!tail) throw new Error("用法:/brb ask <要发给 ChatGPT 的文本>"); const prepared = await ensureBinding(ctx, "ask"); await verifyAndCommitCandidate(ctx, prepared, "ask"); const { reply, via, plan } = await relay(ctx, tail); ctx.ui.notify?.( `ChatGPT 回复 ${reply.length} 字${plan ? `;已复制方案 ${plan.length} 字(${via})` : ";未取到复制内容"}`, "info", ); return; } case "sources": { const prepared = await ensureBinding(ctx, "sources"); await verifyAndCommitCandidate(ctx, prepared, "sources"); const page = await attachBoundPage(ctx, { requireReadable: true }); try { const state = await page.state(); if (!state.messages.length && !state.turnNumber) { ctx.ui.notify?.(PROVISIONAL_READ_MESSAGE, "info"); return; } const report = await page.resolveCopySources(); ctx.ui.notify?.( [ `最新轮: ${report.turnId ?? "(未识别)"} 锚点: ${report.anchor}`, `回复预览: ${report.replyPreview.slice(0, 80) || "(空)"}`, `可用复制源 (${report.sources.length}):`, ...report.sources.map( (source) => ` [${source.index}] ${source.kind}${source.isReply ? "/reply" : ""}` + `${source.nearCodeBlock ? "/codeblock" : ""} :: ${source.label}`, ), "", `用法: /brb copy 或 /brb config {"copyLabel":"^复制回复"}`, ].join("\n"), "info", ); } finally { await page.close().catch(() => undefined); } return; } case "copy": { const prepared = await ensureBinding(ctx, "copy"); await verifyAndCommitCandidate(ctx, prepared, "copy"); const page = await attachBoundPage(ctx, { requireReadable: true }); try { const state = await page.state(); if (!state.messages.length && !state.turnNumber) { ctx.ui.notify?.(PROVISIONAL_READ_MESSAGE, "info"); return; } await page.installClipboardHook(); const indexArg = /^[0-9]+$/.test(tail) ? Number(tail) : undefined; const copied = await page.copyLastPlan(config.copyMode, { index: indexArg, label: config.copyLabel, }); ctx.ui.setEditorText?.(copied.text); ctx.ui.notify?.( `${describeCopyReceipt({ chars: copied.text.length, replyTextLength: copied.replyTextLength, via: copied.via, promoted: copied.promoted, })} ` + ` 轮次: ${copied.turnId ?? "?"} (${copied.anchor}) 源: [${copied.label}] 通道: ${copied.via} / ${copied.source} ` + ` 已填入输入框。`, "info", ); } finally { await page.close().catch(() => undefined); } return; } case "plan": { if (!tail) throw new Error("用法:/brb plan <任务描述>"); const prepared = await ensureBinding(ctx, "plan"); await verifyAndCommitCandidate(ctx, prepared, "plan"); const { reply, plan, via, promoted } = await relay(ctx, tail); const next = (plan ?? reply).trim(); ctx.ui.setEditorText?.(next); ctx.ui.notify?.( // BUG-059 ruling applies here too: this path hands the payload to the // caller's editor, so a silently halved plan is just as wrong as in copy. // `reply.length` is the whole reply as this path read it. `${describeCopyReceipt({ chars: next.length, replyTextLength: reply.length, via, promoted })}` + `${via ? `(${via})` : ""},已填入输入框,回车执行。`, "info", ); return; } case "start": { if (loop.active) throw new Error("自动接力已在运行,先 /brb stop"); if (!tail) throw new Error("用法:/brb start <任务描述>"); // Lazy auto-create: a thread that is actually starting work gets its // own new conversation. No adoption of an existing tab, which is what // cross-wired threads before. // PREPARE + CREATE. When `candidate` is set a conversation now exists // in the account, but nothing has been committed: the registry is // untouched until a handshake proves this endpoint works. const startSessionId = localSessionId(ctx); beginBindingOperation(startSessionId); let startLeaseHeld = true; const releaseStartLease = (): void => { if (!startLeaseHeld) return; startLeaseHeld = false; endBindingOperation(startSessionId); }; try { const resolved = await ensureBinding(ctx); if (resolved.candidate) { const lineage = await validateCandidateLineage(resolved.candidate); if (!lineage.ok) { pendingCandidate = null; autoCreateFlights.delete(resolved.candidate.binding.localSessionId); await resolved.candidate.page.close().catch(() => undefined); throw new Error(`${lineage.reason}当前线程仍未绑定;本次确定性失败没有登记 orphan,也不会自动重试。`); } } useBinding(resolved.binding); if (!resolved.candidate) bindingStore.touch(resolved.binding.localSessionId); const bindingUrl = resolved.binding.conversationUrl; loop.conversationUrl = bindingUrl; // Attach first so the lock can name a real endpoint: acquiring before // knowing the conversation would mean keying on nothing. A page from // lazy creation is used as-is and kept for the first round. carriedPage = resolved.page; const probe = resolved.page ?? (await attachPage({ url: bindingUrl, targetId: resolved.binding.targetId })); const conversationKey = isProvisionalBinding(resolved.binding) ? `target:${resolved.binding.targetId ?? probe.target.id}` : conversationKeyOf(probe.target.url, probe.target.id); if (!resolved.page) await probe.close().catch(() => undefined); // Take the driving lock before touching anything. Two adapters // drive the same conversation, and the lock is about *driving // rights* only -- read-only diagnostics never need it. const acquired = conversationLock.acquire({ conversationId: conversationKey, owner: lockOwner, sid: "", }); if (!acquired.ok) { releaseStartLease(); ctx.ui.notify?.(`[brb] ${acquired.reason}`, "error"); // Nothing was committed, so there is nothing to roll back -- but // in-memory state must stop pointing at the uncommitted candidate. if (resolved.candidate) { useBinding(resolved.candidate.replaces); loop.conversationUrl = resolved.candidate.replaces?.conversationUrl ?? ""; } return; } if (acquired.tookOverStale) { ctx.ui.notify?.(`[brb] ${acquired.reason}`, "warning"); } lockedConversation = conversationKey; loopSessionId = localSessionId(ctx); loop.iterations = 0; loop.lastPlanHash = ""; loop.lastError = ""; loop.repairs = 0; // A3: one explicit run authorization envelope. Recovery time counts // against maxWallClockMs (elapsedMs is wall-clock since start); rounds // only count when a protocol round actually dispatches (beginRound). loop.run = new RunContext({ startedBy: "start", deadlineMs: boundaries.maxWallClockMs, maxRounds: boundaries.maxRounds, }); loop.lastLeafId = (ctx.sessionManager.getLeafId?.() as string | null) ?? null; loop.rounds = []; loop.stall = resetStall(); loop.currentPlan = null; loop.startedAt = Date.now(); loop.roundStartedAt = loop.startedAt; loop.objective = tail; loop.rootRequest = tail; loop.active = true; releaseStartLease(); try { if (resolved.candidate) { if (resolved.candidate.verificationInFlight) { throw new Error("AUTO_CREATE_BLOCKED_IN_FLIGHT:候选会话正在握手;不重复发送、不再创建。"); } resolved.candidate.verificationInFlight = true; setTransientStatus(ctx, "正在验证会话身份…"); } // Handshake first. Until an ACK validates, no task is sent: // a half-established session would have pi executing plans // against a contract the other side never agreed to. const established = await driverEstablish({ transport: createTransport(ctx), rootRequest: tail, objective: tail, contract: FULL_CONTRACT, tokenBudget: config.promptTokens, onEvent: (event) => { if (event.kind === "stopped") { ctx.ui.notify?.(`[brb] 握手被拒:${event.errors?.join(";")}`, "error"); } }, }); if (!established.ok || !established.session) { loop.active = false; // VERIFY failed. The old binding was never modified, so this is // not a rollback -- only the in-memory pointer moves back. The // conversation created at CREATE is left alone rather than // deleted, and recorded so the inventory can see it. pendingCandidate = null; if (resolved.candidate) { autoCreateFlights.delete(resolved.candidate.binding.localSessionId); const previous = resolved.candidate.replaces; useBinding(previous); loop.conversationUrl = previous?.conversationUrl ?? ""; // Named and kept, so a failed rotation leaves something a person can find. const orphan = { conversationUrl: resolved.candidate.binding.conversationUrl, targetId: resolved.candidate.binding.targetId ?? "", grantId: resolved.candidate.grantId, localSessionId: resolved.candidate.binding.localSessionId, reason: "握手未建立:" + established.errors.join(";"), at: Date.now(), }; if (!isDeterministicAutoCreateFailure(orphan.reason)) orphanStore.record(orphan); ctx.ui.notify?.( `[brb] 这次轮换未提交,但平台上可能已经多出一条会话(${isDeterministicAutoCreateFailure(orphan.reason) ? "确定性失败,未登记 orphan" : "已登记,不会自动删除"}): ${orphan.conversationUrl} 用 brb 会话 可以看到它。`, "warning", ); } stopLoop(ctx, `握手未建立:${established.errors.join(";")}`); return; } // COMMIT. The only place a new conversation becomes `current`, and // it is reachable only through a validated handshake. if (resolved.candidate) { setTransientStatus(ctx, "正在绑定当前线程…"); const committed = commitCandidate(resolved.candidate); if (!committed.ok || !committed.binding) { loop.active = false; const commitReason = `绑定落盘失败:${committed.reason}`; if (!isDeterministicAutoCreateFailure(commitReason)) orphanStore.record({ conversationUrl: resolved.candidate.binding.conversationUrl, targetId: resolved.candidate.binding.targetId ?? "", grantId: resolved.candidate.grantId, localSessionId: resolved.candidate.binding.localSessionId, reason: commitReason, at: Date.now(), }); pendingCandidate = null; autoCreateFlights.delete(resolved.candidate.binding.localSessionId); useBinding(resolved.candidate.replaces); stopLoop(ctx, `握手已通过但绑定落盘失败:${committed.reason}`); ctx.ui.notify?.( `[brb] 绑定未提交;候选会话仍可能存在:${resolved.candidate.binding.conversationUrl}。` + (isDeterministicAutoCreateFailure(commitReason) ? "确定性失败未登记 orphan,不会自动重试。" : "已登记 orphan,不会自动重试。"), "error", ); return; } bindingStore.touch(committed.binding.localSessionId); ctx.ui.notify?.( resolved.candidate.replaces ? `[brb] 已轮换到新会话(旧的进入 predecessors): ${describeBinding(committed.binding)}` : `[brb] 本线程还没有绑定,已新建一个 ChatGPT 会话并绑定: ${describeBinding(committed.binding)}`, "info", ); setTransientStatus(ctx, "已连接"); } loop.session = established.session; loop.qid = established.session.qid; ctx.ui.notify?.(`[brb] 握手已建立 ${describeSession(loop.session)}`, "info"); // ROUND 1 is a real ROUND, and it is where the request is // delivered in full exactly once. Sending the raw task text // here was an earlier bug: no FRAME, no receipt, no context. const run = await driverRunRound({ transport: createTransport(ctx), session: loop.session, repairs: loop.repairs, contract: FULL_CONTRACT, promptTokens: config.promptTokens, facts: { rootRequest: tail, objective: tail, digest: digestNow(ctx), receipt: "(尚未执行任何步骤;这是本轮的第一个请求)", }, onEvent: (event) => { if (event.kind === "gate-rejected" || event.kind === "repair-sent") { ctx.ui.notify?.(`[brb] ${event.kind}:${event.detail}`, "warning"); } }, }); loop.repairs = run.repairs; if (run.outcome.kind !== "plan") { stopLoop(ctx, run.outcome.reason); return; } for (const warning of run.outcome.warnings) { ctx.ui.notify?.(`[brb] 提示(不阻断):${warning}`, "warning"); } const next = run.outcome.raw.trim(); if (!next) { stopLoop(ctx, "ChatGPT 返回了空方案"); return; } loop.currentPlan = run.outcome.plan; ctx.ui.notify?.( `[brb] 首个方案 ${next.length} 字 ${summarizePlan(run.outcome.plan)}`, "info", ); store.save( { ...store.load(), session: loop.session, currentPlan: loop.currentPlan, rounds: loop.rounds, pending: loop.pending, repairs: loop.repairs, objective: loop.objective, rootRequest: loop.rootRequest, lastPlanHash: loop.lastPlanHash, }, lockOwner, ); loop.lastLeafId = (ctx.sessionManager.getLeafId?.() as string | null) ?? loop.lastLeafId; deliver(ctx, next); } catch (error) { stopLoop(ctx, error instanceof Error ? error.message : String(error)); } return; } finally { releaseStartLease(); } } case "resume": { // Recovery entry point. It reports what the policy would do and // does not silently invent a new round: the newest ROUND in the // conversation already carries the authoritative frame. const page = await attachBoundPage(ctx); try { const discovery = await page.discoverProtocolFrames(12); const checkpoint = loop.session?.checkpoint ?? null; const recoveryCase = loop.session ? "browser-reload" : discovery.session ? "pi-restart-no-cache" : "context-lost"; const decision = planRecovery({ recoveryCase, checkpoint, currentRound: loop.session?.round ?? discovery.session?.round ?? 1, currentRoundDispatched: Boolean(loop.currentPlan), frameFromHistory: discovery.session, hasLocalSession: Boolean(loop.session), }); const lines = [ `场景判定: ${recoveryCase}`, `历史可读轮次: ${discovery.frames.length} 个协议消息(HELLO ${discovery.helloSent ? "在" : "已被虚拟化"},ACK ${discovery.ackSeen ? "在" : "不在"})`, discovery.session ? `历史最新 FRAME: sid=${discovery.session.sid} r=${discovery.session.round} p=${discovery.session.prev}` : "历史里没有可用的 FRAME", checkpoint ? `本地 checkpoint: ${checkpoint.round}:${checkpoint.planKey}` : "本地无 checkpoint", "", `建议动作: ${decision.action}`, `下一轮: ${decision.nextRound} 不自动重放起点: ${decision.replayFromRound}`, `理由: ${decision.reason}`, ]; ctx.ui.notify?.(lines.join("\n"), decision.action === "new-session" ? "warning" : "info"); } finally { await page.close().catch(() => undefined); } return; } case "stop": stopLoop(ctx, "用户手动停止"); return; case "kill": { const closed = await killBrowser(config.port); ctx.ui.notify?.( closed ? "已关闭桥接启动的浏览器。" : "没有正在运行的调试端口。", config.profileMode === "existing" ? "warning" : "info", ); return; } case "browser": { const wanted = tail.trim(); if (!wanted) { ctx.ui.notify?.(browserChoiceText(), "info"); return; } if (wanted !== "auto" && wanted !== "chrome" && wanted !== "edge") { ctx.ui.notify?.(`用法:/brb browser [auto|chrome|edge](当前策略:${config.browser})`, "warning"); return; } const patch = assessConfigPatch({ browser: wanted }); if (Object.keys(patch.applied).length) config = { ...config, ...patch.applied }; // An explicit choice means "resolve again from this policy": the sticky // result is an observation, not a preference, so it must not survive // (decision 2026-09-24). This is the only place besides /brb config // that clears it — an automatic resolve never does. const cleared = config.resolvedBrowser !== undefined || config.resolvedExecutable !== undefined; config = { ...config, browserPolicyEpoch: new Date().toISOString() }; saveConfig(config); ctx.ui.notify?.(browserChoiceText({ cleared }), "info"); return; } case "config": { if (!tail) { ctx.ui.notify?.(configEchoText(config), "info"); return; } const patch = assessConfigPatch(JSON.parse(tail)); if (Object.keys(patch.applied).length) { config = { ...config, ...patch.applied }; // Same rule as /brb browser: an explicit browser change retires the // sticky observation (decision 2026-09-24). if (patch.applied.browser !== undefined) { config = { ...config, browserPolicyEpoch: new Date().toISOString() }; } saveConfig(config); } ctx.ui.notify?.(configPatchResultText(patch), "info"); return; } default: // The usage line IS help for a new user, so it carries the hint too. ctx.ui.notify?.( withHint(brbSlashUsageText()), "warning", ); return; } } catch (error) { // BUG-029 belt-and-braces: whatever a command published before failing, the footer must not // keep a task phase on it. restore() re-publishes the cached badge (or blanks the line when // there is nothing cached) — a failed command is never a reason to display "正在握手…" forever. restoreIdleStatus(ctx); const message = error instanceof Error ? error.message : String(error); ctx.ui.notify?.(`[brb] ${message}`, "error"); } }, }); pi.on("session_start", async (_event, ctx) => { // WEBGPT-IDLE-BADGE-v1: establish the idle badge as soon as the session is // up — one observation per session start, no polling. void refreshIdleStatus(ctx); }); pi.on("turn_start", async (_event, ctx) => { // BUG-057: a transient belongs to the operation that published it. If an // operation ends without restoring it -- a tool exit that returns instead of // throwing, or a session that settled mid-flight -- the text would otherwise // stay on the line forever, because a badge refresh deliberately never // clobbers a live transient ("正在创建 ChatGPT 会话…" is the observed case). // A new turn is the safe boundary: drop a stale transient, then re-cache. restoreIdleStatus(ctx); // Refresh the badge at the start of each agent turn. When a transient task // status is on screen the coordinator only re-caches (never clobbers the // live progress text) -- that still holds *within* a turn. void refreshIdleStatus(ctx); }); pi.on("session_shutdown", async () => { loop.active = false; // BUG-027: the session is over — no page this host opened may keep a CDP session alive. if (carriedPage) { await carriedPage.close().catch(() => undefined); carriedPage = null; } // Session over: forget badge state and blank the line for good. for (const badge of idleBadges.values()) badge.clear(); idleBadges.clear(); }); }