diff --git a/docs/superpowers/plans/2026-07-30-extension-options-panel.md b/docs/superpowers/plans/2026-07-30-extension-options-panel.md index aa62d23..e871ef9 100644 --- a/docs/superpowers/plans/2026-07-30-extension-options-panel.md +++ b/docs/superpowers/plans/2026-07-30-extension-options-panel.md @@ -486,7 +486,12 @@ function applyTemplate(template, query) { } const tpl = typeof template === "string" ? template : ""; if (tpl.includes("{query}")) { - return tpl.replaceAll("{query}", query); + // Corrected during execution: the replacer MUST be a function. In the + // string form, $&, $`, $' and $$ are substitution patterns in the + // replacement — and the replacement is arbitrary page-selected text. + // Measured on the default {query} template: "awk print $& here" produced + // "awk print {query} here". + return tpl.replaceAll("{query}", () => query); } const trimmed = tpl.trim(); return trimmed === "" ? query : `${trimmed} ${query}`; @@ -704,7 +709,11 @@ async function readSelection() { let results; try { results = await browser.tabs.executeScript(tabId, { - code: "window.getSelection().toString()", + // Corrected during execution: gated on document.hasFocus(). Each frame + // owns an independent Selection that is not cleared when the user + // selects elsewhere, so an unfocused (possibly cross-origin) frame's + // stale selection could otherwise win on unspecified result ordering. + code: "document.hasFocus() ? window.getSelection().toString() : ''", allFrames: true, matchAboutBlank: true, }); diff --git a/docs/superpowers/specs/2026-07-29-extension-options-design.md b/docs/superpowers/specs/2026-07-29-extension-options-design.md index 4ffcfc4..aa7279b 100644 --- a/docs/superpowers/specs/2026-07-29-extension-options-design.md +++ b/docs/superpowers/specs/2026-07-29-extension-options-design.md @@ -293,7 +293,14 @@ mid-2025; the root form is what OpenAI's own extension uses. 1. **Build the prompt.** Let `q = String(rawQuery ?? "").trim()`. - If `q === ""` → `prompt = ""` (the template is ignored entirely). - Else if the template contains `{query}` → replace **every** occurrence - (`replaceAll`). + (`replaceAll`). Pass a **replacer function**, not the string: + `tpl.replaceAll("{query}", () => q)`. In the string form, `$&`, `` $` ``, + `$'`, and `$$` are substitution patterns *in the replacement*, and the + replacement here is arbitrary page-selected text. Corrected during + execution after this was measured on the default `{query}` template: + selecting `awk print $& here` produced `awk print {query} here`, and + `PID is $$ in bash` produced `PID is $ in bash`. Shell and regex snippets + are a headline use case, so this fired on ordinary input for every user. - Else if the trimmed template is empty → `prompt = q`. - Else → `prompt = trimmedTemplate + " " + q` (template treated as a prefix; the selection is never silently discarded). @@ -345,8 +352,20 @@ both. Pass **both** `allFrames: true` (a selection inside an iframe is invisible to the top frame) and `matchAboutBlank: true` (needed for `about:blank` - frames). Have the injected snippet report which frame it came from, and - take the first non-empty result. + frames), then take the first non-empty result. + + Corrected during execution: gate the injected snippet on + `document.hasFocus()` — + `"document.hasFocus() ? window.getSelection().toString() : ''"`. Each frame + owns an independent `Selection`, and a selection made in a frame is *not* + cleared when the user selects elsewhere, so without the gate a stale + selection in an unfocused (possibly cross-origin) frame can win, decided by + an `executeScript` result ordering that is not specified. `hasFocus()` is + true for the focused document and all its ancestors, so the top frame wins + when the user selected there and an iframe is used only when it is itself + focused. This supersedes the earlier idea of having the snippet report its + own frame — that was only ever needed to establish provenance for + auto-submit, which Appendix B rejects. **Null-check every element of the result array.** `executeScript` does not only throw — on parent-process `about:` pages such as `about:addons` and