Skip to content

Extension platform: host user-authored apps inside Agent Code#577

Draft
Juliusolsson05 wants to merge 18 commits into
mainfrom
feat/extension-platform
Draft

Extension platform: host user-authored apps inside Agent Code#577
Juliusolsson05 wants to merge 18 commits into
mainfrom
feat/extension-platform

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

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

  • Read-only investigation of every integration surface
  • Architecture decision (4 consultants running)
  • Spike: verify the chosen loading path in dev and a packaged build
  • Plan file
  • Implementation

What the investigation found

packages/workflow-mcp is already a complete extension host. Discovery, acorn
metadata parsing without execution, SHA-256 integrity, an approval store keyed to
canonicalIdentity + sourceHash, a node:vm sandbox, and a separate utilityProcess with
heartbeats 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:

Effort Satisfies "install from Settings"?
A compiled-in src/apps/<id>/ 1–2 days ❌ needs a rebuild
B runtime-loaded bundle 2–4 weeks
C iframe on a custom scheme 1.5–3 weeks

The constraints that decide it are in
docs/superpowers/specs/2026-07-20-extension-platform-investigation.md §3–§8. The two
non-obvious ones:

  • Interaction ownership is a DOM attribute query and paint order is DOM sibling
    index
    — neither survives a frame boundary, which is a direct cost against C.
  • B's real blocker is a stable injected host ABI, not CSP. React cannot be imported
    twice and app chunks are content-hashed, so every host object must be passed in.

Notes

  • window.api is never handed to an extension — 153 flat methods, no namespacing, no
    sender validation. Extensions get a narrow zod-validated bridge instead, following the
    remote protocol's "the union is the allow-list" principle.
  • Extension settings must not go in the zustand-persist Settings store; forgetting the
    version bump shipped a black-screen launch bug twice (Add settings for command picker visibility #249).
  • Related to but distinct from Host user-authored MCP servers as Agent Code extensions #244, which hosts user-authored MCP servers — that gives
    extensions agent capabilities, this gives them app capabilities.

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits July 20, 2026 12:41
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>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Architecture settled — Stage 1 plan pushed

Four consultants ran under orchestration run extension-platform-consult. Both Claude
consultants were given opposing briefs — one to steelman compiled-in, one to steelman
runtime-loaded — and independently converged on the same staging. That convergence is the
main reason to trust it.

Decision: Stage 1 is compiled-in, built as Stage 2's substrate.

What changed from the investigation's assumptions

Stage 2's loader is one line, not a multi-week problem. out/renderer/assets/editor.main-*.js
already contains a fully runtime-variable import() that Rollup passed through untouched, and
92 Monaco language chunks load via ESM dynamic import from file:// in production. Both halves
of the loading risk are empirically retired — by code already shipping.

Two objections to the iframe option were refuted. Paint order survives (the iframe wrapper
is a normal registry sibling), and interaction ownership survives with a marker on the host shell
(~1–10 LOC, fixes all seven consumers). The perf-baseline objection was also wrong — it only
colors a number yellow. What is genuinely broken is keyboard: every host shortcut dies while
focus is inside a frame, and Cmd+W closes the window rather than the pane. Full iframe bridge
priced at ~350–650 LOC.

The API is transport-independent. Same schemas for compiled-in, runtime-loaded, utility-process
and iframe — only bootstrap, serialization, mounting and teardown differ. So AgentCodeApiV1 gets
built now and used even while apps are compiled in.

The two rules that make Stage 2 a swap

  1. An app imports nothing from @renderer/* and receives one prop, api: AgentCodeApiV1.
  2. Every host call returns a Promise, including ones that could be synchronous — postMessage
    cannot be made sync later, and widening those signatures afterwards touches every call site in
    every app.

The plan ends with the grep that proves rule 1 held. If it fails, Stage 1 built something Stage 2
would tear out — and that is worth finding while there is exactly one app.

Stage 2 is conditional, not scheduled

Trigger: extensions shipping to people who run packaged releases. The owner runs npm start
against a local build and rebuilds routinely, so Stage 1 is sufficient for personal use
indefinitely.

📄 docs/superpowers/plans/2026-07-20-extension-platform-stage1.md

Juliusolsson05 and others added 6 commits July 20, 2026 13:08
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>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Stage 1 implemented — all six tasks

Six commits, 28 files, +2668. Host built before its first consumer so the
infrastructure could not be shaped around one app's needs; Timer landed last.

npm run typecheck
npm test ✅ 209 files / 1187 tests
npm run build
portability grep

What's in it

  • src/main/extensions/storage.ts — per-app JSON under STATE_DIR/extensions/<id>/,
    atomic temp+rename. 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 ../timer and timer onto one directory.
  • apps/api/types.tsAgentCodeApiV1. Tier 0 only (storage, close, toast, theme
    tokens). Every method returns a Promise even where it is synchronous internally,
    because postMessage cannot be made sync later.
  • apps/surfaces/AppHostSurface.tsx — one surface entry, appended last in
    modalSurfaces. Apps mount inside DialogContent so they inherit the
    interaction-ownership marker that seven consumers query synchronously.
  • apps/commands/appCommands.ts — commands derived by .map(APPS), so an app
    cannot ship without an opener.
  • Settings → Apps — fourth instance of the existing self-subscribing marker pattern.
  • Timer — SVG progress ring, presets, pause/resume, deadline-based countdown.

The test that matters

$ grep -rn "@renderer/" src/renderer/src/apps/timer/
apps/timer/index.ts:      import type { AppDefinition } from '@renderer/apps/types'
apps/timer/index.ts:      import { TimerApp } from '@renderer/apps/timer/ui/TimerApp'
apps/timer/ui/TimerApp.tsx: import type { AgentCodeApiV1 } from '@renderer/apps/api/types'

Only the ABI types and its own file. The Timer directory should lift into its own repo in
Stage 2 unchanged — that claim is currently under adversarial audit.

Deviations from the plan, both found by reading

  • uiShell actions are declared in app-state/types.ts (UiShellSlice), not
    uiShell/types.ts as the plan assumed. State field went in the latter, actions in the
    former.
  • Timer styling is inline style={{ }} with var(--theme-*) rather than Tailwind
    utilities — the utilities are a build-time binding that will not exist out-of-tree.

Still open

Two reviewers running (extension-platform-review): correctness/integration, and an
adversarial audit of the Stage-2 migration claim. Findings to follow.

No manual smoke run yet — npm start verification pending.

Juliusolsson05 and others added 8 commits July 20, 2026 14:10
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>
@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Full extension platform — Phase B complete

16 commits, 42 files, +5528. typecheck ✅ · 1187 tests ✅ · build

The compiled-in app approach was removed; this is now a real VS Code-style
extension system — install from a repository, declare contributions, activate lazily.

Phase B

B1 agent-code-ext:// scheme + CSP
B2 Host runtime global
B3 Manifest contributions + activation events
B4 Extension host — import, activate, disposables
B5 View bridge
B6 Declarations → palette / Settings
B7 Four review defects

Verified, not assumed

Scheme spike — real built renderer, real CSP, file:// origin:

document origin       file:            dynamic import        activate() OK
missing extension     404              relative ./sibling.js resolved
../../../.ssh         404 blocked      ..%2f..%2f encoded    404 blocked

Install — the real installer against a real repository:

id timer · ref v0.1.0 · sha256 1fb05a80… · 194 KB on disk · exports activate

Plus error paths: unknown repo, private repo, malformed input.

Manifest — the real timer manifest accepted, and nine rejection cases
including foreign-namespace ids, duplicate ids, dangling keybindings, unknown
activation events, and traversal entries.

Production build__agentCodeHost and agent-code-ext:// both present in
the shipped renderer bundle.

First real extension

Juliusolsson05/agent-code-timer v0.1.0 —
ported from focus-flow-timer, restructured so a headless engine owns the session
and the view is a subscriber. Closing the window does not stop the timer; a
wall-clock deadline means it survives an app restart too.

It is already installed on this machine and will appear on next launch.

Two bugs found by building the real thing

  • The contribution-id regex demanded kebab-case and rejected timer.inheritTheme.
    Caught by running the schema against the real manifest.
  • GlobalToast sat at z-50, under the dialog scrim at z-[1100] — every toast
    raised from inside a modal was invisible. Harmless while all callers were
    non-modal chrome; fatal for an extension whose reminders fire as toasts.

Not verified

Nobody has launched the app and clicked it. Everything above is automated
verification of the pieces. The end-to-end run is the remaining gap.

Juliusolsson05 and others added 2 commits July 20, 2026 15:29
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant