Extension platform: host user-authored apps inside Agent Code#577
Extension platform: host user-authored apps inside Agent Code#577Juliusolsson05 wants to merge 18 commits into
Conversation
First artifact on this branch. This is deliberately NOT the plan — the architecture decision (compiled-in vs runtime-loaded vs iframe) is still open at §8 and four consultants are being run against it. Committing the evidence first because the expensive mistake here is planning against an unverified substrate: three of the four constraints that decide this design (the CSP, the dev/prod origin split, and the fact that interaction-ownership and paint-order are DOM-level contracts) are non-obvious and were each found by reading, not by assumption. Also records that workflow-mcp is already a complete extension host — sandbox, approval store, utilityProcess, SHA-256 pinning — which reframes the whole problem as UI-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Six tasks: main-owned per-app storage, the AgentCodeApiV1 ABI, the apps host surface, the Timer, derived palette commands, and a Settings listing. The design was settled by four consultants. Both Claude consultants were assigned opposing briefs (steelman compiled-in / steelman runtime-loaded) and independently converged on the same staging, which is the main reason to trust it. Two findings changed the shape of this plan: - Monaco already performs a fully runtime-variable dynamic import in the shipped bundle, and 92 language chunks load via ESM dynamic import from file:// in production. Stage 2's loader is therefore one line, not the multi-week problem it was priced at. - The frame-boundary audit refuted two of the objections to the iframe option (paint order and interaction ownership both survive if the host wrapper carries the marker) but confirmed the keyboard one: every host shortcut dies while focus is inside a frame, and Cmd+W closes the window. The load-bearing constraint is that an app imports nothing from @renderer/* and receives one prop, `api: AgentCodeApiV1`. Every host call returns a Promise even where it could be synchronous, because postMessage cannot be made sync later. Those two rules are what make Stage 2 a swap rather than a rewrite, and the plan ends with the grep that proves it. Stage 2 is deliberately unplanned and conditional: its trigger is extensions shipping to people running packaged releases, not the owner's own use. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Architecture settled — Stage 1 plan pushedFour consultants ran under orchestration run Decision: Stage 1 is compiled-in, built as Stage 2's substrate. What changed from the investigation's assumptionsStage 2's loader is one line, not a multi-week problem. Two objections to the iframe option were refuted. Paint order survives (the iframe wrapper The API is transport-independent. Same schemas for compiled-in, runtime-loaded, utility-process The two rules that make Stage 2 a swap
The plan ends with the grep that proves rule 1 held. If it fails, Stage 1 built something Stage 2 Stage 2 is conditional, not scheduledTrigger: extensions shipping to people who run packaged releases. The owner runs 📄 |
One JSON file per app under STATE_DIR/extensions/<id>/, written temp+rename
so a crash mid-write leaves the old file or the new one, never a half-written
one that fails to parse forever.
App ids are validated and rejected, not sanitized. An id becomes a directory
name, so a permissive id is a traversal primitive — and sanitizing silently
collapses distinct ids onto one directory ("../timer" and "timer" would share
state). Rejecting is the only handling where the failure is visible.
Deliberately NOT the zustand-persist Settings store: app-state/store.ts records
that a forgotten persist version bump black-screened launch twice (#249), and
extension state is authored outside the release cycle so nobody will ever bump
a version for it.
Deliberately NOT registered with debugRetention, unlike every other STATE_DIR
root: those are disposable forensic caches with a disk budget, this is user
data. A retention sweep would silently delete an app's saved state.
appId is a namespace, not authority — Stage 1 apps share one WebContents so the
sender cannot distinguish them. The registration comment records why storage is
the only capability acceptable under that limitation, and that Tier 1-3
capabilities must wait for Stage 2's per-app frame.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Defined before it has consumers, because it is the migration boundary rather than a convenience. An app that talks only through this object lifts into its own repo unchanged; an app that reaches into @renderer/* cannot, and finding that out later means rewriting every app at once. Every method returns a Promise even where it is synchronous internally. A postMessage transport cannot be made sync, and widening void -> Promise<void> later touches every call site in every app. The ABI comment calls this out explicitly because it is the easiest property to lose by accident. Surface is Tier 0 only — storage, close, toast, theme tokens. Workspace, session, git, filesystem and network stay unrepresentable until a real app needs them and there is a capability model to gate them; a wrong guess in a versioned ABI costs more than a late addition. Only the --theme-* layer is exposed, never the --color-* Tailwind bindings: the former is a stable contract, the latter is build-system detail that would couple an app to the host's toolchain. Token NAMES are cached per renderer lifetime — a theme change rewrites values on :root but never invents a token, so the stylesheet walk runs once instead of on every poll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One surface entry hosts every app. AppDefinition stays serializable-plus-one so it is the shape a Stage 2 manifest resolves into — a loader would read id/title/description/keywords from disk and produce Component, leaving everything else under apps/ untouched. Apps mount inside DialogContent so they inherit the interaction-ownership marker for exactly the focus-trap interval. Seven consumers query that marker synchronously — keybind router, palette native-menu guard, global editor Escape, composer Enter registry, type-to-focus, paste-to-focus, native dictation. Without it an app's input can leak Enter/paste into a background agent composer, the failure components/ui/README.md records from the old hand-rolled composer guards. Unknown openAppId degrades to closed rather than throwing, deliberately unlike the provider pane registry which is right to throw. A stale id here — after an app is removed, or uninstalled in Stage 2 — would otherwise take down the whole renderer from an unconditionally mounted surface. Registry is empty; no user-visible change. Timer lands after the host is complete so the infrastructure is never shaped around its one consumer. Also records in components/ui/README.md why this is not the "surface factory" the guardrails forbid, and notes that the runtime-load and iframe designs both WOULD require one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One .map over APPS. An app physically cannot ship without an opener, which a hand-written command per app would make a silent failure mode. Keeps the cost of adding an app at one import plus one array entry. Adding openApp to CommandContext.ui makes the compiler name every object literal implementing the context — the intended safety net, and it confirmed CommandPalette.tsx is the only construction site. Deliberately not pre-abstracting commandDefs into a getter for Stage 2's dynamic app list: that is anticipatory indirection the repo guardrails argue against, and it is a five-line change at one call site when it is actually needed. Noted in the module comment instead. APPS is still empty, so this adds zero commands today. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fourth instance of the existing self-subscribing marker pattern (cli-update-behavior, theme-picker, dictation-api-key), and the purest case of it: there is no value at all. The row lists apps/registry.ts, compile-time data the Settings store has no business mirroring — and extension-adjacent state must stay out of the zustand-persist blob regardless (#249). A listing, not a manager. Apps are compiled in, so install/enable/remove toggles would be UI for state that cannot vary, and a control that always reads "enabled" looks broken. Stage 2 replaces the component body and keeps the slot. Category is last in SETTING_CATEGORIES: apps are additive tooling, not a setting that changes shell or agent behaviour, so they should not push the behavioural categories down. Open closes Settings first — Settings is a full-takeover surface that also carries the interaction-ownership marker, and leaving it mounted under an app would stack two owners of the same input. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Built last, deliberately: the host was finished before its first consumer existed so the infrastructure could not be quietly shaped around one app's needs. Imports react and the ABI types. Nothing else — verified by `grep -rn "@renderer/" src/renderer/src/apps/timer/`, which is the migration test: this directory should lift into its own repository in Stage 2 with no edits inside it. Styling is inline var(--theme-*) rather than Tailwind utilities. The utilities are a build-time binding that will not exist out-of-tree; the token names will, and the host can push them across a frame boundary unchanged. The SVG ring also demonstrates the arbitrary-pixels case the declarative-vocabulary alternative could not have served. Countdown is deadline-based, not decrement-based. A per-tick subtraction drifts by however long each tick was delayed, and Chromium throttles background renderers hard enough that a 25-minute pomodoro in an unfocused window would finish minutes late. Recomputing from a wall-clock deadline makes every late tick self-correct, and handles sleep/wake by the same property. Presets are validated on read rather than trusted: state.json is user-editable on disk and a hand-edit should degrade to defaults, not render NaN buttons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 1 implemented — all six tasksSix commits, 28 files, +2668. Host built before its first consumer so the
What's in it
The test that mattersOnly the ABI types and its own file. The Timer directory should lift into its own repo in Deviations from the plan, both found by reading
Still openTwo reviewers running ( No manual smoke run yet — |
The Timer was built as a compiled-in app in src/renderer/src/apps/timer/. That made it app source code behind an API boundary, not an extension — it could not be installed, could not come from a repo, and shipped inside the bundle. That is not what this PR is for. Extensions are installed from a repository at runtime; nothing in the product ships an app in-tree. Keeping the Timer would have made the compiled-in path look like a supported way to add functionality, and the first person to copy it (including future me) would have built the wrong thing again. The host stays: AgentCodeApiV1, AppHostSurface, the derived commands, the Settings slot, and per-app storage are all the resolution target the loader fills at runtime. APPS is now empty by design. A timer comes back later as a real installed extension from its own repository, which is also the honest test of the loader. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings > Extensions: paste owner/repo, install, update, remove. This is the
thing the PR was actually for; the compiled-in path it replaces was never an
extension system.
Install sequence — everything that can fail happens in a temp directory, and
the bundle only moves into place after the manifest validates and the entry is
proven to resolve inside it. A failed install therefore leaves no partial
directory for a loader to find.
normalize repo -> resolve ref -> download -> sha256 -> extract to tmp
-> validate manifest -> verify entry containment -> atomic rename -> ledger
Ref resolution prefers the latest release over the default branch, because a
release is the author saying "this is ready" while a branch is whatever was
pushed thirty seconds ago. Falls back to the default branch so an extension
without releases still installs — which matters early, when author and user are
the same person. Probed against real GitHub: no-releases correctly 404s to the
fallback, and codeload returns no content-length, so the size cap has to be
enforced post-download as well as pre.
Manifest is zod-validated; the schema IS the allow-list, following the remote
protocol's precedent. `entry` rejects absolute paths, backslashes and ".."
segments, and install additionally realpath-checks that it stays inside the
bundle — the schema cannot catch a symlink committed to the repository, and tar
will happily recreate one pointing at ~/.ssh.
Install returns {ok:false,error} rather than rejecting: every failure here is
user-actionable (wrong repo, private, no manifest, wrong API version, too
large), and an IPC rejection mangles the message into "Error invoking remote
method ..." and loses the error class across the bridge.
PATH SPLIT — installed bundles now live at extensions/<id>/ and per-extension
state moved to extension-state/<id>/. Review flagged the collision: the bundle
directory is replaced wholesale on update, which would have taken the user's
saved state with it. Uninstall deliberately keeps state, because
uninstall-then-reinstall is a normal troubleshooting move.
STORAGE RACE FIX (reproduced 3/3 in review) — writes used a fixed
"${file}.tmp", so two concurrent writers raced: the first rename consumed the
temp file and the second failed ENOENT, losing one write AND rejecting. Temp
names are now unique per write, and every write for an app goes through a
per-app promise chain so read-modify-write cycles cannot interleave. The
all-Promise ABI invites exactly the fire-and-forget shape that triggered it.
The install ledger is separate from the bundles on purpose: scanning
directories would make the filesystem the source of truth and a hand-copied
folder would look installed. The ledger records what the app decided to
install, with repo, ref and hash — questions a directory cannot answer.
Extensions still cannot LOAD; that is the next commit. This one makes the
install half real and independently verifiable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Supersedes the Stage 1 plan, which was executed and whose product (a compiled-in app) was deleted. The successor plans what was actually asked for: extensions installed from a repository and loaded at runtime. Adopts the VS Code model rather than the narrower one an earlier draft proposed. A manifest declares contribution points — commands, views, settings, keybindings — which reach the palette and Settings WITHOUT the module being loaded, and the module exports activate(context)/deactivate() imported lazily on a declared activation event. Why the narrower "one module exports mount(element, api), host generates one Open X command" design was rejected, recorded because it is the tempting one: an extension is not always a window (a watcher reacting to an agent going idle has no UI), one extension has many contributions, and without declarations every extension must be imported at startup just to populate a command list, which makes lazy activation impossible. Resolves the React gap the audit found. React is NOT added to AgentCodeApiV1 — that would version the host's React major into the extension API, making a React 19 upgrade an ABI break for extensions that never used React. Instead it is published on a frozen namespaced global before any extension loads, and views mount at the DOM level so React stays optional entirely. Records the eight host changes the superseded plan missed (module-scope registry and command snapshots, synchronous bootstrap, listPickerCommandMeta, and the rest), and the four defects inherited from Stage 1 that Task B7 fixes: the toast that renders behind its own dialog scrim, theme.tokens() missing runtime-only font tokens, JsonValue admitting NaN, and a missing useMemo dep. Marks the superseded plan with a Status header naming both of its false claims so neither is reused. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Serves installed extension bundles to the renderer. This is what makes runtime loading possible at all, and it is verified rather than assumed — a spike ran the real built renderer at the real file:// origin under the real CSP: document origin file: fetch missing extension 404 fetch fixture entry 200 text/javascript traversal ../../../.ssh 404 blocked ENCODED traversal ..%2f..%2f 404 blocked dynamic import of extension module activate() imported OK relative import inside bundle ./sibling.js resolved WHY a custom scheme rather than importing the file path directly: the renderer's document origin differs between dev (http://localhost) and production (file://), so a raw filesystem path cannot be made to work identically in both — it is blocked by the localhost origin in dev and is a cross-origin file:// fetch in prod. A registered scheme has one spelling that resolves the same way in both, and adding it to script-src is a single auditable concession rather than relaxing the policy for all code. Every privilege is load-bearing and the file says why: `standard` is what makes relative specifiers inside a bundle resolve (proven above — without it every intra-extension import would need an absolute URL, which no bundler emits); `secure` is required for module scripts; `corsEnabled` plus an explicit access-control-allow-origin is required because module scripts are always fetched in CORS mode and `standard` makes this a distinct origin from the document. Omitting that header is the single most likely thing to cost a day — it fails as an opaque CORS error that says nothing about the cause. Containment is realpath-based and runs on the DECODED path. Install-time validation already proves the manifest's entry stays inside the bundle, but this handler serves arbitrary paths on demand, and a symlink committed to a repository survives extraction and points wherever it likes. The `+ sep` in the prefix check matters: a bare startsWith would accept extensions/timer-evil against extensions/timer. CSP gains agent-code-ext: on script-src and connect-src (default-src backstops connect-src, so an extension fetching its own JSON would otherwise fail), plus the asset directives so extensions can ship CSS, icons and fonts without inlining everything as data: URIs. Deliberately NOT unsafe-eval, NOT blob:, NOT file:. worker-src is untouched — the #513 comment above it is unrelated and load-bearing. Registration is at module scope with a comment explaining why: Electron silently treats a post-ready registration as opaque, with no runtime warning, and the resulting error looks like a CSP problem rather than an ordering one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
B2 — globalThis.__agentCodeHost publishes React, ReactDOM and the JSX runtime before any extension module can be imported. An extension cannot bundle its own React (two reconcilers, "invalid hook call" on every hook) and cannot import the host's by specifier (chunks are content-hashed and rotate every build), so the runtime has to be handed over. Deliberately NOT a field on AgentCodeApiV1: that would version the host's React major into the extension ABI, making a React 19 upgrade a breaking change for extensions that never used React. Deliberately not an import map either — a map must be declared before the document's first module and would resolve `react` to a shim that reads a global anyway. Frozen and non-configurable against accidents, not attacks. A same-realm extension can still reach window.api; only moving extensions into a frame changes that, and the comment says so rather than implying isolation we do not have. B7/1 — GlobalToast z-50 -> z-[1200]. It sat UNDER the Radix dialog scrim (z-[1100], 88% opaque), so any toast raised while a modal was open was washed out to invisibility. Harmless while every caller was non-modal chrome; broken the moment an extension toasts from a hosted view, where the toast can only fire while a dialog is open — hidden 100% of the time. This is load-bearing for the timer extension, whose reminders fire as toasts when its window is closed. B7/2 — theme.tokens() missed --theme-accent, --theme-accent-fg, --theme-app-font and --theme-font-code. Those are assigned at runtime via setProperty and appear in no stylesheet, so walking document.styleSheets could never find them. It went unnoticed because var() does not care where a variable was set — it would break the moment an extension had to push tokens across a frame boundary, which is the case the accessor exists for. Empty values are now skipped too: pushing '--theme-x': '' sets an invalid value that defeats the consumer's fallback. B7/3 — extensionStorageSet rejects non-finite numbers. JsonValue's `number` is unrestricted so TypeScript accepts NaN and Infinity, but JSON.stringify turns all three into null — a silent corruption an author could only find by noticing wrong behaviour much later. Validated in main because that is the last point where the value is still structured. B7/4 — openApp added to CommandPalette's useMemo deps. Benign today (zustand action identities are stable) but a stale closure the moment that changes, and there is no lint config in this repo to catch it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extensions now DECLARE what they add — commands, views, settings, keybindings — plus when the host should import them. Declarations are what make lazy activation possible: the palette can list a command whose module has never been loaded. Without them, populating a command list would require importing every installed extension at startup, and one slow extension would degrade launch for everything. Cross-field rules the schema cannot express, each rejecting a failure that is otherwise silent: - Contribution ids must sit in the extension's own namespace. Ids land in one registry beside ~95 first-party commands, so `session.kill` from an extension would collide with a real one and resolve arbitrarily. Install is the last point where the user can act on it. - Duplicate ids within a manifest are an authoring mistake and fail. Duplicates ACROSS extensions are deliberately not handled here — neither author can see the other's manifest, so failing the install would punish whoever installed second. Resolved at load instead. - A keybinding targeting a command the extension does not contribute fails. - An activation event naming a missing command or view fails. This is the hardest authoring mistake to diagnose otherwise: the extension simply never activates, with no error anywhere. Verified against the REAL timer manifest plus nine rejection cases, and that caught a genuine bug: the contribution-id regex demanded kebab-case throughout and rejected `timer.inheritTheme`. camelCase after the first dot is idiomatic and now allowed; the extension id itself stays kebab-only because it becomes a directory name and is constrained by the filesystem, not by taste. View mount is a closed enum rather than a free string — the host owns the chrome, and an unknown kind must fail at install rather than resolve to nothing at render time. Only 'modal' exists; 'panel'/'tab' are the same registration with a different shell. Number setting defaults are `.finite()`: a NaN default would be written straight into extension storage, where JSON.stringify turns it into null. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
B4/B5/B6. Installed extensions now load, activate and render. EXTENSION HOST. One dynamic import over agent-code-ext://, then activate(context). Every failure is a VALUE, never a throw: third-party code that fails to import, exports no activate, or throws inside it must leave every other extension running and must not be able to blank the renderer. Failures land in Settings with the message that names the fault. Activation is memoized on the IN-FLIGHT PROMISE rather than a boolean. A palette command and a view open can race in the same frame, and a boolean set after the await would let both call activate() on one module. Disposal runs deactivate() first, then subscriptions in REVERSE order — later subscriptions were created against earlier ones, so disposing forwards can tear down a dependency while a dependant still holds it. A throwing deactivate does not skip disposal; the subscriptions are the host's cleanup contract, and skipping them would leak intervals and AudioContexts for the session. registerCommand/registerView REJECT an id the manifest did not declare. A handler the palette has no entry for can never be invoked, so accepting it silently would leave the author with a dead command and no clue why. VIEW BRIDGE. Adapts a DOM-level ViewMount into AppDefinition.Component, and is the entire adapter between the host's React world and the extension's DOM world — the piece that gets replaced, not rewritten, if extensions later move into an iframe. It activates on render, which is what onView: activation means: the palette can open a view whose module has never been imported. A throw during mount is contained rather than allowed to unwind into the host tree, where React would unmount to the root. LIVE REGISTRY — the part the superseded plan under-priced as "a five-line change". Removed three module-scope snapshots: APPS/APP_BY_ID (deleted), appCommands (deleted), and commandDefs (now builtInCommandDefs plus a per-call concat). listPickerCommandMeta takes extension commands too, so Settings' command-visibility row covers them. Installed extensions live in the store, because the palette, Settings and the view host all have to re-render when one is installed — a module array cannot notify them. Commands and views are derived from MANIFESTS, never from loaded modules. That is the whole point of declaring contributions: ten installed extensions must not mean ten third-party module evaluations before the app is usable. Only onStartupFinished/* activate eagerly. Cross-extension id collisions resolve at load (first wins, loser dropped) rather than at install — neither author can see the other's manifest, so failing the install would punish whoever installed second. useAppHostApi became createAppHostApi, a plain factory: the host must build an API at activation time, which can happen from a palette command with no component mounted, and a hook cannot be called there. Moved the module contract out of src/shared into renderer/apps/host/: it references AgentCodeApiV1, and src/shared is compiled by the node project which cannot see @renderer/*. That boundary is real — main validates manifests and never touches an extension module. typecheck / 1187 tests / build all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Manifest reference, the activate/deactivate contract, the DOM-level view mount, the React-from-host requirement, build output rules, the Tier 0 API, theming, publishing, and a failure-mode table keyed by the exact error strings the host produces. Includes a section on what v1 deliberately does NOT have — no agent access, no process isolation, no marketplace, modal-only views — because each is a decision with a reason, and an author who hits one should find the reasoning rather than assume it was forgotten. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full extension platform — Phase B complete16 commits, 42 files, +5528. The compiled-in app approach was removed; this is now a real VS Code-style Phase B
Verified, not assumedScheme spike — real built renderer, real CSP, Install — the real installer against a real repository: Plus error paths: unknown repo, private repo, malformed input. Manifest — the real timer manifest accepted, and nine rejection cases Production build — First real extension
It is already installed on this machine and will appear on next launch. Two bugs found by building the real thing
Not verifiedNobody has launched the app and clicked it. Everything above is automated |
Vite's library mode does not replace process.env.NODE_ENV, and the output runs in a renderer with nodeIntegration:false where `process` does not exist. Nearly every React-ecosystem package guards dev warnings with it, so the first guard to execute throws — AFTER the extension installs and imports cleanly, which makes it look like the author's code rather than their build config. The reference timer hit this on its first real run in the app. Documented with the fix, the reason not to shim a global `process` instead, and an entry in the failure-mode table keyed to the exact error string. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… imports Three bugs, all found by actually running it. The symptom was an empty modal with no error anywhere. 1. PER-MOUNT ELEMENT. React.StrictMode runs effects mount -> cleanup -> mount. An extension whose disposer defers its unmount — queueMicrotask, requestAnimationFrame, a transition — tears down root #1 AFTER root #2 has rendered into the same node, and root1.unmount() empties the element root2 lives in. The view mounts "successfully" and shows nothing. The timer extension defers exactly this way, which is how it surfaced. Each mount now gets its own child element, discarded on cleanup, so a late disposer operates on a detached node it cannot reach anything through. That makes the host immune to extension cleanup timing rather than requiring every extension to get it right. 2. NO SILENT EARLY RETURN. `if (!element) return` left status at whatever it started as — and when the extension already activated at startup that is 'ready', producing a visible empty view with nothing logged. Any exit from the mount path now sets a status. 3. CACHE-BUSTED IMPORT URL. The ES module registry caches by URL and caches FAILURES: once import(url) rejects, every later import of that URL returns the same rejection for the life of the realm, even after the file on disk is replaced. So an extension that failed once could never be fixed by reinstalling — the user updates it, sees the identical error, and has no signal that their fix landed. Reloading the window was the only escape and nothing said so. The key is version + the installer's sha256, which changes exactly when the bytes change. The query never reaches the filesystem: the scheme handler builds its path from url.pathname, which excludes the search string. typecheck / 1187 tests / build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds the platform for user-authored workflow tools that live inside Agent Code —
installed by the user, invoked from the command palette, opening their own page, backed
by a documented app API.
Reference case is a timer app. The timer is the forcing function, not the goal; the goal
is app-integrated extensions that can see and act on workspace, session, and git state
without anyone editing core app source.
This PR holds the full implementation. The first commit is investigation evidence, not
the plan — the plan file lands once the architecture decision is settled.
Status
What the investigation found
packages/workflow-mcpis already a complete extension host. Discovery,acornmetadata parsing without execution, SHA-256 integrity, an approval store keyed to
canonicalIdentity + sourceHash, anode:vmsandbox, and a separateutilityProcesswithheartbeats and timeouts — all shipping today. What it has no story for is UI.
So the open question is narrower than it looked: not how to host user code, but how
user-authored UI reaches the screen.
Three candidates, unresolved:
src/apps/<id>/The constraints that decide it are in
docs/superpowers/specs/2026-07-20-extension-platform-investigation.md§3–§8. The twonon-obvious ones:
index — neither survives a frame boundary, which is a direct cost against C.
twice and app chunks are content-hashed, so every host object must be passed in.
Notes
window.apiis never handed to an extension — 153 flat methods, no namespacing, nosender validation. Extensions get a narrow zod-validated bridge instead, following the
remote protocol's "the union is the allow-list" principle.
Settingsstore; forgetting theversion bump shipped a black-screen launch bug twice (Add settings for command picker visibility #249).
extensions agent capabilities, this gives them app capabilities.
🤖 Generated with Claude Code