summaryrefslogtreecommitdiffhomepage
path: root/packages/transport-contract/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/transport-contract/src')
-rw-r--r--packages/transport-contract/src/contract.types.test.ts477
-rw-r--r--packages/transport-contract/src/index.ts885
2 files changed, 854 insertions, 508 deletions
diff --git a/packages/transport-contract/src/contract.types.test.ts b/packages/transport-contract/src/contract.types.test.ts
index 0aad643..3cc1b1e 100644
--- a/packages/transport-contract/src/contract.types.test.ts
+++ b/packages/transport-contract/src/contract.types.test.ts
@@ -8,71 +8,95 @@
import { describe, expect, it } from "vitest";
import type {
- ChatRequest,
- Computer,
- ComputerEntry,
- ComputerListResponse,
- ComputerResponse,
- ComputerStatusResponse,
- ConversationComputerResponse,
- CwdResponse,
- LspServerInfo,
- LspServerState,
- LspStatusResponse,
- McpStatusResponse,
- SetConversationComputerRequest,
- SetCwdRequest,
- SetWorkspaceDefaultComputerRequest,
- TestComputerResponse,
+ ChatRequest,
+ Computer,
+ ComputerEntry,
+ ComputerListResponse,
+ ComputerResponse,
+ ComputerStatusResponse,
+ ConversationComputerResponse,
+ CwdResponse,
+ LspServerInfo,
+ LspServerState,
+ LspStatusResponse,
+ McpStatusResponse,
+ ModelsResponse,
+ SetConversationComputerRequest,
+ SetCwdRequest,
+ SetWorkspaceDefaultComputerRequest,
+ TestComputerResponse,
} from "./index.js";
// ─── CwdResponse ─────────────────────────────────────────────────────────────
const _cwdNull: CwdResponse = {
- conversationId: "conv-1",
- cwd: null,
+ conversationId: "conv-1",
+ cwd: null,
};
const _cwdSet: CwdResponse = {
- conversationId: "conv-2",
- cwd: "/home/user/project",
+ conversationId: "conv-2",
+ cwd: "/home/user/project",
};
// ─── SetCwdRequest ───────────────────────────────────────────────────────────
const _setCwd: SetCwdRequest = {
- cwd: "/tmp/workspace",
+ cwd: "/tmp/workspace",
};
// ─── ChatRequest.computerId (additive optional) ──────────────────────────────
const _chatWithComputer: ChatRequest = {
- message: "run the test suite",
- computerId: "prod-box",
+ message: "run the test suite",
+ computerId: "prod-box",
};
const _chatWithoutComputer: ChatRequest = {
- message: "hello",
+ message: "hello",
+};
+
+// ─── ChatRequest.images (additive optional) ──────────────────────────────────
+
+const _chatWithImages: ChatRequest = {
+ message: "What's in this screenshot?",
+ images: [{ url: "data:image/png;base64,iVBORw0KGgo=", mimeType: "image/png" }],
+};
+
+const _chatWithHttpImage: ChatRequest = {
+ message: "analyze this",
+ images: [{ url: "https://example.com/diagram.png" }],
+};
+
+// ─── ChatRequest.title (additive optional) ───────────────────────────────────
+
+const _chatWithTitle: ChatRequest = {
+ message: "implement the feature",
+ title: "Summon: add --title flag",
+};
+
+const _chatWithoutTitle: ChatRequest = {
+ message: "hello",
};
// ─── Computer list / single response ─────────────────────────────────────────
const _computer: Computer = {
- alias: "prod-box",
- hostName: "10.0.0.5",
- port: 22,
- user: "deploy",
- identityFile: "/home/user/.ssh/id_ed25519",
- knownHost: true,
+ alias: "prod-box",
+ hostName: "10.0.0.5",
+ port: 22,
+ user: "deploy",
+ identityFile: "/home/user/.ssh/id_ed25519",
+ knownHost: true,
};
const _computerEntry: ComputerEntry = {
- ..._computer,
- usageCount: 3,
+ ..._computer,
+ usageCount: 3,
};
const _computerList: ComputerListResponse = {
- computers: [_computerEntry],
+ computers: [_computerEntry],
};
const _computerResponse: ComputerResponse = _computer;
@@ -80,51 +104,51 @@ const _computerResponse: ComputerResponse = _computer;
// ─── Computer status / test probe ────────────────────────────────────────────
const _statusConnected: ComputerStatusResponse = {
- alias: "prod-box",
- state: "connected",
- knownHost: true,
+ alias: "prod-box",
+ state: "connected",
+ knownHost: true,
};
const _statusError: ComputerStatusResponse = {
- alias: "prod-box",
- state: "error",
- error: "connection refused",
- knownHost: false,
+ alias: "prod-box",
+ state: "error",
+ error: "connection refused",
+ knownHost: false,
};
const _testOk: TestComputerResponse = {
- alias: "prod-box",
- ok: true,
+ alias: "prod-box",
+ ok: true,
};
const _testFail: TestComputerResponse = {
- alias: "prod-box",
- ok: false,
- error: "auth failed",
+ alias: "prod-box",
+ ok: false,
+ error: "auth failed",
};
// ─── Per-conversation + workspace computer ───────────────────────────────────
const _setConvComputer: SetConversationComputerRequest = {
- computerId: "prod-box",
+ computerId: "prod-box",
};
const _clearConvComputer: SetConversationComputerRequest = {
- computerId: null,
+ computerId: null,
};
const _convComputer: ConversationComputerResponse = {
- conversationId: "conv-1",
- computerId: "prod-box",
+ conversationId: "conv-1",
+ computerId: "prod-box",
};
const _convComputerNull: ConversationComputerResponse = {
- conversationId: "conv-2",
- computerId: null,
+ conversationId: "conv-2",
+ computerId: null,
};
const _setDefaultComputer: SetWorkspaceDefaultComputerRequest = {
- computerId: null,
+ computerId: null,
};
// ─── LspServerState ──────────────────────────────────────────────────────────
@@ -137,178 +161,217 @@ const _stateNotStarted: LspServerState = "not-started";
// ─── LspServerInfo ───────────────────────────────────────────────────────────
const _serverOk: LspServerInfo = {
- id: "typescript",
- name: "TypeScript Language Server",
- root: "/home/user/project",
- extensions: [".ts", ".tsx"],
- state: "connected",
+ id: "typescript",
+ name: "TypeScript Language Server",
+ root: "/home/user/project",
+ extensions: [".ts", ".tsx"],
+ state: "connected",
};
const _serverErr: LspServerInfo = {
- id: "luau-lsp",
- name: "Luau LSP",
- root: "/home/user/game",
- extensions: [".luau"],
- state: "error",
- error: "Failed to start: binary not found",
+ id: "luau-lsp",
+ name: "Luau LSP",
+ root: "/home/user/game",
+ extensions: [".luau"],
+ state: "error",
+ error: "Failed to start: binary not found",
};
const _serverWithSource: LspServerInfo = {
- id: "ruby-lsp",
- name: "Ruby LSP",
- root: "/home/user/raylib",
- extensions: [".rb"],
- state: "connected",
- configSource: ".dispatch/lsp.json",
+ id: "ruby-lsp",
+ name: "Ruby LSP",
+ root: "/home/user/raylib",
+ extensions: [".rb"],
+ state: "connected",
+ configSource: ".dispatch/lsp.json",
};
// ─── LspStatusResponse ───────────────────────────────────────────────────────
const _lspNoCwd: LspStatusResponse = {
- conversationId: "conv-3",
- cwd: null,
- servers: [],
+ conversationId: "conv-3",
+ cwd: null,
+ servers: [],
};
const _lspWithServers: LspStatusResponse = {
- conversationId: "conv-4",
- cwd: "/home/user/project",
- servers: [_serverOk, _serverErr],
+ conversationId: "conv-4",
+ cwd: "/home/user/project",
+ servers: [_serverOk, _serverErr],
};
// ─── Runtime smoke (vitest needs a suite) ────────────────────────────────────
describe("transport-contract types compile and are exported", () => {
- it("CwdResponse: null cwd round-trips", () => {
- expect(_cwdNull).toEqual({ conversationId: "conv-1", cwd: null });
- });
-
- it("CwdResponse: set cwd round-trips", () => {
- expect(_cwdSet.cwd).toBe("/home/user/project");
- });
-
- it("SetCwdRequest: carries cwd", () => {
- expect(_setCwd.cwd).toBe("/tmp/workspace");
- });
-
- it("LspServerState: all four variants are valid", () => {
- const states: LspServerState[] = [
- _stateConnected,
- _stateStarting,
- _stateError,
- _stateNotStarted,
- ];
- expect(states).toHaveLength(4);
- });
-
- it("LspServerInfo: ok server has no error field", () => {
- expect(_serverOk.state).toBe("connected");
- expect(_serverOk.error).toBeUndefined();
- });
-
- it("LspServerInfo: error server carries error message", () => {
- expect(_serverErr.state).toBe("error");
- expect(_serverErr.error).toBe("Failed to start: binary not found");
- });
-
- it("LspServerInfo: carries optional configSource", () => {
- expect(_serverWithSource.configSource).toBe(".dispatch/lsp.json");
- expect(_serverOk.configSource).toBeUndefined();
- });
-
- it("LspStatusResponse: empty servers when cwd is null", () => {
- expect(_lspNoCwd.servers).toEqual([]);
- });
-
- it("LspStatusResponse: populated servers when cwd is set", () => {
- expect(_lspWithServers.servers).toHaveLength(2);
- });
-
- // ─── MCP status ─────────────────────────────────────────────────────────────
-
- it("McpStatusResponse: empty servers when cwd is null", () => {
- const _noCwd: McpStatusResponse = { conversationId: "c1", cwd: null, servers: [] };
- expect(_noCwd.servers).toEqual([]);
- });
-
- it("McpStatusResponse: populated servers when cwd is set", () => {
- const _withServers: McpStatusResponse = {
- conversationId: "c2",
- cwd: "/home/user/project",
- servers: [
- { id: "freecad", state: "connected", toolCount: 12, configSource: ".dispatch/mcp.json" },
- { id: "chrome", state: "error", error: "spawn failed", toolCount: 0 },
- ],
- };
- expect(_withServers.servers).toHaveLength(2);
- expect(_withServers.servers[0]?.toolCount).toBe(12);
- expect(_withServers.servers[1]?.error).toBe("spawn failed");
- });
-
- // ─── ChatRequest.computerId ──────────────────────────────────────────────
-
- it("ChatRequest: computerId is additive optional (omittable)", () => {
- expect(_chatWithoutComputer.computerId).toBeUndefined();
- });
-
- it("ChatRequest: carries computerId when set", () => {
- expect(_chatWithComputer.computerId).toBe("prod-box");
- });
-
- // ─── Computers ───────────────────────────────────────────────────────────
-
- it("ComputerListResponse: carries entries with usage counts", () => {
- expect(_computerList.computers).toHaveLength(1);
- expect(_computerList.computers[0]?.usageCount).toBe(3);
- expect(_computerList.computers[0]?.alias).toBe("prod-box");
- });
-
- it("ComputerResponse: is a single Computer", () => {
- expect(_computerResponse.alias).toBe("prod-box");
- expect(_computerResponse.port).toBe(22);
- });
-
- it("ComputerStatusResponse: all four states are valid", () => {
- const states: ComputerStatusResponse["state"][] = [
- "disconnected",
- "connecting",
- "connected",
- "error",
- ];
- expect(states).toHaveLength(4);
- });
-
- it("ComputerStatusResponse: connected has no error field", () => {
- expect(_statusConnected.state).toBe("connected");
- expect(_statusConnected.error).toBeUndefined();
- });
-
- it("ComputerStatusResponse: error carries message", () => {
- expect(_statusError.state).toBe("error");
- expect(_statusError.error).toBe("connection refused");
- });
-
- it("TestComputerResponse: ok has no error field", () => {
- expect(_testOk.ok).toBe(true);
- expect(_testOk.error).toBeUndefined();
- });
-
- it("TestComputerResponse: failure carries error", () => {
- expect(_testFail.ok).toBe(false);
- expect(_testFail.error).toBe("auth failed");
- });
-
- it("SetConversationComputerRequest: null clears to inherit/local", () => {
- expect(_setConvComputer.computerId).toBe("prod-box");
- expect(_clearConvComputer.computerId).toBeNull();
- });
-
- it("ConversationComputerResponse: null computerId round-trips", () => {
- expect(_convComputer.computerId).toBe("prod-box");
- expect(_convComputerNull.computerId).toBeNull();
- });
-
- it("SetWorkspaceDefaultComputerRequest: null clears to local", () => {
- expect(_setDefaultComputer.computerId).toBeNull();
- });
+ it("CwdResponse: null cwd round-trips", () => {
+ expect(_cwdNull).toEqual({ conversationId: "conv-1", cwd: null });
+ });
+
+ it("CwdResponse: set cwd round-trips", () => {
+ expect(_cwdSet.cwd).toBe("/home/user/project");
+ });
+
+ it("SetCwdRequest: carries cwd", () => {
+ expect(_setCwd.cwd).toBe("/tmp/workspace");
+ });
+
+ it("LspServerState: all four variants are valid", () => {
+ const states: LspServerState[] = [
+ _stateConnected,
+ _stateStarting,
+ _stateError,
+ _stateNotStarted,
+ ];
+ expect(states).toHaveLength(4);
+ });
+
+ it("LspServerInfo: ok server has no error field", () => {
+ expect(_serverOk.state).toBe("connected");
+ expect(_serverOk.error).toBeUndefined();
+ });
+
+ it("LspServerInfo: error server carries error message", () => {
+ expect(_serverErr.state).toBe("error");
+ expect(_serverErr.error).toBe("Failed to start: binary not found");
+ });
+
+ it("LspServerInfo: carries optional configSource", () => {
+ expect(_serverWithSource.configSource).toBe(".dispatch/lsp.json");
+ expect(_serverOk.configSource).toBeUndefined();
+ });
+
+ it("LspStatusResponse: empty servers when cwd is null", () => {
+ expect(_lspNoCwd.servers).toEqual([]);
+ });
+
+ it("LspStatusResponse: populated servers when cwd is set", () => {
+ expect(_lspWithServers.servers).toHaveLength(2);
+ });
+
+ // ─── MCP status ─────────────────────────────────────────────────────────────
+
+ it("McpStatusResponse: empty servers when cwd is null", () => {
+ const _noCwd: McpStatusResponse = { conversationId: "c1", cwd: null, servers: [] };
+ expect(_noCwd.servers).toEqual([]);
+ });
+
+ it("McpStatusResponse: populated servers when cwd is set", () => {
+ const _withServers: McpStatusResponse = {
+ conversationId: "c2",
+ cwd: "/home/user/project",
+ servers: [
+ { id: "freecad", state: "connected", toolCount: 12, configSource: ".dispatch/mcp.json" },
+ { id: "chrome", state: "error", error: "spawn failed", toolCount: 0 },
+ ],
+ };
+ expect(_withServers.servers).toHaveLength(2);
+ expect(_withServers.servers[0]?.toolCount).toBe(12);
+ expect(_withServers.servers[1]?.error).toBe("spawn failed");
+ });
+
+ // ─── ChatRequest.computerId ──────────────────────────────────────────────
+
+ it("ChatRequest: computerId is additive optional (omittable)", () => {
+ expect(_chatWithoutComputer.computerId).toBeUndefined();
+ });
+
+ it("ChatRequest: carries computerId when set", () => {
+ expect(_chatWithComputer.computerId).toBe("prod-box");
+ });
+
+ // ─── ChatRequest.images (additive optional) ──────────────────────────────
+
+ it("ChatRequest: images is additive optional (omittable)", () => {
+ expect(_chatWithoutComputer.images).toBeUndefined();
+ });
+
+ it("ChatRequest: carries images (data URL) when set", () => {
+ expect(_chatWithImages.images).toHaveLength(1);
+ expect(_chatWithImages.images?.[0]?.url).toContain("base64");
+ expect(_chatWithImages.images?.[0]?.mimeType).toBe("image/png");
+ });
+
+ it("ChatRequest: carries images (http URL, mimeType optional)", () => {
+ expect(_chatWithHttpImage.images?.[0]?.url).toBe("https://example.com/diagram.png");
+ expect(_chatWithHttpImage.images?.[0]?.mimeType).toBeUndefined();
+ });
+
+ // ─── ChatRequest.title (additive optional) ────────────────────────────────
+
+ it("ChatRequest: title is additive optional (omittable)", () => {
+ expect(_chatWithoutTitle.title).toBeUndefined();
+ });
+
+ it("ChatRequest: carries title when set", () => {
+ expect(_chatWithTitle.title).toBe("Summon: add --title flag");
+ });
+
+ it("ModelsResponse: ModelMetadata carries optional vision flag", () => {
+ const resp: ModelsResponse = {
+ models: ["umans/kimi-k2.7", "umans/glm-5.2"],
+ modelInfo: {
+ "umans/kimi-k2.7": { contextWindow: 200000, vision: true },
+ "umans/glm-5.2": { contextWindow: 128000 },
+ },
+ };
+ expect(resp.modelInfo?.["umans/kimi-k2.7"]?.vision).toBe(true);
+ expect(resp.modelInfo?.["umans/glm-5.2"]?.vision).toBeUndefined();
+ });
+
+ // ─── Computers ───────────────────────────────────────────────────────────
+
+ it("ComputerListResponse: carries entries with usage counts", () => {
+ expect(_computerList.computers).toHaveLength(1);
+ expect(_computerList.computers[0]?.usageCount).toBe(3);
+ expect(_computerList.computers[0]?.alias).toBe("prod-box");
+ });
+
+ it("ComputerResponse: is a single Computer", () => {
+ expect(_computerResponse.alias).toBe("prod-box");
+ expect(_computerResponse.port).toBe(22);
+ });
+
+ it("ComputerStatusResponse: all four states are valid", () => {
+ const states: ComputerStatusResponse["state"][] = [
+ "disconnected",
+ "connecting",
+ "connected",
+ "error",
+ ];
+ expect(states).toHaveLength(4);
+ });
+
+ it("ComputerStatusResponse: connected has no error field", () => {
+ expect(_statusConnected.state).toBe("connected");
+ expect(_statusConnected.error).toBeUndefined();
+ });
+
+ it("ComputerStatusResponse: error carries message", () => {
+ expect(_statusError.state).toBe("error");
+ expect(_statusError.error).toBe("connection refused");
+ });
+
+ it("TestComputerResponse: ok has no error field", () => {
+ expect(_testOk.ok).toBe(true);
+ expect(_testOk.error).toBeUndefined();
+ });
+
+ it("TestComputerResponse: failure carries error", () => {
+ expect(_testFail.ok).toBe(false);
+ expect(_testFail.error).toBe("auth failed");
+ });
+
+ it("SetConversationComputerRequest: null clears to inherit/local", () => {
+ expect(_setConvComputer.computerId).toBe("prod-box");
+ expect(_clearConvComputer.computerId).toBeNull();
+ });
+
+ it("ConversationComputerResponse: null computerId round-trips", () => {
+ expect(_convComputer.computerId).toBe("prod-box");
+ expect(_convComputerNull.computerId).toBeNull();
+ });
+
+ it("SetWorkspaceDefaultComputerRequest: null clears to local", () => {
+ expect(_setDefaultComputer.computerId).toBeNull();
+ });
});
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index b32c8a0..32b03d3 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -21,33 +21,36 @@
import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
import type {
- AgentEvent,
- Computer,
- ComputerEntry,
- ConversationMeta,
- ConversationStatus,
- QueuedMessage,
- ReasoningEffort,
- StoredChunk,
- TurnMetrics,
- Workspace,
- WorkspaceEntry,
+ AgentEvent,
+ Computer,
+ ComputerEntry,
+ ConversationMeta,
+ ConversationStatus,
+ ImageInput,
+ QueuedMessage,
+ ReasoningEffort,
+ StoredChunk,
+ TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
export type {
- AgentEvent,
- CompactionResult,
- Computer,
- ComputerEntry,
- ConversationMeta,
- ConversationStatus,
- QueuedMessage,
- ReasoningEffort,
- StepMetrics,
- StoredChunk,
- TurnMetrics,
- Workspace,
- WorkspaceEntry,
+ AgentEvent,
+ CompactionResult,
+ Computer,
+ ComputerEntry,
+ ConversationMeta,
+ ConversationStatus,
+ ImageChunk,
+ ImageInput,
+ QueuedMessage,
+ ReasoningEffort,
+ StepMetrics,
+ StoredChunk,
+ TurnMetrics,
+ Workspace,
+ WorkspaceEntry,
} from "@dispatch/wire";
/**
@@ -58,53 +61,83 @@ export type {
* response header (useful when `conversationId` was omitted).
*/
export interface ChatRequest {
- /**
- * The conversation to continue. Omit to start a fresh conversation — the
- * server mints an id and returns it via the `X-Conversation-Id` header.
- */
- readonly conversationId?: string;
-
- /** The user's message text for this turn. */
- readonly message: string;
-
- /**
- * The model to use, as a model name in `<credentialName>/<model>` form — one
- * of the exact strings returned by `GET /models`. Omit to use the server's
- * default credential + model.
- */
- readonly model?: string;
-
- /**
- * Working directory for this turn's tool execution. Defaults server-side when
- * omitted. Forwarded to tools for path resolution; never part of the model
- * prompt (so it does not affect prompt caching).
- */
- readonly cwd?: string;
-
- /**
- * The computer to run this turn's tools on — an SSH config `Host` alias
- * (one of the `alias` values returned by `GET /computers`). Omit to inherit
- * the resolved chain: per-conversation `computerId` → the workspace's
- * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is
- * a per-turn tool-execution target forwarded to tools and never part of the
- * model prompt (so it does not affect prompt caching). Mirrors `cwd`.
- */
- readonly computerId?: string;
-
- /**
- * Reasoning-effort override for THIS turn only (does not persist). When
- * omitted, the server resolves the conversation's persisted value, falling
- * back to `"high"`. Must be one of the `ReasoningEffort` levels; an
- * unrecognized value → HTTP 400 `{ error }`.
- */
- readonly reasoningEffort?: ReasoningEffort;
-
- /**
- * The workspace to assign this conversation to. Omit for `"default"`.
- * If the workspace doesn't exist yet, it is auto-created (title = id,
- * defaultCwd = null).
- */
- readonly workspaceId?: string;
+ /**
+ * The conversation to continue. Omit to start a fresh conversation — the
+ * server mints an id and returns it via the `X-Conversation-Id` header.
+ */
+ readonly conversationId?: string;
+
+ /** The user's message text for this turn. */
+ readonly message: string;
+
+ /**
+ * Images attached to this turn (e.g. a user-pasted screenshot). Each entry's
+ * `url` is a base64 data URL (`data:image/…;base64,…`) or an `http(s)://`
+ * URL. The server converts these to `image` chunks on the persisted user
+ * message. For a VISION-capable model (e.g. kimi), the images are passed
+ * through to the provider natively. For a NON-vision model (e.g. glm-5.2),
+ * the server's vision handoff transcribes each image 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. Optional — omit for a
+ * text-only turn (backward compatible).
+ */
+ readonly images?: readonly ImageInput[];
+
+ /**
+ * The model to use, as a model name in `<credentialName>/<model>` form — one
+ * of the exact strings returned by `GET /models`. Omit to use the server's
+ * default credential + model.
+ */
+ readonly model?: string;
+
+ /**
+ * Working directory for this turn's tool execution. Defaults server-side when
+ * omitted. Forwarded to tools for path resolution; never part of the model
+ * prompt (so it does not affect prompt caching).
+ */
+ readonly cwd?: string;
+
+ /**
+ * The computer to run this turn's tools on — an SSH config `Host` alias
+ * (one of the `alias` values returned by `GET /computers`). Omit to inherit
+ * the resolved chain: per-conversation `computerId` → the workspace's
+ * `defaultComputerId` → `null`/local (today's behavior). Like `cwd`, this is
+ * a per-turn tool-execution target forwarded to tools and never part of the
+ * model prompt (so it does not affect prompt caching). Mirrors `cwd`.
+ */
+ readonly computerId?: string;
+
+ /**
+ * Reasoning-effort override for THIS turn only (does not persist). When
+ * omitted, the server resolves the conversation's persisted value, falling
+ * back to `"high"`. Must be one of the `ReasoningEffort` levels; an
+ * unrecognized value → HTTP 400 `{ error }`.
+ */
+ readonly reasoningEffort?: ReasoningEffort;
+
+ /**
+ * The workspace to assign this conversation to. Omit for `"default"`.
+ * If the workspace doesn't exist yet, it is auto-created (title = id,
+ * defaultCwd = null).
+ */
+ readonly workspaceId?: string;
+
+ /**
+ * A human-readable title for the conversation tab — persisted at creation
+ * time, after the new-conversation workspace setup resolves (so workspace
+ * assignment and first-turn system-prompt construction are not skipped) and
+ * before the first message append (so the append's auto-derived title does
+ * not overwrite it). The tab shows it instead of the default derived from
+ * the first message (`"Untitled"` until the first append). Omit to keep the
+ * auto-derived title. When present, the value is trimmed server-side; a
+ * whitespace-only value is treated as absent (auto-derive). A non-string
+ * value → HTTP 400 `{ error }`.
+ *
+ * Backward compatible — clients that omit it are unaffected. Mirrors the
+ * dedicated `PUT /conversations/:id/title` endpoint but is atomic with the
+ * turn (no second round-trip from the client).
+ */
+ readonly title?: string;
}
/**
@@ -117,13 +150,21 @@ export interface ChatRequest {
* read `models` are unaffected.
*/
export interface ModelsResponse {
- readonly models: readonly string[];
- readonly modelInfo?: Readonly<Record<string, ModelMetadata>>;
+ readonly models: readonly string[];
+ readonly modelInfo?: Readonly<Record<string, ModelMetadata>>;
}
/** Per-model metadata returned alongside the model catalog. */
export interface ModelMetadata {
- readonly contextWindow?: number;
+ readonly contextWindow?: number;
+ /**
+ * Whether this model can natively accept image input (vision/multimodal).
+ * When `true`, image chunks in a user message are passed through to the
+ * provider. When `false`/absent, the server's vision handoff transcribes
+ * images to text before the model sees them. A client may use this to show a
+ * vision badge in the model picker. Optional — absent when unknown.
+ */
+ readonly vision?: boolean;
}
/**
@@ -172,8 +213,8 @@ export interface ModelMetadata {
* the store contract.)
*/
export interface ConversationHistoryResponse {
- readonly chunks: readonly StoredChunk[];
- readonly latestSeq: number;
+ readonly chunks: readonly StoredChunk[];
+ readonly latestSeq: number;
}
/**
@@ -192,15 +233,15 @@ export interface ConversationHistoryResponse {
* absent until then.
*/
export interface ConversationMetricsResponse {
- readonly turns: readonly TurnMetrics[];
+ readonly turns: readonly TurnMetrics[];
}
export interface ConversationStatusResponse {
- readonly conversationId: string;
- /** True if the orchestrator has an in-memory active turn for this conversation. */
- readonly isActive: boolean;
- /** The persisted lifecycle status from the conversation store. */
- readonly status: ConversationStatus;
+ readonly conversationId: string;
+ /** True if the orchestrator has an in-memory active turn for this conversation. */
+ readonly isActive: boolean;
+ /** The persisted lifecycle status from the conversation store. */
+ readonly status: ConversationStatus;
}
/** The aggregation window for `GET /metrics/throughput`. */
@@ -214,16 +255,16 @@ export type ThroughputPeriod = "day" | "week" | "month";
* waits).
*/
export interface ThroughputModelStat {
- /** The model name in `<credentialName>/<model>` form (as selected). */
- readonly model: string;
- /** Token-weighted average tokens/second over the period. */
- readonly tokensPerSecond: number;
- /** Total output tokens generated across the period's turns. */
- readonly totalOutputTokens: number;
- /** Total pure generation time across the period's turns, in milliseconds. */
- readonly totalGenMs: number;
- /** Number of turns that contributed. */
- readonly turns: number;
+ /** The model name in `<credentialName>/<model>` form (as selected). */
+ readonly model: string;
+ /** Token-weighted average tokens/second over the period. */
+ readonly tokensPerSecond: number;
+ /** Total output tokens generated across the period's turns. */
+ readonly totalOutputTokens: number;
+ /** Total pure generation time across the period's turns, in milliseconds. */
+ readonly totalGenMs: number;
+ /** Number of turns that contributed. */
+ readonly turns: number;
}
/**
@@ -237,21 +278,21 @@ export interface ThroughputModelStat {
* `tokensPerSecond` descending.
*/
export interface ThroughputResponse {
- readonly period: ThroughputPeriod;
- readonly date: string;
- /** Inclusive start of the window, epoch-ms. */
- readonly start: number;
- /** Exclusive end of the window, epoch-ms. */
- readonly end: number;
- readonly models: readonly ThroughputModelStat[];
+ readonly period: ThroughputPeriod;
+ readonly date: string;
+ /** Inclusive start of the window, epoch-ms. */
+ readonly start: number;
+ /** Exclusive end of the window, epoch-ms. */
+ readonly end: number;
+ readonly models: readonly ThroughputModelStat[];
}
// ─── Per-conversation working directory (cwd) ─────────────────────────────────
/** Response of `GET /conversations/:id/cwd`. `cwd` is null when never set. */
export interface CwdResponse {
- readonly conversationId: string;
- readonly cwd: string | null;
+ readonly conversationId: string;
+ readonly cwd: string | null;
}
/**
@@ -265,8 +306,8 @@ export interface CwdResponse {
* `"default"` if none).
*/
export interface SetCwdRequest {
- readonly cwd: string;
- readonly workspaceId?: string;
+ readonly cwd: string;
+ readonly workspaceId?: string;
}
// ─── Per-conversation reasoning effort ────────────────────────────────────────
@@ -277,8 +318,8 @@ export interface SetCwdRequest {
* `"high"`).
*/
export interface ReasoningEffortResponse {
- readonly conversationId: string;
- readonly reasoningEffort: ReasoningEffort | null;
+ readonly conversationId: string;
+ readonly reasoningEffort: ReasoningEffort | null;
}
/**
@@ -288,7 +329,7 @@ export interface ReasoningEffortResponse {
* unrecognized level → HTTP 400 `{ error }`.
*/
export interface SetReasoningEffortRequest {
- readonly reasoningEffort: ReasoningEffort;
+ readonly reasoningEffort: ReasoningEffort;
}
// ─── Per-conversation model ──────────────────────────────────────────────────
@@ -299,8 +340,8 @@ export interface SetReasoningEffortRequest {
* then resolves turns using the default provider + model).
*/
export interface ModelResponse {
- readonly conversationId: string;
- readonly model: string | null;
+ readonly conversationId: string;
+ readonly model: string | null;
}
/**
@@ -311,7 +352,7 @@ export interface ModelResponse {
* at turn time; an unknown model → turn error, not a 400).
*/
export interface SetModelRequest {
- readonly model: string | null;
+ readonly model: string | null;
}
// ─── Conversation close (explicit tab close) ──────────────────────────────────
@@ -331,9 +372,9 @@ export interface SetModelRequest {
* `abortedTurn: false`.
*/
export interface CloseConversationResponse {
- readonly conversationId: string;
- /** True when an in-flight turn existed and was aborted by this close. */
- readonly abortedTurn: boolean;
+ readonly conversationId: string;
+ /** True when an in-flight turn existed and was aborted by this close. */
+ readonly abortedTurn: boolean;
}
// ─── System prompt template ───────────────────────────────────────────────────
@@ -348,8 +389,8 @@ export interface CloseConversationResponse {
* and reused on all subsequent turns (cache-safe — no per-turn reconstruction).
*/
export interface SystemPromptTemplateResponse {
- /** The template text (may be empty — then no system prompt is sent). */
- readonly template: string;
+ /** The template text (may be empty — then no system prompt is sent). */
+ readonly template: string;
}
/**
@@ -360,7 +401,7 @@ export interface SystemPromptTemplateResponse {
* conversations use the new template on their first turn.
*/
export interface SetSystemPromptTemplateRequest {
- readonly template: string;
+ readonly template: string;
}
/**
@@ -369,22 +410,39 @@ export interface SetSystemPromptTemplateRequest {
* selector buttons.
*/
export interface SystemPromptVariable {
- /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */
- readonly type: string;
- /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */
- readonly name: string;
- /** Human-readable description of what the variable resolves to. */
- readonly description: string;
- /**
- * When `true`, any name is valid for this type (e.g. `file:<path>` accepts
- * any file path). The frontend should allow free-text input for the name.
- */
- readonly dynamic?: boolean;
+ /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */
+ readonly type: string;
+ /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */
+ readonly name: string;
+ /** Human-readable description of what the variable resolves to. */
+ readonly description: string;
+ /**
+ * When `true`, any name is valid for this type (e.g. `file:<path>` accepts
+ * any file path). The frontend should allow free-text input for the name.
+ */
+ readonly dynamic?: boolean;
}
/** Response of `GET /system-prompt/variables`. */
export interface SystemPromptVariablesResponse {
- readonly variables: readonly SystemPromptVariable[];
+ readonly variables: readonly SystemPromptVariable[];
+}
+
+// ─── Vision settings (global) ──────────────────────────────────────────────────
+
+/**
+ * Response of `GET /settings/vision` — the global vision configuration shared
+ * across all conversations and vision models.
+ */
+export interface VisionSettingsResponse {
+ readonly imageLimit: number;
+ readonly compactionModel: string | null;
+}
+
+/** Body of `PUT /settings/vision` — a partial update. */
+export interface SetVisionSettingsRequest {
+ readonly imageLimit?: number;
+ readonly compactionModel?: string | null;
}
// ─── Message queue (steering) ─────────────────────────────────────────────────
@@ -405,12 +463,12 @@ export interface SystemPromptVariablesResponse {
* `text` must be non-empty (after trim) → HTTP 400 `{ error }` otherwise.
*/
export interface QueueRequest {
- readonly text: string;
- /**
- * The workspace to assign the conversation to (if a new conversation is
- * started). Omit for `"default"`. Auto-creates if missing.
- */
- readonly workspaceId?: string;
+ readonly text: string;
+ /**
+ * The workspace to assign the conversation to (if a new conversation is
+ * started). Omit for `"default"`. Auto-creates if missing.
+ */
+ readonly workspaceId?: string;
}
/**
@@ -422,9 +480,30 @@ export interface QueueRequest {
* the chat channel as usual.
*/
export interface QueueResponse {
- readonly conversationId: string;
- readonly startedTurn: boolean;
- readonly queue: readonly QueuedMessage[];
+ readonly conversationId: string;
+ readonly startedTurn: boolean;
+ readonly queue: readonly QueuedMessage[];
+}
+
+/**
+ * Response body for
+ * `DELETE /conversations/:id/queue/:messageId` — cancel (remove) a single
+ * queued steering message by id so it never runs.
+ *
+ * `cancelled` is `true` when a message with the given id was found in the
+ * conversation's queue and removed (it will never be delivered as steering nor
+ * carried into a new turn). `cancelled` is `false` when the message was not in
+ * the queue (already drained/delivered, never existed, unknown conversation)
+ * OR when the message-queue extension isn't loaded (degraded — feature off).
+ * `queue` is the post-cancel snapshot (empty when no queue extension is
+ * loaded). Idempotent — cancelling a message that is no longer queued returns
+ * `cancelled: false` with HTTP 200 (not an error), so a client may optimistically
+ * fire-and-forget a cancel and reconcile from the surface.
+ */
+export interface QueueCancelResponse {
+ readonly conversationId: string;
+ readonly cancelled: boolean;
+ readonly queue: readonly QueuedMessage[];
}
// ─── Per-conversation LSP status ──────────────────────────────────────────────
@@ -434,39 +513,39 @@ export type LspServerState = "connected" | "starting" | "error" | "not-started";
/** One language server's status as reported to the frontend. */
export interface LspServerInfo {
- /** Stable server id, e.g. "typescript", "luau-lsp". */
- readonly id: string;
- /** Human-readable display name. */
- readonly name: string;
- /** The resolved workspace root the server is (or would be) rooted at (absolute). */
- readonly root: string;
- /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */
- readonly extensions: readonly string[];
- /** Current connection state. */
- readonly state: LspServerState;
- /** Present only when `state === "error"`: a short human-readable reason. */
- readonly error?: string;
- /**
- * Which config source this server was resolved from: `".dispatch/lsp.json"`,
- * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted
- * when not yet resolved. Surfaces config-shadow debugging to the status caller
- * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`).
- */
- readonly configSource?: string;
+ /** Stable server id, e.g. "typescript", "luau-lsp". */
+ readonly id: string;
+ /** Human-readable display name. */
+ readonly name: string;
+ /** The resolved workspace root the server is (or would be) rooted at (absolute). */
+ readonly root: string;
+ /** File extensions this server handles, e.g. [".ts", ".tsx"] or [".luau"]. */
+ readonly extensions: readonly string[];
+ /** Current connection state. */
+ readonly state: LspServerState;
+ /** Present only when `state === "error"`: a short human-readable reason. */
+ readonly error?: string;
+ /**
+ * Which config source this server was resolved from: `".dispatch/lsp.json"`,
+ * `"opencode.json"`, or `"built-in"` (the built-in TypeScript default). Omitted
+ * when not yet resolved. Surfaces config-shadow debugging to the status caller
+ * (a broken `.dispatch/lsp.json` silently shadowing `opencode.json`).
+ */
+ readonly configSource?: string;
}
/** Response of `GET /conversations/:id/lsp`. */
export interface LspStatusResponse {
- readonly conversationId: string;
- /**
- * The resolved working directory the LSP connects on, or `null` when no
- * cwd has been set for the conversation (then `servers` is empty). When
- * non-null, this is the effective cwd — a relative persisted cwd resolved
- * against the conversation's workspace `defaultCwd`.
- */
- readonly cwd: string | null;
- /** The language servers configured for `cwd` and their live state. */
- readonly servers: readonly LspServerInfo[];
+ readonly conversationId: string;
+ /**
+ * The resolved working directory the LSP connects on, or `null` when no
+ * cwd has been set for the conversation (then `servers` is empty). When
+ * non-null, this is the effective cwd — a relative persisted cwd resolved
+ * against the conversation's workspace `defaultCwd`.
+ */
+ readonly cwd: string | null;
+ /** The language servers configured for `cwd` and their live state. */
+ readonly servers: readonly LspServerInfo[];
}
// ─── MCP status ──────────────────────────────────────────────────────
@@ -475,29 +554,29 @@ export type McpServerState = "connecting" | "connected" | "error" | "disconnecte
/** One MCP server's status as reported to the frontend. */
export interface McpServerInfo {
- /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */
- readonly id: string;
- /** Current connection state. */
- readonly state: McpServerState;
- /** Present only when `state === "error"`: a short human-readable reason. */
- readonly error?: string;
- /** Number of tools discovered from this server. */
- readonly toolCount: number;
- /** Which config source this server was resolved from. */
- readonly configSource?: string;
+ /** Stable server id (the config key from `.dispatch/mcp.json`), e.g. "freecad". */
+ readonly id: string;
+ /** Current connection state. */
+ readonly state: McpServerState;
+ /** Present only when `state === "error"`: a short human-readable reason. */
+ readonly error?: string;
+ /** Number of tools discovered from this server. */
+ readonly toolCount: number;
+ /** Which config source this server was resolved from. */
+ readonly configSource?: string;
}
/** Response of `GET /conversations/:id/mcp`. */
export interface McpStatusResponse {
- readonly conversationId: string;
- /**
- * The resolved working directory the MCP servers are configured for, or
- * `null` when no cwd has been set for the conversation (then `servers` is
- * empty). Mirrors the LSP status endpoint behavior.
- */
- readonly cwd: string | null;
- /** The MCP servers configured for `cwd` and their live state. */
- readonly servers: readonly McpServerInfo[];
+ readonly conversationId: string;
+ /**
+ * The resolved working directory the MCP servers are configured for, or
+ * `null` when no cwd has been set for the conversation (then `servers` is
+ * empty). Mirrors the LSP status endpoint behavior.
+ */
+ readonly cwd: string | null;
+ /** The MCP servers configured for `cwd` and their live state. */
+ readonly servers: readonly McpServerInfo[];
}
/**
@@ -511,17 +590,17 @@ export interface McpStatusResponse {
* prefix is byte-identical to a real turn (which is what makes the cache hit).
*/
export interface WarmRequest {
- /** The conversation whose prompt cache to warm. */
- readonly conversationId: string;
+ /** The conversation whose prompt cache to warm. */
+ readonly conversationId: string;
- /**
- * The model name in `<credentialName>/<model>` form the conversation uses, so
- * the warm resolves the same provider + prefix. Omit to use the server default.
- */
- readonly model?: string;
+ /**
+ * The model name in `<credentialName>/<model>` form the conversation uses, so
+ * the warm resolves the same provider + prefix. Omit to use the server default.
+ */
+ readonly model?: string;
- /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */
- readonly cwd?: string;
+ /** Working directory matching the conversation's turns (for cwd-aware tool assembly). */
+ readonly cwd?: string;
}
/**
@@ -533,26 +612,26 @@ export interface WarmRequest {
* server responds `409` with `{ error }` instead of this body.
*/
export interface WarmResponse {
- readonly inputTokens: number;
- readonly outputTokens: number;
- readonly cacheReadTokens: number;
- readonly cacheWriteTokens: number;
- /**
- * **Cache rate** — what fraction of THIS request's prompt was served from cache:
- * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`).
- * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].)
- */
- readonly cachePct: number;
- /**
- * **Expected cache (retention)** — of the cacheable prefix this warm touched, how
- * much was still warm and read back vs. had to be (re)written:
- * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the
- * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still
- * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite
- * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's
- * tiny fresh probe makes ~equal, but which on a real turn reflects new content).
- */
- readonly expectedCacheRate: number;
+ readonly inputTokens: number;
+ readonly outputTokens: number;
+ readonly cacheReadTokens: number;
+ readonly cacheWriteTokens: number;
+ /**
+ * **Cache rate** — what fraction of THIS request's prompt was served from cache:
+ * `round(cacheReadTokens / inputTokens * 100)` (0 when `inputTokens <= 0`).
+ * (`inputTokens` is the TOTAL prompt incl. cached, so this is in [0,100].)
+ */
+ readonly cachePct: number;
+ /**
+ * **Expected cache (retention)** — of the cacheable prefix this warm touched, how
+ * much was still warm and read back vs. had to be (re)written:
+ * `round(cacheReadTokens / (cacheReadTokens + cacheWriteTokens) * 100)` (0 when the
+ * sum is 0). For a healthy warm this is ~**100%** (the whole prefix was still
+ * cached); it drops toward 0 as the cache expires/busts and the warm has to rewrite
+ * it. This is the warming HEALTH signal — distinct from `cachePct` (which a warm's
+ * tiny fresh probe makes ~equal, but which on a real turn reflects new content).
+ */
+ readonly expectedCacheRate: number;
}
// ─── WebSocket chat ops ───────────────────────────────────────────────────────
@@ -567,7 +646,7 @@ export interface WarmResponse {
* `AgentEvent`s (each carries `conversationId`).
*/
export interface ChatSendMessage extends ChatRequest {
- readonly type: "chat.send";
+ readonly type: "chat.send";
}
/**
@@ -577,8 +656,8 @@ export interface ChatSendMessage extends ChatRequest {
* carrier.
*/
export interface ChatDeltaMessage {
- readonly type: "chat.delta";
- readonly event: AgentEvent;
+ readonly type: "chat.delta";
+ readonly event: AgentEvent;
}
/**
@@ -587,9 +666,9 @@ export interface ChatDeltaMessage {
* `TurnErrorEvent` inside a `chat.delta`.)
*/
export interface ChatErrorMessage {
- readonly type: "chat.error";
- readonly conversationId?: string;
- readonly message: string;
+ readonly type: "chat.error";
+ readonly conversationId?: string;
+ readonly message: string;
}
/**
@@ -609,8 +688,8 @@ export interface ChatErrorMessage {
* `chat.subscribe` for conversations it is viewing but did not send to.
*/
export interface ChatSubscribeMessage {
- readonly type: "chat.subscribe";
- readonly conversationId: string;
+ readonly type: "chat.subscribe";
+ readonly conversationId: string;
}
/**
@@ -620,8 +699,8 @@ export interface ChatSubscribeMessage {
* the socket closes — again WITHOUT aborting any in-flight turn.
*/
export interface ChatUnsubscribeMessage {
- readonly type: "chat.unsubscribe";
- readonly conversationId: string;
+ readonly type: "chat.unsubscribe";
+ readonly conversationId: string;
}
/**
@@ -636,14 +715,32 @@ export interface ChatUnsubscribeMessage {
* latter being equivalent to `chat.send`).
*/
export interface ChatQueueMessage {
- readonly type: "chat.queue";
- readonly conversationId: string;
- readonly text: string;
- /**
- * The workspace to assign the conversation to (if a new conversation is
- * started). Omit for `"default"`. Auto-creates if missing.
- */
- readonly workspaceId?: string;
+ readonly type: "chat.queue";
+ readonly conversationId: string;
+ readonly text: string;
+ /**
+ * The workspace to assign the conversation to (if a new conversation is
+ * started). Omit for `"default"`. Auto-creates if missing.
+ */
+ readonly workspaceId?: string;
+}
+
+/**
+ * Client → server: cancel (remove) a SINGLE queued steering message by id so
+ * it never runs. The WebSocket counterpart of the HTTP
+ * `DELETE /conversations/:id/queue/:messageId` (`QueueCancelResponse`).
+ * Fire-and-forget: success is confirmed by the message-queue SURFACE updating
+ * (the cancelled message leaves the snapshot); a failure (missing/empty
+ * `conversationId` or `messageId`) arrives as a `chat.error`. Idempotent —
+ * cancelling a message that is no longer queued (already drained/delivered) is
+ * a silent no-op (no surface update, no error). `messageId` is the stable
+ * client-visible `QueuedMessage.id` (obtained from the queue surface snapshot
+ * or the enqueue response).
+ */
+export interface ChatQueueCancelMessage {
+ readonly type: "chat.queue.cancel";
+ readonly conversationId: string;
+ readonly messageId: string;
}
/**
@@ -651,23 +748,24 @@ export interface ChatQueueMessage {
* ops. A server discriminates on `type`.
*/
export type WsClientMessage =
- | SurfaceClientMessage
- | ChatSendMessage
- | ChatSubscribeMessage
- | ChatUnsubscribeMessage
- | ChatQueueMessage;
+ | SurfaceClientMessage
+ | ChatSendMessage
+ | ChatSubscribeMessage
+ | ChatUnsubscribeMessage
+ | ChatQueueMessage
+ | ChatQueueCancelMessage;
/**
* Every server → client WS message: surface ops (`@dispatch/ui-contract`) + chat
* ops. A client discriminates on `type`.
*/
export type WsServerMessage =
- | SurfaceServerMessage
- | ChatDeltaMessage
- | ChatErrorMessage
- | ConversationOpenMessage
- | ConversationStatusChangedMessage
- | ConversationCompactedMessage;
+ | SurfaceServerMessage
+ | ChatDeltaMessage
+ | ChatErrorMessage
+ | ConversationOpenMessage
+ | ConversationStatusChangedMessage
+ | ConversationCompactedMessage;
// ─── Conversation list + metadata ────────────────────────────────────────────
@@ -677,14 +775,14 @@ export type WsServerMessage =
* — the backend just signals. Additive to `WsServerMessage`.
*/
export interface ConversationOpenMessage {
- readonly type: "conversation.open";
- readonly conversationId: string;
- /**
- * The conversation's actual workspace id, so a frontend can open/focus it
- * in the correct workspace instead of stamping it with the viewer's current
- * workspace.
- */
- readonly workspaceId: string;
+ readonly type: "conversation.open";
+ readonly conversationId: string;
+ /**
+ * The conversation's actual workspace id, so a frontend can open/focus it
+ * in the correct workspace instead of stamping it with the viewer's current
+ * workspace.
+ */
+ readonly workspaceId: string;
}
/**
@@ -693,15 +791,15 @@ export interface ConversationOpenMessage {
* devices in real time.
*/
export interface ConversationStatusChangedMessage {
- readonly type: "conversation.statusChanged";
- readonly conversationId: string;
- readonly status: ConversationStatus;
- /**
- * The conversation's actual workspace id, so a frontend can open/focus it
- * in the correct workspace instead of stamping it with the viewer's current
- * workspace.
- */
- readonly workspaceId: string;
+ readonly type: "conversation.statusChanged";
+ readonly conversationId: string;
+ readonly status: ConversationStatus;
+ /**
+ * The conversation's actual workspace id, so a frontend can open/focus it
+ * in the correct workspace instead of stamping it with the viewer's current
+ * workspace.
+ */
+ readonly workspaceId: string;
}
/**
@@ -710,11 +808,11 @@ export interface ConversationStatusChangedMessage {
* via `GET /conversations/:id` to reflect the compacted state.
*/
export interface ConversationCompactedMessage {
- readonly type: "conversation.compacted";
- readonly conversationId: string;
- readonly newConversationId: string;
- readonly messagesSummarized: number;
- readonly messagesKept: number;
+ readonly type: "conversation.compacted";
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
}
/**
@@ -724,7 +822,7 @@ export interface ConversationCompactedMessage {
* Optional `?q=` query param filters by id prefix (short-id resolution).
*/
export interface ConversationListResponse {
- readonly conversations: readonly ConversationMeta[];
+ readonly conversations: readonly ConversationMeta[];
}
/**
@@ -734,9 +832,9 @@ export interface ConversationListResponse {
* `turnId` is the turn that produced the message (absent if no turn ran).
*/
export interface LastMessageResponse {
- readonly conversationId: string;
- readonly content: string;
- readonly turnId?: string;
+ readonly conversationId: string;
+ readonly content: string;
+ readonly turnId?: string;
}
/**
@@ -744,22 +842,22 @@ export interface LastMessageResponse {
* signal was broadcast to connected WS clients.
*/
export interface OpenConversationResponse {
- readonly conversationId: string;
+ readonly conversationId: string;
}
/**
* Request body for `PUT /conversations/:id/title` — set a human-readable title.
*/
export interface SetTitleRequest {
- readonly title: string;
+ readonly title: string;
}
/**
* Response for `GET/PUT /conversations/:id/title` — the current title.
*/
export interface TitleResponse {
- readonly conversationId: string;
- readonly title: string;
+ readonly conversationId: string;
+ readonly title: string;
}
/**
@@ -767,10 +865,10 @@ export interface TitleResponse {
* history was compacted (old messages summarized, recent messages retained).
*/
export interface CompactResponse {
- readonly conversationId: string;
- readonly newConversationId: string;
- readonly messagesSummarized: number;
- readonly messagesKept: number;
+ readonly conversationId: string;
+ readonly newConversationId: string;
+ readonly messagesSummarized: number;
+ readonly messagesKept: number;
}
/**
@@ -778,15 +876,15 @@ export interface CompactResponse {
* at which automatic compaction triggers (0 = manual only).
*/
export interface CompactPercentResponse {
- readonly conversationId: string;
- readonly threshold: number;
+ readonly conversationId: string;
+ readonly threshold: number;
}
/**
* Request body for `PUT /conversations/:id/compact-percent`.
*/
export interface SetCompactPercentRequest {
- readonly threshold: number;
+ readonly threshold: number;
}
// ─── Workspaces ───────────────────────────────────────────────────────────────
@@ -797,10 +895,10 @@ export interface SetCompactPercentRequest {
* an existing workspace is returned as-is.
*/
export interface EnsureWorkspaceRequest {
- /** Display title. Default: the workspace id. Only used on create. */
- readonly title?: string;
- /** Default cwd. Default: null (inherit server default). Only used on create. */
- readonly defaultCwd?: string | null;
+ /** Display title. Default: the workspace id. Only used on create. */
+ readonly title?: string;
+ /** Default cwd. Default: null (inherit server default). Only used on create. */
+ readonly defaultCwd?: string | null;
}
/** Response of `GET`/`PUT /workspaces/:id` — the workspace itself. */
@@ -808,17 +906,17 @@ export interface WorkspaceResponse extends Workspace {}
/** Response of `GET /workspaces` — all workspaces sorted by `lastActivityAt` desc. */
export interface WorkspaceListResponse {
- readonly workspaces: readonly WorkspaceEntry[];
+ readonly workspaces: readonly WorkspaceEntry[];
}
/** Body of `PUT /workspaces/:id/title` — rename (display only; id unchanged). */
export interface SetWorkspaceTitleRequest {
- readonly title: string;
+ readonly title: string;
}
/** Body of `PUT /workspaces/:id/default-cwd` — set or clear the default cwd. */
export interface SetWorkspaceDefaultCwdRequest {
- readonly defaultCwd: string | null;
+ readonly defaultCwd: string | null;
}
/**
@@ -827,9 +925,9 @@ export interface SetWorkspaceDefaultCwdRequest {
* workspace entity is deleted. `"default"` is non-deletable (HTTP 409).
*/
export interface DeleteWorkspaceResponse {
- readonly workspaceId: string;
- /** Conversations that were closed (status → "closed") by this delete. */
- readonly closedCount: number;
+ readonly workspaceId: string;
+ /** Conversations that were closed (status → "closed") by this delete. */
+ readonly closedCount: number;
}
// ─── Computers ───────────────────────────────────────────────────────────────
@@ -842,7 +940,7 @@ export interface DeleteWorkspaceResponse {
* block to `~/.ssh/config` and Dispatch discovers it on the next read.
*/
export interface ComputerListResponse {
- readonly computers: readonly ComputerEntry[];
+ readonly computers: readonly ComputerEntry[];
}
/**
@@ -859,10 +957,10 @@ export interface ComputerResponse extends Computer {}
* `state === "error"`; `knownHost` mirrors the read-only `Computer` field.
*/
export interface ComputerStatusResponse {
- readonly alias: string;
- readonly state: "disconnected" | "connecting" | "connected" | "error";
- readonly error?: string;
- readonly knownHost: boolean;
+ readonly alias: string;
+ readonly state: "disconnected" | "connecting" | "connected" | "error";
+ readonly error?: string;
+ readonly knownHost: boolean;
}
/**
@@ -874,7 +972,7 @@ export interface ComputerStatusResponse {
* a 400). Mirrors the cwd/model PUT clear semantics.
*/
export interface SetConversationComputerRequest {
- readonly computerId: string | null;
+ readonly computerId: string | null;
}
/**
@@ -883,8 +981,8 @@ export interface SetConversationComputerRequest {
* the workspace default → local). Parallel to `CwdResponse`.
*/
export interface ConversationComputerResponse {
- readonly conversationId: string;
- readonly computerId: string | null;
+ readonly conversationId: string;
+ readonly computerId: string | null;
}
/**
@@ -894,7 +992,7 @@ export interface ConversationComputerResponse {
* `computerId` of their own inherit this.
*/
export interface SetWorkspaceDefaultComputerRequest {
- readonly computerId: string | null;
+ readonly computerId: string | null;
}
/**
@@ -904,7 +1002,192 @@ export interface SetWorkspaceDefaultComputerRequest {
* failure reason (e.g. auth refused, host unreachable) when `ok` is false.
*/
export interface TestComputerResponse {
- readonly alias: string;
- readonly ok: boolean;
- readonly error?: string;
+ readonly alias: string;
+ readonly ok: boolean;
+ readonly error?: string;
+}
+
+// ─── Heartbeat ───────────────────────────────────────────────────────────────
+
+/**
+ * The per-workspace Heartbeat config — a single record stored per workspace.
+ *
+ * A heartbeat is an AI that runs on a configurable loop per workspace: when
+ * `enabled`, the backend summons a NEW conversation every `intervalMinutes`
+ * minutes, gives the heartbeat AI `systemPrompt` + sends `taskPrompt` as the
+ * opening user message, and inherits the workspace's computer (SSH) + cwd +
+ * standard tool kit. The main purpose is monitoring (e.g. checking if chats
+ * are stuck).
+ *
+ * `model` is a model name in `<credentialName>/<model>` form (one of the strings
+ * from `GET /models`), or the empty string to use the server default.
+ * `reasoningEffort` is `null` to inherit the workspace default, or an explicit
+ * level. The scheduler resets its timer after each run completes (not a fixed
+ * wall-clock schedule); on backend restart it resumes scheduling for enabled
+ * heartbeats.
+ *
+ * `inactiveOnly` (default `true`) gates each fire on the configured workspace
+ * having NO active agents (no conversation with status `"active"` or `"queued"`):
+ * the heartbeat stays quiet while the user is actively working, and only fires
+ * when the workspace is idle. Set `false` to fire unconditionally.
+ */
+export interface HeartbeatConfig {
+ /** Whether the heartbeat loop is active for this workspace. */
+ readonly enabled: boolean;
+ /**
+ * When `true` (the default), the heartbeat SKIPS a fire when the configured
+ * workspace has any active agents — conversations whose persisted status is
+ * `"active"` (driving a turn) or `"queued"` (waiting on the message queue).
+ * The fire is silently skipped (no run is recorded); the scheduler re-arms
+ * and tries again at the next interval. When `false`, the heartbeat fires
+ * unconditionally regardless of workspace activity. The spawned heartbeat
+ * conversation lives in the DEDICATED heartbeat workspace, so it never
+ * counts as an "active agent" of the configured workspace (no self-block).
+ */
+ readonly inactiveOnly: boolean;
+ /** Custom system prompt for the heartbeat AI (empty = no system prompt). */
+ readonly systemPrompt: string;
+ /** Task prompt sent as the first user message when the heartbeat fires. */
+ readonly taskPrompt: string;
+ /** How often to fire, in minutes (default 30). */
+ readonly intervalMinutes: number;
+ /** Model name (`<credentialName>/<model>`), or empty string = server default. */
+ readonly model: string;
+ /** Reasoning-effort level, or `null` = inherit the workspace default. */
+ readonly reasoningEffort: ReasoningEffort | null;
+}
+
+/**
+ * Body of `PUT /workspaces/:id/heartbeat` — a partial update. All fields are
+ * optional; only the provided fields are applied. `reasoningEffort` accepts
+ * `null` to clear to the workspace default. `intervalMinutes` must be a positive
+ * integer (clamped server-side to a minimum of 1). An unrecognized
+ * `reasoningEffort` → HTTP 400.
+ */
+export interface UpdateHeartbeatRequest {
+ readonly enabled?: boolean;
+ readonly inactiveOnly?: boolean;
+ readonly systemPrompt?: string;
+ readonly taskPrompt?: string;
+ readonly intervalMinutes?: number;
+ readonly model?: string;
+ readonly reasoningEffort?: ReasoningEffort | null;
+}
+
+/** The status of a single heartbeat run. */
+export type HeartbeatRunStatus = "running" | "completed" | "stopped";
+
+/**
+ * One heartbeat run — created each time the heartbeat fires. A run tracks the
+ * conversation it spawned and its lifecycle. `triggeredAt` is an ISO-8601
+ * timestamp. `status` is `"running"` while the turn is in flight,
+ * `"completed"` when the turn sealed normally, or `"stopped"` when the user
+ * stopped it via the stop endpoint (the turn is aborted and seals `"stopped"`).
+ */
+export interface HeartbeatRun {
+ readonly id: string;
+ readonly conversationId: string;
+ readonly triggeredAt: string;
+ readonly status: HeartbeatRunStatus;
+}
+
+/** Response of `GET /workspaces/:id/heartbeat/runs` — runs, most-recent first. */
+export interface HeartbeatRunsResponse {
+ readonly runs: readonly HeartbeatRun[];
+}
+
+/** Response of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */
+export interface StopHeartbeatRunResponse {
+ readonly ok: true;
+}
+
+// ─── Provider concurrency limits ──────────────────────────────────────────────
+
+/**
+ * Response of `GET /concurrency/limits` — all providers with configured
+ * concurrency limits. Each entry pairs a provider id (e.g. "umans",
+ * "openai-compat") with its maximum concurrent in-flight requests. Providers
+ * not listed here have no limit (unlimited).
+ */
+export interface ConcurrencyLimitsResponse {
+ readonly limits: readonly { readonly providerId: string; readonly limit: number }[];
+}
+
+/**
+ * Body of `PUT /concurrency/limits/:providerId` — set or update the concurrency
+ * limit for a provider. `limit` must be a positive integer. When a limit is
+ * set, requests beyond the limit queue (oldest-agent-first) rather than being
+ * sent immediately.
+ */
+export interface SetConcurrencyLimitRequest {
+ readonly limit: number;
+}
+
+/** Response of `GET/PUT /concurrency/limits/:providerId` — the configured limit. */
+export interface ConcurrencyLimitResponse {
+ readonly providerId: string;
+ readonly limit: number;
+}
+
+/**
+ * One provider's live concurrency status.
+ *
+ * - `inFlight`: how many slots are currently held (tokens being generated).
+ * - `queued`: how many agents are waiting for a slot.
+ * - `paused`: whether the queue is paused due to a 429 backoff.
+ * - `pausedUntil`: when the pause expires (epoch-ms), present only when paused.
+ * - `cooldownMs`: the per-slot release cooldown (ms). A recycled slot is held
+ * this long before the next waiter is admitted — covers the upstream
+ * provider's accounting lag. Configurable + persisted per provider.
+ * - `autoReduced`: whether the limit was auto-reduced by 1 after a 429
+ * (adaptive headroom, one-way, persisted). The user restores the limit
+ * manually via `PUT /concurrency/limits/:providerId`, which clears the flag.
+ * When `true`, the frontend renders a visible notice/banner.
+ * - `autoReducedFrom`: the original limit before auto-reduction (present only
+ * when `autoReduced` is true).
+ * - `notice`: a human-readable notice string for the frontend to render as a
+ * banner when the limit was auto-reduced (present only when `autoReduced`).
+ */
+export interface ConcurrencyStatusEntry {
+ readonly providerId: string;
+ readonly limit: number;
+ readonly inFlight: number;
+ readonly queued: number;
+ readonly paused: boolean;
+ readonly pausedUntil?: number;
+ readonly cooldownMs: number;
+ readonly autoReduced: boolean;
+ readonly autoReducedFrom?: number;
+ readonly notice?: string;
+}
+
+/**
+ * Response of `GET /concurrency/status` — live status for every provider with a
+ * configured limit. Providers without a limit are absent (they are unlimited).
+ */
+export interface ConcurrencyStatusResponse {
+ readonly providers: readonly ConcurrencyStatusEntry[];
+}
+
+// ─── Provider concurrency cooldown ────────────────────────────────────────────
+
+/**
+ * Response of `GET /concurrency/cooldown/:providerId` — the per-slot release
+ * cooldown (ms) for a provider. A recycled slot is held this long before the
+ * next waiter is admitted, covering the upstream provider's accounting lag.
+ * When no cooldown was explicitly set, the server default (350ms) is returned.
+ */
+export interface ConcurrencyCooldownResponse {
+ readonly providerId: string;
+ readonly cooldownMs: number;
+}
+
+/**
+ * Body of `PUT /concurrency/cooldown/:providerId` — set the release cooldown
+ * (ms) for a provider. `cooldownMs` must be a non-negative integer (0 = no
+ * cooldown, instant re-admission). The value is persisted and applied to
+ * subsequently recycled slots.
+ */
+export interface SetConcurrencyCooldownRequest {
+ readonly cooldownMs: number;
}