Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@
- Planned 0.2.0 N-1 support targets 0.1.8, the latest published patch of 0.1.x, and remains additive on API 1:
- Plugin API: verified by a directional type bridge (old plugin values accepted by the current console; current console values satisfy old plugins; historical optional keys are retained) and the exact published 0.1.8 conformance kit against a 0.1.8-shaped provider opened by the current loader. New principal capabilities remain opt-in.
- Routes: verified for the legacy browser session, tool listing, ping, panel snapshots, actions, confirmation-token exchange and a sample plugin asset through both tool-id and load-reference paths. Other Access/bearer and static-shell routes remain supported by policy but are not exercised by the added historical probe.
- Space route: a producer Space reply `{changed: false, cursor}` without a scene now passes through unchanged instead of being projected to an empty scene. A 0.1.8 static shell treats it as a failed source for that poll instead of blanking the scene; current shells keep the last scene.
- Manifest files: verified for unversioned and versioned API 1 plugin registrations with module/hash validation. Existing pet/asset pack manifests and static node configuration remain supported by policy but are not covered by this N-1 probe; the principal registry is an additive new surface.

### Added

- Add header-only snapshot versions and `X-Acon-Known` conditional request headers, preserving plugin cursor replies and forwarding version/freshness headers through the static transport. Adapt polling to request duration while keeping the inbox's 5-second minimum, pause hidden panel/Space polls with immediate visible refresh, progressively render inbox sources, refresh expired snoozes on unchanged rounds, and drive Room motion from the client observation clock without marking healthy SWR sources failed (#105).

- Add the Space Board view with per-group zone columns, producer-linked cards, owner detail controls, and collapsed Done history showing the last five supplied cards. Keep horizontal scrolling within each board row, preserve focus/disclosures, and cover the responsive matrix with a Board probe (#101).

- Coalesce identical snapshots across viewers in a bounded in-memory LRU cache with per-panel TTL overrides, stale-while-revalidate, action/reload invalidation and separate principal keys. Declared readOnly principal tools preserve the cache; other principal actions and all console actions invalidate it. Keep producer bodies and error snapshots unchanged, expose freshness only through cache/age/sample-time headers and add System cache/provider timing diagnostics, and add a private-output snapshot timing probe (#105).
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ Build one static shell only over equally trusted nodes (such as your own nodes).

The output contains fixed shell/vendor files, public `config.json`, `_headers`, `vercel.json`, an assets-only `wrangler.jsonc`, `.assetsignore` and `.vercelignore` (each host skips the other's configuration). It contains no plugin data or credentials. Upload the complete directory to a preview with the supplied header configuration. The first `vercel deploy` of a new project can become a production deployment on a public alias even with `--target preview`: link the project first (`vercel link`), then check that `vercel inspect` shows `target preview` before sharing a URL. On Workers, `wrangler versions upload` needs an existing Worker, so the first `wrangler deploy` is a production deployment on workers.dev and needs the same authorization as any production shell. Main-document CSP permits connections, scripts and images only from self and the configured node origins; plugin styles are fetched with node auth into constructed stylesheets, and attachments use a local sandboxed viewer. The sandboxed visual/attachment documents have separate header policies.

In System, each node has its own bearer-token form and Access sign-in/reconnect controls. Bearer tokens stay in that tab's `sessionStorage`; Access uses that node's cookies with credentialed requests. Access CORS/cookie policy must also permit the shell origin. Authenticated nodes issue short-lived GET-only resource grants for panel modules and attachments because native module/image requests cannot carry bearer headers. These runtime grants are node-specific, contain no bearer token and never authorize actions. Keep node access logs private, since runtime resource URLs contain these grants. The "Open attachment" link also includes a GET-only grant and can leave it in browser history for its remaining lifetime (at most 1 hour); keep that history private too.
In System, each node has its own bearer-token form and Access sign-in/reconnect controls. Bearer tokens stay in that tab's `sessionStorage`; Access uses that node's cookies with credentialed requests. Access CORS/cookie policy must also permit the shell origin. Authenticated nodes issue short-lived GET-only resource grants for panel modules and attachments because native module/image requests cannot carry bearer headers. These runtime grants are node-specific, contain no bearer token and never authorize actions. Keep node access logs private, since runtime resource URLs contain these grants. The "Open attachment" link also includes a GET-only grant and can leave it in browser history for its remaining lifetime (at most 1 hour); keep that history private too. For conditional snapshots the Access CORS policy must also allow the `X-Acon-Known` request header; if a node refuses it in preflight, the shell stops sending it to that node for the rest of the page and falls back to full replies.

Tools are namespaced `node:tool`; panel `host.node` identifies the owner and `host.tool` keeps its original tool id. All actions, confirmations and read-only restrictions stay on that owner. System distinguishes online, offline, unauthorized and `refused (origin)` nodes. A bounded opaque health probe distinguishes an unreachable node from a reachable node blocked by CORS; persistent failures refresh at most once per tool-list interval, without repeated prompts. Use System to reconnect after fixing a URL, token or `--origin`.

Expand Down Expand Up @@ -303,6 +303,10 @@ During one extra TTL of grace, a cached reply can be served immediately while on

The cache holds at most 256 entries and 4 MiB, evicts least recently used entries, and writes nothing to disk. System > Snapshot cache shows entry/byte totals and hit, miss and stale counts by tool, panel and namespace. Its p50/p95 provider durations use a rolling window of the last 128 calls; cache hits do not add provider timing samples. Older nodes without these diagnostics remain usable.

Conditional snapshots (#105): each successful snapshot carries an `X-Acon-Version` content hash. A shell can send its last hash in the `X-Acon-Known` request header. Conditional control stays outside the snapshot body and cache key, so producer input remains unchanged, including any producer-owned `known` field. A matching successful value returns `{changed: false}` with the version header. Plugin `after` cursors and producer `changed: false` replies take precedence, including their cursor fields. Version and freshness metadata stay in headers, so producer body fields are unchanged by this protocol. Configured-origin preflight allows `X-Acon-Known`; the H2 transport forwards that request header and exposes the same version/cache/sample-time response headers to the shell; nodes without those headers keep their existing full-reply behavior.

Panel and Space polling use `max(pollMs, 3 * last request duration)`, capped at 60 seconds, with `pollMs` clamped to 2-60 seconds. Per-tool inbox polling uses the same adaptive interval with an `INBOX_MS` (5-second) floor, so it never polls faster than the previous inbox cadence; an omitted tool `pollMs` also uses that fallback. The interval recovers when requests become faster. Hidden tabs start no panel or Space polls, and becoming visible triggers one immediate refresh; existing Mini/Picture-in-Picture inbox behavior is preserved. Inbox sources render as each tool finishes instead of waiting for the slowest source. Each inbox round also rechecks time-dependent snoozes through signature-checked renderers, even when no provider data changes. Room motion uses the client observation time, so repeated cached sample times cannot create false source gaps. Routine SWR reuse remains healthy and does not idle pets or show a refresh-failure warning; only unsuccessful replies mark the source failed.

Run `bun scripts/perf-snapshots.ts` to load registered plugins and print cold and warm provider timings for each declared panel, inbox and Space. It reads the standard `AGENTHUB_HOME`, calls snapshots directly to expose provider cost rather than a cache hit, and closes providers afterward. Treat the output as private operational data; do not commit personal plugin timings or results.

- Inbox: declare `capabilities.inbox` and answer `snapshot({panel: "inbox"})` with `{items: InboxItem[]}`. Verbs run through `action({action: verb.id, id})`. Keep each item's `at` the same across snapshots: snooze and oldest-first order key on it, and conformance checks it.
Expand Down
12 changes: 12 additions & 0 deletions scripts/ui-check-snapshot-fixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Probes that rewrite a snapshot body act as legacy fixture providers: an upstream
// hash/sample time no longer describes their synthetic scene. Request the full
// source and remove that metadata, while protocol behavior is tested separately.
const metadata = new Set(["x-acon-version", "x-acon-cache", "x-acon-cache-age-ms", "x-acon-sampled-at", "content-length"]);
export async function fetchFullSnapshot(route) {
const headers = Object.fromEntries(Object.entries(route.request().headers()).filter(([name]) => name.toLowerCase() !== "x-acon-known"));
return route.fetch({ headers });
}
export async function fulfillSnapshotFixture(route, response, body) {
const headers = Object.fromEntries(Object.entries(response.headers()).filter(([name]) => !metadata.has(name.toLowerCase())));
await route.fulfill({ response, headers, json: body });
}
5 changes: 3 additions & 2 deletions scripts/ui-check-space-board.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// #101 Delivery 4: per-project Board, Done disclosure and producer-card navigation.
import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs";
import assert from "node:assert/strict";
import { join } from "node:path";

Expand All @@ -17,7 +18,7 @@ async function installDoneFixture(page) {
let input;
try { input = route.request().postDataJSON(); } catch { await route.continue(); return; }
if (input?.panel !== "space") { await route.continue(); return; }
const response = await route.fetch();
const response = await fetchFullSnapshot(route);
const body = await response.json();
if (body.scene) {
const zone = body.scene.zones.find((item) => item.kind === "done" && item.group === "delivery");
Expand All @@ -30,7 +31,7 @@ async function installDoneFixture(page) {
});
zone.count = 20; // The producer total deliberately exceeds the seven supplied cards.
}
await route.fulfill({ response, json: body });
await fulfillSnapshotFixture(route, response, body);
});
}

Expand Down
36 changes: 34 additions & 2 deletions scripts/ui-check-space-flow.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// #101 Delivery 3: public-link observations, Flow layout and detail return paths.
import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs";
import assert from "node:assert/strict";
import { join } from "node:path";

Expand Down Expand Up @@ -88,14 +89,14 @@ async function changedLinkProbe({ browser, ticket, report }) {
try { input = route.request().postDataJSON(); } catch { await route.continue(); return; }
if (input?.panel !== "space") { await route.continue(); return; }
const observedStage = stage;
const response = await route.fetch();
const response = await fetchFullSnapshot(route);
const body = await response.json();
if (body.scene) {
if (observedStage >= 1) body.scene.links.push({ kind: "message", from: "claude", to: "codex", label: "Flow changed-link probe", at });
if (observedStage >= 2) body.scene.actors.find((actor) => actor.id === "local").name = "Flow rerender probe";
if (observedStage >= 3) body.scene.links.push({ kind: "message", from: "codex", to: "local", label: "Flow hidden-link probe", at });
}
await route.fulfill({ response, json: body });
await fulfillSnapshotFixture(route, response, body);
});
await enterFlow(page, ticket, 100);
await page.evaluate(() => {
Expand Down Expand Up @@ -140,6 +141,36 @@ async function changedLinkProbe({ browser, ticket, report }) {
} finally { await context.close(); }
}

// Preserve SWR headers in this protocol case. Body-rewriting motion fixtures
// intentionally remove upstream metadata because it no longer describes their body.
async function staleGraceProbe({ browser, ticket, report, output }) {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, colorScheme: "light", reducedMotion: "reduce" });
const page = await context.newPage();
page.setDefaultTimeout(12000);
const sampledAt = new Date(Date.now() - 10000).toISOString();
let replies = 0;
try {
await page.route("**/p/*/snapshot", async (route) => {
if (route.request().postDataJSON()?.panel !== "space") { await route.continue(); return; }
const response = await fetchFullSnapshot(route);
replies++;
// No body mutation: the upstream version remains valid. SWR reuses a good sample.
await route.fulfill({ response, headers: { ...response.headers(), "x-acon-cache": "stale", "x-acon-sampled-at": sampledAt } });
});
await enterFlow(page, ticket, 100);
assert.equal(await page.locator(".space-flow-actor").filter({ hasText: /\(stale\)/ }).count(), 0, "Routine SWR marked healthy actors stale");
assert.equal(await page.locator("#view-space > .notice").filter({ hasText: "could not refresh" }).isVisible(), false, "Routine SWR showed a failure banner");
const writes = await unchangedWrites(page);
assert(replies >= 4, "SWR probe did not preserve headers across both producers and a repeat poll");
const screenshot = "space-swr-grace.png";
await page.screenshot({ path: join(output, screenshot), fullPage: true });
report.captures.push({ key: "space-swr-grace", screenshot, width: 1440, theme: "light", textPercent: 100, view: "space-flow" });
report.spaceCacheFreshness = { cacheHeader: "stale", sampledAt, replies, failureBanner: false, staleActors: 0, unchangedWrites: writes };
} catch (error) {
report.findings.push({ kind: "space-swr-grace", detail: error.message });
} finally { await context.close(); }
}

export async function runSpaceFlow({ browser, ticket, report, output }) {
report.spaceFlow = [];
for (const width of [375, 768, 1024, 1440]) for (const theme of ["light", "dark"]) for (const textPercent of [100, 200]) {
Expand All @@ -166,4 +197,5 @@ export async function runSpaceFlow({ browser, ticket, report, output }) {
} finally { await context.close(); }
}
await changedLinkProbe({ browser, ticket, report });
await staleGraceProbe({ browser, ticket, report, output });
}
5 changes: 3 additions & 2 deletions scripts/ui-check-space-foundation.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// #101 Delivery 1: routing/layout and raised-cap performance, synthetic scenes only.
import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs";
import assert from "node:assert/strict";
import { join } from "node:path";
export async function runSpaceFoundation({ browser, ticket, report, output }) {
Expand Down Expand Up @@ -50,7 +51,7 @@ export async function runSpaceFoundation({ browser, ticket, report, output }) {
try {
const page = await context.newPage(); let calls = 0, change = false;
await page.route("**/p/*/snapshot", async (route) => {
const response = await route.fetch(); const data = await response.json();
const response = await fetchFullSnapshot(route); const data = await response.json();
if (route.request().postDataJSON()?.panel !== "space" || !data.scene) { await route.fulfill({ response }); return; }
if (route.request().url().includes("/p/echo/")) data.scene = { actors: [], zones: [], cards: [], links: [] };
else {
Expand All @@ -60,7 +61,7 @@ export async function runSpaceFoundation({ browser, ticket, report, output }) {
scene.cards = Array.from({ length: 200 }, (_, i) => ({ ...card, id: `card-${i}`, owner: `actor-${i % 96}`, group: "delivery" }));
scene.links = Array.from({ length: 128 }, (_, i) => ({ kind: "cohort", from: `actor-${i % 96}`, to: `actor-${(i + 1) % 96}`, label: `Public relation ${i}` }));
}
await route.fulfill({ response, json: data });
await fulfillSnapshotFixture(route, response, data);
});
await page.goto((await ticket()).url); await page.evaluate(() => { location.hash = "#/local/space?view=room"; });
await page.waitForFunction(() => document.querySelectorAll(".space-actor").length === 96 && document.querySelectorAll(".space-card").length === 200);
Expand Down
9 changes: 5 additions & 4 deletions scripts/ui-check.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
// Agent-run browser probe. Playwright is discovered at runtime, never a package dependency.
import { fetchFullSnapshot, fulfillSnapshotFixture } from "./ui-check-snapshot-fixture.mjs";
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
Expand Down Expand Up @@ -846,7 +847,7 @@ async function runSpaceMotion(ticket) {
const currentStage = stage;
const gate = currentStage === 3 ? gapGate : null;
const tool = new URL(route.request().url()).pathname.split("/")[2];
const response = await route.fetch();
const response = await fetchFullSnapshot(route);
const body = await response.json();
if (tool === "demo" && body.scene) {
const { scene } = body;
Expand Down Expand Up @@ -882,7 +883,7 @@ async function runSpaceMotion(ticket) {
if (gate.pending.has("demo") && gate.pending.has("echo")) gate.markReady();
await gate.promise;
}
await route.fulfill({ response, json: body });
await fulfillSnapshotFixture(route, response, body);
});
try {
await page.goto((await ticket()).url, { waitUntil: "networkidle" });
Expand Down Expand Up @@ -1170,9 +1171,9 @@ async function runSpaceViews(ticket) {
if (width === 1440 && theme === "light" && textPercent === 100) {
const beforeBurst = await page.locator(".space-webgl").evaluate((el) => Number(el.dataset.renderCount));
await page.route("**/p/demo/snapshot", async (route) => {
const response = await route.fetch(), body = await response.json();
const response = await fetchFullSnapshot(route), body = await response.json();
if (body.scene) body.scene.actors[0].state = "blocked";
await route.fulfill({ response, json: body });
await fulfillSnapshotFixture(route, response, body);
});
findings.push(...await waitSpacePoll(page));
await page.locator(".space-webgl[data-pending-frames='0']").waitFor();
Expand Down
Loading
Loading