}) {
+ return (
+
+
{state.title}
+
{state.detail}
+
+ );
+}
+
+function Entry({ entry }: { readonly entry: CatalogExplorerEntry }) {
+ return (
+
+
+ {entry.name}
+ {!entry.supported && Unavailable}
+
+
+ {entry.id}
+
+ {entry.description !== null && {entry.description}
}
+ {entry.reason !== null && {entry.reason}
}
+ {entry.metadata.length > 0 && (
+
+ {entry.metadata.map((metadata) => (
+
+
- {metadata.key}
+ - {metadata.value}
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function CatalogExplorerBlock({ input }: { readonly input: CatalogExplorerInput }) {
+ const state = catalogExplorerState(input);
+ return (
+
+
+
+ Host capabilities
+
+
+ Read-only catalog published by {state.host.hostLabel}{" "}
+ ({state.host.hostId})
+
+
+
+ {state.status !== "ready" ? (
+
+ ) : (
+
+ {state.groups.map((group) => (
+
+
+
+ {group.label}
+
+ {group.entries.length}
+
+
+ {group.entries.map((entry) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/settings/LiveSettingsScreen.tsx b/apps/web/src/features/settings/LiveSettingsScreen.tsx
index 66199990..0201132d 100644
--- a/apps/web/src/features/settings/LiveSettingsScreen.tsx
+++ b/apps/web/src/features/settings/LiveSettingsScreen.tsx
@@ -25,6 +25,7 @@ import { useAppUpdateState } from "../updates/update-store.ts";
import type { SaveChallenge } from "./live-controller.ts";
import type { HostSelection } from "./HostSelector.tsx";
import {
+ catalogExplorerInputForLiveState,
createLiveSettingsScreenModel,
type LiveSettingsScreenModel,
} from "./live-screen-model.ts";
@@ -64,6 +65,7 @@ const WAIT_COPY: Record<"no-host" | "connecting" | "disconnected" | "not-publish
},
};
+
export function LiveSettingsScreen({
controller,
onBack,
@@ -130,6 +132,7 @@ export function LiveSettingsScreen({
activeTargetId: state.phase === "ready" ? state.active.targetId : state.activeTargetId,
onSelect: (targetId) => model.selectHost(targetId),
};
+ const catalogExplorer = catalogExplorerInputForLiveState(controller, state);
if (state.phase !== "ready") {
const copy =
@@ -143,6 +146,7 @@ export function LiveSettingsScreen({
onBack={onBack}
onOpenHosts={onOpenHosts}
update={update}
+ {...(catalogExplorer === undefined ? {} : { catalogExplorer })}
/>
);
}
@@ -154,6 +158,7 @@ export function LiveSettingsScreen({
brokerStatus={{ view: state.broker, onRefresh: () => model.refreshBrokerStatus() }}
catalogChoices={{ models: state.models, agents: state.agents, roleTags: state.roleTags }}
hostSelection={hostSelection}
+ {...(catalogExplorer === undefined ? {} : { catalogExplorer })}
onBack={onBack}
onOpenHosts={onOpenHosts}
scopes={["global", "session"]}
diff --git a/apps/web/src/features/settings/SettingsWorkspace.tsx b/apps/web/src/features/settings/SettingsWorkspace.tsx
index db325ed7..f3fd0ffd 100644
--- a/apps/web/src/features/settings/SettingsWorkspace.tsx
+++ b/apps/web/src/features/settings/SettingsWorkspace.tsx
@@ -16,6 +16,8 @@ import {
import { UpdateSettingsPanel } from "../updates/UpdateSettingsPanel.tsx";
import { useAppUpdateState } from "../updates/update-store.ts";
import { AccentRow } from "./AccentRow.tsx";
+import { CatalogExplorerBlock } from "./CatalogExplorerBlock.tsx";
+import type { CatalogExplorerInput } from "./settings-presentation.ts";
import { FIELD_CLASS } from "./controls.tsx";
import { BrokerStatusLine, HostSelector, type BrokerStatusAction, type HostSelection } from "./HostSelector.tsx";
import { buildDiagnosticsExport } from "./diagnostics.ts";
@@ -300,6 +302,7 @@ export function SettingsWorkspace({
scopes = EDITABLE_SCOPES,
restartAction,
catalogChoices = NO_CHOICES,
+ catalogExplorer,
hostSelection,
brokerStatus,
}: {
@@ -314,6 +317,8 @@ export function SettingsWorkspace({
readonly restartAction?: RestartAction;
/** Models and agents the host advertises, for the roles/agents editors. */
readonly catalogChoices?: SettingsCatalogChoices;
+ /** The active host's read-only capability catalog. */
+ readonly catalogExplorer?: CatalogExplorerInput;
/** Connected hosts to switch between; the shell owns the selection. */
readonly hostSelection?: HostSelection;
/** The active host's account-broker status, when the shell tracks it. */
@@ -566,6 +571,8 @@ export function SettingsWorkspace({
)}
+ {catalogExplorer !== undefined && }
+
{searching && shownSections.length === 0 && !shownUpdate && (
Nothing matches “{query.trim()}”. Try a setting name or a word from its
diff --git a/apps/web/src/features/settings/UnavailableSettingsWorkspace.tsx b/apps/web/src/features/settings/UnavailableSettingsWorkspace.tsx
index e0a83ea0..180c126b 100644
--- a/apps/web/src/features/settings/UnavailableSettingsWorkspace.tsx
+++ b/apps/web/src/features/settings/UnavailableSettingsWorkspace.tsx
@@ -5,6 +5,8 @@ import { RAIL_OVERLAY_QUERY, useMediaQuery } from "../../hooks/useMediaQuery.ts"
import { updateIsAvailable, type AppUpdateState } from "../updates/update-model.ts";
import { UpdateSettingsPanel } from "../updates/UpdateSettingsPanel.tsx";
import { HostSelector, type HostSelection } from "./HostSelector.tsx";
+import { CatalogExplorerBlock } from "./CatalogExplorerBlock.tsx";
+import type { CatalogExplorerInput } from "./settings-presentation.ts";
export interface UnavailableSettingsCopy {
readonly title: string;
@@ -20,13 +22,16 @@ export function UnavailableSettingsWorkspace({
onOpenHosts,
update,
hostSelection,
+ catalogExplorer,
}: {
readonly copy: UnavailableSettingsCopy;
readonly onBack: () => void;
readonly onOpenHosts: () => void;
readonly update: AppUpdateState;
- /** Other connected hosts the user can switch to while this one is out. */
+ /** Other connected hosts to switch to while this one is out. */
readonly hostSelection?: HostSelection;
+ /** The active host's read-only catalog state, when its identity is known. */
+ readonly catalogExplorer?: CatalogExplorerInput;
}) {
const railOverlaid = useMediaQuery(RAIL_OVERLAY_QUERY);
return (
@@ -82,6 +87,7 @@ export function UnavailableSettingsWorkspace({
)}
+ {catalogExplorer !== undefined && }
diff --git a/apps/web/src/features/settings/live-screen-model.ts b/apps/web/src/features/settings/live-screen-model.ts
index cf87f8a8..70f03b30 100644
--- a/apps/web/src/features/settings/live-screen-model.ts
+++ b/apps/web/src/features/settings/live-screen-model.ts
@@ -8,7 +8,7 @@
// Pure TypeScript over the runtime port so every transition is testable
// headless.
import type { DesktopRuntimeSnapshot } from "@t4-code/client";
-import { hostId as brandHostId } from "@t4-code/protocol";
+import { hostId as brandHostId, type CatalogFrame } from "@t4-code/protocol";
import {
createBrokerStatusModel,
@@ -26,7 +26,12 @@ import {
type LiveSettingsRuntimePort,
type SaveChallenge,
} from "./live-controller.ts";
-import { NO_ROLE_TAGS, roleTagsFromFrames, type RoleTags } from "./settings-presentation.ts";
+import {
+ NO_ROLE_TAGS,
+ roleTagsFromFrames,
+ type CatalogExplorerInput,
+ type RoleTags,
+} from "./settings-presentation.ts";
import { createSettingsStore, type SettingsStoreApi } from "./settings-store.ts";
export interface ActiveHost {
@@ -77,6 +82,7 @@ export type LiveSettingsScreenState =
readonly phase: "ready";
readonly api: SettingsStoreApi;
readonly active: ActiveHost;
+ readonly catalog: CatalogFrame;
readonly models: readonly ModelChoice[];
readonly agents: AgentCatalog;
readonly roleTags: RoleTags;
@@ -84,6 +90,27 @@ export type LiveSettingsScreenState =
readonly broker: BrokerStatusView;
};
+/** Derive the explorer input from the same live state and host projection. */
+export function catalogExplorerInputForLiveState(
+ runtime: Pick
,
+ state: LiveSettingsScreenState,
+): CatalogExplorerInput | undefined {
+ if (state.phase === "ready") {
+ return {
+ host: { hostLabel: state.active.hostLabel, hostId: state.active.hostId },
+ catalog: state.catalog,
+ };
+ }
+ if (state.hostLabel === null || state.activeTargetId === null) return undefined;
+ const snapshot = runtime.getSnapshot();
+ const hostId = snapshot.targetHosts.get(state.activeTargetId);
+ if (hostId === undefined) return undefined;
+ return {
+ host: { hostLabel: state.hostLabel, hostId },
+ phase: state.phase === "waiting" && (state.detail === "connecting" || state.detail === "not-published") ? "waiting" : "unavailable",
+ };
+}
+
export interface LiveSettingsScreenModel {
getState(): LiveSettingsScreenState;
/** First subscriber attaches the runtime subscription; the last one
@@ -159,43 +186,66 @@ interface ConnectionProblem {
detail: SettingsWaitDetail;
error: string | null;
label: string | null;
+ targetId: string | null;
}
/** The most useful thing to say when no candidate target is connected. */
function connectionProblem(snapshot: DesktopRuntimeSnapshot): ConnectionProblem {
- let connecting: string | null = null;
+ let connecting: { readonly label: string; readonly targetId: string } | null = null;
for (const [targetId, target] of snapshot.targets) {
const state = snapshot.connections.get(targetId) ?? target.state;
if (state === "error") {
const runtimeError = [...snapshot.runtimeErrors].reverse().find((entry) => entry.targetId === targetId);
const reason = runtimeError === undefined ? "" : ` ${runtimeError.message}`;
- return { detail: "no-host", error: `The connection to ${target.label} failed.${reason}`, label: target.label };
+ return {
+ detail: "no-host",
+ error: `The connection to ${target.label} failed.${reason}`,
+ label: target.label,
+ targetId,
+ };
}
if (state === "pairing-required") {
- return { detail: "no-host", error: `${target.label} needs pairing before its settings can load.`, label: target.label };
+ return {
+ detail: "no-host",
+ error: `${target.label} needs pairing before its settings can load.`,
+ label: target.label,
+ targetId,
+ };
}
- if (state === "connecting") connecting = target.label;
+ if (state === "connecting") connecting = { label: target.label, targetId };
+ }
+ if (connecting !== null) {
+ return { detail: "connecting", error: null, label: connecting.label, targetId: connecting.targetId };
}
- if (connecting !== null) return { detail: "connecting", error: null, label: connecting };
- return { detail: "no-host", error: null, label: null };
+ return { detail: "no-host", error: null, label: null, targetId: null };
}
/** The same, scoped to the explicitly chosen target: its trouble is named,
* never papered over by silently opening another host's settings. */
function connectionProblemFor(snapshot: DesktopRuntimeSnapshot, targetId: string): ConnectionProblem {
const target = snapshot.targets.get(targetId);
- if (target === undefined) return { detail: "no-host", error: null, label: null };
+ if (target === undefined) return { detail: "no-host", error: null, label: null, targetId: null };
const state = snapshot.connections.get(targetId) ?? target.state;
if (state === "error") {
const runtimeError = [...snapshot.runtimeErrors].reverse().find((entry) => entry.targetId === targetId);
const reason = runtimeError === undefined ? "" : ` ${runtimeError.message}`;
- return { detail: "no-host", error: `The connection to ${target.label} failed.${reason}`, label: target.label };
+ return {
+ detail: "no-host",
+ error: `The connection to ${target.label} failed.${reason}`,
+ label: target.label,
+ targetId,
+ };
}
if (state === "pairing-required") {
- return { detail: "no-host", error: `${target.label} needs pairing before its settings can load.`, label: target.label };
+ return {
+ detail: "no-host",
+ error: `${target.label} needs pairing before its settings can load.`,
+ label: target.label,
+ targetId,
+ };
}
- if (state === "connecting") return { detail: "connecting", error: null, label: target.label };
- return { detail: "disconnected", error: null, label: target.label };
+ if (state === "connecting") return { detail: "connecting", error: null, label: target.label, targetId };
+ return { detail: "disconnected", error: null, label: target.label, targetId };
}
/** Everything one target+host's workspace owns; kept across host switches
@@ -204,6 +254,7 @@ interface StoreEntry {
readonly api: SettingsStoreApi;
settingsRevision: string;
catalogRevision: string;
+ catalog: CatalogFrame;
models: readonly ModelChoice[];
agents: AgentCatalog;
/** Frame pair the roleTags were computed from; either frame can move. */
@@ -265,6 +316,7 @@ export function createLiveSettingsScreenModel(options: LiveSettingsScreenModelOp
(state.phase === "ready" &&
next.phase === "ready" &&
state.api === next.api &&
+ state.catalog === next.catalog &&
state.models === next.models &&
state.agents === next.agents &&
state.roleTags === next.roleTags &&
@@ -336,10 +388,16 @@ export function createLiveSettingsScreenModel(options: LiveSettingsScreenModelOp
let problem: ConnectionProblem;
if (selectedTargetId !== null) {
active = hostForTarget(snapshot, selectedTargetId);
- problem = active === null ? connectionProblemFor(snapshot, selectedTargetId) : { detail: "no-host", error: null, label: null };
+ problem =
+ active === null
+ ? connectionProblemFor(snapshot, selectedTargetId)
+ : { detail: "no-host", error: null, label: null, targetId: active.targetId };
} else {
active = resolveActiveHost(snapshot);
- problem = active === null ? connectionProblem(snapshot) : { detail: "no-host", error: null, label: null };
+ problem =
+ active === null
+ ? connectionProblem(snapshot)
+ : { detail: "no-host", error: null, label: null, targetId: active.targetId };
}
broker.sync(active === null ? null : { targetId: active.targetId, hostId: active.hostId });
@@ -353,7 +411,7 @@ export function createLiveSettingsScreenModel(options: LiveSettingsScreenModelOp
message: problem.error,
hostLabel: problem.label,
hosts,
- activeTargetId: selectedTargetId,
+ activeTargetId: problem.targetId,
});
} else {
setState({
@@ -361,7 +419,7 @@ export function createLiveSettingsScreenModel(options: LiveSettingsScreenModelOp
detail: problem.detail,
hostLabel: problem.label,
hosts,
- activeTargetId: selectedTargetId,
+ activeTargetId: problem.targetId,
});
}
return;
@@ -411,6 +469,7 @@ export function createLiveSettingsScreenModel(options: LiveSettingsScreenModelOp
api: createSettingsStore(built.catalog, controller),
settingsRevision: built.catalog.revision,
catalogRevision: "",
+ catalog: catalogFrame,
models: [],
agents: { agents: [], unavailableReason: null },
tagsKey: "",
@@ -451,6 +510,7 @@ export function createLiveSettingsScreenModel(options: LiveSettingsScreenModelOp
if (String(catalogFrame.revision) !== entry.catalogRevision) {
entry.catalogRevision = String(catalogFrame.revision);
+ entry.catalog = catalogFrame;
entry.models = modelChoicesFromCatalog(catalogFrame);
entry.agents = agentChoicesFromCatalog(catalogFrame);
}
@@ -467,6 +527,7 @@ export function createLiveSettingsScreenModel(options: LiveSettingsScreenModelOp
phase: "ready",
api: entry.api,
active,
+ catalog: entry.catalog,
models: entry.models,
agents: entry.agents,
roleTags: entry.roleTags,
diff --git a/apps/web/src/features/settings/settings-presentation.test.ts b/apps/web/src/features/settings/settings-presentation.test.ts
index 4822518f..de0f1ca8 100644
--- a/apps/web/src/features/settings/settings-presentation.test.ts
+++ b/apps/web/src/features/settings/settings-presentation.test.ts
@@ -11,6 +11,7 @@ import type { ModelChoice } from "./live-catalog.ts";
import {
agentDisplayName,
aliasRoleOf,
+ catalogExplorerState,
formatAlias,
humanizeIdentifier,
modelOptionLabel,
@@ -219,3 +220,73 @@ describe("routing search text", () => {
expect(text.disabled).not.toContain("scout");
});
});
+
+describe("host catalog explorer", () => {
+ const host = { hostLabel: "Work Mac", hostId: "host-work" };
+
+ it("groups supported host entries by kind and keeps only bounded safe metadata", () => {
+ const { catalog } = frames({}, [
+ {
+ id: "model:anthropic/claude",
+ kind: "model",
+ name: "Claude",
+ description: "A hosted model.",
+ metadata: { provider: "anthropic", modelId: "claude", contextWindow: 200_000, nested: { hidden: true } },
+ },
+ {
+ id: "tool:search",
+ kind: "tool",
+ name: "Search",
+ capabilities: ["catalog.read"],
+ metadata: { aliases: ["find", "lookup"] },
+ },
+ { id: "command:compact", kind: "command", name: "Compact" },
+ { id: "setting:secret", kind: "setting", name: "Not a capability" },
+ { id: "future:item", kind: "future", name: "Unknown kind" },
+ ]);
+
+ const state = catalogExplorerState({ host, catalog });
+ expect(state.status).toBe("ready");
+ if (state.status !== "ready") return;
+ expect(state.groups.map((group) => group.kind)).toEqual(["command", "tool", "model"]);
+ expect(state.groups.map((group) => group.entries.map((entry) => entry.name))).toEqual([
+ ["Compact"],
+ ["Search"],
+ ["Claude"],
+ ]);
+ expect(state.groups[2]?.entries[0]?.metadata).toEqual([
+ { key: "provider", value: "anthropic" },
+ { key: "modelId", value: "claude" },
+ { key: "contextWindow", value: "200000" },
+ ]);
+ expect(state.groups[1]?.entries[0]?.metadata).toEqual([
+ { key: "capabilities", value: "catalog.read" },
+ { key: "aliases", value: "find, lookup" },
+ ]);
+ });
+
+ it("names waiting and unavailable host catalog states precisely", () => {
+ expect(catalogExplorerState({ host, phase: "waiting" })).toMatchObject({
+ status: "waiting",
+ title: "Waiting for host catalog",
+ detail: expect.stringContaining("Work Mac"),
+ });
+ expect(catalogExplorerState({ host, phase: "unavailable" })).toMatchObject({
+ status: "unavailable",
+ title: "Host catalog unavailable",
+ detail: expect.stringContaining("Work Mac"),
+ });
+ });
+
+ it("distinguishes an empty catalog from a catalog that is not ready", () => {
+ const { catalog } = frames({}, [{ id: "setting:one", kind: "setting", name: "Setting only" }]);
+ expect(catalogExplorerState({ host, catalog })).toMatchObject({
+ status: "empty",
+ title: "No capability entries published",
+ });
+ expect(catalogExplorerState({ host })).toMatchObject({
+ status: "unavailable",
+ title: "Host catalog unavailable",
+ });
+ });
+});
diff --git a/apps/web/src/features/settings/settings-presentation.ts b/apps/web/src/features/settings/settings-presentation.ts
index 2b507191..8e08a308 100644
--- a/apps/web/src/features/settings/settings-presentation.ts
+++ b/apps/web/src/features/settings/settings-presentation.ts
@@ -6,7 +6,7 @@
// rewritten on save. The host catalog is the label authority when it names a
// model; every fallback is deterministic so the same input always renders
// the same words.
-import type { CatalogFrame, SettingsFrame } from "@t4-code/protocol";
+import type { CatalogFrame, CatalogItem, SettingsFrame } from "@t4-code/protocol";
import type { ModelChoice } from "./live-catalog.ts";
import { isBuiltinRole, parseSelector, roleInfo, type ThinkingLevel } from "./roles-model.ts";
@@ -364,3 +364,185 @@ export function modelRoutingSearchText(input: ModelRoutingSearchInput): ModelRou
const join = (terms: readonly string[]) => terms.join(" ").toLowerCase();
return { roles: join(roles), cycle: join(cycle), overrides: join(overrides), disabled: join(disabled) };
}
+
+// Host catalog explorer
+
+/** Catalog kinds the settings surface can explain without adding controls. */
+export const CATALOG_EXPLORER_KINDS = ["command", "tool", "skill", "agent", "model", "provider", "mode"] as const;
+export type CatalogExplorerKind = (typeof CATALOG_EXPLORER_KINDS)[number];
+
+const CATALOG_KIND_LABELS: Readonly> = {
+ command: "Commands",
+ tool: "Tools",
+ skill: "Skills",
+ agent: "Agents",
+ model: "Models",
+ provider: "Providers",
+ mode: "Modes",
+};
+const MAX_EXPLORER_METADATA = 8;
+const MAX_EXPLORER_TEXT = 512;
+const MAX_EXPLORER_METADATA_TEXT = 256;
+
+export interface CatalogExplorerHost {
+ readonly hostLabel: string;
+ readonly hostId: string;
+}
+
+export interface CatalogExplorerMetadata {
+ readonly key: string;
+ readonly value: string;
+}
+
+export interface CatalogExplorerEntry {
+ readonly id: string;
+ readonly name: string;
+ readonly description: string | null;
+ readonly metadata: readonly CatalogExplorerMetadata[];
+ readonly supported: boolean;
+ readonly reason: string | null;
+}
+
+export interface CatalogExplorerGroup {
+ readonly kind: CatalogExplorerKind;
+ readonly label: string;
+ readonly entries: readonly CatalogExplorerEntry[];
+}
+
+export type CatalogExplorerState =
+ | {
+ readonly status: "waiting";
+ readonly host: CatalogExplorerHost;
+ readonly title: "Waiting for host catalog";
+ readonly detail: string;
+ }
+ | {
+ readonly status: "unavailable";
+ readonly host: CatalogExplorerHost;
+ readonly title: "Host catalog unavailable";
+ readonly detail: string;
+ }
+ | {
+ readonly status: "empty";
+ readonly host: CatalogExplorerHost;
+ readonly title: "No capability entries published";
+ readonly detail: string;
+ }
+ | {
+ readonly status: "ready";
+ readonly host: CatalogExplorerHost;
+ readonly groups: readonly CatalogExplorerGroup[];
+ readonly itemCount: number;
+ };
+
+export interface CatalogExplorerInput {
+ readonly host: CatalogExplorerHost;
+ readonly catalog?: CatalogFrame;
+ /** Explicit non-ready state for a host that is still connecting or lacks the catalog feature. */
+ readonly phase?: "waiting" | "unavailable";
+}
+
+function explorerText(value: unknown, max = MAX_EXPLORER_TEXT): string | undefined {
+ if (typeof value !== "string" || value.length === 0 || value.length > max || CONTROL_CHARS.test(value)) return undefined;
+ return value;
+}
+
+function explorerMetadataValue(value: unknown): string | undefined {
+ if (typeof value === "string") return explorerText(value, MAX_EXPLORER_METADATA_TEXT);
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
+ if (typeof value === "boolean") return String(value);
+ if (!Array.isArray(value) || value.length > 8) return undefined;
+ const values = value.map((item) => (typeof item === "string" ? explorerText(item, 64) : typeof item === "number" && Number.isFinite(item) ? String(item) : typeof item === "boolean" ? String(item) : undefined));
+ return values.every((item): item is string => item !== undefined) ? values.join(", ") : undefined;
+}
+
+const SAFE_EXPLORER_METADATA_KEYS = new Set([
+ "aliases",
+ "contextWindow",
+ "cycle",
+ "cycleIndex",
+ "modelId",
+ "provider",
+ "role",
+]);
+
+
+function explorerMetadata(item: CatalogItem): readonly CatalogExplorerMetadata[] {
+ const metadata: CatalogExplorerMetadata[] = [];
+ if (item.capabilities !== undefined) {
+ const capabilities = explorerMetadataValue(item.capabilities);
+ if (capabilities !== undefined) metadata.push({ key: "capabilities", value: capabilities });
+ }
+ if (item.metadata === undefined || typeof item.metadata !== "object" || item.metadata === null || Array.isArray(item.metadata)) {
+ return metadata;
+ }
+ for (const [key, rawValue] of Object.entries(item.metadata)) {
+ if (metadata.length >= MAX_EXPLORER_METADATA || !SAFE_EXPLORER_METADATA_KEYS.has(key)) continue;
+ const value = explorerMetadataValue(rawValue);
+ if (value !== undefined) metadata.push({ key, value });
+ }
+ return metadata;
+}
+
+function explorerEntry(item: CatalogItem): CatalogExplorerEntry | null {
+ if (!(CATALOG_EXPLORER_KINDS as readonly string[]).includes(item.kind)) return null;
+ const name = explorerText(item.name, 256);
+ const id = explorerText(String(item.id), 256);
+ if (name === undefined || id === undefined) return null;
+ return {
+ id,
+ name,
+ description: explorerText(item.description) ?? null,
+ metadata: explorerMetadata(item),
+ supported: item.supported !== false,
+ reason: item.supported === false ? explorerText(item.reason) ?? "Not available on this host." : null,
+ };
+}
+export function catalogExplorerState(input: CatalogExplorerInput): CatalogExplorerState {
+ if (input.phase === "waiting") {
+ return {
+ status: "waiting",
+ host: input.host,
+ title: "Waiting for host catalog",
+ detail: `Waiting for ${input.host.hostLabel} to publish its capability catalog.`,
+ };
+ }
+ if (input.phase === "unavailable" || input.catalog === undefined) {
+ return {
+ status: "unavailable",
+ host: input.host,
+ title: "Host catalog unavailable",
+ detail: `The capability catalog is unavailable from ${input.host.hostLabel}.`,
+ };
+ }
+
+ const byKind = new Map();
+ let itemCount = 0;
+ // The protocol decoder bounds catalog items to its 1,000-item array limit;
+ // keep every supported entry rather than imposing a smaller UI cap.
+ for (const item of input.catalog.items) {
+ const entry = explorerEntry(item);
+ if (entry === null) continue;
+ const kind = item.kind as CatalogExplorerKind;
+ const entries = byKind.get(kind) ?? [];
+ if (entries.some((candidate) => candidate.id === entry.id)) continue;
+ entries.push(entry);
+ byKind.set(kind, entries);
+ itemCount++;
+ }
+ const groups = CATALOG_EXPLORER_KINDS.flatMap((kind) => {
+ const entries = byKind.get(kind);
+ if (entries === undefined || entries.length === 0) return [];
+ entries.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
+ return [{ kind, label: CATALOG_KIND_LABELS[kind], entries }] satisfies CatalogExplorerGroup[];
+ });
+ if (groups.length === 0) {
+ return {
+ status: "empty",
+ host: input.host,
+ title: "No capability entries published",
+ detail: `${input.host.hostLabel} published no capability entries in its catalog.`,
+ };
+ }
+ return { status: "ready", host: input.host, groups, itemCount };
+}
diff --git a/apps/web/test/catalog-explorer.test.ts b/apps/web/test/catalog-explorer.test.ts
new file mode 100644
index 00000000..5c4bd8a8
--- /dev/null
+++ b/apps/web/test/catalog-explorer.test.ts
@@ -0,0 +1,107 @@
+import { catalogId, hostId, revision } from "@t4-code/protocol";
+
+import { describe, expect, it } from "vite-plus/test";
+
+import { catalogExplorerState } from "../src/features/settings/settings-presentation.ts";
+const V = "omp-app/1" as const;
+
+const HOST = { hostLabel: "Build Mac", hostId: "host-a" } as const;
+
+describe("host capability explorer", () => {
+ it("names waiting, unavailable, and empty host states", () => {
+ expect(catalogExplorerState({ host: HOST, phase: "waiting" })).toMatchObject({
+ status: "waiting",
+ title: "Waiting for host catalog",
+ });
+ expect(catalogExplorerState({ host: HOST, phase: "unavailable" })).toMatchObject({
+ status: "unavailable",
+ title: "Host catalog unavailable",
+ });
+ expect(
+ catalogExplorerState({
+ host: HOST,
+ catalog: { v: V, type: "catalog", hostId: hostId(HOST.hostId), revision: revision("1"), items: [] },
+ }),
+ ).toMatchObject({
+ status: "empty",
+ title: "No capability entries published",
+ detail: "Build Mac published no capability entries in its catalog.",
+ });
+ });
+
+ it("groups supported kinds and omits settings, unsafe metadata, and malformed entries", () => {
+ const state = catalogExplorerState({
+ host: HOST,
+ catalog: {
+ v: V,
+ hostId: hostId(HOST.hostId),
+ type: "catalog",
+ revision: revision("7"),
+ items: [
+ {
+ id: catalogId("model:claude"),
+ kind: "model",
+ name: "Claude",
+ description: "A hosted model.",
+ metadata: {
+ provider: "anthropic",
+ modelId: "claude",
+ contextWindow: 200_000,
+ endpoint: "https://user:secret@example.invalid/",
+ value: "SUPER_SECRET",
+ nested: { hidden: true },
+ },
+ },
+ {
+ id: catalogId("tool:search"),
+ kind: "tool",
+ name: "Search",
+ capabilities: ["catalog.read"],
+ metadata: { aliases: ["find", "lookup"] },
+ },
+ { id: catalogId("command:compact"), kind: "command", name: "Compact" },
+ { id: catalogId("setting:secret"), kind: "setting", name: "Not a capability" },
+ { id: catalogId("agent:review"), kind: "agent", name: "Review", supported: false },
+ { id: catalogId("bad"), kind: "provider", name: "bad\u0000name" },
+ ],
+ },
+ });
+
+ expect(state.status).toBe("ready");
+ if (state.status !== "ready") throw new Error("expected ready explorer state");
+ expect(state.itemCount).toBe(4);
+ expect(state.groups.map((group) => group.kind)).toEqual(["command", "tool", "agent", "model"]);
+ expect(state.groups[1]?.entries[0]?.metadata).toEqual([
+ { key: "capabilities", value: "catalog.read" },
+ { key: "aliases", value: "find, lookup" },
+ ]);
+ expect(state.groups[3]?.entries[0]?.metadata).toEqual([
+ { key: "provider", value: "anthropic" },
+ { key: "modelId", value: "claude" },
+ { key: "contextWindow", value: "200000" },
+ ]);
+ });
+ it("keeps every valid decoded catalog entry beyond the old 128-item display cap", () => {
+ const items = Array.from({ length: 184 }, (_, index) => ({
+ id: catalogId(`model:fixture-${index}`),
+ kind: "model" as const,
+ name: `Fixture model ${index}`,
+ }));
+ const state = catalogExplorerState({
+ host: HOST,
+ catalog: {
+ v: V,
+ hostId: hostId(HOST.hostId),
+ type: "catalog",
+ revision: revision("184"),
+ items,
+ },
+ });
+
+ expect(state.status).toBe("ready");
+ if (state.status !== "ready") throw new Error("expected ready explorer state");
+ expect(state.itemCount).toBe(184);
+ expect(state.groups).toHaveLength(1);
+ expect(state.groups[0]?.entries).toHaveLength(184);
+ });
+});
diff --git a/apps/web/test/live-settings-screen.test.ts b/apps/web/test/live-settings-screen.test.ts
index a0195b0c..4a9feae8 100644
--- a/apps/web/test/live-settings-screen.test.ts
+++ b/apps/web/test/live-settings-screen.test.ts
@@ -20,6 +20,7 @@ import type {
import type { LiveSettingsRuntimePort } from "../src/features/settings/live-controller.ts";
import {
+ catalogExplorerInputForLiveState,
createLiveSettingsScreenModel,
type LiveSettingsScreenState,
} from "../src/features/settings/live-screen-model.ts";
@@ -189,6 +190,7 @@ describe("live settings screen model", () => {
expect(second.phase).toBe("ready");
if (second.phase !== "ready") return;
expect(second.api).toBe(first.api);
+ expect(second.catalog.revision).toBe("rev-2");
expect(second.api.getState().viewModel.revision).toBe("rev-2");
expect(second.api.getState().announcement).toBe("Settings refreshed from the host.");
});
@@ -242,6 +244,45 @@ describe("live settings screen model", () => {
const { model } = attachedModel(runtime);
expect(model.getState()).toMatchObject({ phase: "waiting", detail: "no-host" });
});
+ it("mounts a waiting explorer for a connected host before catalog frames arrive", () => {
+ const runtime = new FakeRuntime();
+ runtime.connectLocal();
+ const { model } = attachedModel(runtime);
+ const input = catalogExplorerInputForLiveState(runtime, model.getState());
+ expect(input).toMatchObject({
+ host: { hostLabel: "This computer", hostId: "host-1" },
+ phase: "waiting",
+ });
+ });
+
+ it("mounts an unavailable explorer when the active host disconnects", () => {
+ const runtime = new FakeRuntime();
+ runtime.connectLocal();
+ runtime.snapshot.connections.set("local", "error");
+ const { model } = attachedModel(runtime);
+ const input = catalogExplorerInputForLiveState(runtime, model.getState());
+ expect(input).toMatchObject({
+ host: { hostLabel: "This computer", hostId: "host-1" },
+ phase: "unavailable",
+ });
+ });
+
+ it("does not guess an active host from duplicate display labels", () => {
+ const runtime = new FakeRuntime();
+ runtime.snapshot.targets.set("first", { targetId: "first", label: "Remote", state: "connected" });
+ runtime.snapshot.targets.set("second", { targetId: "second", label: "Remote", state: "connected" });
+ runtime.snapshot.targetHosts.set("first", "host-1");
+ runtime.snapshot.targetHosts.set("second", "host-2");
+ const state: LiveSettingsScreenState = {
+ phase: "waiting",
+ detail: "connecting",
+ hostLabel: "Remote",
+ hosts: [],
+ activeTargetId: null,
+ };
+
+ expect(catalogExplorerInputForLiveState(runtime, state)).toBeUndefined();
+ });
});
// ─── Full-catalog rendering and sensitive hygiene ───────────────────────────
@@ -304,6 +345,10 @@ describe("full catalog rendering", () => {
unavailableReason: null,
});
});
+ it("renders each entry's raw host catalog ID as visible monospace metadata", () => {
+ const source = readFileSync(join(SETTINGS_SRC, "CatalogExplorerBlock.tsx"), "utf8");
+ expect(source).toMatch(/font-mono[^"]*" title="Raw host catalog ID"[\s\S]*\{entry\.id\}/);
+ });
});
// ─── Defensive labels, Advanced grouping, and search clarity ────────────────
From 4ca2db31febc1695302e042022c91b457057cce6 Mon Sep 17 00:00:00 2001
From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com>
Date: Fri, 17 Jul 2026 21:44:01 -0700
Subject: [PATCH 3/6] feat: cache recent sessions for offline startup
---
apps/desktop/src/ipc.ts | 22 +++++
apps/desktop/src/lifecycle.ts | 11 ++-
apps/desktop/src/preload.ts | 19 ++++
apps/desktop/src/stores.ts | 97 +++++++++++++++++++
apps/desktop/test/ipc-lifecycle.test.ts | 36 +++++++
apps/desktop/test/lifecycle-runtime.test.ts | 25 +++++
apps/desktop/test/stores-lifecycle.test.ts | 77 ++++++++++++++-
.../features/session-runtime/live-runtime.ts | 24 +++--
apps/web/src/platform/desktop-runtime.ts | 26 ++++-
apps/web/src/platform/live-workspace.ts | 20 +++-
apps/web/test/live-composer.test.ts | 88 +++++++++++++++++
.../client/src/desktop-runtime-contracts.ts | 5 +
packages/client/src/desktop-runtime.ts | 2 +
packages/client/src/projection-cache.ts | 3 +-
packages/client/src/projection.ts | 3 +-
packages/client/test/projection.test.ts | 5 +
packages/protocol/src/desktop-ipc.ts | 68 +++++++++++++
packages/protocol/test/desktop-ipc.test.ts | 39 ++++++++
18 files changed, 554 insertions(+), 16 deletions(-)
diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts
index 76ba6a57..1d9cc014 100644
--- a/apps/desktop/src/ipc.ts
+++ b/apps/desktop/src/ipc.ts
@@ -5,6 +5,8 @@ import {
decodeLocalProfileId,
decodeDesktopUpdateRendererReadyResult,
decodeDesktopUpdateState,
+ decodeProjectionCacheLoadResult,
+ decodeProjectionCacheSaveResult,
type BootstrapResult,
type CommandRequest,
type CommandResult,
@@ -29,6 +31,9 @@ import {
type PairRequest,
type PairResult,
type PairLinksDrainResult,
+ type ProjectionCacheLoadResult,
+ type ProjectionCacheSaveRequest,
+ type ProjectionCacheSaveResult,
type RuntimeErrorEvent,
type ServiceActionResult,
type ServiceAvailabilityIssue,
@@ -72,6 +77,10 @@ export interface IpcRuntime {
readonly restartToUpdate: () => DesktopUpdateState;
readonly subscribe: (listener: (state: DesktopUpdateState) => void) => () => void;
};
+ readonly projectionCache?: {
+ readonly load: () => ProjectionCacheLoadResult | Promise;
+ readonly save: (value: string) => ProjectionCacheSaveResult | Promise;
+ };
}
export class RemotePairingUnavailableError extends Error {
readonly code = "remote_pairing_unavailable" as const;
@@ -271,6 +280,18 @@ export class DesktopIpcRegistry {
openSettings: this.runtime.drainPendingUpdateOpen?.() ?? false,
});
});
+ this.ipc.handle("app:projection-cache:load", async (event, payload: unknown): Promise => {
+ this.assertSender(event);
+ decodeRequest("app:projection-cache:load", payload);
+ const result = await (this.runtime.projectionCache?.load() ?? { available: false, value: null });
+ return decodeProjectionCacheLoadResult(result);
+ });
+ this.ipc.handle("app:projection-cache:save", async (event, payload: unknown): Promise => {
+ this.assertSender(event);
+ const input = decodeRequest("app:projection-cache:save", payload).payload as ProjectionCacheSaveRequest;
+ const result = await (this.runtime.projectionCache?.save(input.value) ?? { saved: false });
+ return decodeProjectionCacheSaveResult(result);
+ });
this.updateUnsubscribe = this.runtime.updateController?.subscribe((state) => {
this.emit("app:update:state", state);
});
@@ -289,6 +310,7 @@ export class DesktopIpcRegistry {
"omp:service:restart", "omp:service:uninstall",
"app:update:get-state", "app:update:check", "app:update:download", "app:update:restart",
"app:update:renderer-ready",
+ "app:projection-cache:load", "app:projection-cache:save",
] as const) this.ipc.removeHandler(channel);
this.installed = false;
}
diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts
index 47d1cf73..f705072e 100644
--- a/apps/desktop/src/lifecycle.ts
+++ b/apps/desktop/src/lifecycle.ts
@@ -8,7 +8,7 @@ import type { ServiceManager } from "@t4-code/service-manager";
import type { RemoteTargetRegistry } from "./remote-runtime/registry.ts";
import { createDesktopWindow, type DesktopWindowHandle } from "./window.ts";
import { DesktopIpcRegistry, runtimeError, type IpcRuntime } from "./ipc.ts";
-import { ElectronCursorStore, ElectronRemoteTargetStore, ElectronCredentialCiphertextStore, ElectronLocalProfileStore, electronSafeStorage, loadDeviceIdentity, type DeviceIdentity } from "./stores.ts";
+import { ElectronCursorStore, ElectronRemoteTargetStore, ElectronCredentialCiphertextStore, ElectronLocalProfileStore, ElectronProjectionCacheStore, electronSafeStorage, loadDeviceIdentity, type DeviceIdentity } from "./stores.ts";
import { VersionedRemoteTargetRegistry, DeviceCredentialStore } from "./remote-runtime/index.ts";
import { LocalTargetManager, type TargetManagerOptions } from "./target-manager.ts";
import { createAppserverServiceManager, discoverOmpExecutable, OmpAppserverCompatibilityError, probeOmpAppserver, NodeServiceFileSystem } from "./service.ts";
@@ -19,6 +19,8 @@ import { DesktopUpdateController } from "./update-controller.ts";
import { LocalProfileRegistry } from "./local-profiles.ts";
import { LocalProfileRuntime } from "./profile-runtime.ts";
+type ProjectionCacheRuntime = NonNullable;
+
export function appserverLogsDirectory(
homeDirectory: string,
platform: NodeJS.Platform = process.platform,
@@ -48,6 +50,7 @@ export interface DesktopLifecycleOptions {
readonly createRemoteRegistry?: () => RemoteTargetRegistry;
readonly createCredentials?: () => DeviceCredentialStore | undefined;
readonly createLocalProfileRegistry?: () => LocalProfileRegistry;
+ readonly createProjectionCache?: () => ProjectionCacheRuntime;
readonly discoverExecutable?: () => Promise;
readonly probeAppserver?: (executable: string) => Promise;
readonly createServiceManager?: (options: Parameters[0]) => ServiceManager;
@@ -75,6 +78,7 @@ export class DesktopLifecycle {
private readonly remoteRegistryFactory: () => RemoteTargetRegistry;
private readonly credentialsFactory: () => DeviceCredentialStore | undefined;
private readonly localProfileRegistryFactory: () => LocalProfileRegistry;
+ private readonly projectionCacheFactory: () => ProjectionCacheRuntime;
private readonly executableFactory: () => Promise;
private readonly serviceFactory: (options: Parameters[0]) => ServiceManager;
private readonly speechServiceFactory: (options: { readonly discoverExecutable: () => Promise }) => DesktopSpeechService;
@@ -88,6 +92,7 @@ export class DesktopLifecycle {
private localProfileRegistry: LocalProfileRegistry | undefined;
private speechService: DesktopSpeechService | undefined;
private profileRuntime: LocalProfileRuntime | undefined;
+ private projectionCache: ProjectionCacheRuntime | undefined;
private serviceManager: ServiceManager | undefined;
private readonly serviceManagers = new Map();
private serviceAvailabilityIssue: ServiceAvailabilityIssue | undefined;
@@ -121,6 +126,7 @@ export class DesktopLifecycle {
this.localProfileRegistryFactory = options.createLocalProfileRegistry ?? (
() => new LocalProfileRegistry(new ElectronLocalProfileStore())
);
+ this.projectionCacheFactory = options.createProjectionCache ?? (() => new ElectronProjectionCacheStore());
this.executableFactory = options.discoverExecutable ?? (() => discoverOmpExecutable());
this.appserverProbe = options.probeAppserver ?? ((executable) => probeOmpAppserver(executable));
this.serviceFactory = options.createServiceManager ?? createAppserverServiceManager;
@@ -165,6 +171,7 @@ export class DesktopLifecycle {
ingest(value);
});
await this.electronApp.whenReady();
+ this.projectionCache = this.projectionCacheFactory();
if (process.platform === "darwin") this.electronApp.setAsDefaultProtocolClient("t4-code");
this.updateController = this.updateControllerFactory();
this.menuInstaller({ onOpenUpdates: () => this.openUpdatesFromMenu() });
@@ -227,6 +234,7 @@ export class DesktopLifecycle {
this.mainWindow = undefined;
this.updateController?.dispose();
this.updateController = undefined;
+ this.projectionCache = undefined;
const manager = this.manager;
this.manager = undefined;
const recovery = this.serviceRecoveryPromise;
@@ -423,6 +431,7 @@ export class DesktopLifecycle {
getServiceAvailabilityIssue: () => this.serviceAvailabilityIssue,
...(this.speechService === undefined ? {} : { speech: this.speechService }),
...(this.profileRuntime === undefined ? {} : { profileRuntime: this.profileRuntime }),
+ ...(this.projectionCache === undefined ? {} : { projectionCache: this.projectionCache }),
drainPairLinks: () => this.pendingPairs.drain(),
drainPendingUpdateOpen: () => this.markUpdateRendererReady(),
...(this.updateController === undefined ? {} : { updateController: this.updateController }),
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index 7c922904..c80b35d2 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -3,6 +3,8 @@ import {
decodeDesktopEvent,
decodeDesktopUpdateRendererReadyResult,
decodeDesktopUpdateState,
+ decodeProjectionCacheLoadResult,
+ decodeProjectionCacheSaveResult,
type BootstrapResult,
type CommandRequest,
type CommandResult,
@@ -39,6 +41,9 @@ import {
type TerminalResult,
type SpeechRequest,
type SpeechResult,
+ type ProjectionCacheLoadResult,
+ type ProjectionCacheSaveRequest,
+ type ProjectionCacheSaveResult,
} from "@t4-code/protocol/desktop-ipc";
export interface OmpShellBridge {
@@ -62,6 +67,8 @@ export interface OmpShellBridge {
readonly serviceStop: () => Promise;
readonly serviceRestart: () => Promise;
readonly serviceUninstall: () => Promise;
+ readonly loadProjectionCache: () => Promise;
+ readonly saveProjectionCache: (request: ProjectionCacheSaveRequest) => Promise;
readonly getUpdateState: () => Promise;
readonly checkForUpdate: () => Promise;
readonly downloadUpdate: () => Promise;
@@ -84,6 +91,7 @@ export interface OmpShellBridge {
readonly onConnectionState: (listener: (event: ConnectionStateEvent) => void) => () => void;
readonly onRuntimeError: (listener: (event: RuntimeErrorEvent) => void) => () => void;
readonly onPairLink: (listener: (event: PairLinkEvent) => void) => () => void;
+
readonly onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;
readonly onOpenUpdateSettings: (listener: (event: DesktopUpdateOpenEvent) => void) => () => void;
}
@@ -92,6 +100,13 @@ function invoke;
}
+function invokeProjectionCache(
+ channel: "app:projection-cache:load" | "app:projection-cache:save",
+ payload: unknown,
+): Promise {
+ return ipcRenderer.invoke(channel, { channel, payload }) as Promise;
+}
+
type SubscriptionPayload = C extends "omp:server-frame"
? RendererServerFrameEvent
: C extends "omp:connection-state"
@@ -146,6 +161,10 @@ const bridge: OmpShellBridge = {
downloadUpdate: () => invoke<"app:update:download", unknown>("app:update:download", {}).then(decodeDesktopUpdateState),
restartToUpdate: () => invoke<"app:update:restart", unknown>("app:update:restart", {}).then(decodeDesktopUpdateState),
updateRendererReady: () => invoke<"app:update:renderer-ready", unknown>("app:update:renderer-ready", {}).then(decodeDesktopUpdateRendererReadyResult),
+ loadProjectionCache: () =>
+ invokeProjectionCache("app:projection-cache:load", {}).then(decodeProjectionCacheLoadResult),
+ saveProjectionCache: (request) =>
+ invokeProjectionCache("app:projection-cache:save", request).then(decodeProjectionCacheSaveResult),
listTargets: () => invoke("omp:targets:list", {}),
addTarget: (request) => invoke("omp:targets:add", request),
removeTarget: (request) => invoke("omp:targets:remove", request),
diff --git a/apps/desktop/src/stores.ts b/apps/desktop/src/stores.ts
index 048e105c..ba30eaa6 100644
--- a/apps/desktop/src/stores.ts
+++ b/apps/desktop/src/stores.ts
@@ -2,6 +2,12 @@ import { randomBytes } from "node:crypto";
import { hostname } from "node:os";
import ElectronStore from "electron-store";
import { safeStorage } from "electron";
+import {
+ decodeProjectionCacheSaveRequestValue,
+ MAX_PROJECTION_CACHE_BYTES,
+ type ProjectionCacheLoadResult,
+ type ProjectionCacheSaveResult,
+} from "@t4-code/protocol/desktop-ipc";
import type { CursorRecord, CursorStore } from "@t4-code/client";
import type { CredentialEntry, CredentialVault, PairedHostRecord, TargetRegistry } from "@t4-code/remote";
import type { CredentialCiphertextStore, RemoteTargetRecord, RemoteTargetStore, SafeStorageAdapter } from "./remote-runtime/index.ts";
@@ -21,6 +27,10 @@ interface RegistryState { readonly targets: Record; }
interface VaultState { readonly ciphertext: Record; }
interface RemoteRegistryState { readonly version: 1; readonly records: readonly RemoteTargetRecord[]; }
interface CredentialCiphertextState { readonly version: 1; readonly ciphertexts: Record; }
+interface ProjectionCacheCiphertextState {
+ readonly version: 1;
+ readonly ciphertext: string | null;
+}
function recordKey(hostId: string, sessionId: string): string { return `${hostId}\u0000${sessionId}`; }
function decodeRemoteState(value: unknown): RemoteRegistryState {
@@ -40,6 +50,28 @@ function decodeCredentialState(value: unknown): CredentialCiphertextState {
}
return { version: 1, ciphertexts };
}
+const MAX_PROJECTION_CACHE_CIPHERTEXT_LENGTH =
+ 4 * Math.ceil((MAX_PROJECTION_CACHE_BYTES + 64) / 3);
+function decodeProjectionCacheState(value: unknown): ProjectionCacheCiphertextState {
+ if (!value || typeof value !== "object" || Array.isArray(value))
+ throw new Error("invalid projection cache state");
+ const root = value as Record;
+ if (Object.keys(root).some((key) => key !== "version" && key !== "ciphertext"))
+ throw new Error("invalid projection cache state");
+ if (root.version !== 1) throw new Error("invalid projection cache state");
+ const ciphertext = root.ciphertext;
+ if (ciphertext === null) return { version: 1, ciphertext: null };
+ if (
+ typeof ciphertext !== "string" ||
+ ciphertext.length === 0 ||
+ ciphertext.length > MAX_PROJECTION_CACHE_CIPHERTEXT_LENGTH ||
+ ciphertext.length % 4 !== 0 ||
+ !/^[A-Za-z0-9+/]*={0,2}$/u.test(ciphertext) ||
+ Buffer.from(ciphertext, "base64").toString("base64") !== ciphertext
+ )
+ throw new Error("invalid projection cache state");
+ return { version: 1, ciphertext };
+}
function enqueueWrite(queue: { tail: Promise }, operation: () => void): Promise {
const result = queue.tail.then(operation, operation);
queue.tail = result.then(() => undefined, () => undefined);
@@ -130,6 +162,71 @@ export class ElectronCredentialCiphertextStore implements CredentialCiphertextSt
}
}
+export class ElectronProjectionCacheStore {
+ private readonly store: ElectronStore;
+ private readonly writeQueue = { tail: Promise.resolve() };
+ private readonly safeStorage: SafeStorageAdapter;
+ constructor(
+ store = new ElectronStore({
+ name: "projection-cache",
+ defaults: { version: 1, ciphertext: null },
+ }),
+ safeStorage: SafeStorageAdapter = electronSafeStorage,
+ ) {
+ this.store = store;
+ this.safeStorage = safeStorage;
+ }
+ load(): ProjectionCacheLoadResult {
+ if (!this.safeStorage.isEncryptionAvailable()) return { available: false, value: null };
+ let state: ProjectionCacheCiphertextState;
+ try {
+ state = decodeProjectionCacheState(this.store.store);
+ } catch {
+ this.clear();
+ return { available: true, value: null };
+ }
+ if (state.ciphertext === null) return { available: true, value: null };
+ try {
+ const value = this.safeStorage.decryptString(Buffer.from(state.ciphertext, "base64"));
+ decodeProjectionCacheSaveRequestValue(value);
+ return { available: true, value };
+ } catch {
+ this.clear();
+ return { available: true, value: null };
+ }
+ }
+ save(value: string): Promise {
+ try {
+ decodeProjectionCacheSaveRequestValue(value);
+ } catch {
+ return Promise.resolve({ saved: false });
+ }
+ return enqueueWrite(this.writeQueue, () => {
+ if (!this.safeStorage.isEncryptionAvailable()) throw new Error("projection cache encryption unavailable");
+ const ciphertext = this.safeStorage.encryptString(value).toString("base64");
+ if (
+ ciphertext.length === 0 ||
+ ciphertext.length > MAX_PROJECTION_CACHE_CIPHERTEXT_LENGTH ||
+ ciphertext.length % 4 !== 0 ||
+ !/^[A-Za-z0-9+/]*={0,2}$/u.test(ciphertext) ||
+ Buffer.from(ciphertext, "base64").toString("base64") !== ciphertext
+ )
+ throw new Error("projection cache ciphertext is invalid");
+ this.store.store = { version: 1, ciphertext };
+ }).then(
+ () => ({ saved: true }),
+ () => ({ saved: false }),
+ );
+ }
+ private clear(): void {
+ try {
+ this.store.store = { version: 1, ciphertext: null };
+ } catch {
+ /* best-effort removal of corrupt state */
+ }
+ }
+}
+
export class ElectronLocalProfileStore implements LocalProfileStore {
private readonly store: ElectronStore;
private readonly writeQueue = { tail: Promise.resolve() };
diff --git a/apps/desktop/test/ipc-lifecycle.test.ts b/apps/desktop/test/ipc-lifecycle.test.ts
index dc1a2515..46e33de1 100644
--- a/apps/desktop/test/ipc-lifecycle.test.ts
+++ b/apps/desktop/test/ipc-lifecycle.test.ts
@@ -107,6 +107,42 @@ describe("desktop IPC lifecycle proof", () => {
await expect(handler(event, request("omp:targets:list", { extra: true }))).rejects.toThrow();
await expect(handler(event, request("omp:connect", {}))).rejects.toThrow();
});
+ it("routes projection cache load/save through the trusted shell service", async () => {
+ const ipc = new FakeIpc();
+ const { runtime: baseRuntime } = makeRuntime();
+ const value = JSON.stringify({
+ kind: "t4-code-projection",
+ version: 1,
+ data: { sessions: [], sessionIndex: [], lru: [], freshness: "cached" },
+ });
+ const saves: string[] = [];
+ const runtime: IpcRuntime = {
+ ...baseRuntime,
+ projectionCache: {
+ load: () => ({ available: true, value }),
+ save: (serialized) => {
+ saves.push(serialized);
+ return { saved: true };
+ },
+ },
+ };
+ new DesktopIpcRegistry(runtime, ipc).install();
+ const event = { sender: runtime.window.webContents, senderFrame: runtime.window.webContents.mainFrame };
+ expect(await ipc.handlers.get("app:projection-cache:load")!(
+ event,
+ request("app:projection-cache:load"),
+ )).toEqual({ available: true, value });
+ expect(await ipc.handlers.get("app:projection-cache:save")!(
+ event,
+ request("app:projection-cache:save", { value }),
+ )).toEqual({ saved: true });
+ expect(saves).toEqual([value]);
+ await expect(ipc.handlers.get("app:projection-cache:save")!(
+ event,
+ request("app:projection-cache:save", { value, storageKey: "renderer-selected-file" }),
+ )).rejects.toThrow();
+ expect(saves).toEqual([value]);
+ });
it("serializes concurrent service actions", async () => {
const ipc = new FakeIpc();
const order: string[] = [];
diff --git a/apps/desktop/test/lifecycle-runtime.test.ts b/apps/desktop/test/lifecycle-runtime.test.ts
index b5abe362..d20d406b 100644
--- a/apps/desktop/test/lifecycle-runtime.test.ts
+++ b/apps/desktop/test/lifecycle-runtime.test.ts
@@ -87,6 +87,7 @@ function setup(
overrides: {
readonly discoverExecutable?: () => Promise;
readonly createServiceManager?: NonNullable;
+ readonly createProjectionCache?: NonNullable;
} = {},
) {
const app = new FakeApp();
@@ -132,6 +133,7 @@ function setup(
createCursorStore: () => ({ load: () => [], save: () => {} }),
createCredentials: () => undefined,
createLocalProfileRegistry: () => localProfileRegistry,
+ ...(overrides.createProjectionCache === undefined ? {} : { createProjectionCache: overrides.createProjectionCache }),
discoverExecutable: overrides.discoverExecutable ?? (serviceManager === undefined ? async () => undefined : async () => "/opt/omp/bin/omp"),
...(
overrides.createServiceManager === undefined && serviceManager === undefined
@@ -181,6 +183,29 @@ describe("desktop Electron lifecycle", () => {
expect(fixture.managerOptions).toBeDefined();
await fixture.lifecycle.stop();
});
+ it("creates one projection cache after readiness and injects it into every IPC binding", async () => {
+ const cache = {
+ load: () => ({ available: false, value: null }),
+ save: (_value: string) => ({ saved: false }),
+ };
+ let cacheCreates = 0;
+ const fixture = setup(undefined, async () => true, {
+ createProjectionCache: () => {
+ cacheCreates += 1;
+ return cache;
+ },
+ });
+ expect(cacheCreates).toBe(0);
+ await fixture.lifecycle.start();
+ expect(cacheCreates).toBe(1);
+ expect(fixture.runtimes[0]).toMatchObject({ projectionCache: cache });
+ fixture.windows[0]!.close();
+ fixture.app.listeners.get("activate")?.();
+ expect(fixture.runtimes).toHaveLength(2);
+ expect(fixture.runtimes[1]).toMatchObject({ projectionCache: cache });
+ expect(cacheCreates).toBe(1);
+ await fixture.lifecycle.stop();
+ });
it("routes the native update menu to the trusted renderer and schedules one passive check", async () => {
const fixture = setup();
await fixture.lifecycle.start();
diff --git a/apps/desktop/test/stores-lifecycle.test.ts b/apps/desktop/test/stores-lifecycle.test.ts
index a405b2d7..d09e078b 100644
--- a/apps/desktop/test/stores-lifecycle.test.ts
+++ b/apps/desktop/test/stores-lifecycle.test.ts
@@ -3,7 +3,7 @@ import type { CursorStore, OmpTransport } from "@t4-code/client";
import { hostId, type WelcomeFrame } from "@t4-code/protocol";
import { DeviceCredentialStore, type CredentialCiphertextStore, type RemoteTargetRecord, type RemoteTargetRegistry } from "../src/remote-runtime/index.ts";
import { DesktopTargetManager } from "../src/target-manager.ts";
-import { ElectronCredentialCiphertextStore, ElectronRemoteTargetStore, loadDeviceIdentity } from "../src/stores.ts";
+import { ElectronCredentialCiphertextStore, ElectronProjectionCacheStore, ElectronRemoteTargetStore, loadDeviceIdentity } from "../src/stores.ts";
type State = Record;
class MemoryStore {
@@ -99,6 +99,81 @@ describe("desktop persisted lifecycle stores", () => {
expect(() => new DeviceCredentialStore(store, { isEncryptionAvailable: () => false, encryptString: (_value: string) => Buffer.alloc(0), decryptString: (_value: Buffer) => "" })).toThrow();
expect(() => new DeviceCredentialStore(store, { isEncryptionAvailable: () => true, selectedStorageBackend: () => "basic_text", encryptString: (_value: string) => Buffer.alloc(0), decryptString: (_value: Buffer) => "" })).toThrow();
});
+ it("stores only safeStorage ciphertext and restores valid projection cache", async () => {
+ const backing = new MemoryStore({ version: 1 as const, ciphertext: null as string | null });
+ const safeStorage = {
+ isEncryptionAvailable: () => true,
+ encryptString: (value: string) => Buffer.from(`ciphertext:${value}`),
+ decryptString: (value: Buffer) => {
+ const encoded = value.toString();
+ if (!encoded.startsWith("ciphertext:")) throw new Error("corrupt ciphertext");
+ return encoded.slice("ciphertext:".length);
+ },
+ };
+ const store = new ElectronProjectionCacheStore(backing as never, safeStorage);
+ const value = JSON.stringify({
+ kind: "t4-code-projection",
+ version: 1,
+ data: { sessions: [], sessionIndex: [], lru: [], freshness: "cached" },
+ });
+ expect(await store.save(value)).toEqual({ saved: true });
+ expect(backing.store.ciphertext).not.toContain(value);
+ expect(store.load()).toEqual({ available: true, value });
+ });
+ it("degrades when safeStorage is unavailable and clears incompatible state", async () => {
+ const unavailableBacking = new MemoryStore({ version: 1 as const, ciphertext: null as string | null });
+ const unavailable = new ElectronProjectionCacheStore(unavailableBacking as never, {
+ isEncryptionAvailable: () => false,
+ encryptString: () => Buffer.from("unused"),
+ decryptString: () => "unused",
+ });
+ expect(unavailable.load()).toEqual({ available: false, value: null });
+ expect(await unavailable.save(JSON.stringify({
+ kind: "t4-code-projection",
+ version: 1,
+ data: {},
+ }))).toEqual({ saved: false });
+
+ const corruptBacking = new MemoryStore({ version: 2 as never, ciphertext: "not-valid" });
+ const corrupt = new ElectronProjectionCacheStore(corruptBacking as never, {
+ isEncryptionAvailable: () => true,
+ encryptString: (value: string) => Buffer.from(value),
+ decryptString: (value: Buffer) => value.toString(),
+ });
+ expect(corrupt.load()).toEqual({ available: true, value: null });
+ expect(corruptBacking.store).toEqual({ version: 1, ciphertext: null });
+ const payloadBacking = new MemoryStore({
+ version: 1 as const,
+ ciphertext: Buffer.from("encrypted").toString("base64"),
+ });
+ const payloadCorrupt = new ElectronProjectionCacheStore(payloadBacking as never, {
+ isEncryptionAvailable: () => true,
+ encryptString: (value: string) => Buffer.from(value),
+ decryptString: () => JSON.stringify({ kind: "t4-code-projection", version: 2, data: {} }),
+ });
+ expect(payloadCorrupt.load()).toEqual({ available: true, value: null });
+ expect(payloadBacking.store).toEqual({ version: 1, ciphertext: null });
+ });
+ it("serializes concurrent projection cache writes", async () => {
+ const backing = new MemoryStore({ version: 1 as const, ciphertext: null as string | null });
+ const store = new ElectronProjectionCacheStore(backing as never, {
+ isEncryptionAvailable: () => true,
+ encryptString: (value: string) => Buffer.from(value),
+ decryptString: (value: Buffer) => value.toString(),
+ });
+ const makeValue = (marker: string) => JSON.stringify({
+ kind: "t4-code-projection",
+ version: 1,
+ data: { marker },
+ });
+ const first = makeValue("first");
+ const second = makeValue("second");
+ expect(await Promise.all([store.save(first), store.save(second)])).toEqual([
+ { saved: true },
+ { saved: true },
+ ]);
+ expect(store.load()).toEqual({ available: true, value: second });
+ });
});
describe("pairing credential sink boundaries", () => {
diff --git a/apps/web/src/features/session-runtime/live-runtime.ts b/apps/web/src/features/session-runtime/live-runtime.ts
index 818deff3..9df38b1d 100644
--- a/apps/web/src/features/session-runtime/live-runtime.ts
+++ b/apps/web/src/features/session-runtime/live-runtime.ts
@@ -624,7 +624,9 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
let attaching = false;
let retryAfterAttach = false;
let connectionGeneration = 0;
- let previousConnected = controller.getSnapshot().connections.get(targetId) === "connected";
+ let previousAttachAuthority =
+ controller.getSnapshot().connections.get(targetId) === "connected" &&
+ controller.getSnapshot().targetHosts.get(targetId) === options.hostId;
const initialAuthoritativeWorking = authoritativeWorkingState(
controller.getSnapshot(),
targetId,
@@ -646,19 +648,21 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
// turn/compaction whose active ref delta has not arrived yet.
transcript = settleTranscriptTurn(transcript);
}
- const attachIfConnected = (runtime: DesktopRuntimeSnapshot) => {
+ const attachIfAuthoritative = (runtime: DesktopRuntimeSnapshot) => {
if (disposed) return;
- const connected = runtime.connections.get(targetId) === "connected";
- if (connected !== previousConnected) {
- previousConnected = connected;
+ const hasAttachAuthority =
+ runtime.connections.get(targetId) === "connected" &&
+ runtime.targetHosts.get(targetId) === options.hostId;
+ if (hasAttachAuthority !== previousAttachAuthority) {
+ previousAttachAuthority = hasAttachAuthority;
connectionGeneration += 1;
- if (!connected) {
+ if (!hasAttachAuthority) {
attached = false;
transcriptImagesAttached = false;
syncTranscriptImageAvailability(runtime);
}
}
- if (!connected) return;
+ if (!hasAttachAuthority) return;
if (attached) return;
if (attaching) {
retryAfterAttach = true;
@@ -688,7 +692,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
if (disposed) return;
if (retryAfterAttach && generation !== connectionGeneration) {
retryAfterAttach = false;
- attachIfConnected(controller.getSnapshot());
+ attachIfAuthoritative(controller.getSnapshot());
} else {
retryAfterAttach = false;
}
@@ -715,7 +719,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
const unsubscribeRuntime = controller.subscribe((runtime) => {
const retainedTranscript = withWarmHistoryTruncation(transcript, runtime);
if (retainedTranscript !== transcript) transcript = retainedTranscript;
- attachIfConnected(runtime);
+ attachIfAuthoritative(runtime);
const authoritativeWorking = authoritativeWorkingState(
runtime,
targetId,
@@ -750,7 +754,7 @@ export function createLiveSessionRuntime(options: LiveRuntimeOptions): SessionRu
// part of the attach round-trip; no replay frame may land in the gap between
// runtime construction and listener registration.
syncTranscriptImageAvailability(controller.getSnapshot());
- attachIfConnected(controller.getSnapshot());
+ attachIfAuthoritative(controller.getSnapshot());
const pendingChallenge = (runtime: DesktopRuntimeSnapshot): PendingChallenge | null => {
const confirmations = warmSession(runtime)?.confirmations;
diff --git a/apps/web/src/platform/desktop-runtime.ts b/apps/web/src/platform/desktop-runtime.ts
index 8dcbd391..6f058063 100644
--- a/apps/web/src/platform/desktop-runtime.ts
+++ b/apps/web/src/platform/desktop-runtime.ts
@@ -5,9 +5,11 @@
// mode has no shell and keeps the fixture path.
import {
createDesktopRuntimeController,
+ createProjectionStore,
type DesktopRuntimeController,
type DesktopRuntimeSnapshot,
type DesktopShellPort,
+ type ProjectionCacheStore,
} from "@t4-code/client";
import { useSyncExternalStore } from "react";
@@ -31,6 +33,22 @@ export interface RuntimePageLifecycleTarget {
/** Anything that can carry the window's runtime slot (globalThis in prod). */
export type RuntimeSlotHolder = object & { [RUNTIME_SLOT]?: RuntimeSlot };
+function projectionCacheStore(shell: DesktopShellPort): ProjectionCacheStore | undefined {
+ const load = shell.loadProjectionCache?.bind(shell);
+ const save = shell.saveProjectionCache?.bind(shell);
+ if (load === undefined || save === undefined) return undefined;
+ return {
+ load: async () => {
+ const result = await load();
+ return result.available ? result.value : null;
+ },
+ save: async (value) => {
+ const result = await save({ value });
+ if (!result.saved) throw new Error("projection cache save failed");
+ },
+ };
+}
+
/**
* The holder's controller, created on first acquisition and reused for
* every later call — including StrictMode's doubled render pass and HMR
@@ -42,7 +60,13 @@ export function acquireRuntimeController(
): DesktopRuntimeController {
let slot = holder[RUNTIME_SLOT];
if (slot === undefined) {
- const controller = createDesktopRuntimeController({ shell });
+ const cacheStore = projectionCacheStore(shell);
+ const controller = createDesktopRuntimeController({
+ shell,
+ ...(cacheStore === undefined
+ ? {}
+ : { projection: createProjectionStore({ cacheStore }) }),
+ });
slot = {
controller,
disposeComposerCleanup: bindAuthoritativeComposerCleanup(controller),
diff --git a/apps/web/src/platform/live-workspace.ts b/apps/web/src/platform/live-workspace.ts
index f3a600db..f46368de 100644
--- a/apps/web/src/platform/live-workspace.ts
+++ b/apps/web/src/platform/live-workspace.ts
@@ -182,7 +182,25 @@ export function deriveWorkspaceData(snapshot: DesktopRuntimeSnapshot): Workspace
}
const hosts: WorkspaceHost[] = [];
- for (const [hostId, meta] of snapshot.hosts) {
+ const hostIds = new Set(snapshot.hosts.keys());
+ for (const ref of snapshot.projection.sessionIndex.values()) {
+ hostIds.add(String(ref.hostId));
+ }
+ for (const hostId of hostIds) {
+ const meta = snapshot.hosts.get(hostId);
+ if (meta === undefined) {
+ // Projection caches retain stable host ids but intentionally do not
+ // persist target bindings or welcome capabilities. This display-only
+ // placeholder keeps cached rows grouped without inventing authority;
+ // the first live welcome replaces it with authenticated metadata.
+ hosts.push({
+ id: hostId,
+ name: hostId,
+ kind: "remote",
+ sessionInventoryTruncated: true,
+ });
+ continue;
+ }
const target = snapshot.targets.get(meta.targetId);
const inventoryMetadata = snapshot.projection.sessionIndexMetadata.get(hostId);
hosts.push({
diff --git a/apps/web/test/live-composer.test.ts b/apps/web/test/live-composer.test.ts
index 6d8e2ea4..8640a9d6 100644
--- a/apps/web/test/live-composer.test.ts
+++ b/apps/web/test/live-composer.test.ts
@@ -11,6 +11,7 @@ import {
applyPublicFrame,
createDesktopRuntimeController,
createProjectionSnapshot,
+ decodeProjectionCache,
encodeProjectionCache,
type DesktopRuntimeController,
type DesktopRuntimeSnapshot,
@@ -2934,6 +2935,93 @@ describe("window runtime slot", () => {
expect(shell.connectCalls).toBe(1);
await first.stop();
});
+ it("hydrates the renderer projection from shell cache and persists later mutations", async () => {
+ let warm = applyPublicFrame(
+ createProjectionSnapshot(),
+ snapshotFrame(1, [entry("cached", "from shell cache")]),
+ );
+ warm = applyPublicFrame(warm, pendingPromptsSessionsFrame([], 1, "idle"));
+ const cachedValue = encodeProjectionCache(warm);
+ const saves: string[] = [];
+ let loads = 0;
+ const cacheLoad = deferred<{ available: boolean; value: string | null }>();
+ type CacheShell = FakeShell & {
+ loadProjectionCache: () => Promise<{ available: boolean; value: string | null }>;
+ saveProjectionCache: (request: { value: string }) => Promise<{ saved: boolean }>;
+ };
+ const shell = new FakeShell() as CacheShell;
+ shell.loadProjectionCache = () => {
+ loads += 1;
+ return cacheLoad.promise;
+ };
+ shell.saveProjectionCache = async ({ value }) => {
+ saves.push(value);
+ return { saved: true };
+ };
+ const holder: RuntimeSlotHolder = {};
+ const controller = acquireRuntimeController(shell, holder);
+ const starting = controller.start();
+ await settle();
+ expect(loads).toBe(1);
+ expect(shell.bootstrapCalls).toBe(0);
+ cacheLoad.resolve({ available: true, value: cachedValue });
+ await starting;
+ expect(shell.bootstrapCalls).toBe(1);
+ const cachedSession = controller.getSnapshot().projection.sessions.get(`${HOST}\u0000${SESSION}`);
+ expect(cachedSession?.entries.map((item) => item.data)).toContainEqual({
+ role: "assistant",
+ text: "from shell cache",
+ });
+ const cachedGroups = buildProjectGroups(
+ deriveWorkspaceData(controller.getSnapshot()),
+ {},
+ {},
+ );
+ expect(cachedGroups).toHaveLength(1);
+ expect(cachedGroups[0]?.sessions.map((row) => row.session.id)).toContain(
+ sessionViewId(HOST, SESSION),
+ );
+ expect(cachedGroups[0]?.host).toMatchObject({
+ id: HOST,
+ name: HOST,
+ kind: "remote",
+ });
+ const cachedRow = cachedGroups[0]?.sessions[0];
+ expect(cachedRow?.session.freshness).toBe("offline");
+ const cachedRuntime = createLiveSessionRuntime({
+ controller,
+ targetId: "local",
+ hostId: HOST,
+ sessionId: SESSION,
+ });
+ expect(cachedRuntime.getSnapshot()).toMatchObject({
+ link: "cached",
+ canPrompt: false,
+ canCancel: false,
+ });
+ expect(shell.commandCount("session.attach")).toBe(0);
+
+ expect(shell.connectCalls).toBe(1);
+ shell.emitFrame({
+ targetId: "local",
+ frame: makeWelcome(HOST, ["sessions.prompt"]),
+ });
+ shell.emitFrame({
+ targetId: "local",
+ frame: durableEntryFrame(2, entry("live", "persist this mutation")),
+ });
+ await settle();
+ expect(shell.commandCount("session.attach")).toBe(1);
+ cachedRuntime.dispose();
+ expect(saves.length).toBeGreaterThan(0);
+ const persisted = decodeProjectionCache(saves.at(-1)!);
+ const persistedSession = persisted.sessions.get(`${HOST}\u0000${SESSION}`);
+ expect(persistedSession?.entries.map((item) => item.data)).toContainEqual({
+ role: "assistant",
+ text: "persist this mutation",
+ });
+ await controller.stop();
+ });
it("retains one live controller across a persisted pagehide/pageshow", async () => {
class FakePageLifecycleTarget {
diff --git a/packages/client/src/desktop-runtime-contracts.ts b/packages/client/src/desktop-runtime-contracts.ts
index b070607a..469c0cf1 100644
--- a/packages/client/src/desktop-runtime-contracts.ts
+++ b/packages/client/src/desktop-runtime-contracts.ts
@@ -37,6 +37,9 @@ import type {
TerminalResult,
SpeechRequest,
SpeechResult,
+ ProjectionCacheLoadResult,
+ ProjectionCacheSaveRequest,
+ ProjectionCacheSaveResult,
} from "@t4-code/protocol/desktop-ipc";
import type { CatalogFrame, SettingsFrame, WelcomeFrame } from "@t4-code/protocol";
import { ImmutableMap } from "./immutable-map.ts";
@@ -76,6 +79,8 @@ export interface DesktopShellPort {
readonly downloadUpdate?: () => Promise;
readonly restartToUpdate?: () => Promise;
readonly updateRendererReady?: () => Promise;
+ readonly loadProjectionCache?: () => Promise;
+ readonly saveProjectionCache?: (request: ProjectionCacheSaveRequest) => Promise;
readonly listTargets: () => Promise;
readonly addTarget: (request: TargetAddRequest) => Promise;
readonly removeTarget: (request: TargetRequest) => Promise;
diff --git a/packages/client/src/desktop-runtime.ts b/packages/client/src/desktop-runtime.ts
index 3edfa1c2..3e702c1f 100644
--- a/packages/client/src/desktop-runtime.ts
+++ b/packages/client/src/desktop-runtime.ts
@@ -626,6 +626,8 @@ export class DesktopRuntimeController {
let bootstrap: BootstrapResult;
let listed: TargetListResult;
try {
+ await this.projection.ready();
+ if (this.stopped) throw new DesktopRuntimeError("stopped", "desktop runtime is stopped");
bootstrap = await this.shell.bootstrap();
if (this.stopped) throw new DesktopRuntimeError("stopped", "desktop runtime is stopped");
this.replace({ platform: bootstrap.platform, desktopVersion: bootstrap.version });
diff --git a/packages/client/src/projection-cache.ts b/packages/client/src/projection-cache.ts
index cca9dc8a..119d7e33 100644
--- a/packages/client/src/projection-cache.ts
+++ b/packages/client/src/projection-cache.ts
@@ -8,6 +8,7 @@ import type {
ReviewFrame,
SessionRef,
} from "@t4-code/protocol";
+import { MAX_PROJECTION_CACHE_BYTES } from "@t4-code/protocol/desktop-ipc";
import { MAX_INDEXED_SESSION_REFS } from "./projection.ts";
import type {
AgentTranscriptProjection,
@@ -23,7 +24,7 @@ import { ImmutableMap } from "./immutable-map.ts";
import { retainedJsonBytes } from "./transcript-retention.ts";
export const PROJECTION_CACHE_VERSION = 1 as const;
-export const MAX_PROJECTION_CACHE_BYTES = 2 * 1024 * 1024;
+export { MAX_PROJECTION_CACHE_BYTES };
export const MAX_PROJECTION_CACHE_SESSIONS = 8;
const MAX_PROJECTION_CACHE_TRANSCRIPT_BYTES = Math.floor(MAX_PROJECTION_CACHE_BYTES * 0.75);
diff --git a/packages/client/src/projection.ts b/packages/client/src/projection.ts
index 7b2511ce..0f1a44f8 100644
--- a/packages/client/src/projection.ts
+++ b/packages/client/src/projection.ts
@@ -1438,7 +1438,8 @@ export class ProjectionStore {
}
private async drainCacheSaves(): Promise {
try {
- while (this.pendingSnapshot !== undefined && !this.disposed) {
+ // Disposal blocks new mutations, but already-coalesced snapshots must still drain.
+ while (this.pendingSnapshot !== undefined) {
const snapshot = this.pendingSnapshot;
this.pendingSnapshot = undefined;
let serialized: string;
diff --git a/packages/client/test/projection.test.ts b/packages/client/test/projection.test.ts
index b5a09e45..ca02e6f9 100644
--- a/packages/client/test/projection.test.ts
+++ b/packages/client/test/projection.test.ts
@@ -1603,10 +1603,15 @@ describe("client projections", () => {
store.applyPublicFrame(frame("snapshot"));
store.applyPublicFrame(frame("event"));
expect(saves).toHaveLength(1);
+ const shutdown = store.dispose();
+ expect(saves).toHaveLength(1);
release?.();
await Promise.resolve();
await Promise.resolve();
expect(saves).toHaveLength(2);
+ expect(saves[1]).not.toBe(saves[0]);
+ release?.();
+ await shutdown;
dispose();
store.applyPublicFrame({ ...frame("event"), cursor: { epoch: "e1", seq: 3 } });
expect(calls).toBe(2);
diff --git a/packages/protocol/src/desktop-ipc.ts b/packages/protocol/src/desktop-ipc.ts
index fedd06a4..dffc4628 100644
--- a/packages/protocol/src/desktop-ipc.ts
+++ b/packages/protocol/src/desktop-ipc.ts
@@ -65,6 +65,8 @@ export const DESKTOP_IPC_CHANNELS = [
"omp:speech:stop",
"app:update:restart",
"app:update:renderer-ready",
+ "app:projection-cache:load",
+ "app:projection-cache:save",
] as const;
export type DesktopInvokeChannel = (typeof DESKTOP_IPC_CHANNELS)[number];
export const DESKTOP_IPC_EVENTS = [
@@ -276,6 +278,18 @@ export interface SpeechResult {
readonly error?: string;
}
+export const MAX_PROJECTION_CACHE_BYTES = 2 * 1024 * 1024;
+export interface ProjectionCacheLoadRequest {}
+export interface ProjectionCacheLoadResult {
+ readonly available: boolean;
+ readonly value: string | null;
+}
+export interface ProjectionCacheSaveRequest {
+ readonly value: string;
+}
+export interface ProjectionCacheSaveResult {
+ readonly saved: boolean;
+}
export interface DesktopUpdateRequest {}
export interface DesktopUpdateRendererReadyResult {
readonly openSettings: boolean;
@@ -453,6 +467,8 @@ export interface DesktopInvokeRequestMap {
"app:update:download": DesktopUpdateRequest;
"app:update:restart": DesktopUpdateRequest;
"app:update:renderer-ready": DesktopUpdateRequest;
+ "app:projection-cache:load": ProjectionCacheLoadRequest;
+ "app:projection-cache:save": ProjectionCacheSaveRequest;
}
export interface DesktopInvokeResponseMap {
"omp:targets:list": TargetListResult;
@@ -489,6 +505,8 @@ export interface DesktopInvokeResponseMap {
"app:update:download": DesktopUpdateState;
"app:update:restart": DesktopUpdateState;
"app:update:renderer-ready": DesktopUpdateRendererReadyResult;
+ "app:projection-cache:load": ProjectionCacheLoadResult;
+ "app:projection-cache:save": ProjectionCacheSaveResult;
}
export interface RendererServerFrameEvent {
targetId: string;
@@ -746,6 +764,50 @@ export function decodeSpeechText(value: unknown): string {
}
return value;
}
+function projectionCacheSerialized(value: unknown): string {
+ if (typeof value !== "string" || value.length === 0 || utf8ByteLength(value) > MAX_PROJECTION_CACHE_BYTES)
+ throw new Error("invalid projection cache value");
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(value);
+ } catch {
+ throw new Error("invalid projection cache value");
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
+ throw new Error("invalid projection cache value");
+ const root = object(parsed, "projection cache");
+ const data = root.data;
+ if (
+ root.kind !== "t4-code-projection" ||
+ root.version !== 1 ||
+ !data ||
+ typeof data !== "object" ||
+ Array.isArray(data)
+ )
+ throw new Error("invalid projection cache value");
+ return value;
+}
+export function decodeProjectionCacheLoadResult(value: unknown): ProjectionCacheLoadResult {
+ const result = object(value, "projection cache load result");
+ exact(result, ["available", "value"]);
+ const available = result.available;
+ const cacheValue = result.value;
+ if (typeof available !== "boolean" || (cacheValue !== null && typeof cacheValue !== "string"))
+ throw new Error("invalid projection cache load result");
+ if (cacheValue !== null) projectionCacheSerialized(cacheValue);
+ if (!available && cacheValue !== null) throw new Error("invalid projection cache load result");
+ return Object.freeze({ available, value: cacheValue });
+}
+export function decodeProjectionCacheSaveResult(value: unknown): ProjectionCacheSaveResult {
+ const result = object(value, "projection cache save result");
+ exact(result, ["saved"]);
+ const saved = result.saved;
+ if (typeof saved !== "boolean") throw new Error("invalid projection cache save result");
+ return Object.freeze({ saved });
+}
+export function decodeProjectionCacheSaveRequestValue(value: unknown): string {
+ return projectionCacheSerialized(value);
+}
export function decodeDesktopInvokeRequest(input: unknown): DesktopInvokeRequest {
const frame = inputObject(input);
exact(frame, ["channel", "payload"]);
@@ -813,6 +875,12 @@ export function decodeDesktopInvokeRequest(input: unknown): DesktopInvokeRequest
case "app:update:renderer-ready":
exact(payload, []);
return { channel, payload: {} };
+ case "app:projection-cache:load":
+ exact(payload, []);
+ return { channel, payload: {} };
+ case "app:projection-cache:save":
+ exact(payload, ["value"]);
+ return { channel, payload: { value: projectionCacheSerialized(payload.value) } };
case "omp:command":
exact(payload, ["targetId", "intent"]);
{
diff --git a/packages/protocol/test/desktop-ipc.test.ts b/packages/protocol/test/desktop-ipc.test.ts
index 029110de..af0915d7 100644
--- a/packages/protocol/test/desktop-ipc.test.ts
+++ b/packages/protocol/test/desktop-ipc.test.ts
@@ -5,7 +5,11 @@ import {
decodeDesktopInvokeRequest,
decodeDesktopUpdateRendererReadyResult,
decodeDesktopUpdateState,
+ decodeProjectionCacheLoadResult,
+ decodeProjectionCacheSaveRequestValue,
+ decodeProjectionCacheSaveResult,
decodeSpeechText,
+ MAX_PROJECTION_CACHE_BYTES,
MAX_SPEECH_TEXT_BYTES,
isDesktopInvokeRequest,
} from "../src/desktop-ipc.ts";
@@ -421,4 +425,39 @@ describe("desktop IPC boundary", () => {
expect(() => decodeSpeechText("bad\0text")).toThrow();
expect(() => decodeDesktopInvokeRequest({ channel: "omp:speech:stop", payload: {} })).not.toThrow();
});
+ it("strictly bounds and versions projection cache IPC payloads", () => {
+ const value = JSON.stringify({
+ kind: "t4-code-projection",
+ version: 1,
+ data: { sessions: [], sessionIndex: [], lru: [], freshness: "cached" },
+ });
+ expect(decodeDesktopInvokeRequest({
+ channel: "app:projection-cache:load",
+ payload: {},
+ })).toEqual({ channel: "app:projection-cache:load", payload: {} });
+ expect(decodeDesktopInvokeRequest({
+ channel: "app:projection-cache:save",
+ payload: { value },
+ })).toEqual({ channel: "app:projection-cache:save", payload: { value } });
+ expect(decodeProjectionCacheSaveRequestValue(value)).toBe(value);
+ expect(decodeProjectionCacheLoadResult({ available: true, value })).toEqual({
+ available: true,
+ value,
+ });
+ expect(decodeProjectionCacheSaveResult({ saved: true })).toEqual({ saved: true });
+
+ for (const invalid of [
+ { channel: "app:projection-cache:load", payload: { storageKey: "renderer-choice" } },
+ { channel: "app:projection-cache:save", payload: { value: JSON.stringify({ kind: "t4-code-projection", version: 2, data: {} }) } },
+ { channel: "app:projection-cache:save", payload: { value: "not-json" } },
+ ]) expect(() => decodeDesktopInvokeRequest(invalid)).toThrow();
+ expect(() => decodeProjectionCacheLoadResult({ available: false, value })).toThrow();
+ expect(() => decodeProjectionCacheSaveResult({ saved: "yes" })).toThrow();
+ const oversized = JSON.stringify({
+ kind: "t4-code-projection",
+ version: 1,
+ data: { padding: "x".repeat(MAX_PROJECTION_CACHE_BYTES) },
+ });
+ expect(() => decodeProjectionCacheSaveRequestValue(oversized)).toThrow();
+ });
});
From e307f9dc5326aca0a65e6b56652aa3fc998c718a Mon Sep 17 00:00:00 2001
From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com>
Date: Sat, 18 Jul 2026 00:37:49 -0700
Subject: [PATCH 4/6] fix: disable unavailable projection cache writes
---
apps/web/src/platform/desktop-runtime.ts | 3 ++
apps/web/test/live-composer.test.ts | 35 ++++++++++++++++++++++++
2 files changed, 38 insertions(+)
diff --git a/apps/web/src/platform/desktop-runtime.ts b/apps/web/src/platform/desktop-runtime.ts
index 6f058063..d392545d 100644
--- a/apps/web/src/platform/desktop-runtime.ts
+++ b/apps/web/src/platform/desktop-runtime.ts
@@ -37,12 +37,15 @@ function projectionCacheStore(shell: DesktopShellPort): ProjectionCacheStore | u
const load = shell.loadProjectionCache?.bind(shell);
const save = shell.saveProjectionCache?.bind(shell);
if (load === undefined || save === undefined) return undefined;
+ let cacheAvailable: boolean | undefined;
return {
load: async () => {
const result = await load();
+ cacheAvailable = result.available;
return result.available ? result.value : null;
},
save: async (value) => {
+ if (cacheAvailable === false) return;
const result = await save({ value });
if (!result.saved) throw new Error("projection cache save failed");
},
diff --git a/apps/web/test/live-composer.test.ts b/apps/web/test/live-composer.test.ts
index 8640a9d6..91364c4d 100644
--- a/apps/web/test/live-composer.test.ts
+++ b/apps/web/test/live-composer.test.ts
@@ -3023,6 +3023,41 @@ describe("window runtime slot", () => {
await controller.stop();
});
+ it("skips projection cache saves when the shell reports caching unavailable", async () => {
+ let loads = 0;
+ let saves = 0;
+ type CacheShell = FakeShell & {
+ loadProjectionCache: () => Promise<{ available: boolean; value: string | null }>;
+ saveProjectionCache: (request: { value: string }) => Promise<{ saved: boolean }>;
+ };
+ const shell = new FakeShell() as CacheShell;
+ shell.loadProjectionCache = async () => {
+ loads += 1;
+ return { available: false, value: null };
+ };
+ shell.saveProjectionCache = async () => {
+ saves += 1;
+ return { saved: false };
+ };
+ const holder: RuntimeSlotHolder = {};
+ const controller = acquireRuntimeController(shell, holder);
+
+ await controller.start();
+ shell.emitFrame({
+ targetId: "local",
+ frame: makeWelcome(HOST, ["sessions.prompt"]),
+ });
+ shell.emitFrame({
+ targetId: "local",
+ frame: durableEntryFrame(2, entry("live", "do not persist")),
+ });
+ await settle();
+ await controller.stop();
+
+ expect(loads).toBe(1);
+ expect(saves).toBe(0);
+ });
+
it("retains one live controller across a persisted pagehide/pageshow", async () => {
class FakePageLifecycleTarget {
private readonly listeners = new Map>();
From 2ede1779725052ffcb29fb0b5e4a45014aae9c5f Mon Sep 17 00:00:00 2001
From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com>
Date: Sat, 18 Jul 2026 00:45:28 -0700
Subject: [PATCH 5/6] fix: revision-pin fixture file drafts
---
apps/web/src/features/panes/fixtures.ts | 10 +++++-----
apps/web/test/panes-fixtures.test.ts | 13 +++++++++++++
2 files changed, 18 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/features/panes/fixtures.ts b/apps/web/src/features/panes/fixtures.ts
index 26f33f44..7e30365a 100644
--- a/apps/web/src/features/panes/fixtures.ts
+++ b/apps/web/src/features/panes/fixtures.ts
@@ -633,6 +633,7 @@ export function attach(cursor: Cursor): Replay {
// 4x4 checker PNG, generated once; identity-free sample pixels.
const SAMPLE_IMAGE_SRC =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAF0lEQVR4nGP8//8/AwMDEwMDAwMDAwAkBgMBvR9pJAAAAABJRU5ErkJggg==";
+const FIXTURE_FILE_REVISION = "fixture-revision-1";
const FILE_PREVIEWS: Readonly> = {
"packages/client/src/replay.ts": {
@@ -862,16 +863,15 @@ function fixtureController(api: InspectorStoreApi, clock: () => number): Inspect
return;
}
const edited = editedFiles.get(path);
- resolvePreview(
- api,
+ const preview =
edited === undefined
? FILE_PREVIEWS[path] ?? {
- kind: "diagnostic",
+ kind: "diagnostic" as const,
path,
message: "The host has no readable content at this path.",
}
- : { kind: "code", path, text: edited, truncated: false },
- );
+ : { kind: "code" as const, path, text: edited, truncated: false };
+ resolvePreview(api, preview, preview.kind === "code" ? FIXTURE_FILE_REVISION : null);
});
},
writeFile(path, content) {
diff --git a/apps/web/test/panes-fixtures.test.ts b/apps/web/test/panes-fixtures.test.ts
index 3f41af3b..2b17b2f4 100644
--- a/apps/web/test/panes-fixtures.test.ts
+++ b/apps/web/test/panes-fixtures.test.ts
@@ -61,4 +61,17 @@ describe("fixture determinism", () => {
const comment = store.getState().review.comments.at(-1);
expect(comment?.at).toBe("2026-07-11T12:00:02.000Z");
});
+
+ it("backs sample file drafts with a stable synthetic revision", async () => {
+ installFixtureInspector();
+ const store = getInspectorStore("sess-stream");
+ if (store === null) throw new Error("fixture factory not installed");
+ const path = "packages/client/src/replay.ts";
+
+ store.getState().selectFile(path);
+ await Promise.resolve();
+ store.getState().startFileEdit(path);
+
+ expect(store.getState().files.draftsByPath[path]?.baseRevision).toBe("fixture-revision-1");
+ });
});
From b152cffb1330a952f254bac54f49643b9ec728e6 Mon Sep 17 00:00:00 2001
From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com>
Date: Sat, 18 Jul 2026 00:49:46 -0700
Subject: [PATCH 6/6] fix: refresh revisions after file saves
---
apps/web/src/features/panes/fixtures.ts | 5 ++++
.../web/src/features/panes/inspector-store.ts | 3 +--
apps/web/src/features/panes/live-inspector.ts | 7 ++++--
apps/web/test/panes-files.test.ts | 23 +++++++++++++++----
apps/web/test/panes-live.test.ts | 14 +++++++++++
5 files changed, 43 insertions(+), 9 deletions(-)
diff --git a/apps/web/src/features/panes/fixtures.ts b/apps/web/src/features/panes/fixtures.ts
index 7e30365a..fc66747d 100644
--- a/apps/web/src/features/panes/fixtures.ts
+++ b/apps/web/src/features/panes/fixtures.ts
@@ -878,6 +878,11 @@ function fixtureController(api: InspectorStoreApi, clock: () => number): Inspect
queueMicrotask(() => {
editedFiles.set(path, content);
resolveFileWriteOutcome(api, path, "saved");
+ resolvePreview(
+ api,
+ { kind: "code", path, text: content, truncated: false },
+ FIXTURE_FILE_REVISION,
+ );
});
},
};
diff --git a/apps/web/src/features/panes/inspector-store.ts b/apps/web/src/features/panes/inspector-store.ts
index 66d19563..1acef8ab 100644
--- a/apps/web/src/features/panes/inspector-store.ts
+++ b/apps/web/src/features/panes/inspector-store.ts
@@ -484,7 +484,7 @@ export function resolveFileWriteOutcome(
...state.files,
draftsByPath,
...(state.files.selectedPath === path
- ? { preview: { kind: "code" as const, path, text: draft.text, truncated: false } }
+ ? { preview: "loading" as const }
: {}),
},
};
@@ -550,4 +550,3 @@ export function getInspectorStore(sessionId: string): InspectorStoreApi | null {
export function useInspector(api: InspectorStoreApi, selector: (state: InspectorStore) => T): T {
return useStore(api, selector);
}
-
diff --git a/apps/web/src/features/panes/live-inspector.ts b/apps/web/src/features/panes/live-inspector.ts
index 3b1f07ec..b18126af 100644
--- a/apps/web/src/features/panes/live-inspector.ts
+++ b/apps/web/src/features/panes/live-inspector.ts
@@ -558,8 +558,11 @@ export function createLiveInspectorStore(
}
// A pushed frame can answer a preview the command path could not.
- const { selectedPath, preview } = store.getState().files;
- if (selectedPath !== null && preview === "loading") {
+ const { selectedPath, preview, previewRevision } = store.getState().files;
+ const hasNewPreviewRevision =
+ previewRevision === null ||
+ (warm.revision !== undefined && String(warm.revision) !== previewRevision);
+ if (selectedPath !== null && preview === "loading" && hasNewPreviewRevision) {
const frame = warm.files.get(selectedPath);
if (frame !== undefined && frame.content !== undefined) {
resolvePreview(store, previewFromFileFrame(frame), warm.revision ?? null);
diff --git a/apps/web/test/panes-files.test.ts b/apps/web/test/panes-files.test.ts
index 1e9a5ae3..afe66212 100644
--- a/apps/web/test/panes-files.test.ts
+++ b/apps/web/test/panes-files.test.ts
@@ -116,11 +116,24 @@ describe("file drafts", () => {
resolveFileWriteOutcome(api, "src/app.ts", "saved");
expect(api.getState().files.draftsByPath["src/app.ts"]).toBeUndefined();
- expect(api.getState().files.preview).toEqual({
- kind: "code",
- path: "src/app.ts",
- text: "const value = 2;\n",
- truncated: false,
+ expect(api.getState().files.preview).toBe("loading");
+ api.getState().startFileEdit("src/app.ts");
+ expect(api.getState().files.draftsByPath["src/app.ts"]).toBeUndefined();
+
+ resolvePreview(
+ api,
+ {
+ kind: "code",
+ path: "src/app.ts",
+ text: "const value = 2;\n",
+ truncated: false,
+ },
+ "rev-2",
+ );
+ api.getState().startFileEdit("src/app.ts");
+ expect(api.getState().files.draftsByPath["src/app.ts"]).toMatchObject({
+ baseRevision: "rev-2",
+ originalText: "const value = 2;\n",
});
});
diff --git a/apps/web/test/panes-live.test.ts b/apps/web/test/panes-live.test.ts
index 6dbb5762..d8fcc1e8 100644
--- a/apps/web/test/panes-live.test.ts
+++ b/apps/web/test/panes-live.test.ts
@@ -823,7 +823,21 @@ describe("live pane actions", () => {
},
]);
expect(store.getState().files.draftsByPath["src/app.ts"]).toBeUndefined();
+ expect(store.getState().files.preview).toBe("loading");
+
+ fake.setProjection(
+ project(
+ [snapshotFrame("rev-4"), fileFrame("src/app.ts", "const value = 2;\n")],
+ base,
+ ),
+ );
expect(store.getState().files.preview).toMatchObject({ text: "const value = 2;\n" });
+
+ store.getState().startFileEdit("src/app.ts");
+ store.getState().updateFileDraft("src/app.ts", "const value = 3;\n");
+ store.getState().saveFile("src/app.ts");
+ await fake.settle();
+ expect(fake.commands[1]?.expectedRevision).toBe("rev-4");
});
it("pins a file save to the revision that produced its draft", async () => {