aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-09-26 22:20:21 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-09-26 23:28:51 -0400
commitc5c048b3b2b978d99f6b4f941be842429744c946 (patch)
treecedbf5d81e0b62d0e82d3ec3f0166fdc348e3cf8
parent4d93d850d2b1bb0c1967e87e9a09d841f8c5020c (diff)
Make Chrome primer and authorization log prompt-cache friendly
- Contribute the primer as a structured prompt section instead of forcing systemPrompt. - Log pi-chrome-tool-change as a rendered custom entry that is not sent to the model.
-rw-r--r--CHANGELOG.md3
-rw-r--r--extensions/chrome-profile-bridge/index.ts61
-rw-r--r--package.json6
-rw-r--r--test-suite/unit/prompt-section.test.mjs52
4 files changed, 102 insertions, 20 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 081c2d7..70848e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ All notable user-facing changes to `pi-chrome`.
- **Console capture no longer breaks sites with DevTools detection (#9).** Some sites (BOSS Zhipin) log an object with a getter or custom `toString` and close the tab if anything reads it, since only open DevTools would. The console capture serialized every logged argument immediately, so it tripped the check. Captured arguments are now stored as-is, and `chrome_list_console_messages` serializes them without running page code: data properties only, accessors shown as `[getter]`, and DOM nodes, regexes, dates and errors described through Chrome's native getters.
- **Early capture only in Pi's tabs.** The document-start console/network capture used to be injected into every page you opened. It now runs only in Pi's automation tab and in tabs tracked for a Pi session. Other tabs are not touched until a Pi tool acts on them.
+- **Prompt-cache-friendly primer.** The Chrome primer is now contributed as a structured `chrome-profile-bridge` prompt section instead of a forced `systemPrompt` override. Previously the `before_agent_start` handler returned `systemPrompt` on every run (even while locked), which made pi collapse all system messages into a freshly rendered leading prompt each run. That disabled pi's append-only prompt/tool deltas for the whole session and busted the provider prompt cache when `/chrome authorize`, revoke, or expiry toggled the primer. Pi now appends a small section delta instead. Older pi versions without structured sections keep the previous append behavior.
+- **Authorization log stays out of model context.** `pi-chrome-tool-change` records (authorize, reauthorize, revoke, expiry) are now session custom entries rendered in the transcript instead of model-facing custom messages. The model already sees the change through pi's tool and prompt-section delta, so the message was redundant context.
+- **Validation.** New `prompt-section` unit suite.
## 0.15.54 — 2026-09-26
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index e9e21e9..adfe6cf 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -1,4 +1,5 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
+import { Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { existsSync, readFileSync, statSync } from "node:fs";
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
@@ -801,6 +802,14 @@ const CHROME_TOOL_NAMES = [
"chrome_cdp_targets",
] as const;
const CHROME_TOOL_NAME_SET = new Set<string>(CHROME_TOOL_NAMES);
+const CHROME_TOOL_CHANGE_ENTRY = "pi-chrome-tool-change";
+type ChromeToolChangeEntry = {
+ content: string;
+ action: "authorized" | "reauthorized" | "revoked" | "expired";
+ tools: string[];
+ authorizedUntil?: number | "indefinite";
+ at: number;
+};
function StringEnum<T extends readonly [string, ...string[]]>(values: T) {
return Type.Union(values.map((value) => Type.Literal(value)) as [ReturnType<typeof Type.Literal>, ...ReturnType<typeof Type.Literal>[]]);
@@ -890,17 +899,15 @@ export default function (pi: ExtensionAPI): void {
: action === "expired"
? "Chrome tools disabled because /chrome authorize grant expired."
: "Chrome tools disabled by /chrome revoke.";
- pi.sendMessage({
- customType: "pi-chrome-tool-change",
+ // Session-history log only (custom entry, not sent to the model). The model already learns about
+ // the change from pi's tool/prompt-section delta, so a model-facing message would be redundant.
+ pi.appendEntry<ChromeToolChangeEntry>(CHROME_TOOL_CHANGE_ENTRY, {
content,
- display: true,
- details: {
- action,
- tools: [...CHROME_TOOL_NAMES],
- authorizedUntil: options.authorizedUntil,
- at: Date.now(),
- },
- }, { triggerTurn: false });
+ action,
+ tools: [...CHROME_TOOL_NAMES],
+ authorizedUntil: options.authorizedUntil,
+ at: Date.now(),
+ });
};
// Bound the entire request, including shared-owner forwarding/takeover. Revoke can launch
@@ -1088,6 +1095,12 @@ export default function (pi: ExtensionAPI): void {
updateChromeStatus(ctx);
});
+ // Render authorization log entries in the transcript. Custom entries never reach the model.
+ pi.registerEntryRenderer?.<ChromeToolChangeEntry>(CHROME_TOOL_CHANGE_ENTRY, (entry, _options, theme) => {
+ const content = entry.data?.content ?? "Chrome authorization changed.";
+ return new Text(theme.fg("dim", `[pi-chrome] ${content}`), 1, 0);
+ });
+
pi.on("session_shutdown", async (event) => {
clearAuthExpiryTimer();
clearCountdownInterval();
@@ -1100,13 +1113,26 @@ export default function (pi: ExtensionAPI): void {
}
});
+ // Contribute the primer as a named prompt section instead of returning `systemPrompt`.
+ // Returning `systemPrompt` forces a whole-prompt override, which makes pi collapse every system
+ // message into a new leading prompt each run (busting the provider prompt cache and disabling
+ // pi's append-only section/tool deltas for all extensions). Mutating `systemPromptOptions.sections`
+ // lets pi diff the section and append a small system-message delta when it appears/disappears.
pi.on("before_agent_start", (event) => {
- if (!chromeToolsRegistered || !chromeControlAuthorized()) {
- return { systemPrompt: event.systemPrompt };
+ const active = chromeToolsRegistered && chromeControlAuthorized();
+ const sections = (event as { systemPromptOptions?: { sections?: Record<string, string> } }).systemPromptOptions?.sections;
+ if (sections) {
+ if (active) sections[CHROME_PRIMER_SECTION] = CHROME_PRIMER;
+ else delete sections[CHROME_PRIMER_SECTION];
+ return undefined;
}
- const primer = `
-<chrome-profile-bridge>
-Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
+ // Legacy pi without structured prompt sections: append the primer to the rendered prompt.
+ if (!active) return undefined;
+ return { systemPrompt: `${event.systemPrompt}\n<${CHROME_PRIMER_SECTION}>\n${CHROME_PRIMER}\n</${CHROME_PRIMER_SECTION}>` };
+ });
+
+ const CHROME_PRIMER_SECTION = "chrome-profile-bridge";
+ const CHROME_PRIMER = `Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
Tab/window isolation (important):
- pi-chrome owns a dedicated automation window/tab. When a chrome_* tool runs with no explicit target, it acts on that pi-chrome-owned target — it never reuses or overwrites the user's currently active tab. The dedicated target is created on first use and reused afterward.
@@ -1130,10 +1156,7 @@ Usage rules:
5. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
6. /chrome background on (default) is a hard policy: per-call \`background=false\` cannot override it, new tabs stay inactive, and \`chrome_tab activate\` is blocked. Ask the user to run /chrome background off when they want foreground/watch mode. With background off, per-call \`background=true\` still avoids explicit focus/tab activation. Screenshots use CDP without activating background tabs; debugger failures never fall back to tab activation. Page scripts, trusted input, native prompts, and Chrome/OS behavior can still affect focus.
7. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input.
-8. Run /chrome doctor when in doubt about connectivity or capabilities.
-</chrome-profile-bridge>`;
- return { systemPrompt: event.systemPrompt + primer };
- });
+8. Run /chrome doctor when in doubt about connectivity or capabilities.`;
// Shared handlers, dispatched by the unified /chrome command below.
const doctorHandler = async (ctx: ExtensionContext) => {
diff --git a/package.json b/package.json
index 3904c9f..5ce1a6c 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "pi-chrome",
"version": "0.15.54",
"scripts": {
- "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs",
+ "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs",
"version": "node scripts/sync-manifest-version.js",
"prepublishOnly": "node scripts/sync-manifest-version.js"
},
@@ -67,12 +67,16 @@
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
+ "@earendil-works/pi-tui": "*",
"typebox": "*"
},
"peerDependenciesMeta": {
"@earendil-works/pi-coding-agent": {
"optional": true
},
+ "@earendil-works/pi-tui": {
+ "optional": true
+ },
"typebox": {
"optional": true
}
diff --git a/test-suite/unit/prompt-section.test.mjs b/test-suite/unit/prompt-section.test.mjs
new file mode 100644
index 0000000..b83f526
--- /dev/null
+++ b/test-suite/unit/prompt-section.test.mjs
@@ -0,0 +1,52 @@
+// The Chrome primer must be a structured prompt section, never a forced `systemPrompt` override.
+// A forced override makes pi collapse all system messages into a new leading prompt every run,
+// which busts the provider prompt cache.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { stripTypeScriptTypes } from "node:module";
+import { test } from "node:test";
+
+const source = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8");
+const from = source.indexOf('pi.on("before_agent_start",');
+const to = source.indexOf("// Shared handlers,", from);
+assert.ok(from >= 0 && to > from, "Missing before_agent_start section");
+const handlerSource = stripTypeScriptTypes(source.slice(from, to));
+
+function load({ registered = true, authorized = true } = {}) {
+ let handler;
+ const pi = { on: (name, fn) => { if (name === "before_agent_start") handler = fn; } };
+ vm.runInNewContext(`${handlerSource}`, {
+ pi,
+ chromeToolsRegistered: registered,
+ chromeControlAuthorized: () => authorized,
+ });
+ return handler;
+}
+
+test("authorized: adds primer section and does not force systemPrompt", () => {
+ const event = { systemPrompt: "base", systemPromptOptions: { sections: {} } };
+ const result = load()(event);
+ assert.equal(result, undefined);
+ const primer = event.systemPromptOptions.sections["chrome-profile-bridge"];
+ assert.match(primer, /^Chrome control is available/);
+ assert.doesNotMatch(primer, /<\/?chrome-profile-bridge>/, "pi wraps sections in their tag");
+});
+
+test("locked: removes primer section and does not force systemPrompt", () => {
+ const event = { systemPrompt: "base", systemPromptOptions: { sections: { "chrome-profile-bridge": "stale", other: "keep" } } };
+ assert.equal(load({ authorized: false })(event), undefined);
+ assert.deepEqual(event.systemPromptOptions.sections, { other: "keep" });
+});
+
+test("tools not registered: no primer", () => {
+ const event = { systemPrompt: "base", systemPromptOptions: { sections: {} } };
+ assert.equal(load({ registered: false })(event), undefined);
+ assert.deepEqual(event.systemPromptOptions.sections, {});
+});
+
+test("legacy pi without sections: appends only when authorized", () => {
+ assert.equal(load({ authorized: false })({ systemPrompt: "base" }), undefined);
+ const result = load()({ systemPrompt: "base" });
+ assert.match(result.systemPrompt, /^base\n<chrome-profile-bridge>\nChrome control is available[\s\S]*<\/chrome-profile-bridge>$/);
+});