A silent failure looks exactly like a feature you never built

Your system has memory. Your user is typing into someone else's chat box.

That gap is the whole problem, and it is not a retrieval problem. The place your context needs to arrive is a page you do not control, rendered by a company that has no interest in your product, whose DOM changes on their schedule. Every design you can reach for there is bad in a different way. A browser popup closes the instant the user clicks back into the page, which is the exact moment they need it. In-page injection means you are a permanent guest, mounting your UI into a tree the host owns and re-mounting it every time the host re-renders.

We ran the second option for months. The memory picker was injected in-page, with a 3-second remount timer, on all 22 chat hosts we support. It worked. It also meant that every user with the extension installed had a timer firing four times a minute on every AI site they visited, forever, to fight a re-render that usually never came.

Between 2026-07-29 and 2026-08-02 I moved the whole surface into Chrome's side panel and retired the popup. 45 commits, 43 files. Most of them were not the feature.

What shipped

The toolbar icon now opens a side panel instead of a popup. The panel holds three views: Memory, Activity, Settings. The memory picker lives there, so the 22-host timer is gone. Pairing, connection state, gateway config and import all moved in, so there is one surface instead of a popup that could only be six inches tall and an options page nobody found.

The panel persists while you type. That is the entire reason it exists.

Diagram (beforeafter)

Before: popup closes on blur and the picker is injected into every host page with a 3-second remount timer. After: a side panel that stays open while you type, with no in-page timer.

  BEFORE — Popup + in-page picker
    - closes the moment you click the page
    - picker mounted into 22 host DOMs
    - 3s remount timer, every host, always
    - settings split across popup and options page

  AFTER — Side panel
    - stays open while you type
    - picker lives in our own document
    - no timer on any host
    - Memory | Activity | Settings in one place

The content script still has to exist, because inserting text into the composer and reading the reply out of it can only happen in the page. But it no longer owns any UI worth keeping alive.

Diagram — Where the surface sits

Toolbar click or keyboard command goes to the background service worker, which opens the side panel; the panel reads storage and messages the worker, which holds the socket to the local engine; a separate thin content script runs inside the host chat page

  [Toolbar icon / MacCtrl+Shift+M] --> [background.js]
  [background.js] --open() first, then setOptions--> [sidepanel.js (fixed)]
  [sidepanel.js (fixed)] --messages--> [background.js]
  [background.js] --paired socket--> [Local engine]
  [background.js] --insert / capture--> [content.js]

  notes:
    background.js: owns the socket
    sidepanel.js: our document, our lifetime
    content.js: in the host page, thin

  The panel never talks to the engine directly. One owner for connection state.

What it actually cost

Diagram (timeline)

Five days of work: a spike, a gesture bug, a Mac shortcut that was bound to nothing, a version collision across three builds, an audit that found leaks and dead code, and a 22-site selector sweep

  07-29 am  ->  spike: does open() work from a keyboard command?
  07-29 pm  ->  panel never opened: I awaited before open() (problem)
  07-29     ->  Mac shortcut bound to nothing at all (problem)
  07-29     ->  three builds, three version numbers (problem)
  07-30     ->  popup retired, panel is the surface (fixed)
  07-30     ->  audit: two leaks, a keystroke hijack, ~450 dead lines (problem)
  08-01/02  ->  20 of 22 sites verified end to end (fixed)

The spike existed to make an unknown observable. Before designing anything around the side panel, I needed one fact: does chrome.sidePanel.open() work from a commands keyboard handler, or does Chrome insist on a click? I wired both paths in a throwaway build and made both fail loudly, because a silent failure there is indistinguishable from "the shortcut isn't bound," and we would have re-derived the same question the next session. That instinct was right, and it is the only reason the next bug took an hour instead of a day.

The gesture does not survive an await. sidePanel.open() may only be called in response to a user action, and Chrome's user-gesture flag is consumed the moment you yield. My first version awaited setOptions() and then called open(). It threw every single time. There were two awaits in the way, not one: the command listener was async and fell back to await chrome.tabs.query(...) when it had no tab.

// wrong: the gesture is spent before open() runs
async function openPanel(tabId) {
  await chrome.sidePanel.setOptions({ tabId, path: 'sidepanel.html' });
  await chrome.sidePanel.open({ tabId });   // "may only be called in response to a user action"
}

// right: privileged call first, bookkeeping after
function openPanel(tabId) {
  chrome.sidePanel.open({ tabId });                 // synchronous entry, first async thing
  chrome.sidePanel.setOptions({ tabId, path: 'sidepanel.html' })
    .catch(err => console.error('[vodou] setOptions', err));
}

The command listener is now synchronous and uses the tab the event hands it. If a tab is ever missing it logs loudly instead of awaiting a query, because the await is precisely what breaks it.

A shortcut can be bound to nothing and look bound. I shipped "mac": "Ctrl+Shift+M". Chrome converts a bare Ctrl in a mac suggested_key into Command. So the toggle became Cmd+Shift+M, which is Chrome's own profile switcher and cannot be overridden. Nothing was bound to literal Control+Shift+M, so pressing it did exactly nothing. No error, no log line, no entry anywhere a user could see. The second one was worse: inject-context became Cmd+B, which is bold on all 22 sites, and content.js had carried e.ctrlKey && !e.metaKey for months with the comment "Cmd+B stays the site's bold on macOS." I shipped a manifest that contradicted my own code's comment. Had Chrome actually bound it, Ctrl+B users would have kept working while bold quietly stopped functioning everywhere.

{ "commands": { "toggle-side-panel": {
    "suggested_key": { "default": "Ctrl+Shift+M", "mac": "MacCtrl+Shift+M" } } } }

MacCtrl is the literal Control key, which is what the content-script gate tests for. A test now rejects a bare Ctrl in a mac key.

Three builds claimed one version line. We ship a store build, a sideload build, and a sideload-only build. Each got bumped by whatever work happened to touch it, so they had drifted to .10, .9 and .8. The spike landed in sideload at .9 while the store build sat at .10 without the side panel, which means the higher number was the older code. That is backwards for the one thing a version is for. Worse, dist/ zips are named by version, and a 0.5.97.9-store.zip already existed from a different package. One version string naming two artifacts is how you upload the wrong zip to Google. The fix was not a numbering convention. It was porting the panel into all three builds so that equal versions mean equal features, then setting all three to 0.5.97.11.

Then the stupid ones, which cost the most wall-clock. A backtick inside a CSS comment killed the entire content script, because the stylesheet lived in a template literal. The resting state of our mark rendered as a 42x40 oval, twice, because BIMI's viewBox cropped it. content.js kept throwing after every extension reload, because it was still evaluated in a dead extension context. We sent on a socket that had closed during a chrome.tabs.query. One out-of-order const removed every in-page button on every site.

The audit found what the feature work hid. Before submission I read the diff cold and found two leaks, a keystroke hijack and around 450 dead lines left over from the popup era. The copy said "your chats never leave your computer," which was false as written, so it changed. And in the same sweep I discovered I had deleted the activity log's only write. The Activity view looked completely healthy the whole time, because it was rendering rows written before the deletion.

Capture across 22 sites is a fixture farm. One parameterized extractor covers 20 of them, with per-site save selectors verified one at a time. A Kimi reply was stored as fragments while the console reported success. Auto-attach at send missed Perplexity's send button, then Kimi's, then Kimi's disabled state, then needed a per-site override for Manus, and finally a guarded positional fallback for buttons with no stable marker. NotebookLM shipped with an invalid Chrome match pattern. OpenRouter needed incremental scroll-and-collect before it worked at all. That got us to 20 of 22.

The lesson: a silent failure is indistinguishable from a missing feature

Every expensive bug on this list has one shape. Something did not happen, and nothing said so.

The shortcut fired into an unbound key. The panel refused to open with an error only the console saw. The activity feed rendered stale rows after its writer was deleted. Kimi's capture reported success and stored splinters. The version number said the store build had the newer code.

This is the dominant failure class in agent systems, not just browser extensions. A tool call that returns 200 and writes nothing. A retrieval that returns an empty list because the index is empty, which is byte-identical to a retrieval that correctly found no matches. A guard that fails open on timeout. In all of them the system's own report is consistent, and the only way to know is to compare two independent records of the same event.

Three things you can do today, on any stack:

Assert the effect, not the call. After a tool run, read back the row you claim to have written and compare it to what you sent. We now grade capture by comparing a receipt's count against the ids it actually recorded, because a count is written by the same code path that is lying to you.

Make absence a distinct state from failure. unknown is a valid answer and it is not ok. A health check with no evidence should report that it had no evidence.

Spend a half-day making a gate observable before you build on it. That is what the spike was. It cost four hours and it is the reason the gesture bug was diagnosable from my own code rather than from a user report three weeks later.

The narrower lesson, which generalizes further than it looks: authority is time-scoped. Chrome grants the gesture for the synchronous duration of your handler and await spends it. The same shape shows up in approval windows, short-lived tokens and transaction contexts. Do the privileged thing first. Do the bookkeeping after, with its own error handler.

Where the usual advice stops

The good guidance on agent design is about the loop. Anthropic's Building Effective AI Agents is right that the successful implementations use simple composable patterns rather than frameworks, and OpenAI's practical guide to building agents is right about orchestration and guardrails. Neither has much to say about the surface, and the surface is where an agent that lives alongside a user actually fails.

Two projects are working the same seam. The claude_codex_bridge sidebar provider activity plan states the rule I arrived at by breaking it: the sidebar remains a client of one authority and must not become a separate authority for identity or state. Our panel reads storage and messages the worker; the worker owns the socket. When I let three build artifacts each own their own version number, I got the thing that plan is written to prevent.

And Cordum's ADR-010 contains the cleanest statement of the failure class I know, about a Claude Code PreToolUse deny hook: HTTP hooks can deny with a 2xx JSON response, but connection failures, non-2xx responses and timeouts are non-blocking. A guard whose failure mode is "allow" is not a guard. That is my unbound Mac shortcut with higher stakes.

For the vocabulary of the problem, Agent Surface is worth reading. Making software legible to agents is the mirror image of what we did here, which was making an agent legible inside software that has never heard of it.

What is still not solved

Two of 22 sites have no verified save path. I know which two.

The positional fallback for unmarked send buttons is a guess with a guard on it. It will break, and when it breaks the symptom will be an auto-attach that silently does nothing, which is the exact failure class this post is about. It is the piece I trust least.

Per-site selectors are fixtures against DOMs owned by other people. There is no version of this that stops needing maintenance. We test them, which turns a break into a red suite instead of a support ticket, and that is the whole of the mitigation.

Three build folders still exist with three copies of sidepanel.js. One version line makes them honest. It does not make them one file.

The keyboard shortcut is discoverable now, because it is a real commands entry that appears in chrome://extensions/shortcuts and can be rebound. For its first several months it was a keydown listener in the content script, which meant it was invisible, unbindable, and dead on any host where injection failed. Nobody reported it. That is the point.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论