Skip to content

About

Collider Pilot — browser harness surface for mo:os (side panel + Document PiP). Revival per ffs0#158.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

collider-pilot

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 plan (from #158)

  • 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

Phase 1 — read-only side-panel shell

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.

Build

npm ci
npm run build     # -> dist/  (load-unpacked target)
npm run typecheck # optional: tsc --noEmit

Then, in Chrome/Edge: chrome://extensions → Developer mode → Load unpacked → select dist/. Click the toolbar action to open the side panel.

Testing

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:llm

Z440 surface rooms (tab-group titles)

The 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.

Surface → engine resolution (A16)

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.

Custom engine and the knowledge lens (t337)

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.

Layout

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)

What is mocked / stubbed in Phase 1

  • Everything past the McpAdapter boundary. MockMcpAdapter.getFrame() returns a fixed fixture (src/mcp/fixture.ts). There is no MCP client, no transport, no session, no engine connection, no Origin handshake, no auth — none of it exists yet.
  • The frame is data, not a live read. provenance.mock === true and folded_at is a fixed timestamp. The urns/log_seq/t_day are realistic (engine hp-z440.primary, session sam.z440-cowork-workspace, purpose sam.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_filter is 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).

What is real in Phase 1

  • Narrow MV3 manifest (sidePanel, storage, activeTab; host_permissions limited to http://localhost:8080/* + http://localhost:8000/*; no popup so action.onClicked opens 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.

Phase 2 — live MCP read path (this branch)

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).

What it reads (read-only, no writes)

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.

How the transform works

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.

Live browser testing & the CORS caveat

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.mjs

It 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.

Phase 1 exit criteria (from #158) — met

  • Clean MV3 manifest with narrow permissions (sidePanel, storage, activeTab; localhost :8080/:8000 hosts only; no <all_urls>, history, bookmarks, downloads, or content script).
  • Side panel opens from the toolbar action (chrome.action.onClicked → chrome.sidePanel.open; no default_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.

Phase 2 exit criteria (from #158) — met

  • StreamableHttpMcpAdapter implements McpAdapter (no seam signature change).
  • MCP Streamable HTTP initialize handshake + tools/call graph_state; REST /healthz for provenance.
  • Wrapped-property unwrap + fold → HgFrame transform, shared by adapter and smoke test.
  • Client-side view_filter selection (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.mjs reads a real non-zero frame from the running kernel.

Phase 3 — Document Picture-in-Picture mirror (this branch)

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.

How it works

  • 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 calls openPipMirror() which invokes requestWindow({width, height}) synchronously — no await runs 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, key pilot.scratch.v1 — or pilot.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 via chrome.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 (saveSelectedUrn preserves the panel-authored frame while flipping the shared selection). Selecting a node in either surface reflects in the other. The mirror renders the identical HgFrame the 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, and chrome.storage access 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 linked href for any unreadable sheet). The Pop-out button and the compact .pip-* styles live in the same sidepanel.css that gets adopted, so the mirror looks like a compact side panel.
  • Graceful degradation. pagehide on 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. A pagehide on 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/height are passed; window position is UA-owned. Software remembers content/state, never placement (criterion 5).
  • Feature-detected. isDocumentPipSupported() checks for window.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's lib.dom.d.ts, so src/pip/document-pip.d.ts declares the minimal typed surface.)

Served-page testing (pip-preview.html)

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 port

Manual test (needs the loaded extension + the user gesture)

  1. npm run build, then load dist/ unpacked (chrome://extensions → Load unpacked).
  2. Click the toolbar action → the side panel opens.
  3. Click Pop out ⧉ → a Document PiP window opens mirroring the panel (same provenance header, same graph, same selection).
  4. 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.)
  5. 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 — now built (see the Phase 4 section below)

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.

Phase 3 exit criteria (from #158) — met

  • PiP opens from an EXPLICIT side-panel button (Pop out ⧉ in the header); requestWindow() is called synchronously in the click handler (no await before it).
  • The PiP window shows the SAME frame + selection as the side panel via ONE shared store (chrome.storage.session scratch pilot.scratch.v1); selection mirrors both ways, event-driven on storage.onChanged (+ focus re-read), no polling loop.
  • Same conversation/frame id — the PiP renders the identical HgFrame the panel holds, passed through the shared scratch (not re-fetched), guaranteeing identity.
  • Closing/navigating the opener degrades GRACEFULLY back to the side panel: pagehide tears 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 documentPictureInPicture is 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 /programs appear only in read-only-guarantee comments); typecheck + build pass, pip-preview is a self-contained served page.

Phase 4 — controlled tools + confirmation UI (this branch)

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.

The safety invariant (non-negotiable)

  • No live HG write exists. There is zero executable call to apply_rewrite / apply_program, and zero POST /programs or POST /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 mutate tool cannot act until the user clicks Confirm in ConfirmActionModal, 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's args_schema (a flat JSON-shape check in src/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).

The affordance pack (criterion 1)

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.

The two curated acts (criterion 4)

  1. 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).
  2. pin_ki_to_workspace — a REVIEW-ONLY HG rewrite: Confirm builds a WF19 pins-urn LINK apply_program envelope (kernel actor, pins-urn / pinned-by-session ports — 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."

The model-provider seam (criteria 5 + 6)

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.)

Where it runs

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.

Manual test (loaded extension, live adapter)

  1. npm ci && npm run build, load dist/ unpacked, click the toolbar action.
  2. In the Actions section, confirm the affordance pack shows the frame's actor/workspace/ purpose and a LIVE badge (or MOCK if the engine is unreachable — it degrades cleanly).
  3. 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.
  4. Select a knowledge_item → Preview: pin KI to workspace → the modal shows the WF19 intent → Reveal review-only preview → inspect (and optionally download) the apply_program envelope. Nothing is posted; /healthz log_len is unchanged.
  5. 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.

Served-page testing (preview.html)

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.

Phase 4 exit criteria (from #158) — met

  • MCP affordance discovery filtered by actor/workspace/purpose — deriveAffordancePack projects the frame's actor (session occupant) / workspace / purpose; prefers the live tools/list catalog (read-only), classifies read-vs-mutate, falls back to a labelled MOCK pack.
  • Structured tool calls only — a typed ToolCall {name, args} validated against args_schema (validateToolCall) before the modal and before Confirm. No text parsing anywhere.
  • Confirmation UI for every mutating act — ConfirmActionModal shows 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) and pin_ki_to_workspace (builds a WF19 pins-urn preview on Confirm; never posts).
  • Provider-neutral model adapter — ModelProvider registry defaulting to none-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_program calls; zero POST /programs or POST /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.html is a self-contained served page.

Phase 5 — high-rate data plane (deferred behind a measurement gate)

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.

About

Collider Pilot — browser harness surface for mo:os (side panel + Document PiP). Revival per ffs0#158.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages