summaryrefslogtreecommitdiffhomepage
path: root/.dispatch/wire.reference.md
diff options
context:
space:
mode:
Diffstat (limited to '.dispatch/wire.reference.md')
-rw-r--r--.dispatch/wire.reference.md357
1 files changed, 250 insertions, 107 deletions
diff --git a/.dispatch/wire.reference.md b/.dispatch/wire.reference.md
index 44b0fe7..b430a45 100644
--- a/.dispatch/wire.reference.md
+++ b/.dispatch/wire.reference.md
@@ -4,98 +4,59 @@
> types WITHOUT following the `file:` dep symlink out of this repo (which hangs on a permission
> prompt). Your CODE still imports `@dispatch/wire` normally — this file is for READING only.
>
-> **Orchestrator:** SNAPSHOT of `[email protected]` (compaction). Regenerate
-> whenever `@dispatch/wire` changes.
+> **Orchestrator:** SNAPSHOT of `[email protected]` (workspaces + computers + provider-retry + concurrency-`queued` status). Regenerate whenever `@dispatch/wire` changes.
>
-> **2026-06-22 delta (compaction handoff — package bumped `0.10.0` → `0.11.0`, ADDITIVE):**
-> adds `CompactionResult` — the result of a compaction operation (`summary`, `messagesSummarized`,
-> `messagesKept`). The summary text is the model's output; the FE doesn't render it directly (it
-> becomes the conversation's first system message after compaction).
+> **2026-06-27 delta (workspace starring — ADDITIVE to `[email protected]`, NO version bump):** `Workspace` gains a
+> required `starred: boolean` (defaults to `false` on creation). A starred workspace's agents receive
+> PRIORITY in the concurrency limiter queue — they jump ahead of agents from non-starred workspaces
+> (oldest-agent-first within each group). Toggled via dedicated `PUT`/`DELETE /workspaces/:id/star` endpoints
+> (no body; both create-on-miss and return the updated `Workspace`). `PUT /workspaces/:id` does NOT accept a
+> `starred` field. See `backend-handoff.md`.
>
-> **2026-06-22 delta (conversation lifecycle handoff — package bumped `0.9.0` → `0.10.0`, ADDITIVE):**
-> adds `ConversationStatus` (`"active" | "idle" | "closed"`) — the per-conversation lifecycle
-> status. `ConversationMeta` gains a `status` field. `active` = a turn is generating; `idle` =
-> exists, not generating; `closed` = dismissed (hidden from the tab bar). Transitions are
-> backend-owned: `idle → active` on turn start, `active → idle` on turn settle, `→ closed` on
-> `POST /conversations/:id/close`. Pushed to all WS clients via `conversation.statusChanged`
-> (see `[email protected]`).
+> **2026-06-26 delta (provider concurrency — ADDITIVE to `[email protected]`, NO version bump):** `ConversationStatus`
+> widened to `"active" | "queued" | "idle" | "closed"`. `queued` = the turn is in flight but waiting for a
+> per-provider concurrency slot (broadcast-only via `conversation.statusChanged`, never persisted); the FE shows
+> a loading ring (vs the dots of `active`). See `backend-handoff.md` CR-13.
>
-> **2026-06-21 delta (conversation.open handoff — package bumped `0.8.0` → `0.9.0`, ADDITIVE):**
-> adds `ConversationMeta` — metadata for a conversation (id, title, createdAt, lastActivityAt),
-> returned by `GET /conversations` (the list endpoint, see `[email protected]`).
+> **2026-06-26 delta (vision handoff — ADDITIVE to `[email protected]`, NO version bump):** adds a new
+> `ImageChunk` variant to the `Chunk` union (`{ type: "image", url, mimeType? }` — `url` is a base64 data
+> URL or an `http(s)://` URL) and a transport-facing `ImageInput` (`{ url, mimeType? }`, what a client
+> sends on `ChatRequest.images`; the orchestrator converts each into an `ImageChunk` on the persisted user
+> message). Vision-capable models receive image chunks natively; non-vision models never see them directly
+> — the orchestrator's vision handoff transcribes each to a text description (persisted as a separate
+> `text` chunk in the SAME user message). See `backend-handoff.md` §2j.
>
-> **2026-06-21 delta (message-queue + steering handoff — package bumped `0.7.0` → `0.8.0`, ADDITIVE):**
-> adds the per-conversation **message queue** + **steering** feature. While a turn is GENERATING,
-> a client enqueues a user message (via the `chat.queue` WS op or `POST /conversations/:id/queue`,
-> see `[email protected]`); it is delivered mid-turn as **steering** — injected at the next
-> tool-result boundary so the model sees it alongside the tool results and can adjust course. If the
-> turn ends with a non-empty queue (no tool call fired), the queue is carried into a NEW turn as its
-> opening prompt (no `steering` event — the new turn's `user-message` covers it).
+> **2026-06-26 update (image storage — NO type change, behavior only):** `ImageChunk.url` for PERSISTED
+> chunks is now a compact relative HTTP path (`/images/<conversationId>/<uuid>.png`) served by the backend's
+> new `GET /images/:conversationId/:imageId` endpoint (raw bytes + correct Content-Type), NOT a base64 data
+> URL — images are stored on disk under tmp, not in the SQLite conversation store (keeps payloads small).
+> `ImageInput.url` (what a client SENDS on `ChatRequest.images`) is UNCHANGED — still a data URL or
+> `http(s)://` URL; the backend saves it to tmp and returns the compact path in the persisted chunk. A client
+> resolves a relative `url` against its API base (`resolveImageUrl`); a data URL (the optimistic echo) or an
+> absolute URL passes through unchanged. See `backend-handoff.md` §2j.
>
-> Adds:
-> - **`QueuedMessage`** (`{ id, text, queuedAt }`) — a message held in the queue (stable id for UI
-> keying + dedup).
-> - **`QueuePayload`** (`{ messages: QueuedMessage[] }`) — the payload of the message-queue
-> extension's per-conversation `custom` surface field (`rendererId: "message-queue"`). Carried on
-> the SURFACE channel (NOT the chat stream) — the queue is control/state. Empty `messages` = empty
-> queue. See `transport-contract.reference.md` for the surface + the enqueue op.
-> - **`TurnSteeringEvent`** (`{ type: "steering"; conversationId; turnId; text }`) — a NEW
-> `AgentEvent` union member, emitted on the chat stream when the kernel drains a non-empty queue
-> at a tool-result boundary. Render `text` as a USER bubble in the transcript (positioned after
-> the tool-result it followed); the queue surface separately clears on drain. One event per drain;
-> `text` is the combined text of all drained messages. Late-join safe (buffered into the in-flight
-> turn's event buffer, mirroring `user-message`). Carry-to-new-turn does NOT emit `steering`.
-> ADDITIVE to the union — if you have an exhaustive `AgentEvent` switch, add a `steering` case.
+> **2026-06-23 delta (workspaces handoff — package bumped `0.11.0` → `0.12.0`, ADDITIVE):** adds
+> `Workspace` + `WorkspaceEntry` (a list entry with a conversation count) and a required
+> `workspaceId: string` on `ConversationMeta` (`"default"` for legacy/unspecified conversations). A
+> workspace is a URL-driven grouping of conversations that owns a default cwd; conversations that
+> haven't set their own cwd inherit `workspace.defaultCwd`. See `backend-handoff-workspaces-reply.md`.
>
-> **2026-06-12 delta (reasoning-effort handoff — package bumped `0.6.1` → `0.7.0`, ADDITIVE):**
-> adds the **`ReasoningEffort`** type — the per-request thinking-depth ladder
-> `"low" | "medium" | "high" | "xhigh" | "max"`. Provider-agnostic; the Anthropic provider maps
-> levels to extended-thinking token budgets (low 4096 · medium 10240 · high 16384 · xhigh 32768 ·
-> max 65536); providers without a thinking knob ignore it. Resolution is SERVER-owned (do not
-> re-implement): per-turn `ChatRequest.reasoningEffort` override → persisted per-conversation value
-> (`GET`/`PUT /conversations/:id/reasoning-effort`, see `[email protected]`) → default
-> `"high"`. Higher levels mean longer runs of `reasoning-delta` events before the first text delta.
-> See the `ReasoningEffort` definition below.
+> **2026-06-25 delta (SSH handoff #1 — ADDITIVE to `[email protected]`, NO version bump):** adds a REQUIRED
+> `defaultComputerId: string | null` on `Workspace` (null = local / no SSH; the computer analog of
+> `defaultCwd`) and two new read-only view types: `Computer` (a discovered `~/.ssh/config` `Host` alias)
+> and `ComputerEntry extends Computer` (a list entry with a `usageCount`). `alias` IS the `computerId`
+> users select (persisted per-conversation/per-workspace like cwd). The full HTTP API surface
+> (`GET /computers`, `PUT /conversations/:id/computer`, `PUT /workspaces/:id/default-computer`,
+> `GET /computers/:alias/status`, `chat.send computerId`) comes in a LATER handoff — NOT consumed yet.
>
-> **2026-06-12 delta (CR-5 history windowing — package bumped `0.6.0` → `0.6.1`, DOC-ONLY):** the
-> per-conversation `seq` numbering is now a WRITTEN CONTRACTUAL GUARANTEE on `StoredChunk`:
-> **1-based, monotonic, gap-free** — a conversation's first chunk is always `seq === 1` and
-> numbering never skips. A client holding only a windowed suffix of the log derives "older chunks
-> exist server-side" purely from `oldestLoaded.seq > 1` (no `earliestSeq`/`hasOlder` field exists).
->
-> **2026-06-12 delta (CR-3 user-message handoff — package bumped `0.5.0` → `0.6.0`, ADDITIVE):** adds a
-> new `AgentEvent` union member `TurnInputEvent` (`{ type: "user-message"; conversationId; turnId; text }`)
-> that surfaces the turn's USER prompt INTO the outward event stream. Emitted ONCE as the FIRST event of
-> every turn (before `turn-start`), so it is buffered + replayed to every subscriber — live AND late-join
-> — and rides `chat.delta`/NDJSON like any other event. Fixes CR-3 (a pure watcher couldn't see the prompt
-> until seal). The sender still echoes its own prompt optimistically, so consumers DE-DUP against that
-> (by text); a pure watcher renders it directly. Persistence/metrics unchanged. See `TurnInputEvent` below.
->
-> **2026-06-12 delta (context-size handoff — package bumped `0.4.0` → `0.5.0`):** adds an OPTIONAL
-> `contextSize?: number` to BOTH `TurnDoneEvent` (live `done`) and `TurnMetrics` (persisted) — the
-> turn's FINAL step `inputTokens + outputTokens` (current context occupancy), NOT the aggregate
-> `usage` (which overcounts multi-step turns). The two carriers are equal for the same turn. Current
-> value = the LATEST turn's `contextSize`; `undefined` ⇒ render "unknown", never `0`. See the field
-> doc-comments on `TurnMetrics`/`TurnDoneEvent` below.
->
-> **0.3.0 changes (token + timing metrics):**
-> - **Live per-step/per-turn telemetry on the event stream** (transient — NOT persisted):
-> `TurnUsageEvent` gained an OPTIONAL `stepId?` (attribute tokens per step). A NEW
-> `TurnStepCompleteEvent` (`type: "step-complete"`, REQUIRED `stepId`) carries the per-step
-> generation timing `ttftMs?` / `decodeMs?` / `genTotalMs?` (all optional — present only when the
-> runtime had a clock; `ttftMs`/`decodeMs` additionally require a first content token). `TurnDoneEvent`
-> gained an OPTIONAL `durationMs?` (total turn wall-clock) + OPTIONAL `usage?` (aggregate across
-> steps). `TurnToolResultEvent` gained an OPTIONAL `durationMs?` (tool execution time).
-> - **Durable, replayable metrics** (persisted, keyed per turn): NEW `StepMetrics` + `TurnMetrics`
-> — the persisted counterparts of the live `usage` + `step-complete` + `done` packets. Served by
-> `GET /conversations/:id/metrics` (see `transport-contract.reference.md`). Build the SAME
-> `TurnMetrics` shape from the live events for the in-flight turn; the durable endpoint supplies it
-> for sealed turns. TPS is derived (`usage.outputTokens / (genTotalMs / 1000)`), not on the wire.
-> - **0.2.0 (still current — step grouping):** `ToolCallChunk`/`ToolResultChunk` carry an OPTIONAL
-> `stepId?: StepId`; `TurnToolCallEvent`/`TurnToolResultEvent` carry a REQUIRED `stepId: StepId`.
-> Group batched/parallel tool calls by `stepId` equality. Live: read `event.stepId`. Replay: read
-> `storedChunk.chunk.stepId` (NOT the envelope; tolerate absence). `StoredChunk` envelope is
-> UNCHANGED (`{ seq, role, chunk }` — carries NO `turnId`).
+> **⚠️ CROSS-REPO DIVERGENCE (2026-06-25, BLOCKING FE typecheck):** the backend `feature/ssh-support`
+> branch (where the SSH types landed) was cut from `8a74335` and is MISSING the `TurnProviderRetryEvent` /
+> `provider-retry` `AgentEvent` addition that is on `dev` (and which the FE already consumes — see §2c of
+> `backend-handoff.md`). The mirror below KEEPS `TurnProviderRetryEvent` (it is the FE's expected contract
+> and matches `dev`); it is marked where it appears. Until the backend merges `dev` into
+> `feature/ssh-support`, the FE pinned to the `feature/ssh-support` wire will NOT typecheck (11 errors,
+> all the missing `provider-retry` seam). The SSH `Computer`/`defaultComputerId` types ARE present on
+> `feature/ssh-support` and are consumed below.
```ts
/**
@@ -136,7 +97,8 @@ export type Chunk =
| ToolCallChunk
| ToolResultChunk
| ErrorChunk
- | SystemChunk;
+ | SystemChunk
+ | ImageChunk;
/** A piece of plain text content from the assistant or user. */
export interface TextChunk {
@@ -213,6 +175,51 @@ export interface SystemChunk {
}
/**
+ * An image attached to a message (e.g. a user-pasted screenshot or pasted
+ * photo). Carries a `url` that is EITHER a base64 data URL
+ * (`data:image/png;base64,…`) OR an `http(s)://` URL OR — for PERSISTED chunks
+ * (history/replay) — a compact relative HTTP path (`/images/<conversationId>/
+ * <uuid>.png`) served by the backend's `GET /images/:conversationId/:imageId`
+ * endpoint (images are stored on disk under tmp, NOT in the conversation store,
+ * to keep SQLite payloads small). A client resolves a relative path against its
+ * API base URL; a data URL (the optimistic echo / a pasted image) or an
+ * absolute URL is rendered as-is. Vision-capable models receive it natively
+ * (the provider serializes it to its image-content format); non-vision models
+ * never see it directly — the orchestrator's **vision handoff** transcribes it
+ * to a text description (via a vision-capable model) and feeds that text
+ * instead, so a text-only model can still reason about the image's contents.
+ *
+ * When a transcription was performed, it is persisted as a separate `text`
+ * chunk alongside the `image` chunk in the SAME user message, so the
+ * description is reused on every later turn (no re-transcription) and a
+ * client renders both the original image and its textual analysis.
+ */
+export interface ImageChunk {
+ readonly type: "image";
+ /** Image source: a base64 data URL (`data:image/…;base64,…`), an `http(s)://` URL, or a compact relative path (`/images/<conv>/<uuid>.png`) for persisted chunks. */
+ readonly url: string;
+ /**
+ * Optional MIME type of the image (e.g. `"image/png"`). Inferred from the
+ * data URL when absent; present so a client can render an icon/label without
+ * parsing the URL. Optional — callers that only have a URL omit it.
+ */
+ readonly mimeType?: string;
+}
+
+/**
+ * An image a client attaches to a chat message (`ChatRequest.images`). The
+ * transport-facing input shape; the orchestrator converts each `ImageInput`
+ * into an `ImageChunk` on the persisted user message. Carries the same `url`
+ * semantics as `ImageChunk.url`.
+ */
+export interface ImageInput {
+ /** Image source: a base64 data URL (`data:image/…;base64,…`) or an `http(s)://` URL. */
+ readonly url: string;
+ /** Optional MIME type (e.g. `"image/png"`). Optional — inferred from the data URL when absent. */
+ readonly mimeType?: string;
+}
+
+/**
* A chat message: a role plus an ordered sequence of chunks. Messages are the
* unit passed to and from the provider; chunks are the unit persisted and
* rendered.
@@ -299,8 +306,8 @@ export interface StepMetrics {
* Durable per-turn metrics for a completed (sealed) turn — the persisted,
* replayable counterpart of the live `done` event's aggregate `usage` +
* `durationMs`, plus the per-step breakdown. `usage` is the aggregate across all
- * steps; `steps` carries each step's `StepMetrics` in step order. Stored by
- * `conversation-store` keyed by `turnId` and served by
+ * steps; `steps` carries each step's `StepMetrics` in step order. Persisted per
+ * turn by `conversation-store` (returned in turn-append order) and served by
* `GET /conversations/:id/metrics`. (`turnId` is the plain wire string carried
* on every `AgentEvent`, the join key to the live stream.)
*/
@@ -373,6 +380,7 @@ export type AgentEvent =
| TurnUsageEvent
| TurnStepCompleteEvent
| TurnErrorEvent
+ | TurnProviderRetryEvent // ⚠️ divergent: present on `dev`, MISSING on the pinned `feature/ssh-support` wire (see header + backend-handoff.md §2c)
| TurnDoneEvent
| TurnSealedEvent
| TurnSteeringEvent;
@@ -393,13 +401,15 @@ export interface TurnStartEvent {
/**
* The user prompt that opened this turn, surfaced INTO the turn's outward event
- * stream so a WATCHER (subscribed but not the sender) can render the prompt
- * mid-turn — the user message is otherwise persisted only at seal. Emitted ONCE
- * as the FIRST event of the turn (before `turn-start`); buffered + replayed to
- * every subscriber (live + late-join). The sender echoes its own prompt
- * optimistically, so DE-DUP against that (by text); a pure watcher renders it
- * directly. Carries the raw `text` passed to the provider. (Turn-scoped: it
- * carries `turnId`, so a multi-turn transcript attributes each prompt to its turn.)
+ * stream. The user message is persisted only when the turn seals (atomically with
+ * the assistant reply), so without this event a client that is merely WATCHING a
+ * conversation (subscribed but not the sender) has no source for the prompt text
+ * mid-turn — it would see the streaming reply with no preceding user bubble until
+ * seal. Emitted once, as the FIRST event of the turn (before `turn-start`), so it
+ * is buffered and replayed to every subscriber — live and late-join — exactly like
+ * the rest of the turn. The sender already echoes its own prompt optimistically, so
+ * a consumer should de-dup against that (e.g. by text); a pure watcher renders it
+ * directly. Carries the raw prompt `text` (the same text passed to the provider).
*/
export interface TurnInputEvent {
readonly type: "user-message";
@@ -527,6 +537,31 @@ export interface TurnErrorEvent {
readonly code?: string;
}
+/**
+ * A retryable provider error is being retried with backoff. Emitted once per
+ * scheduled retry, BEFORE the sleep, so the UI can show "⚠ Server overloaded —
+ * retrying in 5s…" immediately. TRANSIENT: emitted to the frontend but NOT
+ * persisted into the model's message history (it never pollutes the prompt).
+ *
+ * When the retry budget is exhausted, the existing `error` event is emitted and
+ * the turn seals — so the final failure is still a persisted error. `attempt` is
+ * 0-based (the Nth retry about to happen); `delayMs` is the scheduled sleep
+ * before that retry fires.
+ */
+export interface TurnProviderRetryEvent {
+ readonly type: "provider-retry";
+ readonly conversationId: string;
+ readonly turnId: string;
+ /** 0-based: this is the Nth retry about to happen. */
+ readonly attempt: number;
+ /** ms the client should expect to wait before the retry fires. */
+ readonly delayMs: number;
+ /** The endpoint's error verbatim (e.g. "HTTP 429: {…overloaded_error…}"). */
+ readonly message: string;
+ /** The HTTP code when known (e.g. "429"). */
+ readonly code?: string;
+}
+
/** The turn has completed (model finished generating). */
export interface TurnDoneEvent {
readonly type: "done";
@@ -545,11 +580,11 @@ export interface TurnDoneEvent {
*/
readonly usage?: Usage;
/**
- * **Context size** — tokens the conversation occupies right now: the turn's
- * FINAL step `inputTokens + outputTokens` (the prompt sent into the last LLM
- * round-trip plus that round-trip's output). This is the "tokens in context"
- * figure a client renders as the chat's current context usage, and a client
- * treats the LATEST turn's value as the live total.
+ * **Context size** — the number of tokens the conversation now occupies: this
+ * (the most recent) turn's FINAL step `inputTokens + outputTokens` (the full
+ * prompt sent into the last LLM round-trip plus that round-trip's output). This
+ * is the "tokens in context" figure a client renders as the chat's current
+ * context usage, and a client treats the LATEST turn's value as the live total.
*
* Deliberately NOT the aggregate `usage` above: `usage` SUMS each step's
* `inputTokens`, which overcounts a multi-step / tool-calling turn because every
@@ -598,18 +633,24 @@ export interface TurnSteeringEvent {
// ─── Conversation metadata ───────────────────────────────────────────────────
/**
- * The per-conversation lifecycle status. `active` = a turn is generating;
- * `idle` = exists, not generating; `closed` = dismissed (hidden from the tab
- * bar, not deleted). Transitions are backend-owned and pushed via the
- * `conversation.statusChanged` WS message (see `transport-contract`).
+ * The lifecycle status of a conversation, used for tab persistence across
+ * devices. `active` = an agent is currently generating; `queued` = the turn is
+ * in flight but waiting for a per-provider concurrency slot (broadcast-only,
+ * never persisted — CR-13; the tab shows a ring vs the dots of `active`);
+ * `idle` = exists but not generating; `closed` = user dismissed the tab
+ * (hidden from the tab bar, not deleted). New conversations start as `idle`;
+ * transitions to `active` on turn-start (or `queued` when the request blocks on
+ * a concurrency slot before generation begins), back to `idle` on turn
+ * done/error, and to `closed` on user close.
*/
-export type ConversationStatus = "active" | "idle" | "closed";
+export type ConversationStatus = "active" | "queued" | "idle" | "closed";
/**
* Metadata for a conversation, returned by `GET /conversations` (the list
* endpoint). The title defaults to the first user message (truncated) and can
* be set via `PUT /conversations/:id/title`. `createdAt` is set on first write;
- * `lastActivityAt` is updated on every append.
+ * `lastActivityAt` is updated on every append. `status` tracks the tab lifecycle
+ * for cross-device persistence.
*/
export interface ConversationMeta {
readonly id: string;
@@ -617,7 +658,17 @@ export interface ConversationMeta {
readonly lastActivityAt: number;
readonly title: string;
readonly status: ConversationStatus;
- /** Points to the archive conversation with full pre-compaction history. */
+ /**
+ * The workspace this conversation belongs to. Always present; reads as
+ * `"default"` for legacy conversations that were never explicitly assigned.
+ * Conversations created with no `workspaceId` default to `"default"`.
+ */
+ readonly workspaceId: string;
+ /**
+ * Set on a compacted conversation: points to the archive conversation ID
+ * that holds the full pre-compaction history. Absent on conversations
+ * that have never been compacted.
+ */
readonly compactedFrom?: string;
}
@@ -627,6 +678,8 @@ export interface ConversationMeta {
* Result of a compaction operation. `summary` is the text the model produced;
* `messagesKept` is how many recent messages were retained after the summary;
* `messagesSummarized` is how many old messages were replaced by the summary.
+ * `newConversationId` is the ID of the new conversation that holds the full
+ * pre-compaction history (non-destructive — the original history is preserved).
*/
export interface CompactionResult {
readonly summary: string;
@@ -634,4 +687,94 @@ export interface CompactionResult {
readonly messagesSummarized: number;
readonly messagesKept: number;
}
+
+// ─── Workspaces ──────────────────────────────────────────────────────────────
+
+/**
+ * A named, URL-driven grouping of conversations that owns a default cwd.
+ * Every conversation belongs to exactly one workspace; conversations that
+ * haven't set their own per-conversation cwd inherit `defaultCwd`.
+ *
+ * Workspaces are backend-owned (so cross-device just works): the workspace
+ * entity and each conversation's `workspaceId` live server-side. The
+ * `"default"` workspace is always present and non-deletable; conversations
+ * created with no `workspaceId` are assigned to `"default"`.
+ */
+export interface Workspace {
+ /** The URL slug (immutable). Lowercase `[a-z0-9-]`, 1–40 chars. */
+ readonly id: string;
+ /** Display title (editable). Defaults to `id` on creation. */
+ readonly title: string;
+ /** The workspace's default cwd, or `null` (fall through to server default). */
+ readonly defaultCwd: string | null;
+ /**
+ * The workspace's default computer — an SSH config `Host` alias that
+ * conversations in this workspace inherit when they set no `computerId` of
+ * their own. `null` means local (no SSH; today's behavior). The computer
+ * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer`
+ * (per-conv `computerId` → this → `null`/local).
+ */
+ readonly defaultComputerId: string | null;
+ /**
+ * Whether the workspace is starred by the user. Starred workspaces receive
+ * PRIORITY in the concurrency limiter queue — their agents jump ahead
+ * of agents from non-starred workspaces (oldest-agent-first within each group).
+ * Defaults to `false` on creation.
+ */
+ readonly starred: boolean;
+ /** Epoch-ms when the workspace was first created. */
+ readonly createdAt: number;
+ /** Epoch-ms of the most recent conversation activity in this workspace. */
+ readonly lastActivityAt: number;
+}
+
+/**
+ * A workspace entry in the list response (`GET /workspaces`) — a `Workspace`
+ * plus a conversation count.
+ */
+export interface WorkspaceEntry extends Workspace {
+ /** Number of conversations assigned to this workspace. */
+ readonly conversationCount: number;
+}
+
+// ─── Computers ───────────────────────────────────────────────────────────────
+
+/**
+ * A read-only view of a remote computer discovered from the system's
+ * `~/.ssh/config` — a "computer" is a `Host` alias, NOT an editable entity
+ * (there is no Computer CRUD store). To add a computer, the user adds a `Host`
+ * block to `~/.ssh/config`; Dispatch discovers it on the next `listComputers()`
+ * read. Every field below is resolved from the config (first-match-wins for
+ * `HostName`/`User`/`Port`/`IdentityFile`).
+ *
+ * `alias` is the `computerId` users select — the string persisted per
+ * conversation and per workspace (the computer analog of `cwd`). `knownHost`
+ * drives the frontend "known/new" indicator and is read-only.
+ */
+export interface Computer {
+ /** The SSH config `Host` alias — also the `computerId` users select. */
+ readonly alias: string;
+ /** Resolved `HostName`/IP from the config (falls back to the alias itself). */
+ readonly hostName: string;
+ /** Resolved port (config `Port`, default 22). */
+ readonly port: number;
+ /** Resolved user (config `User`, default the current user). */
+ readonly user: string;
+ /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */
+ readonly identityFile: string | null;
+ /**
+ * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously
+ * connected). Drives the frontend "known/new" indicator. Read-only.
+ */
+ readonly knownHost: boolean;
+}
+
+/**
+ * A computer entry in the list response (`GET /computers`) — a `Computer` plus
+ * a usage count. Parallel to `WorkspaceEntry`.
+ */
+export interface ComputerEntry extends Computer {
+ /** Number of conversations/workspaces whose `computerId` resolves to this alias. */
+ readonly usageCount: number;
+}
```