/** * Binding registry: which local pi thread owns which ChatGPT conversation. * * The reported bug was that `brb 交接` in one pi thread pushed its handoff into * another thread's ChatGPT conversation. The cause was not the tab-guessing * fallback (that was a second, smaller problem) -- it was that the binding was a * single global value in the config file. Whichever thread bound last owned it for * everyone. * * The durable model the adjudication settled on is three things, not two: * * Local pi endpoint ↔ Bridge Workstream (bindingId) ↔ ChatGPT conversation * * A pi session id is the local endpoint. The conversation URL is the remote * identity. `bindingId` is the workstream that ties them together, and it is what * the runtime state is keyed by -- so a thread keeps its own round history, * checkpoint and lock even as sessions come and go. * * Note what is deliberately NOT a key: `cwd`. It is a grouping key only. Verified * empirically while designing this: session *lookup* is cwd-scoped (pi derives the * sessions directory from cwd), so a `-c` from another directory lands on a * different session entirely. Using cwd for routing would therefore cross-wire * exactly the way the reported bug did. * * Zero dependencies and no browser. */ import { randomBytes } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { conversationIdOf } from "./identity.ts"; import { isLockRecordStale } from "../runtime/mutex.ts"; import { bindingsRegistryPath, bindingStateDir } from "../runtime/paths.ts"; export const REGISTRY_VERSION = 1; /** * Who issued a durable write. A controlled vocabulary, not a free string (verdict * #11): provenance must never carry inferred facts like a project name. */ export const BINDING_SOURCES = [ "control-command", "tool-action", "auto-recovery", "migration", "test-fixture", ] as const; export type BindingSource = (typeof BINDING_SOURCES)[number]; // Paths resolve through paths.ts, the single owner of which files redirect under an // isolated state root (records) and which never do (config, resource locks). export function defaultRegistryPath(): string { return bindingsRegistryPath(); } /** Per-binding runtime state lives in its own directory, keyed by bindingId. */ export function defaultStateDir(): string { return bindingStateDir(); } export function statePathFor(bindingId: string): string { return join(defaultStateDir(), `${sanitizeKey(bindingId)}.json`); } function sanitizeKey(value: string): string { const cleaned = value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120); return cleaned.length > 0 ? cleaned : "unbound"; } export interface Binding { /** Stable id of the workstream. Runtime state is keyed by this. */ bindingId: string; /** The pi session that owns it. */ localSessionId: string; /** Durable remote identity. */ conversationUrl: string; /** `/c/` when the URL has one. */ conversationId: string; /** Registry lifecycle: a target-only record is provisional, an id-bearing record is bound. */ state?: "PROVISIONAL" | "BOUND" | "RELEASED"; /** Explicit lifecycle release audit fields. Present only on RELEASED records. */ releasedAt?: number; releasedBySessionId?: string; releaseReason?: "explicit_unbind"; /** * The tab that carries a conversation which has no id yet. * * A brand-new chat's URL is a bare `https://chatgpt.com/`, which is the same for * every new chat. Until the first message gives it a `/c/`, the tab IS the * endpoint identity -- without this, attaching "to the bound URL" picks whichever * new chat tab happens to come first, which is another thread's. */ targetId?: string; /** * How this conversation came to exist. * * `auto` means the bridge created it because a thread started work without * being bound. Those are the ones that accumulate, so they are the ones the * creation budget counts and the ones the inventory lists. */ origin: "auto" | "explicit"; /** * How the current binding was selected. Optional for legacy records; status * renders those as legacy/unknown rather than inventing provenance. */ source?: "manual" | "auto" | "start"; /** * Conversations this one replaced, newest first. * * Rotation must not lose the old conversation. Without this, pointing a session at * a new conversation simply overwrote the old URL and left it unrecoverable: the * exact "unbind the broken one, fail to create the new one, and now have neither" * shape ChatGPT's ruling calls out. Keeping the chain makes a rotation auditable * and, if it was a mistake, reversible by binding back. */ predecessors?: Array<{ conversationUrl: string; conversationId: string; replacedAt: number }>; /** Display only. Never used for routing, and never required to succeed. */ label: string; cwd: string; createdAt: number; lastUsedAt: number; /** * Provenance (A5+A16, verdict #11). Allocated inside the registry write lock, so * consecutive writes get strictly increasing values; the lock's stale-takeover * window is the honest residual risk and is documented, not claimed away. * Optional because legacy entries predate it and must keep loading. */ revision?: number; /** When this entry last changed durably (not when it was last used). */ updatedAt?: number; /** One of BINDING_SOURCES. */ updatedBy?: BindingSource; /** * A17: the verified account principal this binding was last confirmed under * (identity-principal.ts). A record of a past fact — never a claim about the * current login; side effects re-observe and match before executing. */ affinity?: { observedIdentity: string; source: "localstorage-user-prefix"; verifiedAt: number; verificationVersion: 1; }; /** Last draft route observed on the pinned target (diagnostics only). */ draftRoute?: string; /** A SEND was submitted on this target and its durable identity is pending. */ materializationPending?: boolean; /** Operation lineage for that SEND. */ materializationOperationId?: string; submittedAt?: number; /** Durable id observed while a registry upgrade was not yet committed. */ materializationObservedId?: string; /** Recoverable error from a durable observation that could not be committed. */ materializationError?: string; } export interface RegistryFile { version: number; bindings: Binding[]; /** Set once the legacy global config binding has been claimed explicitly. */ legacyMigrated?: boolean; } export interface BindInput { localSessionId: string; conversationUrl: string; label?: string; cwd?: string; targetId?: string; origin?: "auto" | "explicit"; now?: number; /** Take the conversation even though another session owns it. */ force?: boolean; /** Provenance source for the durable write this bind performs. */ source?: BindingSource; /** Optional provenance of the binding record itself (manual/auto/start). */ bindingSource?: Binding["source"]; /** Explicit lifecycle for target-only commits; id-bearing binds are always BOUND. */ bindingState?: Binding["state"]; /** A17: principal observed at bind time (verified fact), when available. */ affinity?: Binding["affinity"]; /** Provisional materialization provenance. */ draftRoute?: string; materializationPending?: boolean; materializationOperationId?: string; submittedAt?: number; materializationObservedId?: string | null; /** Set to null/empty to clear a previous recoverable error. */ materializationError?: string | null; } export interface UnbindOptions { /** Session that performed the explicit release; defaults to the target session. */ releasedBySessionId?: string; /** Durable provenance for the registry mutation. */ source?: BindingSource; /** Fail closed before mutation when an observable operation is in flight. */ inFlight?: boolean; } export type BindResult = | { ok: true; binding: Binding; rebound: boolean } | { ok: false; reason: string; conflict: Binding }; /** * The registry. * * Two invariants, both there to make cross-wiring impossible rather than * unlikely: * * one local session -> at most one binding * one conversation -> at most one binding * * The second is the important one. Two threads binding the same conversation is * precisely the reported failure, so a rebind away is allowed while a *steal* is * not. */ const REGISTRY_LOCK_TTL_MS = 15_000; interface RegistryLockRecord { owner: string; pid: number; acquiredAt: number; purpose: string; } /** * Cross-process serialization for registry writes. * * A revision field is only as honest as the serialization that allocates it * (verdict #11): two processes that both read revision N must not both write * N+1. Acquire is an atomic rename over the record file; a lock from a dead * pid is taken over; a lock from a live pid is a refusal, because writing * provenance through a collision would manufacture exactly the unreliable * fact this field exists to prevent. */ class RegistryWriteLock { private readonly owner = `pid-${process.pid}`; constructor(private readonly path: string) {} private peek(): RegistryLockRecord | null { try { if (!existsSync(this.path)) return null; return JSON.parse(readFileSync(this.path, "utf8")) as RegistryLockRecord; } catch { return null; // a corrupt record is stale by definition } } private stale(record: RegistryLockRecord, now: number): boolean { // A14: one staleness semantic, shared with the browser mutex and the // conversation driver locks (mutex.ts). No second copy. return isLockRecordStale(record, now, REGISTRY_LOCK_TTL_MS); } private take(purpose: string, now: number): boolean { const existing = this.peek(); if (existing && existing.owner !== this.owner && !this.stale(existing, now)) return false; mkdirSync(dirname(this.path), { recursive: true }); const temp = `${this.path}.tmp-${process.pid}`; writeFileSync( temp, `${JSON.stringify({ owner: this.owner, pid: process.pid, acquiredAt: now, purpose }, null, 2)} `, "utf8", ); renameSync(temp, this.path); return true; } acquire(purpose: string, now = Date.now()): boolean { // Fail-closed, like SharedStore.acquire: a live holder is a refusal, not a // wait. Writes are millisecond transactions; a caller that hits contention // retries deliberately. Sleeping synchronously to poll would add a // main-thread busy loop for a situation this repository treats as an error. return this.take(purpose, now); } release(): void { try { const record = this.peek(); if (record && record.owner === this.owner) unlinkSync(this.path); } catch { // best effort; the TTL reclaims a leaked lock } } } export class BindingStore { readonly path: string; constructor(options: { path?: string } = {}) { this.path = options.path ?? defaultRegistryPath(); } private corrupted = false; /** * A9 (verdict #2 item 13): corruption must never be silently turned into "no * bindings". The bridge stays usable (recovery rebuilds from the frame chain), * but the corruption is REPORTED as a fact — status shows it — because a * silent reset is exactly how an identity loss starts looking normal. */ corruptedRecently(): boolean { return this.corrupted; } load(): RegistryFile { try { if (!existsSync(this.path)) return { version: REGISTRY_VERSION, bindings: [] }; const parsed = JSON.parse(readFileSync(this.path, "utf8")) as Partial; return { version: REGISTRY_VERSION, bindings: Array.isArray(parsed.bindings) ? (parsed.bindings as Binding[]) : [], legacyMigrated: parsed.legacyMigrated === true, }; } catch { // A corrupt registry must not make the bridge unusable; recovery rebuilds // from the conversation FRAME chain anyway — BUT the corruption is not // hidden: a torn write or external edit left the file unreadable, and // silently reading "no bindings" is a lie that looks like a fresh start. this.corrupted = true; return { version: REGISTRY_VERSION, bindings: [] }; } } private save(file: RegistryFile): void { mkdirSync(dirname(this.path), { recursive: true }); const temp = `${this.path}.tmp-${process.pid}`; writeFileSync(temp, `${JSON.stringify({ ...file, version: REGISTRY_VERSION }, null, 2)}\n`, "utf8"); renameSync(temp, this.path); } /** * Every durable registry change goes through here, inside the write lock, with * provenance stamped on exactly the entries the change touched (A5+A16). * * The lock exists because revision only means something if two writers cannot * both read revision N and both write N+1 (verdict #11's collision warning -- * the BUG-005 family again). The lock file sits next to the registry, so it * follows the same state root; acquire is atomic rename; a dead holder's lock * is taken over, a live holder blocks the writer fail-closed rather than * letting it write a revision that would look trustworthy and be wrong. */ private mutate(source: BindingSource, now: number, fn: (file: RegistryFile) => void): void { const lock = new RegistryWriteLock(`${this.path}.lock`); if (!lock.acquire("registry-write")) { throw new Error( `registry 写锁获取失败(${this.path})——另一个进程正在写注册表,拒绝在无法串行化时写入 provenance。`, ); } try { const file = this.load(); const before = JSON.stringify(file); const beforeByEntry = new Map(file.bindings.map((b) => [b.bindingId, JSON.stringify(b)])); fn(file); const after = JSON.stringify({ ...file, version: REGISTRY_VERSION }); if (after === before) return; // a no-op decision must not invent a revision const maxRev = file.bindings.reduce((m, b) => Math.max(m, b.revision ?? 0), 0); for (const binding of file.bindings) { if (beforeByEntry.get(binding.bindingId) === JSON.stringify(binding)) continue; binding.revision = maxRev + 1; binding.updatedAt = now; binding.updatedBy = source; } this.save(file); } finally { lock.release(); } } /** The binding owned by this local pi session. */ findBySession(localSessionId: string): Binding | null { return this.load().bindings.find( (binding) => binding.localSessionId === localSessionId && !isReleasedBinding(binding), ) ?? null; } /** Audit lookup used only to prevent an in-flight runtime candidate from resurrecting a release. */ findBySessionIncludingReleased(localSessionId: string): Binding | null { const entries = this.load().bindings.filter((binding) => binding.localSessionId === localSessionId); return entries.find((binding) => !isReleasedBinding(binding)) ?? entries.at(-1) ?? null; } /** The binding pointing at this conversation, by URL or by conversation id. */ findByConversation(conversationUrl: string, conversationId?: string): Binding | null { const wantedId = conversationId ?? conversationIdOf(conversationUrl); if (!wantedId) return null; return ( this.load().bindings.find((binding) => { if (isReleasedBinding(binding)) return false; if (!isDurableConversationBinding(binding)) return false; if (binding.conversationUrl === conversationUrl) return true; if (binding.conversationId === wantedId) return true; return false; }) ?? null ); } list(): Binding[] { return this.load().bindings; } /** Touch `lastUsedAt` so status output can show what is actually in use. */ touch(localSessionId: string, now = Date.now()): void { this.mutate("tool-action", now, (file) => { const binding = file.bindings.find((entry) => entry.localSessionId === localSessionId && !isReleasedBinding(entry)); if (binding) binding.lastUsedAt = now; }); } /** * Conversations this bridge created, newest first. * * Read-only by design. Listing is how cleanup stays deliberate: deleting * 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. */ autoCreated(): Binding[] { return this.load() .bindings.filter((entry) => entry.origin === "auto") .sort((a, b) => b.createdAt - a.createdAt); } /** Auto-created conversations from the last `windowMs`. */ autoCreatedSince(windowMs: number, now = Date.now()): Binding[] { const cutoff = now - windowMs; return this.autoCreated().filter((entry) => entry.createdAt >= cutoff); } /** * Create or move the binding for one local session. * * Re-binding the same session to a different conversation is allowed: that is * the user deliberately moving their thread, and it keeps the same bindingId so * rounds and checkpoint follow them. */ bind(input: BindInput): BindResult { const now = input.now ?? Date.now(); const conversationId = conversationIdOf(input.conversationUrl); const source: BindingSource = input.source ?? "tool-action"; let result: BindResult | null = null; this.mutate(source, now, (file) => { const existingForSession = file.bindings.find( (entry) => entry.localSessionId === input.localSessionId && !isReleasedBinding(entry), ); // Only a URL that carries a conversation id names a conversation. A brand-new // chat reports a bare `https://chatgpt.com/` until its first message, and two // threads creating lazily at the same time would both "own" that URL -- the // invariant would refuse the second one and break concurrent first use. const existingForConversation = conversationId === null ? undefined : file.bindings.find((entry) => entry.conversationId === conversationId && !isReleasedBinding(entry)); // The invariant that stops the reported bug: one conversation, one owner. if ( existingForConversation && existingForConversation.localSessionId !== input.localSessionId && !input.force ) { result = { ok: false, reason: ownershipConflictMessage(existingForConversation), conflict: existingForConversation, }; return; } if (existingForSession) { // A bare URL gaining its real id is the same conversation being filled in, // not a replacement. Only id-to-id changes are rotations. const previousId = existingForSession.conversationId; const incomingId = conversationId ?? ""; if (previousId && incomingId && previousId !== incomingId) { // Deduplicated on insert, and the incoming conversation is removed from the chain if // it is already there. A conversation can return to being current -- binding back to // a predecessor is exactly how a bad rotation is undone -- and without this the // chain accumulates repeats: observed live as 6aac0144 -> 6aaca8b9 -> 6aac0144, // where the same id appears twice and "replaced once" stops being checkable. existingForSession.predecessors = [ { conversationUrl: existingForSession.conversationUrl, conversationId: previousId, replacedAt: now }, ...(existingForSession.predecessors ?? []).filter( (entry) => entry.conversationId !== previousId && entry.conversationId !== incomingId, ), ].slice(0, 8); } existingForSession.conversationUrl = input.conversationUrl; existingForSession.conversationId = conversationId ?? ""; existingForSession.state = conversationId ? "BOUND" : (input.bindingState ?? existingForSession.state ?? "PROVISIONAL"); existingForSession.targetId = input.targetId ?? (conversationId ? (input.bindingState === "BOUND" ? existingForSession.targetId : undefined) : existingForSession.targetId); existingForSession.cwd = input.cwd ?? existingForSession.cwd; existingForSession.lastUsedAt = now; if (input.label) existingForSession.label = input.label; if (input.affinity) existingForSession.affinity = input.affinity; if (input.bindingSource) existingForSession.source = input.bindingSource; if (input.draftRoute !== undefined) existingForSession.draftRoute = input.draftRoute || undefined; if (input.materializationPending !== undefined) { existingForSession.materializationPending = input.materializationPending; } if (input.materializationOperationId !== undefined) { existingForSession.materializationOperationId = input.materializationOperationId || undefined; } if (input.submittedAt !== undefined) existingForSession.submittedAt = input.submittedAt; if (input.materializationObservedId !== undefined) { existingForSession.materializationObservedId = input.materializationObservedId || undefined; } if (input.materializationError !== undefined) { existingForSession.materializationError = input.materializationError || undefined; } result = { ok: true, binding: existingForSession, rebound: true }; return; } const binding: Binding = { bindingId: newBindingId(), localSessionId: input.localSessionId, origin: input.origin ?? "explicit", source: input.bindingSource, conversationUrl: input.conversationUrl, conversationId: conversationId ?? "", state: conversationId ? "BOUND" : (input.bindingState ?? "PROVISIONAL"), targetId: conversationId ? undefined : input.targetId, label: input.label ?? "", cwd: input.cwd ?? "", createdAt: now, lastUsedAt: now, }; if (input.affinity) binding.affinity = input.affinity; if (input.draftRoute) binding.draftRoute = input.draftRoute; if (input.materializationPending !== undefined) binding.materializationPending = input.materializationPending; if (input.materializationOperationId) binding.materializationOperationId = input.materializationOperationId; if (input.submittedAt !== undefined) binding.submittedAt = input.submittedAt; if (input.materializationObservedId) binding.materializationObservedId = input.materializationObservedId; if (input.materializationError) binding.materializationError = input.materializationError; file.bindings.push(binding); result = { ok: true, binding, rebound: false }; }); if (result === null) throw new Error("bind 未产出结果——mutate 回调没有正常结束"); return result; } unbind(localSessionId: string, nowOrOptions: number | UnbindOptions = Date.now(), options: UnbindOptions = {}): boolean { const now = typeof nowOrOptions === "number" ? nowOrOptions : Date.now(); const effectiveOptions = typeof nowOrOptions === "number" ? options : nowOrOptions; if (effectiveOptions.inFlight) throw new Error("BINDING_BUSY:当前绑定正在进行可观察操作,拒绝释放;未修改注册表。"); let released = false; this.mutate(effectiveOptions.source ?? "tool-action", now, (file) => { const current = file.bindings.find( (entry) => entry.localSessionId === localSessionId && !isReleasedBinding(entry), ); if (!current) return; current.state = "RELEASED"; current.releasedAt = now; current.releasedBySessionId = effectiveOptions.releasedBySessionId ?? localSessionId; current.releaseReason = "explicit_unbind"; released = true; }); return released; } /** Mark the legacy global config binding as claimed, so it is not claimed twice. */ markLegacyMigrated(now = Date.now()): void { this.mutate("migration", now, (file) => { file.legacyMigrated = true; }); } } export function newBindingId(): string { return randomBytes(6).toString("hex"); } /** Single implementation lives in identity.ts (verdict #11); re-exported here. */ export { conversationIdOf } from "./identity.ts"; /** One-line description for status output. */ export function describeBinding(binding: Binding): string { const parts = [ `binding=${binding.bindingId}`, `session=${binding.localSessionId}`, `conversation=${binding.conversationId || binding.conversationUrl}`, ]; if (binding.label) parts.push(`label=${binding.label}`); return parts.join(" "); } /** A provisional record is target ownership only, never durable conversation ownership. */ export function isProvisionalBinding(binding: Binding | null | undefined): boolean { return Boolean(binding && !isReleasedBinding(binding) && !binding.conversationId && (binding.state === "PROVISIONAL" || binding.targetId)); } /** Durable owner queries must ignore target-only provisional records. */ export function isDurableConversationBinding(binding: Binding | null | undefined): boolean { // Older registry entries may omit state; with a durable conversation id they // remain active legacy owners until an explicit lifecycle operation touches them. return Boolean(binding && !isReleasedBinding(binding) && Boolean(binding.conversationId) && binding.state !== "PROVISIONAL"); } /** RELEASED is an audit tombstone, never an active owner or routing target. */ export function isReleasedBinding(binding: Binding | null | undefined): boolean { return binding?.state === "RELEASED"; } /** Frozen ownership-conflict guidance shared by explicit and automatic bind paths. */ export function ownershipConflictMessage(owner: Pick): string { const ownerLabel = owner.label ? `${owner.label} / ` : ""; return ( `该会话已经被另一个 pi 线程绑定:${ownerLabel}session=${owner.localSessionId}` + `(binding ${owner.bindingId})。` + "一个会话只能由一个线程拥有;一个会话只能有一个 owning 线程,以避免串线。" + "请在拥有它的线程执行 /brb unbind;释放后在当前线程重新执行 /brb bind 。" + "本版本不支持从其它线程强制接管绑定。" ); }