From e6c01048363e35b29ebb66e5f8936bdcf60c2913 Mon Sep 17 00:00:00 2001 From: Tianren Dong Date: Mon, 28 Sep 2026 19:30:23 -0400 Subject: Fill whole textareas; cover new tabs' first page with early capture chrome_fill's triple-click selected one line of a multi-line textarea, so fill replaced only that line. Select value fields with the selectAll editing command before deleting. Early capture now waits for in-flight tab.new creations, so a first page that commits before the tab is tracked is still captured. --- CHANGELOG.md | 3 ++ .../browser-extension/service_worker.js | 35 +++++++++++++++++----- test-suite/unit/console-capture.test.mjs | 21 ++++++++++++- test-suite/unit/input-reliability.test.mjs | 12 ++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a605b1d..8576880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable user-facing changes to `pi-chrome`. ## Unreleased +- **`chrome_fill` replaces all of a multi-line textarea.** Fill selected the old value with a triple-click, which in a textarea selects only the clicked line. The rest stayed, and the tool still reported success. Inputs and textareas now select the whole value with Chrome's `selectAll` editing command before deleting. Contenteditables are unchanged. +- **Early capture covers a new tab's first page.** `chrome_tab new` could commit its first page before the tab was recorded as Pi's, so early console/network capture skipped that load. Early capture now waits for in-flight Pi tab creations before deciding. + ## 0.15.55 — 2026-09-26 - **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. diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index 24caa58..bcc795b 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -1232,11 +1232,16 @@ async function chromeInputFill(params) { await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" }); await sleep(rng(20, 60)); } - await contentEditableInTab(tab.id, params); - // Delete selection. - await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }); - await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }); - await sleep(rng(20, 60)); + if (await contentEditableInTab(tab.id, params)) { + // The requested editor's whole contents are already selected; delete the selection. + await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }); + await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 }); + await sleep(rng(20, 60)); + } else { + // A triple-click selects only one line of a multi-line textarea. Select the whole value with + // Chrome's selectAll editing command before deleting. + await selectAllAndDelete(tab.id); + } const text = String(params.text || ""); const typing = await typeTextInTab(tab.id, text, params.perCharacter); if (params.submit) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" }); @@ -1743,8 +1748,20 @@ async function dispatch(action, params) { const existingGroup = await findGroupRecordByTitle(groupTitle); const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) }; if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId; - const tab = await chrome.tabs.create(createParams); - await trackSessionTab(sessionKeyOf(params), tab.id, true); + // Register the creation as in flight until the tab is tracked, so early capture (which only + // runs in tracked tabs) waits for it instead of skipping the new tab's first page load. + const creation = (async () => { + const created = await chrome.tabs.create(createParams); + await trackSessionTab(sessionKeyOf(params), created.id, true); + return created; + })(); + pendingTabCreations.add(creation); + let tab; + try { + tab = await creation; + } finally { + pendingTabCreations.delete(creation); + } try { await bringToFront(tab, params); const grouped = await groupTab(tab, groupTitle, params.groupColor); @@ -2298,8 +2315,12 @@ async function unregisterInitScript(tabId) { // console messages, errors, and network requests that fire during page load, before chrome_snapshot // or chrome_evaluate install the instrumentation normally. installEarlyCapture sets __piChromeWrapped // flags so the post-hoc installPiChromeInstrumentation() call is idempotent. +const pendingTabCreations = new Set(); // in-flight Pi tab creations, resolved once tracked + async function isPiTrackedTab(tabId) { await Promise.all([hydrateAutomationTargets(), hydrateSessionTabs()]); + // A tab Pi is creating right now can commit its first page before tab.new has tracked it. + if (pendingTabCreations.size) await Promise.allSettled([...pendingTabCreations]); if (isPiChromeOwnedTarget(tabId)) return true; for (const tabs of sessionTabs.values()) if (tabs.has(tabId)) return true; return false; diff --git a/test-suite/unit/console-capture.test.mjs b/test-suite/unit/console-capture.test.mjs index ba0410b..48430ca 100644 --- a/test-suite/unit/console-capture.test.mjs +++ b/test-suite/unit/console-capture.test.mjs @@ -20,7 +20,8 @@ function loadWorker() { debugger: { onDetach: noop, onEvent: noop }, storage: { session: { get: async () => ({}), set: async () => {} } }, scripting: { executeScript: async (options) => { injected.push(options); return []; } }, - tabs: { get: async () => null }, + tabs: { get: async () => null, create: async () => { throw new Error("not mocked"); } }, + tabGroups: { query: async () => [] }, }; const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } }; w.self = w; @@ -120,3 +121,21 @@ test("early capture is injected only into tabs Pi owns or tracks", async () => { assert.deepEqual(h.injected.map((o) => o.target.tabId), [11, 12]); assert.ok(h.injected.every((o) => o.func.name === "installEarlyCapture" && o.injectImmediately === true)); }); + +test("a Pi tab whose first page commits before tab.new tracks it still gets early capture", async () => { + const h = loadWorker(); + let commit; + h.w.chrome.tabs.create = async (props) => { + // Chrome can commit the first document while tab.new is still recording ownership. + commit = h.commit(21); + return { id: 21, windowId: 1, url: props.url, status: "loading" }; + }; + h.w.groupTab = async (tab) => ({ tab: { id: tab.id }, group: { title: "Pi" } }); + h.w.waitForTabSettled = async () => true; + h.w.formatTab = async (tab) => tab; + await h.w.dispatch("tab.new", { url: "https://site.test/", background: true, sessionKey: "s" }); + await commit; + assert.deepEqual(h.injected.map((o) => o.target.tabId), [21]); + await h.commit(22); + assert.deepEqual(h.injected.map((o) => o.target.tabId), [21], "unrelated tabs stay untouched"); +}); diff --git a/test-suite/unit/input-reliability.test.mjs b/test-suite/unit/input-reliability.test.mjs index 4a47e9c..8ea4bc5 100644 --- a/test-suite/unit/input-reliability.test.mjs +++ b/test-suite/unit/input-reliability.test.mjs @@ -215,6 +215,18 @@ for (const perCharacter of [false, true]) { }); } +test("fill selects a textarea's whole value with the selectAll command, not just the clicked line", async () => { + const h = harness({ tag: "TEXTAREA", editable: false }); + const result = await h.call("fill", { selector: "#target", text: "NEW", domFallback: false }); + assert.equal(result.input, "chrome"); + const keys = h.commands("Input.dispatchKeyEvent"); + const selectAll = keys.findIndex((c) => (c.params.commands || []).includes("selectAll")); + const del = keys.findIndex((c) => c.params.key === "Delete" && c.params.type === "keyDown"); + assert.ok(selectAll >= 0, "selectAll editing command sent"); + assert.ok(del > selectAll, "delete follows select-all"); + assert.equal(h.selected.length, 0, "no DOM range selection for value fields"); +}); + test("fill refuses to select an unrelated focused editor", async () => { const h = harness({ initial: "keep this" }); h.page.document.activeElement = { isContentEditable: true, contains: () => false }; -- cgit v1.2.3 From 9e4514f020ecab9af0a8b8d36f01fc802ff5123e Mon Sep 17 00:00:00 2001 From: Tianren Dong Date: Mon, 28 Sep 2026 19:38:27 -0400 Subject: Release 0.15.56 --- CHANGELOG.md | 2 ++ extensions/chrome-profile-bridge/browser-extension/manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8576880..94bc359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable user-facing changes to `pi-chrome`. ## Unreleased +## 0.15.56 — 2026-09-28 + - **`chrome_fill` replaces all of a multi-line textarea.** Fill selected the old value with a triple-click, which in a textarea selects only the clicked line. The rest stayed, and the tool still reported success. Inputs and textareas now select the whole value with Chrome's `selectAll` editing command before deleting. Contenteditables are unchanged. - **Early capture covers a new tab's first page.** `chrome_tab new` could commit its first page before the tab was recorded as Pi's, so early console/network capture skipped that load. Early capture now waits for in-flight Pi tab creations before deciding. diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json index 8d81bc1..52adebd 100644 --- a/extensions/chrome-profile-bridge/browser-extension/manifest.json +++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Pi Chrome Connector", - "version": "0.15.55", + "version": "0.15.56", "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.", "permissions": [ "tabs", diff --git a/package-lock.json b/package-lock.json index 86913bc..8c9a3a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-chrome", - "version": "0.15.55", + "version": "0.15.56", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-chrome", - "version": "0.15.55", + "version": "0.15.56", "license": "MIT", "peerDependencies": { "@earendil-works/pi-coding-agent": "*", diff --git a/package.json b/package.json index dfbf033..565e099 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-chrome", - "version": "0.15.55", + "version": "0.15.56", "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 && node test-suite/unit/prompt-section.test.mjs", "version": "node scripts/sync-manifest-version.js", -- cgit v1.2.3 From b33d18552599664901356ae27db0270df3cc3129 Mon Sep 17 00:00:00 2001 From: Tianren Dong Date: Mon, 28 Sep 2026 19:45:18 -0400 Subject: Create session tab groups in the tab's own window chrome.tabs.group without createProperties.windowId creates the group in the current (user's) window and moves the tab there, so a session's automation tab left its dedicated window and became a hidden inactive tab. Make the unit mock match Chrome's behavior. --- CHANGELOG.md | 1 + .../chrome-profile-bridge/browser-extension/service_worker.js | 8 ++++++-- test-suite/unit/automation-target.test.mjs | 11 +++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94bc359..accad1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable user-facing changes to `pi-chrome`. ## 0.15.56 — 2026-09-28 +- **Pi's automation tab stays in its own window.** Joining the session tab group called `chrome.tabs.group` without a window. Chrome then creates the group in the current window (yours) and moves the tab there, where it sits as an inactive, hidden tab and trusted input is refused. This happened on a session's first page action and is the main source of the hidden-tab errors. New groups are now created in the tab's own window. - **`chrome_fill` replaces all of a multi-line textarea.** Fill selected the old value with a triple-click, which in a textarea selects only the clicked line. The rest stayed, and the tool still reported success. Inputs and textareas now select the whole value with Chrome's `selectAll` editing command before deleting. Contenteditables are unchanged. - **Early capture covers a new tab's first page.** `chrome_tab new` could commit its first page before the tab was recorded as Pi's, so early console/network capture skipped that load. Early capture now waits for in-flight Pi tab creations before deciding. diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index bcc795b..68fe8c9 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -1711,10 +1711,14 @@ async function groupTab(tab, title, color) { const groupTitle = cleanGroupTitle(title); let groupId = tab.groupId; if (typeof groupId !== "number" || groupId < 0) { - const existing = await findGroupByTitle(tab.windowId, groupTitle); + // Some browsers omit windowId on freshly created tabs; read it back instead of guessing. + const windowId = typeof tab.windowId === "number" ? tab.windowId : (await chrome.tabs.get(tab.id).catch(() => null))?.windowId; + const existing = await findGroupByTitle(windowId, groupTitle); + // Without createProperties.windowId Chrome creates the group in the *current* (user's) window + // and moves the tab there, pulling Pi's automation tab out of its own window as a hidden tab. groupId = existing !== null ? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] }) - : await chrome.tabs.group({ tabIds: [tab.id] }); + : await chrome.tabs.group(typeof windowId === "number" ? { tabIds: [tab.id], createProperties: { windowId } } : { tabIds: [tab.id] }); } await chrome.tabGroups.update(groupId, { title: groupTitle, color: cleanGroupColor(color), collapsed: false }); const grouped = await chrome.tabs.get(tab.id); diff --git a/test-suite/unit/automation-target.test.mjs b/test-suite/unit/automation-target.test.mjs index 7f46597..73fbc77 100644 --- a/test-suite/unit/automation-target.test.mjs +++ b/test-suite/unit/automation-target.test.mjs @@ -87,14 +87,17 @@ function makeChrome(state, { withWindows = true, withStorage = true, withTabGrou // Chrome closes a window automatically when its final tab is removed. if (tab && ![...tabs.values()].some((other) => other.windowId === tab.windowId)) windows.delete(tab.windowId); }, - group: async ({ groupId, tabIds = [] } = {}) => { + // Like Chrome: a new group goes into createProperties.windowId, else the *current* window + // (the user's), and grouped tabs move into the group's window. + group: async ({ groupId, tabIds = [], createProperties } = {}) => { let gid = groupId; if (typeof gid !== "number") { gid = alloc.group(); - const firstTab = tabs.get(tabIds[0]); - groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId: firstTab ? firstTab.windowId : userWindowId }); + const windowId = typeof createProperties?.windowId === "number" ? createProperties.windowId : userWindowId; + groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId }); } - for (const tid of tabIds) { const t = tabs.get(tid); if (t) t.groupId = gid; } + const groupWindow = groups.get(gid).windowId; + for (const tid of tabIds) { const t = tabs.get(tid); if (t) { t.groupId = gid; t.windowId = groupWindow; } } return gid; }, ungroup: async (id) => { const ids = Array.isArray(id) ? id : [id]; for (const tid of ids) { const t = tabs.get(tid); if (t) t.groupId = -1; } }, -- cgit v1.2.3