import type { CompactResponse, ComputerStatusResponse, LspStatusResponse, McpStatusResponse, ModelMetadata, ReasoningEffort, SystemPromptVariable, TestComputerResponse, WarmResponse, } from "@dispatch/transport-contract"; import type { SurfaceSpec } from "@dispatch/ui-contract"; import type { ComputerEntry, ConversationStatus, ImageInput } from "@dispatch/wire"; import type { WebSocketLike } from "../adapters/ws"; import type { ProtocolState } from "../core/protocol"; import type { AccountUsageProviderId, AccountUsageResult, AccountUsageSettingsResult, UpdateAccountUsageSettingsRequest, } from "../features/account-usage"; import type { ChatStore } from "../features/chat"; import type { ConcurrencyCooldownResult, ConcurrencyDeleteResult, ConcurrencyLimitResult, ConcurrencyLimitsResult, ConcurrencyStatusResult, } from "../features/concurrency"; import type { HeartbeatConfigPatch, HeartbeatConfigResult, HeartbeatNextRunResult, HeartbeatRunsResult, HeartbeatStopResult, } from "../features/heartbeat"; import type { LayoutStore } from "../features/layout"; import type { TextFileClient } from "../features/markdown"; import type { NamedAgent, NamedAgentDeleteResult, NamedAgentInput, NamedAgentSaveResult, NamedAgentSelectionSnapshot, NamedAgentsResult, } from "../features/named-agents"; import type { PluginsStore } from "../features/plugins"; import type { SearchBackendConfigResult } from "../features/search-settings"; import type { Tab } from "../features/tabs"; import type { VisionSettings, VisionSettingsPatch } from "../features/vision"; import type { TileLayoutStore } from "../features/workspaces"; /** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */ export type WarmResult = | { readonly ok: true; readonly response: WarmResponse } | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/cwd`. */ export type CwdResult = | { readonly ok: true; readonly cwd: string | null } | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/computer` (set/clear the per-conversation computer). */ export type ComputerResult = | { readonly ok: true; readonly computerId: string | null } | { readonly ok: false; readonly error: string }; /** Outcome of `GET /computers/:alias/status` (the live connection state). */ export type ComputerStatusResult = | { readonly ok: true; readonly response: ComputerStatusResponse } | { readonly ok: false; readonly error: string }; /** Outcome of `POST /computers/:alias/test` (one-shot connectivity probe). */ export type TestComputerResult = | { readonly ok: true; readonly response: TestComputerResponse } | { readonly ok: false; readonly error: string }; /** Outcome of `GET /conversations/:id/lsp`. */ export type LspResult = | { readonly ok: true; readonly response: LspStatusResponse } | { readonly ok: false; readonly error: string }; /** Outcome of `GET /conversations/:id/mcp`. */ export type McpResult = | { readonly ok: true; readonly response: McpStatusResponse } | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/reasoning-effort`. */ export type ReasoningEffortResult = | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } | { readonly ok: false; readonly error: string }; /** * Outcome of `PUT /conversations/:id/thinking` (PROPOSED — see * `backend-handoff.md`; the endpoint is not yet shipped by the backend). */ export type ThinkingResult = | { readonly ok: true; readonly thinking: boolean } | { readonly ok: false; readonly error: string }; /** Outcome of `POST /conversations/:id/compact` (manual compaction). */ export type CompactResult = | { readonly ok: true; readonly response: CompactResponse } | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /conversations/:id/compact-percent`. */ export type CompactPercentResult = | { readonly ok: true; readonly percent: number } | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /settings/vision` (global vision-settings save). */ export type VisionSettingsResult = | { readonly ok: true; readonly settings: VisionSettings } | { readonly ok: false; readonly error: string }; /** Outcome of `GET /system-prompt` (global template load). */ export type SystemPromptLoadResult = | { readonly ok: true; readonly template: string } | { readonly ok: false; readonly error: string }; /** Outcome of `PUT /system-prompt` (global template save). */ export type SystemPromptSaveResult = SystemPromptLoadResult; /** Outcome of `GET /system-prompt/variables` (variable catalog). */ export type SystemPromptVariablesResult = | { readonly ok: true; readonly variables: readonly SystemPromptVariable[] } | { readonly ok: false; readonly error: string }; /** Outcome of persisting a chat-limit setting (localStorage; FE-local). */ export type ChatLimitResult = | { readonly ok: true; readonly chatLimit: number } | { readonly ok: false; readonly error: string }; export interface AppStore { readonly tabs: readonly Tab[]; readonly activeConversationId: string | null; /** The workspace currently in view (URL slug); tabs are filtered to it. */ readonly activeWorkspaceId: string; /** * The resolved HTTP API base URL (e.g. `http://localhost:24203`). Used to * resolve relative image URLs served by the backend (`/images/…`) into * absolute URLs for ``. */ readonly httpBase: string; readonly activeChat: ChatStore; readonly models: readonly string[]; /** Per-model metadata (contextWindow, etc.) from `GET /models`. */ readonly modelInfo: Readonly>; readonly activeModel: string; readonly catalog: ProtocolState["catalog"]; /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */ readonly surfaces: readonly SurfaceSpec[]; readonly lastError: ProtocolState["lastError"]; /** The localStorage instance the store uses for persistence (tabs, chatLimit). * Exposed so the shell can persist sidebar layout via the same adapter. */ readonly storage: Storage | undefined; /** * The current spec by id, or null if absent. With conversationId, require a * snapshot received for that conversation (not a retained prior-scope spec). */ surface(surfaceId: string, conversationId?: string): SurfaceSpec | null; /** * Send a user message (start a turn). Forwards any staged `images` * (`ImageInput[]` — base64 data URLs / https URLs) on the `chat.send` op; * the server passes them to a vision-capable model natively or transcribes * them via vision handoff for a non-vision model. Omitted on the wire when * none are staged. On a draft, promotes to a tab first. */ send(text: string, images?: readonly ImageInput[]): void; /** * Enqueue a steering message onto the focused conversation's queue * (`chat.queue` WS op). While a turn is generating, the message is delivered * mid-turn at the next tool-result boundary; when idle, the server * auto-starts a turn (equivalent to `send`). Safe to offer whenever the user * wants to add input — the server owns the idle-vs-generating decision. */ queueMessage(text: string, images?: readonly ImageInput[]): void; /** * Cancel (remove) a single queued steering message by id so it never runs * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: the * message-queue surface update reconciles the queue UI (the cancelled message * leaves the snapshot). Targets the focused conversation's queue. The caller * optimistically hides the row; a cancel of an already-drained / unknown * message is a silent server no-op (nothing to roll back). */ cancelQueuedMessage(messageId: string): void; /** Select a raw model for the active conversation. Pass `snapshot` to record a Named Agent selection (plan §7). */ selectModel(model: string, snapshot?: NamedAgentSelectionSnapshot): void; newDraft(): void; /** Switch the active workspace (on route change) + reset to a fresh draft in it. */ setActiveWorkspace(workspaceId: string): void; selectTab(conversationId: string): void; closeTab(conversationId: string): void; renameTab(conversationId: string, title: string): void; /** * Move a tab into a subagent group (the tab group parent's id) or out of * any group (null). Purely presentational (NOT lineage): applied * optimistically, persisted via `PUT /conversations/:id/tab-group` (synced * to other clients by `conversation.tabGroupChanged`), reverted on failure. */ setTabGroup(conversationId: string, parentId: string | null): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; /** * Explicitly (re-)subscribe one surface, scoped to `conversationId` (or the * focused conversation when absent) — the frontend-plugin ABI's * `surfaces.subscribe` path (plan 03). The app auto-subscribes every * catalog entry; this is the re-assertion port (same wire semantics). */ subscribeSurface(surfaceId: string, conversationId?: string): void; /** Drop one surface's subscription (the ABI's `surfaces.unsubscribe`). */ unsubscribeSurface(surfaceId: string, conversationId?: string): void; /** * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). * Returns null when no conversation is focused (a draft has nothing to warm). */ warmNow(): Promise; /** The workspace conversation's persisted working directory, or null when unset. */ readonly cwd: string | null; /** Invalidates live-file routing after known settings changes. */ readonly textFileContextRevision: number; createTextFileClient(conversationId: string): TextFileClient; /** The conversation workspace settings target: the active tab, or the pending draft's id. */ readonly currentConversationId: string; /** * Set the workspace conversation's working directory (`PUT /conversations/:id/cwd`). * Works for a draft too (its id survives promotion), so the first turn runs in it. */ setCwd(cwd: string): Promise; /** * The workspace conversation's persisted computer (an SSH `Host` alias), or * null when never set / local. Seeded from the backend on focus change. */ readonly computerId: string | null; /** * Persist the workspace conversation's computer (`PUT /conversations/:id/computer`). * Pass null to clear → the conversation inherits the workspace default → local. * Works for a draft too (its id survives promotion). Not seen by the agent — a * user-facing tool-execution target only. */ setComputer(computerId: string | null): Promise; /** * Every remote computer discovered from the user's `~/.ssh/config` * (`GET /computers`), fetched on boot. Read-only — there is no Computer CRUD * (the user edits their ssh config to add one). Empty until the `ssh` * extension lands. */ readonly computers: readonly ComputerEntry[]; /** * The live connection state of a computer (`GET /computers/:alias/status`): * whether Dispatch currently holds an open SSH session to it. Returns null * only if no alias is given (the focused conversation is local). Polled by the * `ComputerField` while a computer is selected. */ computerStatus(alias: string): Promise; /** * One-shot connectivity probe (`POST /computers/:alias/test`): Dispatch opens * an SSH connection to the alias, runs a trivial command, then closes. `ok` is * true on success; `error` carries the failure reason otherwise. */ testComputer(alias: string): Promise; /** * The workspace conversation's persisted reasoning effort, or null when never * set (the server then resolves turns at the default, `"high"`). */ readonly reasoningEffort: ReasoningEffort | null; /** * Persist the workspace conversation's reasoning effort * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id * survives promotion), so the first turn already runs at the chosen level. * Takes effect from the NEXT turn; resolution stays server-owned. */ setReasoningEffort(level: ReasoningEffort): Promise; /** * The workspace conversation's persisted thinking flag, or null when never * set (the server then resolves turns with thinking ON — the default). * `false` ⇒ thinking disabled entirely (the SEPARATE "off" axis — NOT a * zero-effort level; the umans route maps it to `reasoning_effort: "none"`). * PROPOSED backend contract — see `backend-handoff.md`. */ readonly thinking: boolean | null; /** * Persist the workspace conversation's thinking flag * (`PUT /conversations/:id/thinking`). Works for a draft too (its id survives * promotion), so the first turn already runs with the chosen setting. Takes * effect from the NEXT turn; resolution stays server-owned. * PROPOSED backend contract — see `backend-handoff.md`. */ setThinking(enabled: boolean): Promise; /** * Manually trigger conversation compaction (`POST /conversations/:id/compact`). * Summarizes old messages + retains the most recent N. Returns null when no * conversation is focused (a draft has nothing to compact). */ compactNow(keepLastN?: number): Promise; /** * Stop an in-flight generation (`POST /conversations/:id/stop`). Aborts the * turn without closing the conversation — partial messages are persisted, the * turn seals with `reason: "aborted"`, and the conversation goes `active → idle`. * Returns null when no conversation is focused. */ stopGeneration(): void; /** * The workspace conversation's auto-compact percent (0-100). `0` = disabled * (manual only); a positive number = auto-compact triggers when the last * turn's input tokens exceed it. Seeded from the backend on focus change. */ readonly compactPercent: number | null; /** * Persist the workspace conversation's auto-compact percent * (`PUT /conversations/:id/compact-percent`). `0` disables; 1-100 sets the * trigger percentage of the model's context window. Default (null) is 85. * number enables. Works for a draft too (its id survives promotion). */ setCompactPercent(percent: number): Promise; /** * The GLOBAL vision settings (`GET /settings/vision`): `imageLimit` (max * native images per turn before compaction; 0 = disabled) + `compactionModel` * (which vision model transcribes old images; null = auto). Shared across all * conversations. Seeded on boot; `null` = not yet fetched. */ readonly visionSettings: VisionSettings | null; /** * Refetch the global vision settings (`GET /settings/vision`). Called by the * vision-settings view on mount; also seeded on boot. */ refreshVisionSettings(): Promise; /** * Save a PARTIAL vision-settings update (`PUT /settings/vision`). Either * field may be omitted. Returns the merged settings on success. */ setVisionSettings(patch: VisionSettingsPatch): Promise; /** * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. */ lspStatus(): Promise; /** * Fetch the workspace conversation's MCP server status (`GET /conversations/:id/mcp`). * Mirrors the LSP status endpoint: returns `{cwd, servers}` with empty `servers` * when no cwd is set; the backend lazily connects servers, so this may take a * moment on the first call for a cwd. */ mcpStatus(): Promise; /** * Load the global system prompt template (`GET /system-prompt`). The template is * conversation-agnostic; it is resolved once per conversation on first turn and * persisted for prompt-cache safety. */ loadSystemPrompt(): Promise; /** * Persist the global system prompt template (`PUT /system-prompt`). Changes apply * to new conversations on their first turn; existing conversations keep their * resolved system prompt until compaction. */ setSystemPrompt(template: string): Promise; /** * Load the static catalog of available system prompt variables (`GET /system-prompt/variables`). * Used by the builder to render the variable selector buttons. */ loadSystemPromptVariables(): Promise; /** The persisted chat limit (max loaded chunks per conversation). */ readonly chatLimit: number; /** * A conversation's backend lifecycle status (`active`/`idle`/`closed`), or * `undefined` when unknown. Drives the tab-bar generating indicator * (cross-device: a tab spinning because another device's turn is running). */ conversationStatus(conversationId: string): ConversationStatus | undefined; /** * Whether at least one conversation in the given workspace is currently * active or queued (generating / waiting for a concurrency slot) — drives * the loading-dots indicator on workspace cards. Backed by a once-derived * `activeWorkspaces` set (the open-tab set × the backend lifecycle statuses) * so this is an O(1) lookup, not a per-card scan of the full tab list. * Reactive: the set is a `$derived`, so a Svelte template expression calling * this re-runs when the tab set or status map changes. */ workspaceHasActiveConversations(workspaceId: string): boolean; /** * Persist + live-apply a new chat limit: writes `dispatch.chatLimit` to * localStorage and propagates to every live chat store (trim if lower, * deferred via the unload gate while a reader is scrolled up; no-op if * higher — page unloaded history back in via "Show earlier"). Stores created * afterwards pick the new limit up at creation. Always succeeds (FE-local). */ setChatLimit(limit: number): Promise; /** * Wire the chat-limit unload gate (composition-root injection, called once by * the shell after it owns the scroll region): unloading old chunks is allowed * only while the gate returns true — i.e. the reader is stuck to the bottom — * so a trim never yanks content out from under someone reading history. * Before attachment unloading is allowed (the initial view starts at the * bottom). */ attachUnloadGate(gate: () => boolean): void; /** * Load the active workspace's heartbeat config * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation): * the backend runs an autonomous agent loop on a configured interval, writing * each run into a dedicated conversation. The config covers the system/task * prompts, model, reasoning effort, interval, and an enabled flag. */ heartbeatConfig(): Promise; /** * Persist a partial heartbeat config patch * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the * stored config; returns the full updated config. */ setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise; /** * Load the active workspace's heartbeat run history * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation * it wrote to — open one via {@link watchConversation} to see its chat live. */ heartbeatRuns(): Promise; /** * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`). * The run's in-flight turn seals (its conversation keeps streaming until it * ends); the run's status flips to `stopped` (visible on the next runs poll). */ stopHeartbeatRun(runId: string): Promise; /** * Fetch the server-authoritative next-run timestamp * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows * a live countdown from this. When the endpoint is absent (404 — backend * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to * an approximation from the runs + config. */ heartbeatNextRun(): Promise; /** * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat * modal): ensures a live {@link ChatStore} for the conversation, subscribing * to its turn stream (`chat.subscribe`) + loading history. Reuses the open * tab's store if the conversation is already a tab; otherwise creates an * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are * routed to it automatically. Pair every open with {@link unwatchConversation} * on close to unsubscribe + dispose the ephemeral store. */ watchConversation(conversationId: string): ChatStore; /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */ unwatchConversation(conversationId: string): void; /** * Load all configured per-provider concurrency limits * (`GET /concurrency/limits`). Global (not workspace-scoped). Returns an empty * list when the concurrency extension isn't loaded (`{ limits: [] }`). */ concurrencyLimits(): Promise; /** * Fetch the configured limit for one provider * (`GET /concurrency/limits/:providerId`). `404` (no limit configured) and * `503` (extension not loaded) both surface as `ok: false`. */ getConcurrencyLimit(providerId: string): Promise; /** * Set or update a provider's concurrency limit * (`PUT /concurrency/limits/:providerId`, body `{ limit }`). `limit` must be a * positive integer (a non-positive body is `400`). At the cap, further requests * queue oldest-agent-first rather than being sent immediately. */ setConcurrencyLimit(providerId: string, limit: number): Promise; /** * Remove a provider's concurrency limit (`DELETE /concurrency/limits/:providerId`), * making it unlimited. `404` (not configured) and `503` (extension not loaded) * both surface as `ok: false`. */ deleteConcurrencyLimit(providerId: string): Promise; /** * Fetch live concurrency status for every provider with a configured limit * (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Each * entry also carries the per-slot release `cooldownMs` + an `autoReduced` flag * (true when a 429 auto-reduced the limit by 1; the FE renders a banner). Returns * an empty list when the extension isn't loaded (`{ providers: [] }`). */ concurrencyStatus(): Promise; /** * Load Claude + Codex subscription usage (`GET /usage`). Served from the * backend's cache (5 min TTL); `refresh: true` sends `?refresh=1`, which the * server still rate-limits (min interval + failure cooldown). A 503 (the * `account-usage` extension isn't loaded) returns `unavailable: true`. */ accountUsage(opts?: { readonly refresh?: boolean }): Promise; /** * Update a provider's usage settings (`PUT /usage/:provider/settings`; today * only `hyper`: `dailyAllowance` + `resetAnchor` epoch-ms or null). Returns * the provider's refreshed report. */ setAccountUsageSettings( provider: AccountUsageProviderId, settings: UpdateAccountUsageSettingsRequest, ): Promise; /** * Load the GLOBAL search-backend selection * (`GET /search/config`): which engine backs the `web_search` tool's search * mode. Not workspace-scoped — the selection is global. `unavailable: true` * on the failure variant marks the 503 degrade (the backend's * search-config service not loaded) so the UI can disable the field. */ searchBackendConfig(): Promise; /** * Persist a new search-backend selection * (`PUT /search/config`, body `{ backend }`). `400` (unknown/unconfigured * backend) and `503` (service not loaded) both surface as `ok: false`. */ setSearchBackend(backend: string): Promise; /** * Fetch the per-slot release cooldown (ms) for one provider * (`GET /concurrency/cooldown/:providerId`). `404` (no concurrency config at * all) and `503` (extension not loaded) both surface as `ok: false`. */ getConcurrencyCooldown(providerId: string): Promise; /** * Set the per-slot release cooldown (ms) for one provider * (`PUT /concurrency/cooldown/:providerId`, body `{ cooldownMs }`). `cooldownMs` * must be a non-negative integer (0 = no cooldown / instant re-admission); an * invalid body is `400`. Persists + applies to subsequently recycled slots. */ setConcurrencyCooldown( providerId: string, cooldownMs: number, ): Promise; /** * A critical error that blocks normal operation (e.g. the cross-device tab * restore fetch failed). When non-null, a full-screen modal is shown with the * error details. Cleared by `clearFatalError` (the modal's dismiss button). */ readonly fatalError: string | null; /** Dismiss the fatal error (called by the error modal's X button). */ clearFatalError(): void; // ── Named Agents (global config; plan §9) ────────────────────────────────── /** * The persisted Named Agents (from `GET /named-agents`). Global user config * (not per-workspace); seeded on boot. Drives the sidebar editor AND the * synthetic "Named Agents" provider in the model selector (plan §6). */ readonly namedAgents: readonly NamedAgent[]; /** * The active conversation's Named Agent selection snapshot (plan §7), or null * when it was selected via a raw model. Survives the Named Agent being renamed * / removed (the snapshot retains the friendly label + captured raw model). */ readonly namedAgentSnapshot: NamedAgentSelectionSnapshot | null; /** Refetch the Named Agents (`GET /named-agents`). */ refreshNamedAgents(): Promise; // ── Plugins (plan 07) ────────────────────────────────────────────────────── /** * The plugin manager + per-workspace enablement store (one instance per * app store). The workspaces page's manager/modal read + mutate through * it; the app store feeds its `onExtensionsChanged` hook. */ readonly plugins: PluginsStore; /** * The ACTIVE workspace's tile tree (plan 02): served by the backend's * layout provider when one is active, the local default otherwise. The * shell reads `tree` for the renderer and feeds edits back through * `onTreeChange` (debounced PUT + rollback); the store owns the probes, * the WS-push filter, and the no-provider degrade. */ readonly layout: LayoutStore; /** * The home grid's synced tile layout (ONE shared document per device * orientation — `wide` = landscape/desktop, `tall` = portrait/mobile), * served by the backend's home-tile-layout plugin. The home page locks the * active orientation to the resolved grid geometry; this store owns the * probes, the WS-push filter, the debounced PUT + rollback, and the * offline degrade. */ readonly tileLayout: TileLayoutStore; /** * Loaded provider-tier plugins effectively enabled in the ACTIVE workspace * (`GET /workspaces/:id/plugins/providers-status`). `null` = unknown — the * first-boot guardrail renders only on a confident `0` (§7). */ readonly enabledProviderCount: number | null; /** * Create a Named Agent (`POST /named-agents`). On success the store's * `namedAgents` list is updated (append/replace) so every consumer — the * sidebar editor AND the model selector — reflects it without a reload. */ createNamedAgent(input: NamedAgentInput): Promise; /** * Update a Named Agent (`PUT /named-agents/:id`). On success the store list is * updated in place. Returns the canonical saved record. */ updateNamedAgent(id: string, input: NamedAgentInput): Promise; /** * Remove a Named Agent (`DELETE /named-agents/:id`). On success it leaves the * store list immediately (removing it from the sidebar + model selector), and * does NOT alter conversations that previously selected it (those keep their * snapshot — plan §7). */ deleteNamedAgent(id: string): Promise; dispose(): void; } export interface CreateAppStoreOptions { url?: string; httpUrl?: string; socketFactory?: (url: string) => WebSocketLike; fetchImpl?: typeof fetch; indexedDB?: IDBFactory; conversationId?: string; localStorage?: Storage; /** The workspace to scope to at boot (its URL slug); "default" if absent. */ workspaceId?: string; /** * How live chat events are batched before folding into a chat store. * Default: once per animation frame (with a timeout fallback for hidden * tabs). `null` = fold every event synchronously (tests that assert on the * transcript immediately after feeding a frame). */ chatFlushScheduler?: ((flush: () => void) => void) | null; }