)}
- {msgs.map((m) => (
+ {renderedReplies.map((m) => (
{displayAuthor(m, authorCtx)}
{m.content}
diff --git a/desktop/src/apps/chat/__tests__/render-helpers.test.tsx b/desktop/src/apps/chat/__tests__/render-helpers.test.tsx
new file mode 100644
index 000000000..5fe9d1b75
--- /dev/null
+++ b/desktop/src/apps/chat/__tests__/render-helpers.test.tsx
@@ -0,0 +1,76 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { renderContent, dayLabel } from "../../MessagesApp";
+
+describe("renderContent", () => {
+ it("passes plain text through", () => {
+ const { container } = render(
{renderContent("hello world")}
);
+ expect(container.textContent).toContain("hello world");
+ });
+
+ it("renders markdown bold, italic, and inline code", () => {
+ const { container } = render(
{renderContent("a **bold** b *italic* c `code`")}
);
+ expect(container.querySelector("strong")?.textContent).toBe("bold");
+ expect(container.querySelector("em")?.textContent).toBe("italic");
+ expect(container.querySelector("code")?.textContent).toBe("code");
+ });
+
+ it("renders a fenced block as a CodeBlock with a copy button", () => {
+ const text = "before\n```\nconst x = 1;\n```\nafter";
+ const { container } = render(
{renderContent(text)}
);
+ expect(container.textContent).toContain("const x = 1;");
+ expect(screen.getByRole("button", { name: /copy/i })).toBeInTheDocument();
+ });
+
+ it("preserves text on both sides of a fence in order", () => {
+ const text = "INTRO\n```\ncodeword\n```\nOUTRO";
+ const { container } = render(
{renderContent(text)}
);
+ const t = container.textContent || "";
+ expect(t.indexOf("INTRO")).toBeGreaterThanOrEqual(0);
+ expect(t.indexOf("codeword")).toBeGreaterThan(t.indexOf("INTRO"));
+ expect(t.indexOf("OUTRO")).toBeGreaterThan(t.indexOf("codeword"));
+ });
+
+ it("renders two fenced blocks as two copy buttons", () => {
+ const text = "```\nalpha\n```\nmid\n```\nbeta\n```";
+ render(
{renderContent(text)}
);
+ expect(screen.getAllByRole("button", { name: /copy/i })).toHaveLength(2);
+ });
+
+ it("does not emit React duplicate-key warnings", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ const text = "a *one* b *two* c *three*\n```\ncode\n```\nd *four* e *five* f *six*";
+ render(
{renderContent(text)}
);
+ const keyWarned = spy.mock.calls.some((args) =>
+ args.some((a) => typeof a === "string" && a.includes("key")),
+ );
+ expect(keyWarned).toBe(false);
+ spy.mockRestore();
+ });
+
+ it("renders markdown lists and external links (react-markdown)", () => {
+ const { container: list } = render(
{renderContent("- one\n- two\n- three")}
);
+ expect(list.querySelectorAll("li").length).toBeGreaterThanOrEqual(3);
+
+ const { container: link } = render(
{renderContent("see [the site](https://example.com)")}
);
+ const a = link.querySelector("a");
+ expect(a?.getAttribute("href")).toBe("https://example.com");
+ expect(a?.getAttribute("target")).toBe("_blank");
+ });
+});
+
+describe("dayLabel", () => {
+ afterEach(() => vi.useRealTimers());
+
+ it("labels Today, Yesterday, and older dates", () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-06-13T12:00:00"));
+ const today = new Date("2026-06-13T08:00:00").getTime();
+ const yesterday = new Date("2026-06-12T08:00:00").getTime();
+ const older = new Date("2026-06-01T08:00:00").getTime();
+ expect(dayLabel(today)).toBe("Today");
+ expect(dayLabel(yesterday)).toBe("Yesterday");
+ expect(dayLabel(older)).not.toBe("Today");
+ expect(dayLabel(older)).not.toBe("Yesterday");
+ });
+});
diff --git a/desktop/src/apps/chat/useChatNotifications.ts b/desktop/src/apps/chat/useChatNotifications.ts
new file mode 100644
index 000000000..356b66020
--- /dev/null
+++ b/desktop/src/apps/chat/useChatNotifications.ts
@@ -0,0 +1,30 @@
+import { useCallback, useRef } from "react";
+
+/**
+ * Browser notifications for messages in channels the user is not viewing.
+ * Permission is requested lazily on the first notify, never on load. No
+ * notification fires while the window is focused (the in-app UI is enough).
+ */
+export function useChatNotifications() {
+ const asked = useRef(false);
+
+ const ensurePermission = useCallback(async (): Promise
=> {
+ if (typeof Notification === "undefined") return false;
+ if (Notification.permission === "granted") return true;
+ if (Notification.permission === "denied" || asked.current) return false;
+ asked.current = true;
+ try { return (await Notification.requestPermission()) === "granted"; }
+ catch { return false; }
+ }, []);
+
+ const notify = useCallback(async (title: string, body: string, onClick: () => void) => {
+ if (!(await ensurePermission())) return;
+ if (document.hasFocus()) return; // window focused: in-app UI is enough
+ try {
+ const n = new Notification(title, { body: body.slice(0, 140) });
+ n.onclick = () => { window.focus(); onClick(); n.close(); };
+ } catch { /* notification constructor can throw on some platforms */ }
+ }, [ensurePermission]);
+
+ return { notify, ensurePermission };
+}
diff --git a/desktop/src/components/Desktop.tsx b/desktop/src/components/Desktop.tsx
index 4055ea548..76f51f8ba 100644
--- a/desktop/src/components/Desktop.tsx
+++ b/desktop/src/components/Desktop.tsx
@@ -4,6 +4,7 @@ import { useProcessStore } from "@/stores/process-store";
import { useThemeStore } from "@/stores/theme-store";
import { useWidgetStore } from "@/stores/widget-store";
import { useSnapZones } from "@/hooks/use-snap-zones";
+import { useDeepNavigation } from "@/hooks/use-deep-navigation";
import { getApp } from "@/registry/app-registry";
import { Window } from "./Window";
import { SnapOverlay } from "./SnapOverlay";
@@ -49,6 +50,10 @@ export function Desktop() {
if (app) openWindow(appId, app.defaultSize);
}, [openWindow]);
+ // Deep-navigation API: `?app=` URL param on load + `taos:open-app` event at
+ // runtime (lets the taOS agent drive the desktop). See the hook for details.
+ useDeepNavigation(openWindow);
+
const menuItems: MenuItem[] = [
{
label: "New Folder",
diff --git a/desktop/src/components/__tests__/CodeBlock.test.tsx b/desktop/src/components/__tests__/CodeBlock.test.tsx
new file mode 100644
index 000000000..127e796e0
--- /dev/null
+++ b/desktop/src/components/__tests__/CodeBlock.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { CodeBlock } from "../CodeBlock";
+
+describe("CodeBlock", () => {
+ let writeText: ReturnType;
+
+ beforeEach(() => {
+ writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText },
+ configurable: true,
+ writable: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("renders multi-line code exactly, preserving newlines", () => {
+ const code = "line one\nline two\nline three";
+ const { container } = render();
+ const pre = container.querySelector("pre");
+ expect(pre?.textContent).toBe(code);
+ });
+
+ it("has a copy button with an accessible name matching /copy/i", () => {
+ render();
+ expect(screen.getByRole("button", { name: /copy/i })).toBeInTheDocument();
+ });
+
+ it("copies the exact code string on click", async () => {
+ const code = "const a = 1;\nconst b = 2;";
+ render();
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /copy/i }));
+ });
+ expect(writeText).toHaveBeenCalledTimes(1);
+ expect(writeText).toHaveBeenCalledWith(code);
+ });
+
+ it("flips the label to copied then reverts after the timeout", async () => {
+ vi.useFakeTimers();
+ try {
+ render();
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: /copy/i }));
+ });
+ expect(screen.getByRole("button", { name: /copied/i })).toBeInTheDocument();
+ act(() => {
+ vi.advanceTimersByTime(1600);
+ });
+ // aria-label reverts to "Copy code" (which does not match /copied/i)
+ expect(screen.getByRole("button", { name: /copy code/i })).toBeInTheDocument();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("renders long single-line code without throwing, inside an overflow container", () => {
+ const code = "x".repeat(500);
+ const { container } = render();
+ expect(container.querySelector(".overflow-x-auto")).not.toBeNull();
+ expect(container.querySelector("pre")?.textContent).toBe(code);
+ });
+
+ it("renders empty code without crashing", () => {
+ const { container } = render();
+ expect(container.querySelector("pre")).not.toBeNull();
+ });
+});
diff --git a/desktop/src/hooks/use-deep-navigation.test.ts b/desktop/src/hooks/use-deep-navigation.test.ts
new file mode 100644
index 000000000..d6cb21641
--- /dev/null
+++ b/desktop/src/hooks/use-deep-navigation.test.ts
@@ -0,0 +1,76 @@
+import { renderHook } from "@testing-library/react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { useDeepNavigation } from "./use-deep-navigation";
+
+function withSearch(search: string) {
+ // jsdom honours history.pushState; URLSearchParams reads window.location.search.
+ window.history.pushState({}, "", "/desktop" + search);
+}
+
+afterEach(() => {
+ window.history.pushState({}, "", "/desktop");
+ vi.restoreAllMocks();
+});
+
+describe("useDeepNavigation", () => {
+ it("opens the app named by ?app= on mount (resolving alias)", () => {
+ const openWindow = vi.fn();
+ withSearch("?app=activity");
+ renderHook(() => useDeepNavigation(openWindow));
+ expect(openWindow).toHaveBeenCalledTimes(1);
+ expect(openWindow.mock.calls[0][0]).toBe("dashboard");
+ });
+
+ it("opens several comma-separated apps", () => {
+ const openWindow = vi.fn();
+ withSearch("?app=messages,settings");
+ renderHook(() => useDeepNavigation(openWindow));
+ const ids = openWindow.mock.calls.map((c) => c[0]);
+ expect(ids).toEqual(["messages", "settings"]);
+ });
+
+ it("passes parsed appProps through to openWindow", () => {
+ const openWindow = vi.fn();
+ withSearch("?app=messages&appProps=" + encodeURIComponent('{"channel":"general"}'));
+ renderHook(() => useDeepNavigation(openWindow));
+ expect(openWindow).toHaveBeenCalledTimes(1);
+ expect(openWindow.mock.calls[0][2]).toEqual({ channel: "general" });
+ });
+
+ it("opens the app without props when appProps is malformed", () => {
+ const openWindow = vi.fn();
+ withSearch("?app=messages&appProps=not-json");
+ renderHook(() => useDeepNavigation(openWindow));
+ expect(openWindow).toHaveBeenCalledTimes(1);
+ expect(openWindow.mock.calls[0][2]).toBeUndefined();
+ });
+
+ it("does nothing for an unknown app token", () => {
+ const openWindow = vi.fn();
+ withSearch("?app=does-not-exist");
+ renderHook(() => useDeepNavigation(openWindow));
+ expect(openWindow).not.toHaveBeenCalled();
+ });
+
+ it("opens an app from a taos:open-app event while mounted", () => {
+ const openWindow = vi.fn();
+ withSearch("");
+ renderHook(() => useDeepNavigation(openWindow));
+ expect(openWindow).not.toHaveBeenCalled();
+ window.dispatchEvent(
+ new CustomEvent("taos:open-app", { detail: { app: "settings", props: { tab: "about" } } }),
+ );
+ expect(openWindow).toHaveBeenCalledTimes(1);
+ expect(openWindow.mock.calls[0][0]).toBe("settings");
+ expect(openWindow.mock.calls[0][2]).toEqual({ tab: "about" });
+ });
+
+ it("removes the event listener on unmount", () => {
+ const openWindow = vi.fn();
+ withSearch("");
+ const { unmount } = renderHook(() => useDeepNavigation(openWindow));
+ unmount();
+ window.dispatchEvent(new CustomEvent("taos:open-app", { detail: { app: "settings" } }));
+ expect(openWindow).not.toHaveBeenCalled();
+ });
+});
diff --git a/desktop/src/hooks/use-deep-navigation.ts b/desktop/src/hooks/use-deep-navigation.ts
new file mode 100644
index 000000000..3606fe3eb
--- /dev/null
+++ b/desktop/src/hooks/use-deep-navigation.ts
@@ -0,0 +1,56 @@
+import { useEffect } from "react";
+import { resolveApp } from "@/registry/app-registry";
+
+type OpenWindow = (
+ appId: string,
+ defaultSize: { w: number; h: number },
+ props?: Record,
+) => string;
+
+/**
+ * Deep-navigation API for the desktop.
+ *
+ * Opens apps from a `?app=` URL param on load (handy for tests, screenshots,
+ * and shareable links) and from a `taos:open-app` CustomEvent at runtime, so
+ * the taOS agent can drive the desktop for the user without a reload.
+ *
+ * A token may be an app id, exact name, or alias ("activity" -> dashboard);
+ * pass several comma-separated. Optional props deep-link into an app (e.g. a
+ * Messages channel) via `?appProps=` or the event detail.
+ * Singleton apps are focused and re-receive props rather than duplicated
+ * (handled by the process store's openWindow).
+ */
+export function useDeepNavigation(openWindow: OpenWindow): void {
+ useEffect(() => {
+ const openByToken = (token: string, props?: Record) => {
+ const app = resolveApp(token);
+ if (app) openWindow(app.id, app.defaultSize, props);
+ };
+
+ const params = new URLSearchParams(window.location.search);
+ const requested = params.get("app");
+ if (requested) {
+ let props: Record | undefined;
+ const rawProps = params.get("appProps");
+ if (rawProps) {
+ try {
+ props = JSON.parse(rawProps);
+ } catch {
+ /* malformed props: open the app without them */
+ }
+ }
+ for (const token of requested.split(",")) {
+ if (token.trim()) openByToken(token, props);
+ }
+ }
+
+ const onOpenApp = (e: Event) => {
+ const detail = (e as CustomEvent).detail as
+ | { app?: string; props?: Record }
+ | undefined;
+ if (detail?.app) openByToken(detail.app, detail.props);
+ };
+ window.addEventListener("taos:open-app", onOpenApp);
+ return () => window.removeEventListener("taos:open-app", onOpenApp);
+ }, [openWindow]);
+}
diff --git a/desktop/src/registry/app-registry.test.ts b/desktop/src/registry/app-registry.test.ts
index 102740994..29e25da94 100644
--- a/desktop/src/registry/app-registry.test.ts
+++ b/desktop/src/registry/app-registry.test.ts
@@ -1,5 +1,32 @@
import { describe, it, expect, vi } from "vitest";
-import { getOrRegisterServiceApp, prefetchApp } from "./app-registry";
+import { getOrRegisterServiceApp, prefetchApp, resolveApp } from "./app-registry";
+
+describe("resolveApp (deep-navigation token resolver)", () => {
+ it("resolves an exact app id", () => {
+ expect(resolveApp("messages")?.id).toBe("messages");
+ });
+
+ it("resolves a case-insensitive app name", () => {
+ // The Activity app's id is "dashboard"; its name is "Activity".
+ expect(resolveApp("Activity")?.id).toBe("dashboard");
+ expect(resolveApp("activity")?.id).toBe("dashboard");
+ });
+
+ it("resolves friendly aliases", () => {
+ expect(resolveApp("monitor")?.id).toBe("dashboard");
+ expect(resolveApp("chat")?.id).toBe("messages");
+ });
+
+ it("trims and lowercases the token", () => {
+ expect(resolveApp(" SETTINGS ")?.id).toBe("settings");
+ });
+
+ it("returns undefined for unknown or empty tokens", () => {
+ expect(resolveApp("does-not-exist")).toBeUndefined();
+ expect(resolveApp("")).toBeUndefined();
+ expect(resolveApp(" ")).toBeUndefined();
+ });
+});
describe("prefetchApp", () => {
it("invokes the lazy component thunk once per app (memoized)", () => {
diff --git a/desktop/src/registry/app-registry.ts b/desktop/src/registry/app-registry.ts
index 1478f3490..2c3dc1cbf 100644
--- a/desktop/src/registry/app-registry.ts
+++ b/desktop/src/registry/app-registry.ts
@@ -63,6 +63,37 @@ export function getApp(id: string): AppManifest | undefined {
return apps.find((a) => a.id === id);
}
+/** Friendly synonyms β canonical app id, for tokens that are neither an id
+ * nor an exact app name. Lets deep links and the agent say "monitor" or
+ * "chat" instead of "dashboard" / "messages". */
+const APP_ALIASES: Record = {
+ activity: "dashboard",
+ "activity-monitor": "dashboard",
+ monitor: "dashboard",
+ chat: "messages",
+ assistant: "taos-agent",
+ agent: "taos-agent",
+};
+
+/**
+ * Resolve a user- or agent-supplied app token to a registered manifest.
+ * Matches in order: exact id, alias, then case-insensitive name. Powers the
+ * deep-navigation API (`?app=` and the `taos:open-app` event) so callers can
+ * use friendly names ("activity", "Activity", "monitor") instead of ids.
+ */
+export function resolveApp(token: string): AppManifest | undefined {
+ const key = token.trim().toLowerCase();
+ if (!key) return undefined;
+ const byId = apps.find((a) => a.id.toLowerCase() === key);
+ if (byId) return byId;
+ const aliasId = APP_ALIASES[key];
+ if (aliasId) {
+ const byAlias = apps.find((a) => a.id === aliasId);
+ if (byAlias) return byAlias;
+ }
+ return apps.find((a) => a.name.toLowerCase() === key);
+}
+
export function getAppsByCategory(category: AppManifest["category"]): AppManifest[] {
return apps.filter((a) => a.category === category);
}
diff --git a/docs/AGENT_HANDOFF.md b/docs/AGENT_HANDOFF.md
deleted file mode 100644
index 4ef63e0e6..000000000
--- a/docs/AGENT_HANDOFF.md
+++ /dev/null
@@ -1,95 +0,0 @@
-# Agent Handoff Playbook
-
-**Why this exists.** Work on taOS runs across rate-limit-prone agents on different platforms (Claude Code, Cursor, Codex, web, etc.). When one hits a limit, another picks up. The failure mode to prevent: an incoming agent acting on **stale knowledge**: re-doing finished work, missing in-flight tasks, or clobbering a branch. This playbook + `STATUS.md` + GitHub issues make the project's state **durable and platform-independent** so a handoff never loses work.
-
-The golden rule: **durable state lives in three committed/hosted places, not in any one agent's memory**: (1) GitHub issues, (2) `docs/STATUS.md`, (3) the A2A bus. If it isn't in one of those, the next agent can't see it.
-
----
-
-## Bootstrap (paste this into a fresh agent, or just tell it "read docs/AGENT_HANDOFF.md")
-
-> You are taking over @taOS work on the taOS repo (`~/Development/tinyagentos`, GitHub `jaylfc/taOS`). Another agent was driving and may have hit a rate limit. Orient yourself from the durable state before doing anything; do not trust assumptions:
->
-> 1. Read `docs/STATUS.md`: current branch tips, open PRs, in-flight work, blockers.
-> 2. Read the rest of this file (`docs/AGENT_HANDOFF.md`): rules, identity, hop protocol. Follow it exactly.
-> 3. `git fetch origin && git log origin/master..origin/dev --oneline`: what is on dev not yet promoted.
-> 4. `gh issue list --state open --limit 40` (canonical task list) and `gh pr list --state open`.
-> 5. A2A bus, live coordination: tail `taos-progress`, `general`, `observability`, `integration` (Pi is `jay@192.168.6.123`, key auth, bus on :7900).
-> 6. (Claude Code only) read `~/.claude/projects/-*-tinyagentos/memory/MEMORY.md`.
->
-> Then take the top unblocked GitHub issue or continue what STATUS.md flags as in flight. You ARE @taOS on the bus: post as `@taOS`, and drop a status line in `taos-progress` when you start, finish, or learn something. Follow the Identity rules below to the letter.
-
----
-
-## On arrival: orient before you act (5 steps, ~2 min)
-
-Run these before touching anything:
-
-1. **Read `docs/STATUS.md`** (repo root β docs/). Current branch tips, open PRs, in-flight work, blockers.
-2. **`git fetch origin && git log origin/master..origin/dev --oneline`**: what's on dev not yet promoted.
-3. **`gh issue list --state open --limit 40`**: the canonical task list. `gh pr list --state open`: what's mid-review.
-4. **A2A bus tail** (live coordination): `curl -s "http://:7900/a2a/messages?thread=general&limit=15"` (also observability, integration). The Pi IP is in your private notes, not committed here.
-5. **(Claude Code only) `~/.claude/.../memory/MEMORY.md`**: durable context index. Other platforms: skip; everything you need is in 1-4.
-
-Only after those: pick the top unblocked GitHub issue, or continue what `STATUS.md` says is in flight.
-
----
-
-## Identity & non-negotiable rules
-
-- **You are @taOS on the A2A bus.** Adopt that handle for every post (`"from": "@taOS"`). You speak as the taOS controller agent, coordinating with @taOSmd (memory/bench) and @hermes (framework agent).
-- **Git identity:** `user.name=jaylfc`, `user.email=jaylfc25@gmail.com`. ALL activity appears as jaylfc.
-- **No AI attribution** anywhere: commits, PR bodies, issue comments, A2A. No "Co-Authored-By: Claude", no "Generated with...". Public repos and the bus must read as fully human-authored.
-- **Voice (anything posted publicly as Jay: commits, PRs, issues, A2A, docs, web copy): NO em dashes, ever.** Use commas, colons, parentheses, or two sentences instead. Strip the usual AI tells (no "it's not just X, it's Y", no "delve", no breathless hedging). For user-facing prose (release notes, web copy, replies), run it through the `content-humanizer` skill before posting. Keep internal terse-but-human.
-- **Design:** any taOS or taOSmd dashboard / inspector / web UI work uses the `frontend-design` (impeccable) skill, kept offline / no-CDN friendly.
-- **No secrets in git:** no IPs, tokens, credentials, Tailscale IPs, env-specific config. The Pi IP and bus URL stay out of committed files (they live in your private notes / this is why the bootstrap names them in chat, not in tracked code).
-- **Branch policy:** small fixes go straight to `dev`. Features/refactors/redesigns get a branch + PR to `dev`. `master` is **protected**: promote only via a `dev`->`master` PR (squash). Protected-master merge needs a `ghp_` PAT or the GitHub UI button (the gh OAuth token 401s on that endpoint). **NEVER `--delete-branch` on a dev->master PR** (deleting `dev` auto-closes every open PR that targets it).
-- **Verify before claiming done:** run the tests/commands, paste real output. Evidence before assertions.
-
----
-
-## When YOU get rate-limited: hand off cleanly (do this the moment you see the limit warning, if you still can)
-
-1. **Commit or stash WIP** on a branch (never leave uncommitted work that only your session knows about). Push it.
-2. **Update `docs/STATUS.md`**: move your task to "In flight" with the branch name + exactly where you stopped + the next concrete step.
-3. **Post one A2A note** as your handle: what you finished, what's mid-flight, the branch, the next step.
-4. **(Claude Code) update memory** if a durable fact changed.
-
-If the limit hits before you can do this, the incoming agent recovers from: last pushed commit + open PR + `STATUS.md` + issues. That's why you push early and often.
-
----
-
-## The freshness cron (keeps the durable layer honest)
-
-An hourly sweep (session-scoped on the active agent; the Pi's :00/:30 cron is the durable backstop) re-checks README / docs / memory / `STATUS.md` against merged commits and fixes trivial drift, opens PRs for bigger rewrites. If you are the active driver, keep it armed. Its job is to ensure steps 1-5 above never read stale.
-
----
-
-## Task hygiene: so nothing is lost
-
-- **Every feature idea, bug, or TODO β a GitHub issue immediately.** Ideas in chat or memory evaporate across a handoff; issues don't. Label them (`feature`, `bug`, `security`, `docs`, `infra`).
-- **One issue = one pickup-able unit** with enough context that a cold agent can start it.
-- `STATUS.md` links to issues; it does not duplicate them.
-
----
-
-## A2A channels (use them; they feed the project memory)
-
-The taosmd-hosted bus ingests messages into the project memory store, so posting there is also how progress becomes durable, searchable context.
-
-- **`taos-progress`** (post here often): @taOS status updates, lessons learned, decisions, "starting X / finished Y / gotcha Z". One line when you start a task, one when you finish, one for anything non-obvious you learned. This is the running log that survives handoffs and lands in memory.
-- **`general`**: cross-agent coordination and @mentions with @taOSmd / @hermes.
-- **`observability`**: memory/bench/observability contract talk with @taOSmd.
-- **`integration`**: cross-repo integration design.
-- @taOSmd keeps its own **`taosmd-progress`** channel for the same purpose on its side.
-
-## The durable stores at a glance
-
-| Store | Scope | Visible to | Use for |
-|-------|-------|-----------|---------|
-| GitHub issues | canonical task list | every platform | backlog, features, bugs, audit findings |
-| `docs/STATUS.md` | current snapshot | every platform (in repo) | "where are we right now" |
-| `docs/AGENT_HANDOFF.md` | the rules + protocol | every platform (in repo) | onboarding, identity, hop protocol |
-| A2A `taos-progress` | running progress log | bus agents + project memory | status, lessons, decisions (feeds memory) |
-| A2A bus (:7900) | live coordination | the bus agents | real-time @mentions, decisions |
-| @taOS Pi memory | durable context | Claude Code only | per-session continuity for CC |
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 9a67b7b6a..b702f0a65 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -1,82 +1,42 @@
-
+SINGLE SOURCE OF TRUTH for cross-agent handoff.
+Last updated: 2026-06-13, @taOS (freshness sweep).
-# taOS: Live Status
+Branch tips: master=99cf786e. dev=355bb5ef (30 ahead of master; do NOT promote dev->master, Jay 2026-06-13: everything to dev only). On dev: Messages train, activity fix, deep-nav API, agent jobs 8/12/16/17, xdist CI fix (#839).
-**Last updated:** 2026-06-12 ~16:30 BST, by @taOS (Mac session). PR TRAIN IN FLIGHT (sequencing matters):
-- #817 (persistent install id in the update ping) MUST land on dev BEFORE #813 promotes, else the taos.my counter counts nothing. #813 head=dev so merging #817 folds in automatically; then #813 promotes the install counter to master.
-- #816 (taos agent self-heal: opencode-born-before-LiteLLM race + silent-empty-stream guard; found live on the Pi today) -> dev.
-- #812 (copy/select agent text everywhere, review-fixed) -> dev.
-- Agent manual is being restructured into a compiled category library (docs/agent-manual/ + scripts/build-agent-manual.py + CI guard); separate PR. Strong taOS identity, facts table, weak-model answer templates. Rule (memory): any agent-affecting work needs a manual update/audit.
-- taos.my site + forever-id install counter (one row per random install uuid, /api/v1/stats public) pushed to private repo jaylfc/taos-website; Jay deploys via Coolify (compose, /data volume).
-- #815 filed: My Apps (private persistent user-app area + manager). Hard rules added: user apps NEVER touch GitHub/external until the user shares to the store; share pipeline gets a secrets+PII safety gate before listing.
-- #744 CLOSED (3/3 grants+revocation e2e + earlier 4/4; caught a real taOSmd auth bypass). GitHub Discussions enabled + welcome post (discussions/814) + site Community links.
+Session state: ACTIVE. A2A poll monitor ARMED. Freshness cron :08/:38 ARMED (now incl. 0f CodeRabbit retrigger). 5h resume-pair ARMED (primary 15:53, retry 16:12 local; session-scoped). 5h usage 78%, weekly 8%. Usage policy: push until ~90% (98% max), don't stop at 70; crons monitor-only past 90.
+NOTE: a parallel session's freshness cron keeps reverting this file to a stale dev tip; if you see an old tip + "usage 47%", it is that churn, re-sync to the real dev tip.
+WEBSITE: all 4 PRs merged to taos-website main (stats/changelog/nav/accessibility); Coolify redeploys taos.my.
+CI: test suite parallelized via #839 (xdist -n auto), ~22 min -> ~13 min. CodeRabbit out of org credits -> rate-limits; freshness 0f retriggers oldest unreviewed PR with "@coderabbitai full review", never merge on a fake pass.
+OPEN: ALL 26 agent jobs DONE. #838 = complete Messages-polish batch (jobs 24/21/22/23/26/10/19/18/13/9); #842 = agent manual templates (job 14). Both: CI+Kilo will pass; BLOCKED on a real CodeRabbit review (org credits exhausted) before merge to dev. jobs 8/12/16/17 + website (11/15/20/25 on taos-website main) already landed.
-**MORNING WRAP, all on master (tip 25f10402):**
-- **#795 CLOSED, port hygiene fully shipped:** rkllama 8080->7833 (#802/#803, promoted via #804) AND LiteLLM host port 4000->7834 (#805, promoted via #806). Container side stays 4000 via the proxy device so deployed agents never change; existing installs AUTO-PIN to their old ports on first boot (config litellm_port pin, verified the hole on the live Pi before it shipped); 783x block (7832 qmd, 7833 rkllama, 7834 LiteLLM) + 4000 + 8080 all in RESERVED_PORTS; breakage-log entries for both moves.
-- **#744 e2e VERIFIED 4/4 by taOSmd** (msg 383, recorded on the issue): verified-claim project binding + body anti-spoof + signature rejection + global behavior, real tokens, isolated serve. OPEN DECISION FOR JAY: taOSmd wants an admin/revoked-feed token to e2e the grants+revocation layer; options on the bus (msg 387): short-lived scoped read-only feed token (small build) vs supervised joint session. Core contract is verified regardless.
-- dev == master. Open PRs: only draft #476. Still waiting: @hermes search keys (task #8, msg 379, no reply yet).
+Done (since last STATUS.md update, 2026-06-13):
+- Messages-polish train (jobs 1-7) ALL on dev via #826/#829/#830 direct + #833 integration (#827/#828/#831/#832); sub-PRs closed superseded, branches deleted.
+- #783 VERIFIED FIXED on Pi: qwen 2.5 3b + 7b instruct rkllm pull to 100%, load, infer on NPU. CAVEAT: tested rkllama directly, NOT the store-UI /api/store install route.
+- fix(activity): dedupe local node in scheduler + detect ARM SoC (RK3588) for CPU label (c55f9292, 4 tests). Pi shows it after next deploy (hardware profile cached).
+- feat(desktop): deep-navigation API (?app= url + taos:open-app event), extracted to tested useDeepNavigation hook (14 tests). Tracked #836.
+- Agent jobs done direct-to-dev: 8 Cmd+K switcher, 12 theme inventory (#837 merged), 16 CodeBlock tests, 17 update-ping toggle.
+- Ideas filed: #796 benchmark pause/resume, #797 native phone, #798 native desktop shared-API, #799 TUI, #834 edit-before-send, #835 copy agent text, #836 deep-nav agent tool.
+- Untracked docs/AGENT_HANDOFF.md (was committed before .gitignore; exposed Pi LAN IP). Restored from memory backup after a branch-switch deleted the working copy.
-**POST-RESET BATCH (04:20-06:00 BST), all landed on dev:**
-- **#795 first half DONE: rkllama default port 8080 -> 7833** (#802 + verification follow-up #803, both merged to dev): installer default, ~10 controller fallbacks, docs, breakage-log entry, `default_rkllama_url()` legacy probe (7833 first, 8080 fallback with update hint, VERIFIED LIVE on the Pi where rkllama still runs on 8080), and the rknpu install verification now probes 7833-then-8080 so fresh installs do not fail their own check (bot-review catch). Second half (LiteLLM off 4000) still open on #795. NOT yet promoted to master: promote #802+#803 together when convenient.
-- **#744 e2e tokens DELIVERED to @taOSmd** (integration msg 378): minted via the real consent flow on the Pi (now on master 58de0d0e), bound token verifiably carries project_id, global omits it; file at /home/jay/.taos-744-e2e-tokens.json (600). First mint silently lacked the claim because the Pi was on a pre-#790 bundle: tokens minted on stale code look fine but are claim-less, worth remembering.
-- **Jay's 6 idea issues FILED: #796-#801** (bench pause/resume + worker lifecycle; Nothing Phone Ubuntu Touch node; native desktop API parity; tuiui TUI client; message editing + re-trigger; copy/select agent text).
-- **Branch cleanup COMPLETE:** repo is down to 9 branches; keepers = master, dev, cla-signatures, design/trust-comms-layer, 2 draft-PR branches (#450/#476), and 3 holding unmerged work for Jay to triage: feat/browser-cdp-driver (3 commits), feat/codebase-indexing (spec), feat/registry-governance (spec), fix/concurrency-idempotency (17 commits, possibly stale).
-- **Hourly repo watch live** (playbook item 9, ~/.taos-repo-watch/poll.sh, QUIET-mode). **Resume-pair protocol upgraded to arm-at-start** (both sides; taOSmd mirrored, msg on general); proven on the 03:20 reset.
-- Waiting on externals: @hermes search keys (task #8, bus msg 379), @taOSmd #744 e2e verification result.
+OPEN PRs (all need: merge on green CI + Kilo + my review; CodeRabbit is out of org credits so reviews are rate-limited fake-passes):
+- #838 feat(messages): empty states (job 24) on feat/msg-polish-2. Per Jay, BATCH more jobs onto this branch before merging (conserve CodeRabbit reviews). CodeRabbit rate-limited.
+- #839 ci: pytest-xdist -n auto. Investigation found the test job was NOT hanging, just slow (~22 min serial, 4845 tests). This parallelizes it. Validate via its own CI run timing, then merge first so the rest merge fast.
+- taos-website #1 stats / #2 changelog / #3 nav / #4 accessibility. Combined preview served from Mac tailscale :8899 for Jay; merge after Jay approves.
-**OVERNIGHT (after the 00:30 snapshot below):** merged to dev and promoting via #789: #788 (docker shortcut allocated port), #790 (#744 project_id JWT claim + ApproveBody override + grants; taOSmd can now verify with real tokens), #791 (#743 docs drift, closed), #792 (#691 ufw bus port, closed), #793 (#606 model catalog cache, closed), #794 (multi-port allocation probe fix), update breakage log (docs/UPDATE_BREAKAGE_LOG.md + agent-manual pointer), README manifest-failure notice. PI SEARX TEST PASSED: legacy searx (8080) uninstalled, store reinstall landed on pool port 36130 with the /apps/searxng/ launcher URL serving 200, rkllama kept :8080 (Pi runs dev via git bundle because GitHub was unreachable from the Pi; bundle-dev branch). #783 auto-closed by the promotion keyword (HarMaximus has NOT yet confirmed; hourly repo watch will catch his reply). 40 merged branches deleted (~26 done, rest failed on the GitHub outage, retry later). Hourly repo watch cron live (~/.taos-repo-watch/poll.sh, QUIET-mode, re-arm every session, now playbook item 9). Kilo Code Review timed out on EVERY PR tonight (504 "Assistant request timed out"); it is a required check so every merge needed the admin API; decision queued for Jay (make non-required vs drop). GitHub API was badly flaky all night (timeouts from Mac AND Pi); retry loops everywhere.
+Decisions (Jay, 2026-06-13): ALWAYS PR for code review (no direct-to-dev). Batch jobs into fewer big PRs (CodeRabbit credits exhausted). Investigate CI slowness (done: #839). Use impeccable + style skills for design work. Widget epic AFTER the job queue.
-**DONE THIS SESSION (the #783 priority is CLOSED):**
-- #786 install fix (rknpu no longer dies when `strings`/binutils missing) PROMOTED to master via **#787 merged (master tip 25f10402)**.
-- Pi rkllm VERIFIED (sonnet subagent, PASS): rkllama starts on the Orange Pi (RK3588), `/api/pull` reaches HuggingFace and streams a Qwen2.5-3B rkllm download, model loads + infers on the NPU (3 cores, rkllm-runtime 1.2.3). "All connection attempts failed" did NOT reproduce. So #783's error most likely = rkllama not running (the #786 install-died cause); secondary possibility = HF reachability from his board. rkllama server LEFT RUNNING on the Pi :8080.
-- **#783 reply POSTED** to HarMaximus (issue #783, comment 4685905715): explains the `strings` root cause + #786 fix + retry `curl -fsSL https://raw.githubusercontent.com/jaylfc/tinyagentos/master/scripts/install-server.sh | sudo bash`, honest about no Rock 5B + the RK3588 verification, HF-connectivity fallback, welcome. Issue left OPEN pending his retry.
-- **A2A channels migrated + old deleted** (Jay's ask): `observability`->`taOS-taOSmd-observability`, `integration`->`taOS-taOSmd-hermes-integration`. taOSmd ran it, caught + fixed a data-loss bug (commit 6c81afb, history was reverse-aliased not physically moved) before deleting; old names now 404, new names keep all history. archive/delete/wipe principle (delete==archive==safe, wipe==only true-delete) relayed + adopted by taOSmd. All 4 of taOSmd's nudge items (msg 349) answered on the bus. Left one stray probe (#357 "probe ignore") on `general` for taOSmd to sweep.
+Next queue (ordered):
+1. Land #839 (fast CI), then finish remaining agent jobs BATCHED onto feat/msg-polish-2/#838: 9, 10, 13, 14, 18, 19, 21, 22, 23, 26
+2. Bring website PRs #1-4 in after Jay views the preview
+3. Light theme (new separate theme; use impeccable skill; theme engine partial in desktop/src/theme)
+4. Agent-friendly API: #836 agent tool to dispatch taos:open-app (deep-nav already shipped)
+5. Build-widget epic: slim userspace runtime from #476 + My Apps home + agent build tool + share gate
+6. #825 key-scope fix; #737 Phase 3 UI (design with Jay)
-**REMAINING (next session β see GitHub issues + TaskList):**
-1. **#788** (docker app Launchpad shortcut records ALLOCATED host port not container port; regression test, 12 pass) is on dev; Kilo is a 504 flake. Include in next dev->master promotion.
-2. **Pi searx reinstall:** searxng container is STOPPED + restart-disabled (I freed :8080 for rkllama) so SEARX IS CURRENTLY DOWN ON THE PI. After #788 is on the Pi, fully remove legacy searxng + reinstall via taOS store so it lands on a 30000-40000 pool port AND auto-creates a Launchpad shortcut opening searx in the Browser; verify rkllama still on :8080. (Jay updates the Pi manually; the store-API reinstall is the authorized remediation.)
-3. **Kilo 504 (investigated):** kilo-code-bot GitHub App times out (~14.5 min, "Assistant request timed out") on most PRs (#788/#787/#784 failed, #781 passed); it is a REQUIRED check so it forces admin-override merges. Recommend making it NON-REQUIRED in branch protection (Jay/admin) + review keep-vs-drop vs CodeRabbit. TaskList #10.
-4. Idea-issue drafts x6 (TaskList #11). #695 reopened (reserve core ports + migrate legacy apps off reserved ports). Web-search keys from hermes (TaskList #8).
+Pending Jay calls: promote dev->master? enable CodeRabbit add-on (billing) to restore real reviews? store-UI install-path check if model store still errors?
-**GOTCHA THIS SESSION:** api.github.com (GraphQL + REST, IP 20.26.156.210) was intermittently timing out for ~1h while git over github.com worked fine; `gh` calls needed retry loops. taOSmd hit the same outage.
-**Repo:** github.com/jaylfc/taOS, branches `master` (stable) <- `dev` (integration). **master tip 25f10402 (PR #787, carries #785 Phase 4 + #786 install fix); dev tip df3b28a1.**
+Blockers: theme/userspace need a working session. taos.my Coolify deploy pending Jay.
-## GOTCHA for the next agent
-- **Protected merges:** `gh pr merge` 401s on the OAuth token but `gh api -X PUT repos/jaylfc/taOS/pulls/N/merge -f merge_method=squash` WORKS (use `merge_method=merge` for dev->master promotions; never squash a promotion, never `--delete-branch`).
-- CodeRabbit fake passes: a green CodeRabbit check can be a rate-limit notice; check the PR comments, use `@coderabbitai full review`. Kilo often 504s ("Assistant request timed out") = infra flake, not findings.
-- `tests/` is NOT an importable package: never `from tests.conftest import X`; expose shared helpers as function-returning fixtures.
-- Worker onboarding now REQUIRES pairing (a worker prints a code, admin approves in Cluster, signing key minted) before register/heartbeat. Signing string + headers in tinyagentos/cluster/worker_auth.py; worker side in tinyagentos/worker/pairing.py. VALIDATED in production (#772 passed).
-- **Pi Claude session is ARCHIVED and its crons are stopped.** Freshness rides ONLY on the active agent's session cron (re-arm on a new session); there is no Pi-side durable backstop. The Pi controller + A2A bus are services and keep running.
+Security queue: #747 #737 #672 #658 #655 #654 #653 #651 #650 #647
-## Recently landed
-- **#737 cluster-worker pairing auth:** Phase 1 backend (#762) + Phase 2 worker scripts/agent signing (#770) DONE and on master (#767, #775). E2E VALIDATED in production (#772 closed: real Pi controller + Fedora worker, full announce->confirm->claim->signed register/heartbeat, unsigned->401). Phases 3 (UI pending-workers + enter-code dialog) and 4 (fleet migration UX) remain.
-- **Beta incident fixes on master:** #763 (knowledge user_id migration self-heals bricked installs + exit-on-startup-failure), #754 (installer sudo gap), #768 (installer re-run ownership / priv-esc), #752 (perf), #758 (controller-rescue runbook), #757 (prefetch placeholder).
-- Pi controller is UPDATED to master 66688348 (done for the #772 test) and has the pairing backend live.
-
-## Immediate next actions
-1. **#737 Phase 3** (UI pending-workers list + enter-code dialog): frontend-design pass, HELD for a design session with Jay (Apple-grade bar), ties into #760/#761 badges.
-2. **#737 Phase 4** (fleet migration UX): existing workers re-pair once with a clear prompt, not silent 401s.
-3. **#774 project/shelf registry:** design DONE + spec at docs/superpowers/specs/2026-06-11-project-shelf-registry-design.md (local, gitignored). taOSmd integration thread OPEN (integration channel msg 322): 3 contract questions (shelf create/archive shape; empty-shelf archive reversibility for link; carve-out re-key vs re-ingest). Implementation plan gated on their answers.
-4. **#776 add-machine over SSH (#737 Phase 3.5):** design DONE + spec at docs/superpowers/specs/2026-06-11-add-machine-ssh-design.md (local). One-click Cluster "Add machine": paramiko SSH (not sshpass), controller auto-installs + auto-pairs (injects TAOS_PAIRING_CODE, confirms itself), key-exchange for durable mgmt, key-based auth v1 (Linux/macOS only; Windows = separate native app). Ready for an implementation plan; sequence the build behind Phase 3 UI (both Cluster-app frontend).
-5. **#744** external coding-agent onboarding: taosmd side merged (their PR #151); our 7 build tasks queued.
-
-## Open issues filed this stretch
-#757 (fixed #771), #759 (fixed #764), #760 host badges everywhere (UI), #761 per-device emoji identity (brainstorm first), #772 fresh-install/pairing smoke (Pi+Fedora, PASSED+closed), #774 project/shelf registry (design done), #776 add-machine over SSH (design done), #777 install identity + version registration (per-bug context now, opt-in central reg later), #778 anonymous active-install count via the update check (aggregate only, no PII), #779 Projects-app code knowledge-graph plugin view for coding projects.
-
-## Cross-project (taosmd / A2A)
-- #744 taosmd side MERGED (their PR #151): grant matching on (canonical_id, project_id) + verified-claim project binding; the `agent` field on data endpoints is a TARGET SHELF, not the caller. Our 7 #744 build tasks queued.
-- Progress channels live: `taos-progress`, `taosmd-progress`. Freshness crons: taOS session :08/:38 (re-armed 2026-06-11, was dark), Pi durable backstop NOT installed (decision pending Jay).
-
-## Blocked / waiting on human (Jay)
-- `#15` exo fork deletion: needs `gh auth refresh -s delete_repo`.
-- `TAOSMD_REGISTRY_URL` cutover: gated on the consent UI shipping.
-- #751 beads buy-vs-build greenlight; #761 emoji brainstorm; #774 -> taOSmd thread.
-- Whether to install a durable Pi-side freshness cron as a backstop to the session cron.
-
-## Where to look
-1. GitHub issues = task list. 2. This file = snapshot. 3. docs/AGENT_HANDOFF.md = rules + bootstrap. 4. A2A bus :7900. 5. @taOS Pi memory (Claude Code only).
+GOTCHA: gh pr merge 401s -- use gh api PUT (squash for sub-PRs, rebase/merge for integration). Admin-merge OK for frontend-only PRs when Python test jobs hang on infra AND spa-build is green. Never --delete-branch on dev->master PR. Jay updates Pi manually.
diff --git a/docs/agent-manual/08-answer-templates.md b/docs/agent-manual/08-answer-templates.md
index 3b2548471..c1d0d1299 100644
--- a/docs/agent-manual/08-answer-templates.md
+++ b/docs/agent-manual/08-answer-templates.md
@@ -17,3 +17,13 @@
**"Is my data private?"** β Yes. Everything runs on your hardware. Agents, chats, files, and memory stay local. Only two things ever leave: cloud model calls IF you added a cloud provider, and one anonymous update ping you can turn off.
**"Something failed to install."** β taOS is in beta and some app and model manifests have not been tried on every hardware combination. Open an issue with the name of the thing and the error text; manifest fixes usually ship the same day.
+
+**"How do I add another machine to the cluster?"** Open the Cluster app on your main taOS, then on the other machine run the worker script from the Cluster app's add-machine instructions. The new machine shows a six digit pairing code; approve it in the Cluster app and it joins the mesh.
+
+**"What models can I run on my hardware?"** Open the Models app: the catalog marks what fits your detected hardware. Small boards run quantized 1 to 3 billion parameter models well; an 8GB board handles 7B quantized; GPUs and Apple Silicon open up larger models. Cloud models work on anything once you add a provider key.
+
+**"How do I back up taOS?"** Your data lives in the data directory of the install (agents, chats, memory, settings). Settings has a backups section; copying the whole data directory while taOS is stopped is also a complete backup.
+
+**"Where do I report a bug?"** github.com/jaylfc/tinyagentos/issues, with the error text and what hardware you are on. If something broke right after an update, mention that; there is a known-breakages log the developers check first.
+
+**"Can taOS work fully offline?"** Yes. With local models installed (rkllama or Ollama backends), every part of taOS runs on your network with no internet. Internet is only needed to download models, install apps from the store, check for updates, and use cloud model providers.
diff --git a/docs/design/theme-token-inventory.md b/docs/design/theme-token-inventory.md
new file mode 100644
index 000000000..f1b5f811c
--- /dev/null
+++ b/docs/design/theme-token-inventory.md
@@ -0,0 +1,278 @@
+# Theme token inventory
+
+A survey of every hardcoded color, shadow, and radius in `desktop/src`, organised by UI surface, so a future theme engine can swap each one for a CSS custom property.
+
+## How to read this
+
+Columns:
+
+- **Where**: the file and component that owns the value.
+- **What**: the role of the value (for example "title bar background").
+- **Current value**: the exact string in the source (hex, rgba, or Tailwind class).
+- **Proposed token name**: a `--taos--` custom property. Values reused across many places share ONE token, with every usage location listed under it.
+
+Two systems already exist in the codebase and are NOT re-inventoried here as "hardcoded":
+
+1. The shell token layer in `desktop/src/theme/tokens.css` (`@theme` block): `--color-shell-bg`, `--color-shell-surface`, `--color-accent`, `--color-dock-bg`, `--shadow-window`, `--spacing-window-radius`, and so on. Window chrome, the dock, the top bar, and the context menu already consume these. They are the destination, not the work.
+2. The board token layer (`:root` in the same file): `--board-*` properties scoped to the Projects Kanban board.
+
+This inventory targets the values that have NOT yet been routed through either token layer: raw Tailwind opacity utilities (`bg-white/5`, `border-white/10`), arbitrary hex classes (`bg-[#1a1a2e]`), and inline `style={{ color: "rgba(...)" }}` literals.
+
+A naming note: the existing tokens are spelled `--color-shell-*` and `--shadow-*`. The proposed `--taos-*` names below are intentionally parallel suggestions for the new theme engine. When the engine lands, the cleaner move is probably to extend the existing `--color-shell-*` family rather than introduce a second prefix. The proposed names are kept as written in the job brief so the mapping intent is clear.
+
+---
+
+## Shared tokens (used across many surfaces)
+
+These five values dominate the codebase. Each should become a single token, not one per call site. Counts are app-wide occurrences (excluding test files).
+
+| Where (file, component) | What | Current value (class) | Proposed token name |
+| --- | --- | --- | --- |
+| App-wide (187 uses), e.g. `ModelPickerModal.tsx:39`, `SettingsApp.tsx:441`, `TopBar.tsx:53` (`border-white/10`) | Standard hairline border | `border-white/10` -> `rgba(255,255,255,0.1)` | `--taos-line` |
+| App-wide (172 uses), e.g. `ModelPickerModal.tsx:42`, `SettingsApp.tsx:181/434/511/752` (`border-white/5`) | Faint divider / table row border | `border-white/5` -> `rgba(255,255,255,0.05)` | `--taos-line-faint` |
+| App-wide (142 uses), e.g. `SettingsApp.tsx:767`, `Launchpad.tsx:101` (`bg-white/5`) | Subtle surface fill | `bg-white/5` -> `rgba(255,255,255,0.05)` | `--taos-surface` |
+| App-wide (75 uses), e.g. `Launchpad.tsx:101`, `LaunchpadIcon.tsx:20` (`bg-white/10`) | Raised surface / search field fill | `bg-white/10` -> `rgba(255,255,255,0.1)` | `--taos-surface-alt` |
+| App-wide (25 uses), e.g. `ModelPickerModal.tsx:26` (`bg-black/60`) | Modal scrim / backdrop | `bg-black/60` -> `rgba(0,0,0,0.6)` | `--taos-scrim` |
+
+Note: `bg-white/5` and `border-white/5` resolve to the same rgba but read as different roles (fill vs. line). They are split into two tokens above so a theme can tune them independently.
+
+---
+
+## Window chrome (title bar, controls, borders, shadows)
+
+File: `desktop/src/components/Window.tsx`. Mostly tokenized already; the only hardcoded values are the traffic-light glyph color.
+
+| Where (file, component) | What | Current value | Proposed token name |
+| --- | --- | --- | --- |
+| `Window.tsx:163` (motion.div) | Window body background | `var(--color-shell-bg)` (already a token) | n/a (already `--color-shell-bg`) |
+| `Window.tsx:151-155` | Window radius + border + shadow | `var(--spacing-window-radius)`, `border-shell-border-strong` / `border-shell-border`, `var(--shadow-window)` / `var(--shadow-window-unfocused)` (already tokens) | n/a (already tokenized) |
+| `Window.tsx:173` (titlebar) | Title bar background | `bg-shell-surface` (already a token) | n/a (already `--color-shell-surface`) |
+| `Window.tsx:176/190/204` (traffic lights) | Close / minimize / maximize button fills | `bg-traffic-close` / `bg-traffic-minimize` / `bg-traffic-maximize` (already tokens) | n/a (already `--color-traffic-*`) |
+| `Window.tsx:184,198,214,224` (traffic glyph SVGs) | Glyph stroke color inside the traffic lights | `rgba(0,0,0,0.55)` (inline `style={{ color }}`) | `--taos-window-control-glyph` |
+
+---
+
+## Dock
+
+Files: `desktop/src/components/dock/MacosDock.tsx`, `desktop/src/components/DockIcon.tsx`. Fully tokenized; no hardcoded color values remain.
+
+| Where (file, component) | What | Current value | Proposed token name |
+| --- | --- | --- | --- |
+| `MacosDock.tsx:22-24` | Dock background, border, shadow | `var(--color-dock-bg)`, `var(--color-dock-border)`, `var(--shadow-dock)` (already tokens) | n/a (already tokenized) |
+| `MacosDock.tsx:18,29` | Dock radius + tile fill | `rounded-2xl`, `bg-shell-surface` / `hover:bg-shell-surface-active` (already tokens) | n/a |
+| `MacosDock.tsx:41,47` | Vertical separator | `bg-shell-border` (already a token) | n/a |
+| `DockIcon.tsx:24` | Icon tile fill + hover | `bg-shell-surface` / `hover:bg-shell-surface-active` (already tokens) | n/a |
+| `DockIcon.tsx:30` | Running-app dot | `bg-accent` (already a token) | n/a (already `--color-accent`) |
+
+There are other dock variants under `desktop/src/components/dock/` (`WindowsTaskbar.tsx` and others registered in `DockVariants.ts`). The active default is `macos-dock`; the Windows taskbar variant was not exhaustively inventoried here (see Gaps).
+
+---
+
+## Top bar
+
+File: `desktop/src/components/TopBar.tsx`. Tokenized except the power-menu popover background.
+
+| Where (file, component) | What | Current value | Proposed token name |
+| --- | --- | --- | --- |
+| `TopBar.tsx:108-110` | Top bar height, background, bottom border | `var(--spacing-topbar-h)`, `var(--color-topbar-bg)`, `var(--color-shell-border)` (already tokens) | n/a (already tokenized) |
+| `TopBar.tsx:53` (PowerMenu content) | Power menu border | `border-white/10` | `--taos-line` (shared) |
+| `TopBar.tsx:54` (PowerMenu content) | Power menu popover background | `rgba(28,26,44,0.96)` (inline `style`) | `--taos-popover-bg` |
+| `TopBar.tsx:73` (PowerMenu separator) | Menu separator line | `bg-white/10` | `--taos-line` (shared) |
+| `TopBar.tsx:156` (notification dot) | Unread badge dot | `bg-red-500` (Tailwind `#ef4444`) | `--taos-badge-alert` |
+
+---
+
+## Launchpad
+
+Files: `desktop/src/components/Launchpad.tsx`, `desktop/src/components/LaunchpadIcon.tsx`.
+
+| Where (file, component) | What | Current value | Proposed token name |
+| --- | --- | --- | --- |
+| `Launchpad.tsx:86` (overlay) | Full-screen launchpad backdrop | `bg-black/40` -> `rgba(0,0,0,0.4)` | `--taos-launchpad-scrim` |
+| `Launchpad.tsx:101` (search bar) | Search field fill | `bg-white/10` | `--taos-surface-alt` (shared) |
+| `Launchpad.tsx:101` (search bar) | Search field border | `border-white/10` | `--taos-line` (shared) |
+| `Launchpad.tsx:105,117,125,139` | Icon / label / heading text | `text-shell-text-tertiary` (already a token) | n/a |
+| `LaunchpadIcon.tsx:20` | App tile hover fill | `bg-white/5` | `--taos-surface` (shared) |
+
+The launchpad scrim (`bg-black/40`) is a different opacity from the generic modal scrim (`bg-black/60`), so it gets its own token.
+
+---
+
+## Desktop background / wallpaper handling
+
+Files: `desktop/src/components/Desktop.tsx`, `desktop/src/theme/tokens.css` (`.taos-wallpaper`).
+
+| Where (file, component) | What | Current value | Proposed token name |
+| --- | --- | --- | --- |
+| `Desktop.tsx:118` | Desktop fallback background color | `wallpaperFallback` (dynamic, from `useThemeStore`) | n/a (already theme-driven, not hardcoded) |
+| `Desktop.tsx:118` | Wallpaper image vars | `--wallpaper-desktop` / `--wallpaper-mobile` (set inline from theme store) | n/a (already theme-driven) |
+| `tokens.css:57-69` (`.taos-wallpaper`) | Wallpaper background sizing | `var(--wallpaper-desktop)` / `var(--wallpaper-mobile)` | n/a (already token-driven) |
+
+The desktop surface is already fully theme-driven (the fallback color and both wallpaper images come from the theme store). Nothing to tokenize here.
+
+---
+
+## Widgets (widget cards)
+
+Files: `desktop/src/components/WidgetLayer.tsx` (card shell), `desktop/src/components/widgets/*.tsx` (content), `desktop/src/theme/tokens.css` (`react-grid-*` overrides). This surface is almost entirely inline-styled with hardcoded values and is the single biggest tokenization job after Messages.
+
+| Where (file, component) | What | Current value | Proposed token name |
+| --- | --- | --- | --- |
+| `WidgetLayer.tsx:127` (widget card) | Widget card background | `rgba(20, 20, 35, 0.65)` (inline) | `--taos-widget-card-bg` |
+| `WidgetLayer.tsx:131` (widget card) | Widget card border | `1px solid rgba(255,255,255,0.1)` (inline) | `--taos-line` (shared) |
+| `WidgetLayer.tsx:123` (widget card) | Widget card radius | `borderRadius: 12` (inline) | `--taos-widget-card-radius` |
+| `WidgetLayer.tsx:156,174` (close button) | Close button idle fill | `rgba(255,255,255,0.1)` (inline) | `--taos-surface-alt` (shared) |
+| `WidgetLayer.tsx:165,173` (close button) | Close button idle glyph | `rgba(255,255,255,0.4)` (inline) | `--taos-ink-dim` |
+| `WidgetLayer.tsx:169-170` (close button hover) | Close button hover fill + glyph | `rgba(239,68,68,0.6)` + `#fff` (inline) | `--taos-danger-soft` + `--taos-ink-on-accent` |
+| `WidgetLayer.tsx:207,219,224` (add button) | Add-widget FAB background (idle / hover) | `rgba(20, 20, 35, 0.7)` / `rgba(40, 40, 60, 0.85)` (inline) | `--taos-widget-fab-bg` / `--taos-widget-fab-bg-hover` |
+| `WidgetLayer.tsx:210` (add button) | Add-widget FAB border | `1px solid rgba(255,255,255,0.15)` (inline) | `--taos-line-strong` |
+| `WidgetLayer.tsx:211` (add button) | Add-widget FAB glyph | `rgba(255,255,255,0.7)` (inline) | `--taos-ink` |
+| `WidgetLayer.tsx:238` (picker popover) | Widget picker popover background | `rgba(20, 20, 35, 0.9)` (inline) | `--taos-widget-popover-bg` |
+| `WidgetLayer.tsx:241` (picker popover) | Widget picker popover border | `1px solid rgba(255,255,255,0.15)` (inline) | `--taos-line-strong` |
+| `WidgetLayer.tsx:265,272` (picker item) | Picker item text + hover fill | `rgba(255,255,255,0.8)` + `rgba(255,255,255,0.1)` (inline) | `--taos-ink` + `--taos-surface-alt` (shared) |
+| `WidgetLayer.tsx:35` (unknown widget) | Unknown-widget placeholder text | `rgba(255,255,255,0.4)` (inline) | `--taos-ink-dim` (shared) |
+| `widgets/ClockWidget.tsx:28,39,54` | Clock primary time text | `rgba(255,255,255,0.95)` (inline) | `--taos-ink-strong` |
+| `widgets/ClockWidget.tsx:45,60` | Clock secondary date / weekday text | `rgba(255,255,255,0.45)` / `rgba(255,255,255,0.6)` (inline) | `--taos-ink-dim` |
+| `widgets/ClockWidget.tsx:63` | Clock tertiary long-date text | `rgba(255,255,255,0.35)` (inline) | `--taos-ink-faint` |
+| `tokens.css:73-86` (`react-grid-placeholder`) | Drag placeholder fill + border | `rgba(139, 146, 163, 0.2)` + `rgba(139, 146, 163, 0.4)` (`!important`) | `--taos-grid-placeholder-bg` + `--taos-grid-placeholder-border` |
+| `tokens.css:85-86` (`react-resizable-handle`) | Resize handle stroke | `rgba(255,255,255,0.4)` | `--taos-ink-dim` (shared) |
+
+Other widget content files (`SystemStatsWidget.tsx`, `AgentStatusWidget.tsx`, `WeatherWidget.tsx`, `QuickNotesWidget.tsx`, `GreetingWidget.tsx`) follow the same `rgba(255,255,255,0.x)` ink pattern for text. They were sampled, not enumerated row by row, because every value collapses to the shared `--taos-ink*` family above.
+
+---
+
+## Messages app (sidebar, message list, composer)
+
+File: `desktop/src/apps/MessagesApp.tsx` (2717 lines). This file mixes Tailwind shell classes (toolbar, container) with a large body of inline `style={{}}` rgba literals in the channel list and badges. The mobile and desktop channel lists duplicate the same value set. Representative rows below; the desktop list (lines ~1410-1490) repeats the mobile values (lines ~1300-1387).
+
+| Where (file, component) | What | Current value | Proposed token name |
+| --- | --- | --- | --- |
+| `MessagesApp.tsx:2303` (root) | App background + text | `bg-shell-base text-white` | n/a (`bg-shell-base` is a token; `text-white` -> `--taos-ink-strong`) |
+| `MessagesApp.tsx:2306` (toolbar) | Toolbar bottom border | `border-white/[0.06]` | `--taos-line-faint` (shared) |
+| `MessagesApp.tsx:2310,2326` (toolbar) | Toolbar title text | `text-white/90` / `text-white/80` | `--taos-ink` |
+| `MessagesApp.tsx:1340-1341` (channel group) | Channel list group fill + border | `rgba(255,255,255,0.05)` + `rgba(255,255,255,0.08)` (inline) | `--taos-surface` (shared) + `--taos-line` (shared) |
+| `MessagesApp.tsx:1358` (channel row, selected) | Selected channel highlight | `rgba(59,130,246,0.15)` (inline) | `--taos-msg-channel-active` |
+| `MessagesApp.tsx:1360` (channel row) | Row divider | `rgba(255,255,255,0.06)` (inline) | `--taos-line-faint` (shared) |
+| `MessagesApp.tsx:1373` (channel name) | Channel name text | `rgba(255,255,255,0.9)` (inline) | `--taos-ink` |
+| `MessagesApp.tsx:1377` (unread badge) | Unread badge fill + text | `#3b82f6` + `#fff` (inline) | `--taos-accent-blue` + `--taos-ink-on-accent` |
+| `MessagesApp.tsx:1381` (chevron) | Disclosure chevron | `rgba(255,255,255,0.25)` (inline) | `--taos-ink-faint` |
+| `MessagesApp.tsx:1307` (status, connected) | "Connected" status dot + text | `#34d399` + `rgba(52,211,153,0.8)` (inline) | `--taos-status-ok` |
+| `MessagesApp.tsx:1309` (status, connecting) | "Connecting" status dot + text | `#fbbf24` + `rgba(251,191,36,0.8)` (inline) | `--taos-status-warn` |
+| `MessagesApp.tsx:1311` (status, offline) | "Offline" status dot + text | `#f87171` + `rgba(248,113,113,0.8)` (inline) | `--taos-status-error` |
+| `MessagesApp.tsx:1323` (empty-state button) | CTA fill / border / text | `rgba(59,130,246,0.2)` / `rgba(59,130,246,0.3)` / `rgba(147,197,253,0.9)` (inline) | `--taos-accent-blue-soft` / `--taos-accent-blue-line` / `--taos-accent-blue-ink` |
+| `MessagesApp.tsx:1317-1334` (empty state) | Empty-state heading / body / icon text | `rgba(255,255,255,0.7)` / `0.35` / `0.15` / `0.2` (inline) | `--taos-ink-dim` / `--taos-ink-faint` |
+| `MessagesApp.tsx:255` (markdown link) | Link color in message body | `text-blue-400` (Tailwind `#60a5fa`) | `--taos-link` |
+| `MessagesApp.tsx:2269` (composer) | Composer placeholder | `placeholder` attr only (no inline color); inherits shell text | n/a (no hardcoded color at the composer textarea) |
+
+The composer surface itself draws its colors from shell tokens and Tailwind text utilities, not inline hex; the inline color work in this file is concentrated in the channel list and status indicators.
+
+---
+
+## Settings app
+
+File: `desktop/src/apps/SettingsApp.tsx` (801 lines). Contains ZERO hardcoded hex or rgba literals. All color comes from shell tokens and Tailwind `white/N` opacity utilities, so it maps cleanly onto the shared tokens.
+
+| Where (file, component) | What | Current value | Proposed token name |
+| --- | --- | --- | --- |
+| `SettingsApp.tsx:177` (info card) | Card fill + border | `bg-white/[0.04]` + `border-white/[0.06]` | `--taos-surface` (shared) + `--taos-line-faint` (shared) |
+| `SettingsApp.tsx:181` (table row) | Row bottom border | `border-white/5` | `--taos-line-faint` (shared) |
+| `SettingsApp.tsx:182` (table label) | Label text | `text-shell-text-secondary` (already a token) | n/a |
+| `SettingsApp.tsx:441` (control) | Control borders | `border-white/10` / `border-white/20` | `--taos-line` (shared) / `--taos-line-strong` |
+| `SettingsApp.tsx:495` (panel) | Panel fill + borders | `bg-white/[0.04]` + `border-white/[0.06]` + `border-white/[0.08]` | `--taos-surface` (shared) + `--taos-line-faint` + `--taos-line` |
+| `SettingsApp.tsx:767` (row) | Hover / fill | `bg-white/5` | `--taos-surface` (shared) |
+
+---
+
+## Modals and popovers
+
+| Where (file, component) | What | Current value | Proposed token name |
+| --- | --- | --- | --- |
+| `ModelPickerModal.tsx:26` (backdrop) | Modal scrim | `bg-black/60` | `--taos-scrim` (shared) |
+| `ModelPickerModal.tsx:39` (panel) | Modal panel fill + border | `bg-shell-surface` (token) + `border-white/10` | n/a + `--taos-line` (shared) |
+| `ModelPickerModal.tsx:42` (header) | Header bottom border | `border-white/5` | `--taos-line-faint` (shared) |
+| `ContextMenu.tsx:103` (menu) | Context menu background | `rgba(30, 31, 50, 0.95)` (inline) | `--taos-popover-bg` (shared with TopBar power menu, value differs) |
+| `ContextMenu.tsx:105` (menu) | Context menu shadow | `0 8px 32px rgba(0,0,0,0.5)` (inline) | `--taos-popover-shadow` |
+| `ContextMenu.tsx:99,113` (menu) | Menu border + separator | `border-shell-border-strong` / `border-shell-border` (already tokens) | n/a |
+| `ContextMenu.tsx:135` (menu item) | Item hover / focus fill | `hover:bg-white/8` / `focus:bg-white/8` | `--taos-surface-hover` |
+
+Popover background note: the TopBar power menu (`rgba(28,26,44,0.96)`) and the ContextMenu (`rgba(30, 31, 50, 0.95)`) use slightly different dark-violet values for the same role. A theme engine should collapse them into one `--taos-popover-bg`; the two source values are recorded so the discrepancy is visible.
+
+---
+
+## Scrollbars
+
+No surface defines styled scrollbar colors. The only scrollbar CSS found HIDES the scrollbar rather than recoloring it:
+
+- `desktop/src/components/mobile/WorkspaceTabPills.tsx:31-32` (`[scrollbar-width:none]`, `[&::-webkit-scrollbar]:hidden`)
+- `desktop/src/apps/BrowserApp/BookmarksBar.tsx:129` (`scrollbarWidth: "none"`)
+
+There are no `::-webkit-scrollbar-thumb` color rules anywhere in `desktop/src`. Nothing to tokenize for scrollbars.
+
+---
+
+## Inline `style={{}}` color usages (the hard ones for later)
+
+These are the values a class-based theme cannot reach without code changes, because they live in JS object literals rather than CSS classes. They are the priority migration targets.
+
+| Where (file, component) | Inline color(s) |
+| --- | --- |
+| `Window.tsx:184,198,214,224` (traffic glyphs) | `color: "rgba(0,0,0,0.55)"` |
+| `TopBar.tsx:54` (power menu) | `backgroundColor: "rgba(28,26,44,0.96)"` |
+| `Desktop.tsx:118` | `backgroundColor: wallpaperFallback` (dynamic, fine) + wallpaper CSS vars |
+| `ContextMenu.tsx:103,105` | `backgroundColor: "rgba(30, 31, 50, 0.95)"`, `boxShadow: "0 8px 32px rgba(0,0,0,0.5)"` |
+| `WidgetLayer.tsx:127,131,156,165,169,170,174,207,210,211,219,224,238,241,265,272,35` | The full widget card / FAB / picker palette listed in the Widgets section, all inline rgba |
+| `widgets/ClockWidget.tsx:28,39,45,54,60,63` | All clock text colors, inline rgba `rgba(255,255,255,0.x)` |
+| `MessagesApp.tsx` (channel list, lines ~1305-1490; desktop list mirrors mobile) | All channel-list / status / badge / empty-state colors, inline hex + rgba |
+
+The two heaviest inline-color surfaces are `WidgetLayer.tsx` and `MessagesApp.tsx`. Everything else is mostly Tailwind classes (reachable by a theme that overrides Tailwind's `white`/`black` palette or by find-and-replace to token-backed utilities).
+
+---
+
+## Summary
+
+### Total distinct color values found
+
+Counting raw hardcoded values that are NOT already routed through the `--color-shell-*` / `--shadow-*` / `--board-*` token layers, and excluding test fixtures and false positives:
+
+- Distinct **hex** values in real UI code (after dropping `#356`, `#618`, `#312`, `#8942` which are PR references / an HTML entity, and `#abc123` from a test): roughly **30**. The recurring real ones are `#8b92a3`, `#6c8df0`, `#f5b86b`, `#3b82f6`, `#d2d2d7`, `#86868b`, `#151625`, `#1a1b2e`, `#1a1a2e`, `#f5f5f7`, `#fff`, `#ff5f57`, `#febc2e`, `#28c840`, `#fbbf24`, `#34d399`, `#f87171`, plus the Matrix-theme greens.
+- Distinct **rgba** values: roughly **45**, dominated by the `rgba(255,255,255,0.0x..0.95)` ink ladder (about 20 distinct alpha steps) plus a handful of brand blues (`rgba(59,130,246,*)`), status colors, and dark panel fills.
+- Distinct **Tailwind opacity classes** (`white/N`, `black/N`): roughly **30** (for example `border-white/10`, `bg-white/5`, `bg-black/60`).
+
+Combined, that is on the order of **100 distinct hardcoded color strings**, but they collapse to far fewer semantic roles. The bulk are alpha variations of white-on-dark ink and surface fills that map to a small `--taos-ink*` / `--taos-surface*` / `--taos-line*` family.
+
+### The 10 most reused values
+
+By raw occurrence across `desktop/src` (Tailwind classes and rgba literals combined, tests excluded):
+
+1. `border-white/10` (`rgba(255,255,255,0.1)`), 187 uses -> `--taos-line`
+2. `border-white/5` (`rgba(255,255,255,0.05)`), 172 uses -> `--taos-line-faint`
+3. `bg-white/5` (`rgba(255,255,255,0.05)`), 142 uses -> `--taos-surface`
+4. `bg-white/10` (`rgba(255,255,255,0.1)`), 75 uses -> `--taos-surface-alt`
+5. `rgba(255,255,255,0.08)` (inline + `bg-white/[0.08]`), 31+ uses -> `--taos-line` / `--taos-surface-alt`
+6. `rgba(255,255,255,0.06)` (inline + `border-white/[0.06]`), 25+ uses -> `--taos-line-faint`
+7. `bg-black/60` (`rgba(0,0,0,0.6)`), 25 uses -> `--taos-scrim`
+8. `rgba(255,255,255,0.4)` (inline), 26 uses -> `--taos-ink-dim`
+9. `rgba(255,255,255,0.45)` (inline), 19 uses -> `--taos-ink-dim`
+10. `rgba(255,255,255,0.95)` (inline), 17 uses -> `--taos-ink-strong`
+
+The headline finding: the entire app's color surface is, semantically, a white-on-dark opacity ladder plus a few accent and status colors. A theme engine that ships `--taos-ink*`, `--taos-surface*`, `--taos-line*`, `--taos-scrim`, one accent, and three status colors would cover the vast majority of these call sites.
+
+### Surfaces whose styling lives somewhere unexpected
+
+- **Window chrome, dock, top bar**: already mostly migrated to the `--color-shell-*` / `--shadow-*` tokens in `tokens.css`. The "inventory" for these is short because the work is largely done; only stray inline glyph/popover colors remain.
+- **Widgets**: styled almost entirely inline in `WidgetLayer.tsx` and the `widgets/*` content files, NOT in CSS or Tailwind classes. This is the surface most resistant to a class-only theme.
+- **react-grid-layout overrides**: the widget drag placeholder and resize-handle colors live in `tokens.css` (lines 73-87) as `.react-grid-*` global selectors with `!important`, not on any component. Easy to miss.
+- **Messages app**: a single 2717-line file carries its own inline color palette (status greens/ambers/reds, accent blues) that no other surface reuses; it is effectively its own mini design system embedded in one component.
+- **Settings app**: the opposite extreme. Zero hardcoded literals; it is the cleanest surface and maps onto shared tokens with no inline work at all.
+- **Projects Kanban board**: not in the requested surface list, but worth flagging that it already has its OWN token layer (`--board-*` in `tokens.css` plus per-component `*.module.css` files under `apps/ProjectsApp/board/`), separate from the shell tokens. A unified theme engine will need to reconcile the `--board-*` family with the `--taos-*` / `--color-shell-*` family.
+
+### Confidence and gaps
+
+Honest gaps where I sampled rather than enumerated every line:
+
+- **Widget content files** (`SystemStatsWidget`, `AgentStatusWidget`, `WeatherWidget`, `QuickNotesWidget`, `GreetingWidget`): sampled. Every value seen collapses to the `--taos-ink*` ladder, but individual rows were not transcribed.
+- **MessagesApp desktop channel list** (lines ~1410-1490): confirmed to mirror the mobile list's value set; not transcribed line-by-line to avoid duplicate rows.
+- **Non-default dock variants** (`WindowsTaskbar.tsx` and any other entries in `DockVariants.ts`): the active default `macos-dock` was inventoried fully; alternate variants were not.
+- **Other app windows** (Browser, Store, Agents, Models, Memory, and the rest of `apps/`): out of the requested surface scope, so not inventoried. They contribute heavily to the app-wide `bg-white/5` / `border-white/10` counts above, which is why those shared-token totals are so high. If the theme engine needs full coverage, those apps are the long tail.
+
+Every row in the per-surface tables above was read from the cited file and line and the value quoted exactly. Where a surface was too large to transcribe in full (Messages, widget content), that is stated rather than guessed.
diff --git a/docs/taos-agent-manual.md b/docs/taos-agent-manual.md
index 7a40bdb7d..32286342c 100644
--- a/docs/taos-agent-manual.md
+++ b/docs/taos-agent-manual.md
@@ -128,3 +128,13 @@ Match the user's symptom against that log before reasoning from scratch. Known c
**"Is my data private?"** β Yes. Everything runs on your hardware. Agents, chats, files, and memory stay local. Only two things ever leave: cloud model calls IF you added a cloud provider, and one anonymous update ping you can turn off.
**"Something failed to install."** β taOS is in beta and some app and model manifests have not been tried on every hardware combination. Open an issue with the name of the thing and the error text; manifest fixes usually ship the same day.
+
+**"How do I add another machine to the cluster?"** Open the Cluster app on your main taOS, then on the other machine run the worker script from the Cluster app's add-machine instructions. The new machine shows a six digit pairing code; approve it in the Cluster app and it joins the mesh.
+
+**"What models can I run on my hardware?"** Open the Models app: the catalog marks what fits your detected hardware. Small boards run quantized 1 to 3 billion parameter models well; an 8GB board handles 7B quantized; GPUs and Apple Silicon open up larger models. Cloud models work on anything once you add a provider key.
+
+**"How do I back up taOS?"** Your data lives in the data directory of the install (agents, chats, memory, settings). Settings has a backups section; copying the whole data directory while taOS is stopped is also a complete backup.
+
+**"Where do I report a bug?"** github.com/jaylfc/tinyagentos/issues, with the error text and what hardware you are on. If something broke right after an update, mention that; there is a known-breakages log the developers check first.
+
+**"Can taOS work fully offline?"** Yes. With local models installed (rkllama or Ollama backends), every part of taOS runs on your network with no internet. Internet is only needed to download models, install apps from the store, check for updates, and use cloud model providers.
diff --git a/pyproject.toml b/pyproject.toml
index 27c118b01..673f9cc5c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,7 @@ dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23.0",
"pytest-timeout>=2.3.0",
+ "pytest-xdist>=3.5.0",
"httpx",
"respx>=0.21.0",
"websockets>=12.0",
diff --git a/tests/test_hardware.py b/tests/test_hardware.py
index d31f1357e..7721f7fc3 100644
--- a/tests/test_hardware.py
+++ b/tests/test_hardware.py
@@ -6,10 +6,29 @@
from tinyagentos.hardware import detect_hardware, get_hardware_profile, HardwareProfile
from tinyagentos import hardware as hardware_mod
from tinyagentos.hardware import _nvidia_vram_for_model, _amd_vram_for_model
+from tinyagentos.hardware import _soc_from_devicetree
import pytest_asyncio
+class TestSocFromDeviceTree:
+ def test_rk3588_from_compatible_when_model_omits_it(self):
+ # The board name ("Orange Pi 5 Plus") does not contain the SoC; the
+ # compatible string does. Both files are concatenated before matching.
+ text = " orange pi 5 plus rockchip,rk3588-orangepi-5-plus rockchip,rk3588"
+ assert _soc_from_devicetree(text) == "rk3588"
+
+ def test_board_name_alone_does_not_match(self):
+ assert _soc_from_devicetree(" orange pi 5 plus") == ""
+
+ def test_raspberry_pi(self):
+ assert _soc_from_devicetree(" raspberry pi 5 brcm,bcm2712") == "bcm2712"
+ assert _soc_from_devicetree(" raspberry pi 4 model b") == "bcm2711"
+
+ def test_unknown_returns_empty(self):
+ assert _soc_from_devicetree(" generic x86 box") == ""
+
+
class TestDetectHardware:
def test_returns_hardware_profile(self):
profile = detect_hardware()
diff --git a/tinyagentos/hardware.py b/tinyagentos/hardware.py
index 21a62e091..72d2efec3 100644
--- a/tinyagentos/hardware.py
+++ b/tinyagentos/hardware.py
@@ -122,6 +122,24 @@ def _run(cmd: list[str]) -> str:
return ""
+def _soc_from_devicetree(text: str) -> str:
+ """Map a lowercased device-tree string to a known SoC id, or "".
+
+ `text` should combine /proc/device-tree/model and
+ /proc/device-tree/compatible: the board name in `model` often omits the
+ SoC ("Orange Pi 5 Plus") while `compatible` names it ("rockchip,rk3588").
+ """
+ if "rk3588" in text:
+ return "rk3588"
+ if "rk3576" in text:
+ return "rk3576"
+ if "bcm2712" in text:
+ return "bcm2712"
+ if "bcm2711" in text or "raspberry pi 4" in text:
+ return "bcm2711"
+ return ""
+
+
def _detect_cpu() -> CpuInfo:
arch = platform.machine()
cores = 0
@@ -134,18 +152,15 @@ def _detect_cpu() -> CpuInfo:
cores += 1
if "model name" in line.lower() or "hardware" in line.lower():
model = line.split(":")[-1].strip()
- # Detect SoC for ARM
- dt_model = Path("/proc/device-tree/model")
- if dt_model.exists():
- soc_str = dt_model.read_text().strip("\x00").lower()
- if "rk3588" in soc_str:
- soc = "rk3588"
- elif "rk3576" in soc_str:
- soc = "rk3576"
- elif "bcm2712" in soc_str:
- soc = "bcm2712"
- elif "bcm2711" in soc_str or "raspberry pi 4" in soc_str:
- soc = "bcm2711"
+ # Detect SoC for ARM. Read both model and compatible: the board name
+ # in device-tree/model often omits the SoC, so device-tree/compatible
+ # ("rockchip,rk3588") is the reliable source.
+ dt_text = ""
+ for dt in ("/proc/device-tree/model", "/proc/device-tree/compatible"):
+ p = Path(dt)
+ if p.exists():
+ dt_text += " " + p.read_text().replace("\x00", " ").lower()
+ soc = _soc_from_devicetree(dt_text)
except OSError:
pass
# macOS / Apple Silicon detection