diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 8ba9eb02d0..3bf84684b6 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -250,8 +250,10 @@ from `apply`: `ctx.agents.present(typeId, (exposed) => ({ icon, Peek, Create, CreateModel, tabs, provider: { DialogBody, DefaultControl } }))`. The type's readers see one type with both halves. `exposed` is the type's `expose`: the handles its UI reaches of the runtime. Each is JSON, a store (`snapshot` and -`subscribe`) or a call returning a promise, so it can later cross to another -page. Claude is the first harness split this way. +`subscribe`), a call returning a promise, or a keyed read (`keyedRead` in +`features/agents/handles.ts`: `read(key)` and `subscribe`, such as one agent's +sessions), so it can later cross to another page. Claude is the first harness +split this way. The type's functions of config alone (`defaults`, `importShared`, `adopt`, `carry`, `describeMigration`, `migrationWarning` and `summary`) go to both diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index a1ed09c6a3..ad78b50354 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -10,7 +10,13 @@ import type { PluginModule } from "../../plugins/api"; import { ClaudeLogoIcon } from "../../shared/design-system/icons"; import { claudeRun, TYPE_ID } from "./run"; import { ClaudeDefaults } from "./defaults"; -import { ClaudeRuntime, type Config, config, DEFAULT_CONFIG } from "./runtime"; +import { + activityHandle, + ClaudeRuntime, + type Config, + config, + DEFAULT_CONFIG, +} from "./runtime"; import { ClaudeSetup, withScript } from "./setup"; import { createTabs } from "./tabs"; import { readUsage } from "./usage"; @@ -160,7 +166,7 @@ function applyAgent(ctx: Parameters[0]) { expose: { setup, defaults: providerDefaults, - runtime, + runtime: activityHandle(runtime), } satisfies ClaudeExposed, ...(spawn && readFile && { diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 821a963128..1effe8b045 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -5,6 +5,7 @@ // so one agent works in several conversations at once. Claude acts in Buzz with // the in-process Buzz tools, signed by the agent's native-held key; the runtime // only reports a turn that fails. +import { keyedRead } from "../../features/agents/handles"; import type { Context } from "../../buzz-mcp/client"; import { RESUMED_PROMPT } from "../../buzz-mcp/prompt"; import { type Listing, respond } from "../../buzz-mcp/rpc"; @@ -941,3 +942,21 @@ export function modelName(model: string) { const name = family[0]?.toUpperCase() + family.slice(1); return id ? `${name} ${id[2]}${id[3] ? `.${id[3]}` : ""}` : name; } + +/** What the Activity tab reaches of `runtime`. */ +export const activityHandle = (runtime: ClaudeRuntime) => + Object.freeze({ + find: (...args: Parameters) => runtime.find(...args), + transcript: (...args: Parameters) => + runtime.transcript(...args), + sessions: keyedRead( + (key) => runtime.sessions(key), + (listener) => runtime.subscribe(listener), + [], + ), + conversations: keyedRead( + (key) => runtime.conversations(key), + (listener) => runtime.subscribe(listener), + [], + ), + }); diff --git a/src/bundled/claude-code/tabs.tsx b/src/bundled/claude-code/tabs.tsx index 372d72abd4..becc9145e9 100644 --- a/src/bundled/claude-code/tabs.tsx +++ b/src/bundled/claude-code/tabs.tsx @@ -38,7 +38,7 @@ import { Field } from "../../shared/design-system/ui/Field"; import { Input } from "../../shared/design-system/ui/Input"; import { Select } from "../../shared/design-system/ui/Select"; import { - type ClaudeRuntime, + type activityHandle, type Config, config as readConfig, effortChoices, @@ -64,10 +64,7 @@ type SetupHandle = Pick< | "cancel" >; type DefaultsHandle = Pick; -type ActivityHandle = Pick< - ClaudeRuntime, - "subscribe" | "sessions" | "conversations" | "find" | "transcript" ->; +type ActivityHandle = ReturnType; import { TURN_LIMIT } from "./transcript"; const message = (error: unknown) => @@ -716,11 +713,13 @@ export function createTabs({ agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; }) { - const sessions = useSyncExternalStore( - (listener) => runtime.subscribe(listener), - () => runtime.sessions(agent.key), + const sessions = useSyncExternalStore(runtime.sessions.subscribe, () => + runtime.sessions.read(agent.key), + ); + const conversations = useSyncExternalStore( + runtime.conversations.subscribe, + () => runtime.conversations.read(agent.key), ); - const conversations = runtime.conversations(agent.key); const busy = new Map( sessions.map((session) => [session.key, session.busy]), ); diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 8045d405e4..ff5ad296be 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -7,7 +7,7 @@ import type { PluginModule } from "../../plugins/api"; import { CodexLogoIcon } from "../../shared/design-system/icons"; import { type Config, config, defaults, validWorkspace } from "./config"; import { codexRun, TYPE_ID } from "./run"; -import { CodexRuntime } from "./runtime"; +import { activityHandle, CodexRuntime } from "./runtime"; import { CodexSetup, withScript } from "./setup"; import { CodexProvider, createTabs, transcripts } from "./tabs"; import { readUsage } from "./usage"; @@ -198,7 +198,7 @@ function applyAgent(ctx: Parameters[0]) { ...typeConfig, expose: { provider: providerController, - runtime, + runtime: activityHandle(runtime), transcript: transcripts(start, runtime), setup, } satisfies CodexExposed, diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index af674ccc91..024c671458 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -1,3 +1,4 @@ +import { keyedRead } from "../../features/agents/handles"; import type { WorkOutcome } from "../../features/agents/local-activity"; import { fromOwner, workflowOf } from "../../features/agents/owner-attestation"; import { @@ -1857,3 +1858,19 @@ export function programs(command: string | undefined): string | undefined { ? `${list.slice(0, 3).join(", ")} and ${list.length - 3} more` : list.join(", "); } + +/** What the Activity tab reaches of `runtime`. */ +export const activityHandle = (runtime: CodexRuntime) => + Object.freeze({ + find: (...args: Parameters) => runtime.find(...args), + sessions: keyedRead( + (key) => runtime.sessions(key), + (listener) => runtime.subscribe(listener), + [], + ), + conversations: keyedRead( + (key) => runtime.conversations(key), + (listener) => runtime.subscribe(listener), + [], + ), + }); diff --git a/src/bundled/codex/tabs.test.tsx b/src/bundled/codex/tabs.test.tsx index a1fe89c0e7..678cd64067 100644 --- a/src/bundled/codex/tabs.test.tsx +++ b/src/bundled/codex/tabs.test.tsx @@ -31,7 +31,7 @@ import { PanelWorkspace } from "../../features/panels/PanelWorkspace"; import { defaults, type Config } from "./config"; import { sessionTag } from "./prompt"; import { CodexProvider, createTabs, transcripts } from "./tabs"; -import { CodexRuntime } from "./runtime"; +import { activityHandle, CodexRuntime } from "./runtime"; import type { Wire } from "./rpc"; import { CodexSetup } from "./setup"; function deferred() { @@ -202,7 +202,7 @@ function fixture({ ); const tabs = createTabs( provider, - runtime, + activityHandle(runtime), transcripts(spawn, runtime), setup, ); @@ -1295,7 +1295,7 @@ it("installs and signs in Buzz's copy of Codex from its provider settings", asyn const provider = new CodexProvider(f.spawn, localStore(), f.setup); const { ProviderDialogBody } = createTabs( provider, - f.runtime, + activityHandle(f.runtime), transcripts(f.spawn, f.runtime), f.setup, ); diff --git a/src/bundled/codex/tabs.tsx b/src/bundled/codex/tabs.tsx index 82d8164a96..30849aa0fb 100644 --- a/src/bundled/codex/tabs.tsx +++ b/src/bundled/codex/tabs.tsx @@ -50,7 +50,11 @@ import { type Model, } from "./config"; import { AppServer, listModels, needsSignIn, type Spawn } from "./rpc"; -import type { CodexRuntime, SavedConversation } from "./runtime"; +import type { + activityHandle, + CodexRuntime, + SavedConversation, +} from "./runtime"; import type { CodexSetup } from "./setup"; import { readTranscript, @@ -214,26 +218,7 @@ export class CodexProvider { this.set({ defaults }); } resolve(raw: unknown): Config { - const explicit = readConfig(raw); - const inheritedModel = this.state.defaults.model; - const unavailable = Boolean( - inheritedModel && this.state.data && !this.model(inheritedModel), - ); - const model = explicit.model || (unavailable ? "" : inheritedModel); - if (explicit.effort) return { ...explicit, model }; - const selected = this.model(model); - const preferred = - unavailable && !explicit.model ? "" : this.state.defaults.effort; - const effort = !preferred - ? "" - : !this.state.data - ? preferred - : selected?.supportedReasoningEfforts.some( - (choice) => choice.reasoningEffort === preferred, - ) - ? preferred - : (selected?.defaultReasoningEffort ?? ""); - return { ...explicit, model, effort }; + return resolveConfig(raw, this.state); } dispose() { this.offSetup?.(); @@ -291,6 +276,32 @@ const catalogModel = (model: string, data?: Catalog) => { const resolved = resolveModel(model, data.models); return data.models.find((choice) => choice.model === resolved); }; +/** `raw` with the provider's defaults filled in, as the catalog in `state` + * allows. */ +function resolveConfig( + raw: unknown, + { defaults, data }: Pick, +): Config { + const explicit = readConfig(raw); + const inheritedModel = defaults.model; + const unavailable = Boolean( + inheritedModel && data && !catalogModel(inheritedModel, data), + ); + const model = explicit.model || (unavailable ? "" : inheritedModel); + if (explicit.effort) return { ...explicit, model }; + const selected = catalogModel(model, data); + const preferred = unavailable && !explicit.model ? "" : defaults.effort; + const effort = !preferred + ? "" + : !data + ? preferred + : selected?.supportedReasoningEfforts.some( + (choice) => choice.reasoningEffort === preferred, + ) + ? preferred + : (selected?.defaultReasoningEffort ?? ""); + return { ...explicit, model, effort }; +} const providerDefaultLabel = (model: string, data?: Catalog) => { const selected = catalogModel(model, data); const name = selected?.displayName || selected?.model; @@ -421,12 +432,9 @@ function CodexSignIn({ // as an agent type's `expose` allows. type ProviderHandle = Pick< CodexProvider, - "snapshot" | "subscribe" | "check" | "resolve" | "saveDefaults" ->; -type ActivityHandle = Pick< - CodexRuntime, - "subscribe" | "sessions" | "conversations" | "find" + "snapshot" | "subscribe" | "check" | "saveDefaults" >; +type ActivityHandle = ReturnType; type SetupHandle = Pick< CodexSetup, "snapshot" | "subscribe" | "run" | "cancel" @@ -850,10 +858,13 @@ export function createTabs( agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; }) { - const sessions = useSyncExternalStore(runtime.subscribe, () => - runtime.sessions(agent.key), + const sessions = useSyncExternalStore(runtime.sessions.subscribe, () => + runtime.sessions.read(agent.key), + ); + const conversations = useSyncExternalStore( + runtime.conversations.subscribe, + () => runtime.conversations.read(agent.key), ); - const conversations = runtime.conversations(agent.key); const { session: here, searching, @@ -991,16 +1002,17 @@ export function createTabs( useEffect(() => { if (error) saveErrorRef.current?.focus(); }, [error]); + const state = useProvider(); const { data, error: catalogError, checking, defaults: providerDefaults, - } = useProvider(); + } = state; useEffect(() => { setDraft(readConfig(agent.config)); }, [agent.config]); - const effective = provider.resolve({ ...draft, effort: "" }); + const effective = resolveConfig({ ...draft, effort: "" }, state); const selected = catalogModel(effective.model, data); const supported = selected?.supportedReasoningEfforts ?? []; const efforts = [ diff --git a/src/bundled/goose/index.ts b/src/bundled/goose/index.ts index 1585942e55..d7e9909b14 100644 --- a/src/bundled/goose/index.ts +++ b/src/bundled/goose/index.ts @@ -7,7 +7,13 @@ import { isTauri } from "@tauri-apps/api/core"; import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { GooseLogoIcon } from "../../shared/design-system/icons"; -import { type Config, config, DEFAULT_CONFIG, GooseRuntime } from "./runtime"; +import { + activityHandle, + type Config, + config, + DEFAULT_CONFIG, + GooseRuntime, +} from "./runtime"; import { gooseRun } from "./run"; import { GooseSetup } from "./setup"; import { createTabs } from "./tabs"; @@ -133,7 +139,11 @@ function applyAgent(ctx: Parameters[0]) { description: "goose on this computer, with any provider goose supports. Each thread it is mentioned in is one continuing session.", ...typeConfig, - expose: { setup, runtime, history } satisfies GooseExposed, + expose: { + setup, + runtime: activityHandle(runtime), + history, + } satisfies GooseExposed, provider: { snapshot: setup.snapshot, subscribe: setup.subscribe, diff --git a/src/bundled/goose/runtime.ts b/src/bundled/goose/runtime.ts index 9a0c8891a4..8b851eb056 100644 --- a/src/bundled/goose/runtime.ts +++ b/src/bundled/goose/runtime.ts @@ -7,6 +7,7 @@ // goose acts in Buzz with the in-process Buzz tools over loopback HTTP MCP, // signed by the agent's native-held key; the runtime only reports a turn that // fails. +import { keyedRead } from "../../features/agents/handles"; import type { Context } from "../../buzz-mcp/client"; import { MemoryReads, @@ -829,3 +830,13 @@ export class GooseRuntime { if (reads) this.memoryReads.forget(reads.connection, pubkey); } } + +/** What the Activity tab reaches of `runtime`. */ +export const activityHandle = (runtime: GooseRuntime) => + Object.freeze({ + sessions: keyedRead( + (key) => runtime.sessions(key), + (listener) => runtime.subscribe(listener), + [], + ), + }); diff --git a/src/bundled/goose/tabs.test.tsx b/src/bundled/goose/tabs.test.tsx index 5f3713864c..57d4fd26cb 100644 --- a/src/bundled/goose/tabs.test.tsx +++ b/src/bundled/goose/tabs.test.tsx @@ -19,14 +19,19 @@ import type { } from "../../features/agents/service"; import type { Agent } from "../../features/agents/service"; import { fakeSpawn } from "./goose-testing"; -import { type Config, DEFAULT_CONFIG, type GooseRuntime } from "./runtime"; +import { + activityHandle, + type Config, + DEFAULT_CONFIG, + type GooseRuntime, +} from "./runtime"; import { GooseSetup } from "./setup"; import { createTabs } from "./tabs"; import { GooseHistory } from "./transcript"; /** New agent's fields read no sessions. */ const unused = { - runtime: {} as GooseRuntime, + runtime: activityHandle({} as GooseRuntime), history: new GooseHistory(undefined), }; @@ -727,10 +732,10 @@ it("lists the agent's goose sessions and shows one's transcript", async () => { }); // The runtime keeps one snapshot until its sessions change. const none: never[] = []; - const runtime = { + const runtime = activityHandle({ subscribe: () => () => {}, sessions: () => none, - } as unknown as GooseRuntime; + } as unknown as GooseRuntime); const { ActivityTab } = createTabs({ setup: new GooseSetup(fake), runtime, diff --git a/src/bundled/goose/tabs.tsx b/src/bundled/goose/tabs.tsx index 51c3a29a60..651a09f721 100644 --- a/src/bundled/goose/tabs.tsx +++ b/src/bundled/goose/tabs.tsx @@ -39,7 +39,7 @@ import { validWorkspace } from "../codex/config"; import { type Config, DEFAULT_CONFIG, - type GooseRuntime, + type activityHandle, config as readConfig, } from "./runtime"; import { @@ -154,7 +154,7 @@ export function createTabs({ history, }: Readonly<{ setup: SetupHandle; - runtime: Pick; + runtime: ReturnType; history: Pick; }>) { function DefaultControl({ onConfigure }: { onConfigure(): void }) { @@ -828,9 +828,8 @@ export function createTabs({ conversation?: AgentViewProps["conversation"]; }) { const workspace = readConfig(agent.config).workspace; - const live = useSyncExternalStore( - (listener) => runtime.subscribe(listener), - () => runtime.sessions(agent.key), + const live = useSyncExternalStore(runtime.sessions.subscribe, () => + runtime.sessions.read(agent.key), ); const running = live.map( (session): Row => ({ diff --git a/src/bundled/pi/index.ts b/src/bundled/pi/index.ts index 6ff9492f2a..f980bb9de4 100644 --- a/src/bundled/pi/index.ts +++ b/src/bundled/pi/index.ts @@ -9,7 +9,13 @@ import { repositoriesContext } from "../../features/communities/repositories"; import type { AgentTypeConfig } from "../../features/agents/service"; import type { PluginModule } from "../../plugins/api"; import { PiLogoIcon } from "../../shared/design-system/icons"; -import { type Config, config, DEFAULT_CONFIG, PiRuntime } from "./runtime"; +import { + activityHandle, + type Config, + config, + DEFAULT_CONFIG, + PiRuntime, +} from "./runtime"; import { createTabs } from "./tabs"; export const TYPE_ID = "pi"; @@ -110,7 +116,10 @@ function applyAgent(ctx: Parameters[0]) { description: "Pi on this computer, with any provider Pi supports. Each thread it is mentioned in is one continuing conversation.", ...typeConfig, - expose: { setup: runtime.setup, runtime } satisfies PiExposed, + expose: { + setup: runtime.setup, + runtime: activityHandle(runtime), + } satisfies PiExposed, ...(host.spawn && { usage: (agent, { since, signal }) => runtime.usage(agent.key, since, signal), diff --git a/src/bundled/pi/runtime.ts b/src/bundled/pi/runtime.ts index 0707b92257..5aa3dbc7e0 100644 --- a/src/bundled/pi/runtime.ts +++ b/src/bundled/pi/runtime.ts @@ -7,6 +7,7 @@ // Buzz tools, signed by the agent's native-held key. The runtime reports a // turn that fails, and posts the answer of a mention's turn that never used // `send`. +import { keyedRead } from "../../features/agents/handles"; import { appClient } from "../../buzz-mcp/app-client"; import type { Context } from "../../buzz-mcp/client"; import { content, errorText } from "../../buzz-mcp/rpc"; @@ -1360,3 +1361,16 @@ function unanswered({ if (reason === "aborted") return "The turn was stopped"; return `Pi did not answer (${reason ?? "unknown"})`; } + +/** What the Activity tab reaches of `runtime`. */ +export const activityHandle = (runtime: PiRuntime) => + Object.freeze({ + places: (key: AgentKey) => runtime.places(key), + transcript: (...args: Parameters) => + runtime.transcript(...args), + working: keyedRead( + (key) => runtime.working(key), + (listener) => runtime.subscribe(listener), + [], + ), + }); diff --git a/src/bundled/pi/tabs.test.tsx b/src/bundled/pi/tabs.test.tsx index af7e11d568..53aeeee1a5 100644 --- a/src/bundled/pi/tabs.test.tsx +++ b/src/bundled/pi/tabs.test.tsx @@ -12,7 +12,13 @@ import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { Agent } from "../../features/agents/service"; -import { type Config, DEFAULT_CONFIG, type Place } from "./runtime"; +import { + activityHandle, + type Config, + DEFAULT_CONFIG, + type PiRuntime, + type Place, +} from "./runtime"; import { type Provider, type Providers, PiSetup } from "./setup"; import { createTabs } from "./tabs"; import { piTurns } from "./transcript"; @@ -91,6 +97,10 @@ function fakePi( } /** The runtime's conversations: a thread Pi works in, and a channel. */ +/** The tabs' handle on a fake runtime. */ +const handle = (runtime: ReturnType) => + activityHandle(runtime as unknown as PiRuntime); + function fakeRuntime() { const places: Place[] = [ { @@ -146,7 +156,7 @@ function fakeRuntime() { function created(pi = fakePi(), modelAppearance?: "glass-ghost") { const { Create, CreateModel } = createTabs({ setup: new PiSetup(pi), - runtime: fakeRuntime(), + runtime: handle(fakeRuntime()), }); const changes: Config[] = []; function Harness() { @@ -382,7 +392,7 @@ it("keeps Try again's providers for what the dialog's Use allows", async () => { it("shows the provider, model and thinking level in Identity, and the provider's setup in Settings", async () => { const { SettingsTab } = createTabs({ setup: new PiSetup(fakePi()), - runtime: fakeRuntime(), + runtime: handle(fakeRuntime()), }); const save = vi.fn(async () => {}); // As the agent's profile shows it: one form, a section at a time. @@ -423,7 +433,7 @@ it("saves a key on Enter without saving the agent's settings", async () => { const pi = fakePi(); const { SettingsTab } = createTabs({ setup: new PiSetup(pi), - runtime: fakeRuntime(), + runtime: handle(fakeRuntime()), }); const save = vi.fn(); render(); @@ -443,7 +453,10 @@ it("saves a key on Enter without saving the agent's settings", async () => { it("lists Pi's conversations in the Activity tab, and shows a transcript", async () => { const runtime = fakeRuntime(); - const { ActivityTab } = createTabs({ setup: new PiSetup(fakePi()), runtime }); + const { ActivityTab } = createTabs({ + setup: new PiSetup(fakePi()), + runtime: handle(runtime), + }); render( ; - runtime: Pick; + runtime: ReturnType; }>) { /** The provider, its key and its model, and a test that they answer. As * `rows` of the agent's settings, only the provider and model: the rest is @@ -690,9 +690,8 @@ export function createTabs({ agent: AgentViewProps["agent"]; conversation?: AgentViewProps["conversation"]; }) { - const working = useSyncExternalStore( - (listener) => runtime.subscribe(listener), - () => runtime.working(agent.key), + const working = useSyncExternalStore(runtime.working.subscribe, () => + runtime.working.read(agent.key), ); // Read again as turns start and end, which can add a conversation. const [listing, reload] = useRead( diff --git a/src/features/agents/handles.test.ts b/src/features/agents/handles.test.ts new file mode 100644 index 0000000000..c8ac41309a --- /dev/null +++ b/src/features/agents/handles.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "vitest"; +import { keyedRead } from "./handles"; + +it("keeps what a key read before while its content is the same", () => { + let sessions: Record = { + a: [{ key: "one", busy: false }], + b: [{ key: "two", busy: true }], + }; + // Each read builds new arrays, as the runtimes' saved-conversation reads do. + const handle = keyedRead( + (key) => (sessions[key] ?? []).map((session) => ({ ...session })), + () => () => {}, + [], + ); + const a = handle.read("a"); + const b = handle.read("b"); + expect(handle.read("a")).toBe(a); + sessions = { ...sessions, a: [{ key: "one", busy: true }] }; + const changed = handle.read("a"); + expect(changed).not.toBe(a); + expect(changed).toEqual([{ key: "one", busy: true }]); + expect(handle.read("b")).toBe(b); +}); diff --git a/src/features/agents/handles.ts b/src/features/agents/handles.ts new file mode 100644 index 0000000000..acaaa93de3 --- /dev/null +++ b/src/features/agents/handles.ts @@ -0,0 +1,30 @@ +// Handles a type's runtime exposes to its screens. Each is JSON, a store +// (`snapshot` and `subscribe`), a call returning a promise, or a keyed read, +// so that screens can later reach them from another page. +import { shared } from "./port"; + +/** A synchronous read by key, such as one agent's sessions, that screens use + * as a store: `useSyncExternalStore(subscribe, () => read(key))`. Equal content + * keeps its identity. Across pages a key reads as `empty` until it arrives. */ +export type KeyedRead = Readonly<{ + read(key: string): T; + subscribe(listener: () => void): () => void; + empty: T; +}>; + +export function keyedRead( + read: (key: string) => T, + subscribe: (listener: () => void) => () => void, + empty: T, +): KeyedRead { + const last = new Map(); + return Object.freeze({ + read(key: string) { + const value = shared(last.get(key), read(key)); + last.set(key, value); + return value; + }, + subscribe, + empty, + }); +}