The Collider Pilot is the browser harness surface for mo:os — a thin, provider-neutral MV3 extension (side panel as the persistent seat, Document PiP as an optional always-on-top mirror) that projects a purpose-selected, timestamped HG frame from an engine over MCP Streamable HTTP, and lifts only user-approved observations and tool intents back through the four rewrites; it is not an engine, owns no graph identity, and the append-only rewrite log remains the sole source of truth. Revival per ffs0#158 (Steinberger readback T=260, decisions D1-D6 approved).
Status: Phase 0-3 merged to main; Phase 4 built on feat/phase4-tools — provenance
preserved, sources pinned, secret-scanned, legacy builds attempted (see
PROVENANCE.md); on top of that, a clean read-only MV3 side-panel shell
(narrow manifest, Cytoscape inspector); the live MCP read path — a
StreamableHttpMcpAdapter that reads a live, purpose-selected HG frame from the Z440
primary engine over MCP Streamable HTTP (read-only); the Document Picture-in-Picture
mirror — an always-on-top compact mirror opened from an explicit button, sharing ONE
state store, feature-detected, degrading gracefully; and now controlled tools + model
targets — the FIRST apply capability, where every mutating act is gated behind a
confirmation UI, tool calls are structured (never text-parsed), and HG rewrites produce a
REVIEW-ONLY program preview that is never posted (no live HG write exists). Legacy
lineage lives on legacy/pilot-2026-01 and legacy/sidepanel-2026-01; main stays clean.
- Phase 0 — preserve and prove provenance (this repo, done)
- Phase 1 — side-panel shell: narrow MV3 manifest, persisted state across worker restarts, mock MCP adapter, Cytoscape inspector on a fixed typed frame; no model, no writes
- Phase 2 — live read path (this branch): MCP Streamable HTTP to the Z440 primary engine, read-only frame/node/relation tools, retry/reconnect; the extension defaults to the live adapter
- Phase 3 — Document PiP mirror (this branch): opened from an explicit side-panel button, same shared state store, feature-detected, graceful degradation
- Phase 4 — controlled tools and model targets: MCP affordance discovery, structured tool calls only, confirmation UI for every mutating act, provider-neutral model adapter
- Phase 5 — optional high-rate data plane (WebTransport) only behind measured need
A clean MV3 extension that opens a side-panel "seat", asks a mock MCP adapter for a fixed typed HG frame, and renders it: a provenance header, a Cytoscape graph, and a textual node inspector. No model, no network, no page access, no writes.
npm ci
npm run build # -> dist/ (load-unpacked target)
npm run typecheck # optional: tsc --noEmitThen, in Chrome/Edge: chrome://extensions → Developer mode → Load unpacked →
select dist/. Click the toolbar action to open the side panel.
Four gates — typecheck plus the three smoke:* scripts — and an in-extension self-test.
build is in the sequence below as a prerequisite (smoke:worker reads dist/), not as a
gate. See TESTING.md for what each one covers, the two dev-harness traps
that have cost real time, and the honest limits that are deliberately not hidden.
npm run typecheck && npm run build
npm run smoke:worker && npm run smoke:live && npm run smoke:llmThe Z440 launcher opens each generated surface window with this extension's own
sidepanel.html?surface=<surface-key> as one of its tabs. That page hands the key to the
MV3 worker, which titles the window's tab group mo:os - <surface-key> in a colour derived
from the key (stable across restarts).
Why a handshake rather than a tab-title marker: reading tab.title would require the
browser-wide tabs permission, which grants url/title/favIconUrl for every tab in every
window and appears to the user as "Read your browsing history" — a very large grant for a
window label, on a seat whose design is minimal privilege. A page can also set its own
title, so a title marker is attacker-controlled: any site could make the extension regroup
and rename whatever window it is in. Here the key travels launcher → the extension's own URL
→ worker, and the window comes from sender.tab.windowId. Neither is forgeable by a web
page and neither needs a permission; only tabGroups is required, for the title and
colour. tabs.query / tabs.group themselves need no permission.
What it will not do: it never sweeps a tab that is pinned or already in one of your own tab
groups (tabs.group MOVES tabs, so absorbing everything would empty and destroy your
group), it only ever touches the sender's window, it is idempotent, and it refuses a key that
does not match [a-z0-9][a-z0-9-]{0,63}. With no ?surface= in the URL — the docked panel,
the pop-out, the full-tab mirror — it does nothing at all.
The same ?surface=<key> also decides which engine the window reads. The key rides on
every GET_FRAME; the worker resolves it at runtime — surface key → the t266
urn:moos:channel:virtual-desktop.<key> channel node (a read-only directory lookup on the
default engine) → the engine that channel names — and builds that window's adapter against
the resolved endpoints (src/mcp/surface-resolver.js). A ?surface=z440-menno (or bare
?surface=menno) window therefore reads urn:moos:kernel:hp-z440.menno on :8001/:9001
instead of inheriting the build-time primary default. The panel's log feed and fold stream
follow the same resolution via provenance.engine_url.
Verification is not the hint: every frame also stamps provenance.engine_reported — the
connected engine's own /healthz kernel_urn. When it differs from the engine the surface
expects (an unreachable engine fell back to the default, or an endpoint serves a different
kernel than the channel names), the PostureStrip renders an ENGINE MISMATCH warning
rather than letting the window silently render the wrong fold. Unknown keys, engine-less
rooms (the manifold/collective desktops), and a down directory all degrade to the default
engine with no warning suppressed and no window dead.
Settings → engine → custom engine… reads an engine the fleet directory does not list:
an engine REST URL, an MCP base URL and an optional urn label, stored under the same
chrome.storage.local['pilot.engine'] key the fleet picker writes. The MCP origin has to
be in public/manifest.json host_permissions — the kernel's MCP endpoint answers a CORS
preflight with 405, so an unlisted origin fails with Failed to fetch. A stored pair that
is not a fleet entry shows in the picker as custom, with its URLs. The Cloudflare Access
token pair is attached only when BOTH URLs are behind Access: a pair that mixes a tunnel
host with any other host gets none, so the secret cannot reach the other host.
Provenance stays honest about such an engine: with no label the frame is stamped with the
kernel_urn the engine reports on /healthz; when it reports none, provenance.engine
reads unidentified engine at <host:port> — never the default engine's urn
(unnamedEngineIdentity in src/mcp/transform.js). The review-only pin preview is not
built on such an engine — its actor would not be a urn — and says so; a urn label on the
custom engine brings it back.
The knowledge lens shows the knowledge graph on its own: claims, the sources they
were taken from, and the field / topic / module hierarchy they belong to. It selects type
claim plus the eight WF12 knowledge ports of ontology 4.0.8; the relation closure brings
in what those relations point at. Every claim node of the engine is shown, also one that
no knowledge relation touches. Port names, converse names, colours and the nesting
relation are read from src/ui/kb-vocab.json — emitted by Lean, copied verbatim, never
edited here (see PROVENANCE.md) — through src/ui/kb-vocab.js, and are
restated nowhere else in src/ or scripts/ (npm run smoke:lens checks both, and that
the file still is the recorded one). A claim is labelled by its text.
How it is drawn. The graph layout defaults to Auto: on a frame where the
vocabulary's nesting relation puts at least one node in a box it draws nested, on every
other frame concentric, as before. A layout picked in Settings is always drawn as
picked. (The choice is stored under a new key: a concentric stored before t337, when it
was the default, is read as no choice; a stored breadthfirst or grid still counts.)
Under nested a claim sits inside its field, a field inside its topic, a topic
inside its module — one box per node, a field or topic before a module, and a
classification scheme is never a box. The boxes are packed deterministically from the
measured label sizes, so no label overlaps another. Since t342 (P3) a knowledge source sits
inside the box that cites it — the box is a presentation-only parent, ids stay urns — and a
box that cites a source drawn in another box says so with ↗N on its title; the column the
sources stood in beside the boxes is gone. Outside the boxes (P2) each connected component is
laid out on its own — breadth-first, a large one refined by a bounded cose — where they
used to stand on one grid sorted by urn. Under the relation-kind palette every other
knowledge relation is a line in
the vocabulary's colour — dashed or thick as the vocabulary says, with no port text on it and
no arrowhead when it is self-converse; a claim always takes the colour of its kind. Under any
other layout the graph is flat and the nesting relation is a line like the rest.
Port colours (t342). By default each relation END is drawn in the colour family its port
has on the engine's /operad/port-colors (κ): one hue when both ends share a family, and
three states that are no colour get a glyph instead — exempt (κ = ""), uncoloured (a port
the map does not know) and undeclared pair (a pair the engine's /operad/rewrite-categories
does not declare; dotted, ◇ midway). Ends in two families — or a family at one end and none
at the other — are a fault since t344 (PIL-13): no declared pair joins two families, so the
line is drawn grey, both ends too, with a filled ■ midway, and the legend's row reads two
colours: a fault; a family's hue goes only on a relation whose two ends share it (it was
drawn in two tones before). The legend's colour by switch goes
back to the relation-kind palette above. The grammar is read from the engine at run time
(src/mcp/engine-grammar.js, cached per engine and ontology version) and the advanced drawer
adds what it declares — types and ports the static lists do not name, and the fold's labels
on no declared pair — so all 56 types of mtdc-2.1.0 can be selected. Nothing of the operad is
written into this repo; an engine without /operad/* is drawn by relation kind and the
legend and the drawer say so (TESTING.md, "Port colours and the engine's grammar").
Under the graph a bar holds the legend toggle — it states the frame's node and relation
totals — zoom − / +, fit and re-layout. fit always shows everything: the zoom
floor of 0.2 drops to what the fit needs, on every layout. The legend lists every relation
port and node kind in the frame with its count and a checkbox that hides it without
re-reading the frame; a relation row's tooltip names the port's converse from the engine's
declared pairs, not only the knowledge vocabulary's (t344 PIL-14) — the one port 4.0.9
declares on two pairs reads both converse names, p (from the other end: a / b). It opens by itself only in a window at least 640 px wide and on a
frame that draws knowledge relations (their lines carry no port text); everywhere else it
starts closed and the canvas keeps its height. Open, it grows to its rows (the panel
scrolls) instead of scrolling inside a 110 px box, and it speaks the six words the pilot, the
Workbench and the manual share — colour family, port end, no colour, exempt, pair
not declared, port on no pair — with no κ, commit hash, route or src_port in a tooltip
(t342 hand-off C), and since t344 a fourth state row, two colours: a fault. An unticked row stays unticked on the other
lenses — the bar counts them and show all ticks every row back — and ticking back a node
kind the layout last ran without runs the layout again, so its nodes get a place.
The whole fold (t342). Every surface opens on everything (Sam, t342: "I need the view
not narrowed at opening"), and since hand-off C the first drawing opens centred on your own
node — the user the frame was read for — at a zoom where labels are drawn (the zoom at
which its neighbourhood fits, never below the step above the label cut, never above 1), with
fit one click away; a frame without that node (anon, or a lens that leaves it out) still
opens on the fit. In the side panel the frame restored from the last session shows at the fit
until the live read lands; that frame then opens on the node, unless the view was moved
meanwhile (t343). Node and relation labels are not drawn below a zoom step — 0.5
at 100 % and 200 % display scaling, 0.8 at 125 %, 0.67 at 150 % — box titles always are,
and the selection, find matches and the hovered node keep a label enlarged to stay
readable, without moving anything. The nodes no relation of the frame touches sit in one
band under the linked drawing, on every layout; the bar's unlinked N chip hides or shows
it, counts as one of the legend's hidden rows (1 hidden) and comes back with show all.
The strip says what is in the frame in words — 445 of 452 nodes · 7 hidden by access,
with the engine, the log position and the version on a second line (t342 hand-off C; it
read 445/452 · access −7 · … on one line before, cut at the panel's width) — counting what
the access posture alone left out, not what is on the canvas, and not protection (see
TESTING.md, "Drawing the whole fold").
A frame re-read that brings the same node and relation ids updates the drawing in place and keeps the zoom and pan. When the canvas changes size — the legend opens, the window is resized — the picture is fitted again, unless the user has zoomed or panned since the last fit. In a drawing with boxes a drag that starts on a box or on a relation line pans the view (a node is still dragged), and a double-click on a box brings that box into view. An empty canvas says so, and names the anon posture when that is why.
Finding and reading. find applies as you type and matches urn and label first and then
a node's text (label / title / name / text / pointer): the best hit is selected, the
hint says when it matched inside a property (2 matches · showing claim · in text), and
every node that did not match fades until the box is cleared — the boxes a match sits in, the
match's own relations and their other ends stay lit, and the hit is brought into view with
them (t342 hand-off C: the one relation a match had was drawn at opacity 0.12 before, its
other end off the canvas). Apply therefore no longer stands beside find: it sits in the
view_filter · advanced drawer with the staged type / port ticks and the t_day ≤ bound it
applies.
The inspector shows a node's text as a paragraph above its property table, names an
incoming relation by its converse port (← has-part: the knowledge vocabulary's, else the port
the relation carries at that end, else — since t344, PIL-14 — the ports the engine's declared
pairs put opposite it), writes the port name in ink with a dot
in the colour the graph draws that end in (a name written in its hue failed 4.5:1), and a
click on a relation row selects the other node, centres the graph on
it and shows it from the top of the inspector. In a drawing with boxes, centring — a find
hit, a relation row — also zooms in to where the node can be read (zoom 1; a box: as far
as the whole box fits); a drawing without boxes centres at the current zoom, as before. In
the side panel the inspector pane grows to 45 % of the panel height for a node that carries
a text, so a claim can be read there without scrolling inside a 120 px pane.
Mirrors. The full-tab, pop-out and PiP mirrors draw the layout chosen in Settings and
follow a change while open, with the same nesting, colours and legend. The panel's search
fade and the centre request of an inspector row reach them through a second session-store
key beside the scratch, pilot.scratchView.v1 (same per-surface scope; panel to mirrors
only). A find hit does not move a mirror's view.
Every button in the six components the probe reads (PostureStrip, SettingsPanel, GraphControls, FrameGraph, LogFeed and NodeInspector; the actions panel and the confirm modal are outside that scope), and the controls named here, carry a data-testid (the lens buttons
lens-<id>, find, apply, reset, the legend rows legend-port-<port> /
legend-kind-<kind> / legend-family-<family>, the colour-by switch colour-by-port /
colour-by-kind, the bar's fit / re-layout / zoom-in / zoom-out, the header's
reload-frame) and the canvas an aria-label that sums up the frame it draws, so a script can
drive the page (t342 hand-off C; npm run probe:ui does). Text in the muted ink and every
link reach WCAG 4.5:1 on the panel's backgrounds; the control and section labels are at least
11 px (the strip, the lens segments and the log rows keep their 8 to 10 px).
The live harness takes preview-live.html?engine=<REST base URL> to read a different
engine. TESTING.md has the measured numbers and the limits.
sidepanel.html side-panel entry (React root)
src/sidepanel.tsx the seat: fetch frame, restore scratch, compose UI
src/sidepanel.css dark theme
src/worker.ts MV3 service worker (opens panel; serves frame via factory)
src/mcp/types.ts HgFrame / HgNode / HgRelation / provenance + McpAdapter (the seam)
src/mcp/fixture.ts the fixed typed frame (real seat model, t259/t260 nodes)
src/mcp/mock-adapter.ts MockMcpAdapter — returns the fixture, no I/O
src/mcp/transform.js SHARED pure fold -> HgFrame transform + view_filter (Phase 2)
src/mcp/streamable-http-client.js SHARED read-only MCP/REST transport (Phase 2)
src/mcp/streamable-http-adapter.ts StreamableHttpMcpAdapter — live read (Phase 2)
src/mcp/adapter-factory.ts mode switch: 'mock' | 'live' (extension defaults live)
src/state/scratch.ts chrome.storage.session helpers (selection + frame cache; onChanged subscription — per-surface store since A17; t337: the scratch VIEW — search fade + centre request, panel to mirrors)
src/state/use-mirror-view.ts what a mirror follows besides frame + selection: the stored layout choice and the scratch view (t337)
src/ui/kb-vocab.json knowledge vocabulary (ontology 4.0.8) — Lean-emitted, copied verbatim, never edited here (t337)
src/ui/kb-vocab.js SHARED typed accessor over it: knowledge ports, per-port style, converse names, nesting port, claim kinds (t337)
src/mcp/engine-grammar.js SHARED read of the engine's grammar: GET /operad/node-types, /rewrite-categories, /port-colors, cached per engine + ontology version (t342)
src/ui/port-colour.js SHARED pure painter: each relation end by κ(port), the three non-colour states, the eight hues (t342)
src/components/ PostureStrip (strip + audit drawer) · SettingsPanel · GraphControls · FrameGraph (Cytoscape) · NodeInspector · ActionsPanel + ConfirmActionModal
src/tools/types.ts controlled-tools contract: ToolKind/ToolChannel, ToolSpec, AffordancePack, ToolCall, PendingAction (Phase 4)
src/tools/tool-call.ts structured ToolCall validator (args_schema JSON-shape check; NO text parsing) (Phase 4)
src/tools/affordance.ts affordance-pack projection: tools/list classify + curated acts + mock fallback (Phase 4)
src/tools/browser-acts.ts the clipboard browser act executor (local; runs on Confirm) (Phase 4)
src/tools/hg-program-preview.ts REVIEW-ONLY WF19 pins-urn apply_program preview builder (stringifies; NEVER posts) (Phase 4)
src/tools/model-providers.ts provider-neutral model registry + WebGPU capability stub (seam; no calls, no keys) (Phase 4)
src/pip/document-pip.d.ts ambient Document PiP types (not yet in lib.dom.d.ts) (Phase 3)
src/pip/pip-content.tsx PipContent — pure compact frame view (reused by the PiP window + the pip-preview harness) (Phase 3)
src/pip/pip-window.tsx Document PiP controller: gesture open, shared-scratch sync, style adoption, teardown (Phase 3)
preview.html / src/preview.tsx dev harness on the MOCK adapter
preview-live.html / preview-live.tsx dev harness on the LIVE adapter (CORS-blocked from a served page)
pip-preview.html / src/pip-preview.tsx dev harness rendering the PiP content view standalone (served-page testable) (Phase 3)
scripts/live-smoke.mjs headless live-read proof (Node; no CORS)
public/manifest.json narrow MV3 manifest
public/icons/ toolbar icons (from legacy/sidepanel-2026-01)
- Everything past the
McpAdapterboundary.MockMcpAdapter.getFrame()returns a fixed fixture (src/mcp/fixture.ts). There is no MCP client, no transport, no session, no engine connection, noOriginhandshake, no auth — none of it exists yet. - The frame is data, not a live read.
provenance.mock === trueandfolded_atis a fixed timestamp. The urns/log_seq/t_day are realistic (enginehp-z440.primary, sessionsam.z440-cowork-workspace, purposesam.cowork-workspace-curation, the t259 access-law / symmetries derivations held at gate 2) but nothing is fetched. - No model, no writes, no page access. No provider/LLM, no rewrite/apply path, no
content script, no
<all_urls>. The four rewrites (ADD/LINK/MUTATE/UNLINK) are not reachable from this build. view_filteris advertised, not applied. Phase 1 returns one fixed frame; the request's filter is ignored (the provenance still states the filter the fixture stands for).
- Narrow MV3 manifest (
sidePanel,storage,activeTab; host_permissions limited tohttp://localhost:8080/*+http://localhost:8000/*; no popup soaction.onClickedopens the panel). - Service worker holds no correctness-critical globals; selection + frame cache live
in
chrome.storage.session, so a forced worker termination is survivable (reopen restores instantly; the⟳button re-asks the worker, which re-answers identically). - Cytoscape inspector with URNs as stable node ids, relations (never "edges") on arrows, layout/selection kept in browser scratch, not node data.
- Provenance header showing engine, log_seq · t_day, workspace, purpose, and view_filter.
- Textual inspector: selected node's urn / type_id / properties + incident relations.
The live read plugs into the McpAdapter seam in src/mcp/types.ts
with no signature change — the worker still calls adapter.getFrame(request) and
forwards the typed frame; the side panel, the GET_FRAME/FRAME envelope, and the
component tree are untouched. The one type change is FrameProvenance.mock widened from
the literal true to boolean, so a live frame can state mock: false (the header now
renders a green LIVE badge instead of the amber MOCK one).
The extension now defaults to the live adapter (src/mcp/adapter-factory.ts). It calls
only these engine reads:
| Surface | Endpoint | Tool / route | Used for |
|---|---|---|---|
| MCP Streamable HTTP | POST http://localhost:8080/sse |
initialize |
handshake (JSON-RPC 2.0, protocol 2024-11-05; server is stateless per request, no mcp-session-id) |
| MCP Streamable HTTP | POST http://localhost:8080/sse |
tools/call → graph_state |
the whole current fold {nodes, relations} |
| MCP Streamable HTTP | POST http://localhost:8080/sse |
tools/call → node_lookup |
single node by urn (helper) |
| REST | GET http://localhost:8000/healthz |
— | log_len, t_day, ontology_version for provenance |
| REST | GET http://localhost:8000/state/relations/src/{urn} |
— | outgoing-relation helper |
There is NO apply path. graph_state / node_lookup are the only tools invoked, gated
by a positive read-only allowlist in streamable-http-client.js; the engine's
apply_rewrite / apply_program tools are never named or called. The four rewrites
(ADD/LINK/MUTATE/UNLINK) are unreachable from this build. No model, no credential, no
Origin-bypassing config is wired.
graph_state returns the fold as stringified JSON inside result.content[0].text, with
each property wrapped as {value, mutability, authority_scope, stratum_origin}. The pure
transform (src/mcp/transform.js) unwraps .value, maps nodes →
HgNode and relations → HgRelation (type_id = rewrite_category/WFxx, label = the
relation-kind port, e.g. provides-kb), then applies the client-side view_filter (L_p):
by default it keeps types [knowledge_item, derivation, purpose, session] scoped to the
live Cowork seat session:sam.z440-cowork-workspace plus its 1-hop neighbourhood — the
same shape as the Phase 1 mock, so the UI stays continuous. Provenance is stamped from
/healthz. A FrameRequest.view_filter ({purpose?, scope_urns?, types?, t?}) overrides
the defaults.
transform.js and streamable-http-client.js are authored in JS + JSDoc precisely so the
same real code runs in the service worker (via the .ts adapter) and in the Node smoke
test — the smoke test is not a re-implementation.
A served page (the vite dev server, preview-live.html opened over http://…)
cannot reach the engine: the browser applies CORS to the cross-origin POST to
http://localhost:8080/sse and forbids overriding Origin, so the request is blocked and
preview-live shows a transport error. That is expected. The live path renders in the
browser only from a CORS-exempt context — i.e. the actual unpacked extension, whose
host_permissions for :8080/:8000 grant the cross-origin reads (or a browser started
with web security disabled). preview.html deliberately stays on the mock adapter for
served-page use.
The headless, CORS-free proof is the Node smoke test:
node scripts/live-smoke.mjsIt imports the same shared streamable-http-client + transform modules, runs
initialize → graph_state → /healthz → selectFrame → node_lookup, prints the resulting
HgFrame summary (node count by type, relation count, provenance header), and exits
non-zero unless a live frame with non-zero nodes was read.
- Clean MV3 manifest with narrow permissions (
sidePanel,storage,activeTab; localhost:8080/:8000hosts only; no<all_urls>, history, bookmarks, downloads, or content script). - Side panel opens from the toolbar action (
chrome.action.onClicked→chrome.sidePanel.open; nodefault_popup, so no onClicked conflict). - Persisted state survives forced service-worker termination (
chrome.storage.session; no correctness-critical worker globals). - Mock MCP adapter returns a fixed typed frame.
- Frame metadata visibly includes source engine, log sequence/time, workspace, purpose, and view filter (the provenance header).
- Cytoscape inspector renders the mock frame plus a textual inspector.
-
StreamableHttpMcpAdapter implements McpAdapter(no seam signature change). - MCP Streamable HTTP
initializehandshake +tools/call graph_state; REST/healthzfor provenance. - Wrapped-property unwrap + fold →
HgFrametransform, shared by adapter and smoke test. - Client-side
view_filterselection (default = live Cowork-seat slice; honors request). - Retry/backoff on transport failure; errors surfaced (not thrown uncaught).
- Read-only helpers (health,
node_lookup, relation neighborhood); no apply. - Runtime adapter switch; extension defaults to live, mock still available.
-
node scripts/live-smoke.mjsreads a real non-zero frame from the running kernel.
A Pop out ⧉ button in the side-panel header opens a Document Picture-in-Picture window that mirrors the panel: the SAME frame, the SAME selection, kept in lock-step through ONE shared state store. It is a pure read/UI surface — no new engine capability, no adapter call, no model, no writes. The four rewrites (ADD/LINK/MUTATE/UNLINK) stay unreachable; the write path (controlled tool calls + confirmation UI) remains gated to Phase 4.
- Explicit, gesture-driven open. The panel header shows a visible Pop out ⧉ button.
Document PiP's
requestWindow()REQUIRES a user activation, so the click handler callsopenPipMirror()which invokesrequestWindow({width, height})synchronously — noawaitruns before it, so the gesture is still valid. Clicking again while a mirror is open just focuses the existing window (never a second one). - One shared store per surface (
chrome.storage.session, keypilot.scratch.v1— orpilot.scratch.v1.<scope>when the page carries a scope key, A17). The scope is the validated?scope=param when present (both pip.html routes thread it from their opener, with no?surface=needed), else the launcher's?surface=room key on sidepanel.html, else the default. A single browser-wide key meant 14 generated windows overwrote one selection and one cached frame; each surface now scratches on its own channel, and a mirror always lands on its opener's channel. The panel and the PiP mirror read and write the same scratch store. Sync is event-driven viachrome.storage.onChanged(plus a re-read when the PiP window regains focus) — no polling loop. The frame flows one-way (panel authors it → scratch → mirror); the selection flows both ways (saveSelectedUrnpreserves the panel-authored frame while flipping the shared selection). Selecting a node in either surface reflects in the other. The mirror renders the identicalHgFramethe panel holds (passed through scratch, not re-fetched — guaranteeing frame identity, criterion 3). - The PiP React tree runs in the opener's JS realm.
createRoot(pipWindow.document.body)targets the PiP document's DOM, but the component code, hooks, andchrome.storageaccess all execute in the side panel's realm — so the extension APIs are always available regardless of the PiP window's own realm. - Styles are adopted, not inherited. A PiP document inherits none of the opener's CSS, so
copyStyles()reconstructs every same-origin sheet as an inline<style>in the PiP head (falling back to a linkedhreffor any unreadable sheet). The Pop-out button and the compact.pip-*styles live in the samesidepanel.cssthat gets adopted, so the mirror looks like a compact side panel. - Graceful degradation.
pagehideon the PiP window tears down the mirror (unmount React, drop handles) and notifies the panel so the button re-enables; the side panel keeps working throughout. Apagehideon the opener proactively closes the PiP first. The PiP window never outlives its opener (browser-enforced) — we handle that cleanly, no dangling root. - No placement control. Only
width/heightare passed; window position is UA-owned. Software remembers content/state, never placement (criterion 5). - Feature-detected.
isDocumentPipSupported()checks forwindow.documentPictureInPicture. When absent (mobile / unsupported browsers) the button is disabled with the tooltip "Document PiP not supported in this browser" and the side panel remains fully functional. (The Document PiP API isn't yet in TypeScript'slib.dom.d.ts, sosrc/pip/document-pip.d.tsdeclares the minimal typed surface.)
A real PiP window needs a user gesture and a loaded extension, so it can't be driven from
a served page. pip-preview.html renders the PiP CONTENT component
(PipContent) standalone — fed a frame from the MOCK adapter, with
selection in local React state (exactly what the shared-scratch sync supplies inside a real
extension). Same pattern as preview.html. Because PipContent is a pure presentational
component, this harness pulls in no chrome.* / Document-PiP code — verified in the build,
where documentPictureInPicture lands only in the side-panel entry chunk, not the shared or
pip-preview chunks. It is a fifth vite entry (pip-preview), built for local/CI UI checks
and never loaded by the extension.
npm ci
npm run typecheck # tsc --noEmit
npm run build # -> dist/ (worker.js stays unhashed; pip-preview.html emitted)
npm run dev:preview # then open /pip-preview.html on an allowed dev portnpm run build, then loaddist/unpacked (chrome://extensions→ Load unpacked).- Click the toolbar action → the side panel opens.
- Click Pop out ⧉ → a Document PiP window opens mirroring the panel (same provenance header, same graph, same selection).
- Select a node in the panel → it highlights in the PiP mirror; select a node in the PiP → it highlights in the panel. (Selection is the shared, bidirectional state.)
- Close the PiP window → the button re-enables and the side panel keeps working. Close/reload the panel → the browser closes the PiP with it.
Phase 4 is controlled tools and model targets and is implemented on feat/phase4-tools.
It is the first phase that mounts an apply capability — nothing before it (Phases 0-3) could
reach ADD/LINK/MUTATE/UNLINK. Crucially, Phase 4 still performs no live HG write: the
apply capability is a REVIEW-ONLY preview, and every mutating act is gated behind a
confirmation UI. The full write-up and exit-criteria checklist are in the Phase 4 section
at the end of this document.
- PiP opens from an EXPLICIT side-panel button (Pop out ⧉ in the header);
requestWindow()is called synchronously in the click handler (noawaitbefore it). - The PiP window shows the SAME frame + selection as the side panel via ONE shared store
(
chrome.storage.sessionscratchpilot.scratch.v1); selection mirrors both ways, event-driven onstorage.onChanged(+ focus re-read), no polling loop. - Same conversation/frame id — the PiP renders the identical
HgFramethe panel holds, passed through the shared scratch (not re-fetched), guaranteeing identity. - Closing/navigating the opener degrades GRACEFULLY back to the side panel:
pagehidetears down PiP state; the panel keeps working; the PiP never outlives its opener. - No programmatic screen placement assumed (only width/height passed; UA owns position).
- Feature-detected: when
documentPictureInPictureis unavailable the button is disabled with a tooltip and the side panel stays fully functional (mobile/unsupported fallback). - Still READ-ONLY: no adapter/engine call in the PiP path, no apply tool anywhere
(
apply_rewrite/apply_program/POST /programsappear only in read-only-guarantee comments); typecheck + build pass,pip-previewis a self-contained served page.
Phase 4 mounts the FIRST apply capability — and does so without ever writing to the HG. The controlling idea: an act is a typed object that must survive a confirmation gate, and an HG rewrite only ever becomes a review-only preview, never a live post.
- No live HG write exists. There is zero executable call to
apply_rewrite/apply_program, and zeroPOST /programsorPOST /rewrites, anywhere in the build. The HG act builds a preview envelope and stringifies it — that is the only thing that happens. - Every mutating act is confirmation-gated. A
mutatetool cannot act until the user clicks Confirm inConfirmActionModal, which shows tool / target / args / actor / workspace / purpose / expected effect. Cancel (and Escape) is a strict no-op. - Structured calls only. An act is a
ToolCall {name, args}validated against the tool'sargs_schema(a flat JSON-shape check insrc/tools/tool-call.ts) before the modal opens and again before Confirm resolves. There is no text/regex/fenced-block parser — the legacy scaffold's ad-hoc```tool`` reader was deleted in Phase 0 and never returns. - Confirm's meaning is fixed by the tool's
channel:browser→ run a local browser act (no engine);hg→ build/reveal the review-only preview (never post).
src/tools/affordance.ts projects the tools reachable for the current frame's
actor / workspace / purpose, modelling the SHAPE of the mo:os session-affordance map
({actor, workspace, purpose, tools:[{name, kind, description, args_schema}]}). It prefers
the LIVE MCP tools/list catalog (fetched read-only through the worker — listing is not
calling), classifies each tool read-vs-mutate by name (unknown ⇒ mutate, i.e. gated), and
down-projects each inputSchema to a flat args_schema. Offline / against the mock adapter
it falls back to a labelled MOCK pack. Discovered MCP tools are catalogued for
transparency but are not actionable — no executable path exists for them, so the write
invariant stays airtight regardless of what the server advertises.
copy_urn_to_clipboard— a harmless BROWSER act: copies the selected node's urn to the clipboard on Confirm. No engine, no HG. Still gated (it touches state outside the panel).pin_ki_to_workspace— a REVIEW-ONLY HG rewrite: Confirm builds a WF19pins-urnLINKapply_programenvelope (kernel actor,pins-urn/pinned-by-sessionports — the canonical WF19 pin form) and displays it with a download button. It is never posted; the panel shows "Not posted. Applying is a separate, out-of-band, human step."
src/tools/model-providers.ts is a provider-neutral registry. Since Phase 7 the seam is
LIVE: it defaults to ollama-local (on-box, keyless, :11434), offers gemini via
the kernel-proxy (:8000/llm/gemini, key server-side, gated on the scope-split LLM bearer
pilot.llmToken + the A3 access posture — never called anon), and keeps none-manual
(no model). Provider/model/bearer are configured in the Settings block (t263); the model
only ever PROPOSES a structured tool call — validateToolCall (incl. the semantic urn
checks) and the confirmation modal gate everything, exactly like a hand-composed act.
(The Phase-4-era "Local WebLLM behind a WebGPU stub" option was never shipped and is gone.)
The Actions section is wired into the side panel only (ActionsPanel, each surface
ErrorBoundary-wrapped and defensively rendered). The PiP mirror deliberately does not
mount it — it stays a read/observe surface; a note in the panel states so.
npm ci && npm run build, loaddist/unpacked, click the toolbar action.- In the Actions section, confirm the affordance pack shows the frame's actor/workspace/
purpose and a
LIVEbadge (orMOCKif the engine is unreachable — it degrades cleanly). - Select a node → Copy urn to clipboard → the confirmation modal shows the structured call → Confirm & run → the urn is on your clipboard. Cancel/Escape ⇒ nothing happens.
- Select a
knowledge_item→ Preview: pin KI to workspace → the modal shows the WF19 intent → Reveal review-only preview → inspect (and optionally download) theapply_programenvelope. Nothing is posted;/healthzlog_lenis unchanged. - Open Settings (top of the panel): the provider selector defaults to Ollama (local · keyless); Gemini (via kernel-proxy) requires the scope-split LLM bearer (set/clear it there). With Ollama running, "ask the model" proposes a structured call that lands in the same confirmation modal as the buttons — nothing auto-applies.
preview.html renders the real ActionsPanel on the MOCK adapter (liveTools=null ⇒ the
labelled mock pack), so the whole confirmation flow, the review-only preview, and the provider
seam are browser-testable without the extension.
- MCP affordance discovery filtered by actor/workspace/purpose —
deriveAffordancePackprojects the frame's actor (session occupant) / workspace / purpose; prefers the livetools/listcatalog (read-only), classifies read-vs-mutate, falls back to a labelled MOCK pack. - Structured tool calls only — a typed
ToolCall {name, args}validated againstargs_schema(validateToolCall) before the modal and before Confirm. No text parsing anywhere. - Confirmation UI for every mutating act —
ConfirmActionModalshows tool / target / args / actor / workspace / purpose / expected effect with Confirm / Cancel; Cancel and Escape are strict no-ops. - One harmless browser act + one review-only HG preview —
copy_urn_to_clipboard(executes on Confirm) andpin_ki_to_workspace(builds a WF19pins-urnpreview on Confirm; never posts). - Provider-neutral model adapter —
ModelProviderregistry defaulting tonone-manual; a seam with no real invocation and no credential. - Local WebLLM only after a capability check — the WebLLM provider is gated behind the
hasWebGpu()stub (navigator.gpu); not bundled. - No live HG write — zero executable
apply_rewrite/apply_programcalls; zeroPOST /programsorPOST /rewrites. Those strings appear only in comments, UI labels, the mock catalog, and the preview builder that stringifies (never posts) an envelope. typecheck + build pass;preview.htmlis a self-contained served page.
WebTransport / a second transport is deferred by decision: the reliable MCP path already
sustains ~61 Hz at p95 for a full graph read (measured — see docs/phase5-data-plane.md), which
exceeds any realistic UI update rate. No high-rate surface exists in the Pilot to justify a lossy
data plane. The gate + instrument:
- Decision + criteria:
docs/phase5-data-plane.md - Baseline benchmark (run before ever proposing WebTransport):
node scripts/bench-frame-read.mjs
Durable tool/rewrite semantics never leave the reliable MCP path regardless.