diff --git a/packages/web-readonly-status/index.html b/packages/web-readonly-status/index.html
new file mode 100644
index 0000000..ac1dddf
--- /dev/null
+++ b/packages/web-readonly-status/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+ OpenCode Read-Only Status
+
+
+
+
+
+
diff --git a/packages/web-readonly-status/package.json b/packages/web-readonly-status/package.json
new file mode 100644
index 0000000..c1aad81
--- /dev/null
+++ b/packages/web-readonly-status/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@openchamber/web-readonly-status",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "type-check": "tsc --noEmit",
+ "lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
+ },
+ "dependencies": {
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.10",
+ "@types/react-dom": "^19.1.7",
+ "@vitejs/plugin-react": "^5.0.0",
+ "typescript": "~5.9.3",
+ "vite": "^7.1.2"
+ }
+}
diff --git a/packages/web-readonly-status/src/App.tsx b/packages/web-readonly-status/src/App.tsx
new file mode 100644
index 0000000..cc4adb4
--- /dev/null
+++ b/packages/web-readonly-status/src/App.tsx
@@ -0,0 +1,170 @@
+import { useEffect, useState } from "react";
+import {
+ REAL_ADAPTER_SUMMARY_URL,
+ assertRealAdapterSummaryUrl,
+} from "./adapter/realAdapter";
+import {
+ type RealAdapterSummary,
+ fetchRealAdapterSummary,
+} from "./adapter/realAdapterClient";
+
+type LoadState = "idle" | "loading" | "ready" | "error";
+
+function formatBoolean(value: boolean | null): string {
+ if (value === true) {
+ return "true";
+ }
+ if (value === false) {
+ return "false";
+ }
+ return "unknown";
+}
+
+function formatStatusCode(value: number | null): string {
+ return value === null ? "unknown" : String(value);
+}
+
+function formatTagCount(value: number | null): string {
+ return value === null ? "unknown" : String(value);
+}
+
+function formatTimestamp(value: string | null): string {
+ if (!value) {
+ return "Not refreshed yet";
+ }
+ const date = new Date(value);
+ return Number.isNaN(date.valueOf()) ? value : date.toLocaleString();
+}
+
+function App() {
+ const [loadState, setLoadState] = useState("idle");
+ const [summary, setSummary] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [lastRefreshedAt, setLastRefreshedAt] = useState(null);
+
+ async function refreshSummary(): Promise {
+ try {
+ setLoadState("loading");
+ setErrorMessage(null);
+ assertRealAdapterSummaryUrl();
+ const nextSummary = await fetchRealAdapterSummary();
+ setSummary(nextSummary);
+ setLastRefreshedAt(new Date().toISOString());
+ setLoadState("ready");
+ } catch (error) {
+ setErrorMessage(error instanceof Error ? error.message : "Unknown error");
+ setLastRefreshedAt(new Date().toISOString());
+ setLoadState("error");
+ }
+ }
+
+ useEffect(() => {
+ void refreshSummary();
+ }, []);
+
+ return (
+
+
+ Read-only local adapter view
+ OpenCode Read-Only Status
+
+ This page is limited to one approved browser request and displays
+ status only.
+
+
+ Allowed endpoint
+ {REAL_ADAPTER_SUMMARY_URL}
+
+
+
+
+ Last refreshed: {formatTimestamp(lastRefreshedAt)}
+
+
+
+
+
+
+
+ Adapter
+ {loadState}
+
+
+
+
- Status
+ - {summary?.adapterStatus ?? "unknown"}
+
+
+
- OpenCode reachable
+ - {summary?.opencodeReachable ? "true" : "false"}
+
+
+
- OpenCode HTTP status
+ - {formatStatusCode(summary?.opencodeStatus ?? null)}
+
+
+
+
+
+
+ Repository
+ Display only
+
+
+
+
- Branch
+ - {summary?.repoBranch ?? "unknown"}
+
+
+
- HEAD
+ - {summary?.repoHead ?? "unknown"}
+
+
+
- Clean
+ - {formatBoolean(summary?.repoClean ?? null)}
+
+
+
- Stage 3 tag count
+ - {formatTagCount(summary?.repoStage3TagCount ?? null)}
+
+
+
+
+
+
+ Safety boundary
+ Hard-coded
+
+
+ - No direct 4096 browser egress
+ - No chat, agent, terminal, git, or file actions
+ - No provider setup, auth, cookies, or embedded credentials
+ - No POST, PUT, PATCH, or DELETE routes
+
+
+
+
+ {errorMessage ? (
+
+ Adapter request failed
+ {errorMessage}
+
+ This page should fail closed and must not fall back to any other
+ endpoint.
+
+
+ ) : null}
+
+ );
+}
+
+export default App;
diff --git a/packages/web-readonly-status/src/adapter/realAdapter.ts b/packages/web-readonly-status/src/adapter/realAdapter.ts
new file mode 100644
index 0000000..967e1b0
--- /dev/null
+++ b/packages/web-readonly-status/src/adapter/realAdapter.ts
@@ -0,0 +1,31 @@
+/**
+ * Web Spike 3P: read-only browser client with one fixed summary endpoint.
+ *
+ * This package must never expand into chat, terminal, git mutation, provider,
+ * filesystem, auth, or direct 4096 access without a separate approved review.
+ */
+export const REAL_ADAPTER_READONLY_MODE = true;
+
+export const REAL_ADAPTER_SUMMARY_URL =
+ "http://192.168.50.202:8790/api/local/v0/summary";
+
+export function assertRealAdapterSummaryUrl(
+ summaryUrl: string = REAL_ADAPTER_SUMMARY_URL,
+): void {
+ const url = new URL(summaryUrl);
+ if (url.protocol !== "http:") {
+ throw new Error(`real adapter refuses non-http protocol: ${url.protocol}`);
+ }
+ if (url.username || url.password) {
+ throw new Error("real adapter refuses embedded credentials");
+ }
+ if (url.hostname !== "192.168.50.202") {
+ throw new Error(`real adapter refuses unexpected host: ${url.hostname}`);
+ }
+ if (url.port !== "8790") {
+ throw new Error(`real adapter refuses unexpected port: ${url.port}`);
+ }
+ if (url.pathname !== "/api/local/v0/summary") {
+ throw new Error(`real adapter refuses unexpected path: ${url.pathname}`);
+ }
+}
diff --git a/packages/web-readonly-status/src/adapter/realAdapterClient.ts b/packages/web-readonly-status/src/adapter/realAdapterClient.ts
new file mode 100644
index 0000000..63df428
--- /dev/null
+++ b/packages/web-readonly-status/src/adapter/realAdapterClient.ts
@@ -0,0 +1,70 @@
+import {
+ REAL_ADAPTER_READONLY_MODE,
+ REAL_ADAPTER_SUMMARY_URL,
+ assertRealAdapterSummaryUrl,
+} from "./realAdapter";
+
+interface AdapterSummaryResponse {
+ adapter?: {
+ status?: unknown;
+ };
+ opencode?: {
+ reachable?: unknown;
+ status?: unknown;
+ };
+ repo?: {
+ branch?: unknown;
+ head?: unknown;
+ clean?: unknown;
+ stage3TagCount?: unknown;
+ };
+}
+
+export interface RealAdapterSummary {
+ adapterStatus: string;
+ opencodeReachable: boolean;
+ opencodeStatus: number | null;
+ repoBranch: string;
+ repoHead: string;
+ repoClean: boolean | null;
+ repoStage3TagCount: number | null;
+}
+
+function guard(): void {
+ if (!REAL_ADAPTER_READONLY_MODE) {
+ throw new Error("real adapter read-only web app is disabled");
+ }
+ assertRealAdapterSummaryUrl(REAL_ADAPTER_SUMMARY_URL);
+}
+
+function toNumber(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+export async function fetchRealAdapterSummary(): Promise {
+ guard();
+ const response = await fetch(REAL_ADAPTER_SUMMARY_URL, {
+ method: "GET",
+ headers: {
+ Accept: "application/json",
+ },
+ credentials: "omit",
+ });
+
+ if (!response.ok) {
+ throw new Error(`real adapter GET /summary -> ${response.status}`);
+ }
+
+ const data = (await response.json()) as AdapterSummaryResponse;
+ return {
+ adapterStatus:
+ typeof data.adapter?.status === "string" ? data.adapter.status : "unknown",
+ opencodeReachable: data.opencode?.reachable === true,
+ opencodeStatus: toNumber(data.opencode?.status),
+ repoBranch:
+ typeof data.repo?.branch === "string" ? data.repo.branch : "unknown",
+ repoHead: typeof data.repo?.head === "string" ? data.repo.head : "unknown",
+ repoClean: typeof data.repo?.clean === "boolean" ? data.repo.clean : null,
+ repoStage3TagCount: toNumber(data.repo?.stage3TagCount),
+ };
+}
diff --git a/packages/web-readonly-status/src/main.tsx b/packages/web-readonly-status/src/main.tsx
new file mode 100644
index 0000000..3589701
--- /dev/null
+++ b/packages/web-readonly-status/src/main.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import "./styles.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/packages/web-readonly-status/src/styles.css b/packages/web-readonly-status/src/styles.css
new file mode 100644
index 0000000..3bd031e
--- /dev/null
+++ b/packages/web-readonly-status/src/styles.css
@@ -0,0 +1,248 @@
+:root {
+ color-scheme: light;
+ font-family:
+ "IBM Plex Sans",
+ "Segoe UI",
+ sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+ background:
+ radial-gradient(circle at top, rgba(214, 232, 255, 0.9), transparent 42%),
+ linear-gradient(180deg, #f3efe2 0%, #f7f8fc 48%, #e4edf9 100%);
+ color: #123048;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+button,
+code,
+.mono {
+ font-family:
+ "IBM Plex Mono",
+ "Cascadia Code",
+ monospace;
+}
+
+button {
+ border: 0;
+ cursor: pointer;
+}
+
+#root {
+ min-height: 100vh;
+}
+
+.shell {
+ width: min(1080px, calc(100% - 2rem));
+ margin: 0 auto;
+ padding: 2rem 0 3rem;
+}
+
+.hero {
+ padding: 1.5rem;
+ border: 1px solid rgba(18, 48, 72, 0.12);
+ border-radius: 28px;
+ background: rgba(255, 252, 245, 0.86);
+ box-shadow: 0 18px 48px rgba(33, 74, 109, 0.12);
+ backdrop-filter: blur(10px);
+}
+
+.eyebrow {
+ margin: 0 0 0.5rem;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: #8e4c2e;
+}
+
+.hero h1 {
+ margin: 0;
+ font-size: clamp(2rem, 7vw, 4.4rem);
+ line-height: 0.96;
+ letter-spacing: -0.04em;
+}
+
+.lede {
+ max-width: 44rem;
+ margin: 1rem 0 0;
+ font-size: 1.05rem;
+ color: #33536d;
+}
+
+.boundary {
+ display: grid;
+ gap: 0.5rem;
+ margin-top: 1.25rem;
+ padding: 1rem;
+ border-radius: 18px;
+ background: rgba(215, 229, 244, 0.8);
+}
+
+.boundary-label,
+.subtle,
+.timestamp,
+dt {
+ font-size: 0.85rem;
+ color: #5a7388;
+}
+
+.boundary code {
+ display: inline-block;
+ overflow-wrap: anywhere;
+ font-size: 0.92rem;
+ color: #113250;
+}
+
+.toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.85rem 1rem;
+ align-items: center;
+ margin-top: 1.25rem;
+}
+
+.refreshButton {
+ padding: 0.9rem 1.2rem;
+ border-radius: 999px;
+ background: linear-gradient(135deg, #0a5d94 0%, #123048 100%);
+ color: #f8fbff;
+ font-size: 0.95rem;
+ font-weight: 700;
+ box-shadow: 0 10px 24px rgba(10, 93, 148, 0.24);
+}
+
+.refreshButton:disabled {
+ cursor: wait;
+ opacity: 0.72;
+}
+
+.grid {
+ display: grid;
+ gap: 1rem;
+ margin-top: 1rem;
+}
+
+.card {
+ padding: 1.25rem;
+ border: 1px solid rgba(18, 48, 72, 0.12);
+ border-radius: 24px;
+ background: rgba(255, 255, 255, 0.84);
+ box-shadow: 0 14px 32px rgba(18, 48, 72, 0.1);
+}
+
+.card header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.card h2,
+.errorPanel h2 {
+ margin: 0;
+ font-size: 1.2rem;
+}
+
+.accent {
+ background:
+ linear-gradient(180deg, rgba(213, 236, 255, 0.92), rgba(255, 255, 255, 0.92));
+}
+
+.warning {
+ background:
+ linear-gradient(180deg, rgba(255, 241, 220, 0.92), rgba(255, 255, 255, 0.92));
+}
+
+.pill {
+ padding: 0.3rem 0.7rem;
+ border-radius: 999px;
+ font-size: 0.76rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.pill-idle,
+.pill-loading {
+ background: #fff0c4;
+ color: #8a5b00;
+}
+
+.pill-ready {
+ background: #d8f1db;
+ color: #1e6d34;
+}
+
+.pill-error {
+ background: #ffd8d5;
+ color: #9b1f19;
+}
+
+dl {
+ display: grid;
+ gap: 0.9rem;
+ margin: 0;
+}
+
+dt {
+ margin-bottom: 0.2rem;
+}
+
+dd {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+
+.guardList {
+ margin: 0;
+ padding-left: 1.2rem;
+ color: #25445b;
+}
+
+.guardList li + li {
+ margin-top: 0.6rem;
+}
+
+.errorPanel {
+ margin-top: 1rem;
+ padding: 1.25rem;
+ border: 1px solid rgba(155, 31, 25, 0.18);
+ border-radius: 24px;
+ background: rgba(255, 241, 241, 0.9);
+ color: #7f241c;
+}
+
+.errorPanel p {
+ margin-bottom: 0;
+}
+
+@media (min-width: 760px) {
+ .shell {
+ width: min(1180px, calc(100% - 3rem));
+ padding-top: 3rem;
+ }
+
+ .hero {
+ padding: 2rem;
+ }
+
+ .grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ align-items: start;
+ }
+}
diff --git a/packages/web-readonly-status/tsconfig.json b/packages/web-readonly-status/tsconfig.json
new file mode 100644
index 0000000..1f66ada
--- /dev/null
+++ b/packages/web-readonly-status/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "moduleDetection": "force",
+ "verbatimModuleSyntax": true,
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/packages/web-readonly-status/vite.config.ts b/packages/web-readonly-status/vite.config.ts
new file mode 100644
index 0000000..ffa1700
--- /dev/null
+++ b/packages/web-readonly-status/vite.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ css: {
+ postcss: {
+ plugins: [],
+ },
+ },
+});