diff options
| author | Igor Soarez <igor@soarez.org> | 2026-07-29 10:38:13 +0100 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-14 12:19:26 +0100 |
| commit | 08e558753e8ef9b2aa96114981a5a1dd37357bad (patch) | |
| tree | 1d52d895f98d7880e7b1cd1ab6ed13321814dd86 /src/index.ts | |
| parent | 6eda4b9cce4b2ae7003ccd7429e3f3aad7c04b66 (diff) | |
feat(cdp): /browser-target to switch browsers without leaving pi
Switching between the shared browser and this machine's own previously meant
leaving pi and relaunching with BU_CDP_HTTP — and, less obviously, killing the
daemon first, or the switch silently did not happen.
/browser-target show the current target
/browser-target local this machine's browser
/browser-target default the built-in default
/browser-target host:port somewhere else
Precedence becomes env > stored > default. The environment still wins so a
one-off `BU_CDP_HTTP=… pi` is never silently overridden by a choice made days
ago; when it is set, the command says so rather than pretending to obey.
Clearing falls back to the default rather than to local — "use the usual
browser", not "go local".
The daemon is the reason this needed more than a settings write. It is
machine-wide, resolves its target once per process, and holds the WebSocket to
the old browser, so re-reading the store would leave tool calls going to the
old browser while /browser-status reported the new one. The command therefore
replaces the process: the daemon now honours an inbound control/shutdown (the
action already existed in the protocol, previously only sent daemon→client),
and stopDaemon() asks for it and polls until the process is really gone before
ensureDaemon() respawns it against the new target. /browser-status now reads
the store too, so it cannot disagree with what the tools will do.
Persistence mirrors the profile pin: atomic temp-file-plus-rename, and every
read failure degrades to "nothing stored" so a corrupt file falls back to the
default instead of breaking browser control. It is a separate file from the
pin, so damage to one cannot cost you the other. The stored value stays in
BU_CDP_HTTP's vocabulary so there is exactly one resolution path.
62 tests: the full precedence table including the stored tier, that a bad value
is blamed on the source that set it rather than sending you hunting through
shell config, that the command only accepts values resolution can actually use,
and eight ways of damaging the store file.
Diffstat (limited to 'src/index.ts')
| -rw-r--r-- | src/index.ts | 83 |
1 files changed, 81 insertions, 2 deletions
diff --git a/src/index.ts b/src/index.ts index 2b95697..152c94d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,9 @@ import { restoreDownloadBehavior } from "./domains/files"; import { createDaemonTransport } from "./daemon/transport"; import { setDebugClicks } from "./util/debug"; import { asBoolean, asString } from "./util/guards"; -import { describeCdpTarget, resolveCdpTarget } from "./cdp/endpoint"; +import { describeCdpTarget, isValidTargetValue, localAliases, resolveCdpTarget } from "./cdp/endpoint"; +import { clearTarget, readTarget, writeTarget } from "./cdp/target-store"; +import { ensureDaemon, stopDaemon } from "./daemon/spawn"; import { queryLiveWsUrl } from "./cdp/discovery"; export default function browserHarnessExtension(pi: ExtensionAPI): void { @@ -47,7 +49,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { // first browser_* tool runs. A plain "Disconnected" reads as a fault // when it is really just "not needed yet", so say which it is, and show // what we would connect to. - const target = resolveCdpTarget(); + const target = resolveCdpTarget(process.env, await readTarget()); const reachable = target.kind === "remote" ? (await queryLiveWsUrl(target.port, 2_000, target.host)) !== null : null; @@ -91,6 +93,83 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { }, }); + // FORK: switch which browser the harness drives, without leaving pi. + // + // The target is resolved per daemon process and the daemon holds the browser + // connection, so a switch has to replace that process — writing the store + // alone would leave tool calls going to the old browser while /browser-status + // reported the new one. + pi.registerCommand("browser-target", { + description: "Show or change which browser to drive (local, default, or host:port)", + handler: async (args, ctx) => { + const wanted = (typeof args === "string" ? args : "").trim(); + + if (wanted === "") { + const current = resolveCdpTarget(process.env, await readTarget()); + ctx.ui.notify( + `Browser target: ${describeCdpTarget(current)}\n` + + `Change with: /browser-target local | default | host:port`, + "info", + ); + return; + } + + const clearing = wanted.toLowerCase() === "default"; + if (!clearing && !isValidTargetValue(wanted)) { + ctx.ui.notify( + `Not a usable target: "${wanted}"\n` + + `Expected "host:port", "default", or one of ${localAliases().join(", ")}.`, + "error", + ); + return; + } + + const saved = clearing ? await clearTarget() : await writeTarget(wanted); + if (!saved.success) { + ctx.ui.notify(saved.error, "error"); + return; + } + + const resolved = resolveCdpTarget(process.env, await readTarget()); + + // Warn rather than silently disobey: the environment deliberately wins, + // so a stored choice made now would not take effect this session. + const envOverride = (process.env["BU_CDP_HTTP"] ?? "").trim(); + if (envOverride !== "") { + ctx.ui.notify( + `Saved, but BU_CDP_HTTP=${envOverride} is set in this environment and takes precedence.\n` + + `Still using: ${describeCdpTarget(resolved)}`, + "warning", + ); + return; + } + + ctx.ui.notify(`Switching to ${describeCdpTarget(resolved)}...`, "info"); + + if (client) await client.stop(); + if (!(await stopDaemon())) { + ctx.ui.notify("Saved, but the browser daemon did not stop — run /browser-setup to pick it up.", "warning"); + return; + } + if (!(await ensureDaemon())) { + ctx.ui.notify("Saved, but the browser daemon did not come back. Run /browser-setup.", "error"); + return; + } + + if (!client) { + ctx.ui.notify(`Browser target set to ${describeCdpTarget(resolved)} ✓ — run /browser-setup to connect.`, "info"); + return; + } + const started = await client.start(); + ctx.ui.notify( + started.success + ? `Browser target set to ${describeCdpTarget(resolved)} ✓` + : `Target saved (${describeCdpTarget(resolved)}) but connecting failed: ${started.error.message}`, + started.success ? "info" : "error", + ); + }, + }); + pi.registerCommand("browser-reload-daemon", { description: "Restart the browser client", handler: async (_args, ctx) => { |
