summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 15:28:13 +0900
committerAdam Malczewski <[email protected]>2026-06-25 15:28:13 +0900
commit3647acfb7f078b2f035dd325f6959980c5b46c9a (patch)
tree59d78d8c503a590f4c49481b0454163b921b39da
parenteb6adc405fab9b55590af6b235106dcabab5946e (diff)
downloaddispatch-3647acfb7f078b2f035dd325f6959980c5b46c9a.tar.gz
dispatch-3647acfb7f078b2f035dd325f6959980c5b46c9a.zip
feat(ssh): wave 4 — computer HTTP/WS endpoints + chat computerId threading
Wave 4 of transparent SSH support (3 parallel owner-agents on disjoint packages). - transport-http: computer routes — GET /computers, GET /computers/:alias, GET /computers/:alias/status, POST /computers/:alias/test (all delegate to a new ComputerService seam, graceful []/disconnected when ssh not loaded); GET/PUT/DELETE /conversations/:id/computer; PUT /workspaces/:id/default-computer (mirror the cwd/default-cwd routes); /chat threads computerId into the orchestrator. Defines ComputerService interface + computerServiceHandle (defineService<ComputerService>('ssh')) in seam.ts — the seam the ssh package provides via host.provideService in wave 5. - transport-ws: chat.send + chat.queue thread computerId onto the route result (mirrors cwd/workspaceId), forwarded to the orchestrator input. - mcp: CR-1 fix — filterMcpTools now preserves computerId on the returned ToolAssembly (mirrors cwd preservation), so the filter chain stays consistent. - orchestrator: added @dispatch/wire dep to transport-http (build/config, my lane) so its seam.ts Computer/ComputerEntry import resolves. Verified: tsc -b EXIT 0, biome clean, 1641 vitest pass (was 1620, +21). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
-rw-r--r--bun.lock1
-rw-r--r--packages/mcp/src/extension.test.ts22
-rw-r--r--packages/mcp/src/extension.ts1
-rw-r--r--packages/transport-http/package.json1
-rw-r--r--packages/transport-http/src/app.test.ts473
-rw-r--r--packages/transport-http/src/app.ts206
-rw-r--r--packages/transport-http/src/extension.ts20
-rw-r--r--packages/transport-http/src/logic.ts14
-rw-r--r--packages/transport-http/src/seam.ts40
-rw-r--r--packages/transport-ws/src/extension.ts1
-rw-r--r--packages/transport-ws/src/router.test.ts33
-rw-r--r--packages/transport-ws/src/router.ts10
-rw-r--r--tasks.md6
13 files changed, 821 insertions, 7 deletions
diff --git a/bun.lock b/bun.lock
index 1c9be84..6b3e667 100644
--- a/bun.lock
+++ b/bun.lock
@@ -310,6 +310,7 @@
"@dispatch/system-prompt": "workspace:*",
"@dispatch/throughput-store": "workspace:*",
"@dispatch/transport-contract": "workspace:*",
+ "@dispatch/wire": "workspace:*",
"hono": "^4.0.0",
},
},
diff --git a/packages/mcp/src/extension.test.ts b/packages/mcp/src/extension.test.ts
index e2d2eab..75515fb 100644
--- a/packages/mcp/src/extension.test.ts
+++ b/packages/mcp/src/extension.test.ts
@@ -49,6 +49,28 @@ describe("filterMcpTools (pure)", () => {
expect(result.tools).toHaveLength(0);
expect(result.conversationId).toBe("c");
expect(result.cwd).toBeUndefined();
+ expect(result.computerId).toBeUndefined();
+ });
+
+ it("preserves computerId when set (mirrors cwd/conversationId preservation)", () => {
+ const toolToServer = new Map<string, string>([["a__x", "a"]]);
+ const connected = new Set<string>(["a"]);
+
+ const result = filterMcpTools(
+ {
+ tools: [stubTool("a__x"), stubTool("other")],
+ cwd: "/p",
+ computerId: "ssh-host",
+ conversationId: "c",
+ },
+ toolToServer,
+ connected,
+ );
+
+ expect(result.tools.map((t) => t.name).sort()).toEqual(["a__x", "other"]);
+ expect(result.computerId).toBe("ssh-host");
+ expect(result.cwd).toBe("/p");
+ expect(result.conversationId).toBe("c");
});
});
diff --git a/packages/mcp/src/extension.ts b/packages/mcp/src/extension.ts
index 9adb879..e1c4d52 100644
--- a/packages/mcp/src/extension.ts
+++ b/packages/mcp/src/extension.ts
@@ -49,6 +49,7 @@ export function filterMcpTools(
return {
tools: filtered,
...(assembly.cwd !== undefined && { cwd: assembly.cwd }),
+ ...(assembly.computerId !== undefined && { computerId: assembly.computerId }),
conversationId: assembly.conversationId,
};
}
diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json
index d95436b..e7eb85c 100644
--- a/packages/transport-http/package.json
+++ b/packages/transport-http/package.json
@@ -14,6 +14,7 @@
"@dispatch/session-orchestrator": "workspace:*",
"@dispatch/throughput-store": "workspace:*",
"@dispatch/transport-contract": "workspace:*",
+ "@dispatch/wire": "workspace:*",
"hono": "^4.0.0",
"@dispatch/system-prompt": "workspace:*"
}
diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts
index c7b7d31..4f64ece 100644
--- a/packages/transport-http/src/app.test.ts
+++ b/packages/transport-http/src/app.test.ts
@@ -21,11 +21,12 @@ import type {
WorkspaceListResponse,
WorkspaceResponse,
} from "@dispatch/transport-contract";
-import type { Workspace } from "@dispatch/wire";
+import type { Computer, ComputerEntry, Workspace } from "@dispatch/wire";
import { describe, expect, it } from "vitest";
import { createApp } from "./app.js";
import { extractLastAssistantText } from "./logic.js";
import type {
+ ComputerService,
ConversationStore,
CredentialStore,
LspService,
@@ -100,7 +101,16 @@ function createFakeConversationStore(
cwdStore: Map<string, string> = new Map(),
reasoningEffortStore: Map<string, ReasoningEffort> = new Map(),
modelStore: Map<string, string> = new Map(),
+ computerStore: Map<string, string> = new Map(),
): ConversationStore {
+ const sampleWorkspace = {
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
return {
async append() {},
async load() {
@@ -133,6 +143,19 @@ function createFakeConversationStore(
async clearCwd(conversationId) {
cwdStore.delete(conversationId);
},
+ async getComputerId(conversationId) {
+ return computerStore.get(conversationId) ?? null;
+ },
+ async setComputerId(conversationId, alias) {
+ if (alias === null) {
+ computerStore.delete(conversationId);
+ } else {
+ computerStore.set(conversationId, alias);
+ }
+ },
+ async clearComputerId(conversationId) {
+ computerStore.delete(conversationId);
+ },
async getReasoningEffort(conversationId) {
return reasoningEffortStore.get(conversationId) ?? null;
},
@@ -171,13 +194,16 @@ function createFakeConversationStore(
return null;
},
async ensureWorkspace() {
- return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return sampleWorkspace;
},
async setWorkspaceTitle() {
- return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return sampleWorkspace;
},
async setWorkspaceDefaultCwd() {
- return { id: "default", title: "default", defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return sampleWorkspace;
+ },
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return { ...sampleWorkspace, id, defaultComputerId };
},
async deleteWorkspace() {
return { closedCount: 0 };
@@ -192,6 +218,9 @@ function createFakeConversationStore(
async getEffectiveCwd(conversationId) {
return cwdStore.get(conversationId) ?? null;
},
+ async getEffectiveComputer(conversationId) {
+ return computerStore.get(conversationId) ?? null;
+ },
};
}
@@ -466,6 +495,27 @@ function createFakeSystemPromptService(
};
}
+function createFakeComputerService(computers: readonly ComputerEntry[] = []): ComputerService {
+ const byAlias = new Map<string, Computer>(computers.map((c) => [c.alias, c]));
+ return {
+ async listComputers() {
+ return computers;
+ },
+ async getComputer(alias) {
+ return byAlias.get(alias) ?? null;
+ },
+ async getStatus(alias) {
+ const known = byAlias.has(alias);
+ return { alias, state: "disconnected", knownHost: known };
+ },
+ async test(alias) {
+ return byAlias.has(alias)
+ ? { alias, ok: true }
+ : { alias, ok: false, error: "Computer not found" };
+ },
+ };
+}
+
const noopLogger = createFakeLogger();
describe("GET /health", () => {
@@ -1065,6 +1115,7 @@ describe("GET /conversations/:id", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -1074,6 +1125,7 @@ describe("GET /conversations/:id", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -1083,6 +1135,7 @@ describe("GET /conversations/:id", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -1184,6 +1237,7 @@ describe("GET /conversations/:id", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -1193,6 +1247,7 @@ describe("GET /conversations/:id", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -1202,6 +1257,7 @@ describe("GET /conversations/:id", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -1372,6 +1428,7 @@ describe("GET /conversations/:id/metrics", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -1381,6 +1438,7 @@ describe("GET /conversations/:id/metrics", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -1390,6 +1448,7 @@ describe("GET /conversations/:id/metrics", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -2807,6 +2866,7 @@ describe("PUT /conversations/:id/reasoning-effort", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -2816,6 +2876,7 @@ describe("PUT /conversations/:id/reasoning-effort", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -2825,6 +2886,7 @@ describe("PUT /conversations/:id/reasoning-effort", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -3480,6 +3542,7 @@ describe("Workspaces", () => {
id: "proj",
title: "proj",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 1000,
lastActivityAt: 2000,
};
@@ -3865,3 +3928,405 @@ describe("GET /system-prompt/variables", () => {
expect(fileEntry?.dynamic).toBe(true);
});
});
+
+// ─── Computers (mirrors the cwd / workspace routes) ─────────────────────────
+
+const sampleComputer: Computer = {
+ alias: "myserver",
+ hostName: "10.0.0.5",
+ port: 22,
+ user: "deploy",
+ identityFile: "/home/user/.ssh/id_ed25519",
+ knownHost: true,
+};
+
+describe("GET /computers", () => {
+ it("returns [] when no ComputerService is wired (graceful degrade)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { computers: readonly ComputerEntry[] };
+ expect(body.computers).toEqual([]);
+ });
+
+ it("delegates to the ComputerService when wired", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 2 }]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { computers: readonly ComputerEntry[] };
+ expect(body.computers).toHaveLength(1);
+ expect(body.computers[0]?.alias).toBe("myserver");
+ expect(body.computers[0]?.usageCount).toBe(2);
+ });
+});
+
+describe("GET /computers/:alias", () => {
+ it("returns the computer when the alias is configured", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ computerService: createFakeComputerService([{ ...sampleComputer, usageCount: 0 }]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as Computer;
+ expect(body.alias).toBe("myserver");
+ expect(body.hostName).toBe("10.0.0.5");
+ });
+
+ it("returns 404 when the alias is not in the config", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ computerService: createFakeComputerService([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/unknown");
+ expect(res.status).toBe(404);
+ });
+
+ it("returns 404 when no ComputerService is wired (no ssh)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver");
+ expect(res.status).toBe(404);
+ });
+});
+
+describe("GET /computers/:alias/status", () => {
+ it("returns disconnected + knownHost:false when no ComputerService is wired", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver/status");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ alias: string;
+ state: string;
+ knownHost: boolean;
+ };
+ expect(body.alias).toBe("myserver");
+ expect(body.state).toBe("disconnected");
+ expect(body.knownHost).toBe(false);
+ });
+});
+
+describe("POST /computers/:alias/test", () => {
+ it("returns ok:false + 'SSH not configured' when no ComputerService is wired", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/computers/myserver/test", { method: "POST" });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { alias: string; ok: boolean; error?: string };
+ expect(body.alias).toBe("myserver");
+ expect(body.ok).toBe(false);
+ expect(body.error).toBe("SSH not configured");
+ });
+});
+
+describe("GET then PUT then GET /conversations/:id/computer", () => {
+ it("round-trips the value", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const get0 = await app.request("/conversations/conv1/computer");
+ expect(get0.status).toBe(200);
+ const get0Body = (await get0.json()) as { conversationId: string; computerId: string | null };
+ expect(get0Body.conversationId).toBe("conv1");
+ expect(get0Body.computerId).toBeNull();
+
+ const putRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(putRes.status).toBe(200);
+ const putBody = (await putRes.json()) as { conversationId: string; computerId: string };
+ expect(putBody.conversationId).toBe("conv1");
+ expect(putBody.computerId).toBe("myserver");
+
+ const getRes = await app.request("/conversations/conv1/computer");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { conversationId: string; computerId: string | null };
+ expect(getBody.computerId).toBe("myserver");
+ });
+});
+
+describe("PUT /conversations/:id/computer with null clears (→ DELETE parity)", () => {
+ it("PUT null clears a previously-set computer", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(putRes.status).toBe(200);
+
+ const clearRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: null }),
+ });
+ expect(clearRes.status).toBe(200);
+ const clearBody = (await clearRes.json()) as {
+ conversationId: string;
+ computerId: string | null;
+ };
+ expect(clearBody.computerId).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/computer");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { computerId: string | null };
+ expect(getBody.computerId).toBeNull();
+ });
+});
+
+describe("DELETE /conversations/:id/computer", () => {
+ it("after a PUT computer → returns { computerId: null } and a subsequent GET returns null", async () => {
+ const store = createFakeConversationStore();
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const putRes = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(putRes.status).toBe(200);
+
+ const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as {
+ conversationId: string;
+ computerId: string | null;
+ };
+ expect(deleteBody.conversationId).toBe("conv1");
+ expect(deleteBody.computerId).toBeNull();
+
+ const getRes = await app.request("/conversations/conv1/computer");
+ expect(getRes.status).toBe(200);
+ const getBody = (await getRes.json()) as { computerId: string | null };
+ expect(getBody.computerId).toBeNull();
+ });
+
+ it("on a conversation that never had a computer set → returns { computerId: null } (idempotent)", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+ const deleteBody = (await deleteRes.json()) as {
+ conversationId: string;
+ computerId: string | null;
+ };
+ expect(deleteBody.computerId).toBeNull();
+ });
+
+ it("does NOT affect other conversations' computers (isolation)", async () => {
+ const computerStore = new Map<string, string>([
+ ["conv1", "myserver"],
+ ["conv2", "otherbox"],
+ ]);
+ const store = createFakeConversationStore(
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ new Map(),
+ computerStore,
+ );
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+
+ const deleteRes = await app.request("/conversations/conv1/computer", { method: "DELETE" });
+ expect(deleteRes.status).toBe(200);
+
+ const get1 = await app.request("/conversations/conv1/computer");
+ expect(get1.status).toBe(200);
+ expect((await get1.json()).computerId).toBeNull();
+
+ const get2 = await app.request("/conversations/conv2/computer");
+ expect(get2.status).toBe(200);
+ expect((await get2.json()).computerId).toBe("otherbox");
+ });
+});
+
+describe("PUT /conversations/:id/computer validation", () => {
+ it("with missing computerId returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect(res.status).toBe(400);
+ const body = (await res.json()) as { error: string };
+ expect(body.error).toContain("computerId");
+ });
+
+ it("with empty-string computerId returns 400", async () => {
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/conversations/conv1/computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "" }),
+ });
+ expect(res.status).toBe(400);
+ });
+});
+
+describe("PUT /workspaces/:id/default-computer", () => {
+ const wsSample: Workspace = {
+ id: "proj",
+ title: "proj",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 1000,
+ lastActivityAt: 2000,
+ };
+
+ it("sets the default computer", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return { ...wsSample, id, defaultComputerId };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: "myserver" }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultComputerId).toBe("myserver");
+ });
+
+ it("clears the default computer with null", async () => {
+ const store: ConversationStore = {
+ ...createFakeConversationStore(),
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return { ...wsSample, id, defaultComputerId };
+ },
+ };
+ const app = createApp({
+ conversationStore: store,
+ orchestrator: createFakeOrchestrator([]),
+ credentialStore: createFakeCredentialStore([]),
+ logger: noopLogger,
+ });
+ const res = await app.request("/workspaces/proj/default-computer", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ computerId: null }),
+ });
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as WorkspaceResponse;
+ expect(body.defaultComputerId).toBeNull();
+ });
+});
+
+describe("POST /chat threads computerId", () => {
+ it("forwards computerId into the orchestrator input when present", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: "hi",
+ conversationId: "conv1",
+ computerId: "myserver",
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.conversationId).toBe("conv1");
+ expect(cap.received?.computerId).toBe("myserver");
+ });
+
+ it("omits computerId when not provided", async () => {
+ const cap = createCapturingOrchestrator();
+ const app = createApp({
+ conversationStore: createFakeConversationStore(),
+ orchestrator: cap,
+ credentialStore: createFakeCredentialStore([]),
+ });
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: "hi", conversationId: "conv1" }),
+ });
+ expect(res.status).toBe(200);
+ expect(cap.received).toBeDefined();
+ expect(cap.received?.computerId).toBeUndefined();
+ });
+});
diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts
index 1d87383..2e81c46 100644
--- a/packages/transport-http/src/app.ts
+++ b/packages/transport-http/src/app.ts
@@ -4,6 +4,10 @@ import type {
CloseConversationResponse,
CompactPercentResponse,
CompactResponse,
+ ComputerListResponse,
+ ComputerResponse,
+ ComputerStatusResponse,
+ ConversationComputerResponse,
ConversationHistoryResponse,
ConversationListResponse,
ConversationMetricsResponse,
@@ -21,9 +25,12 @@ import type {
QueueResponse,
ReasoningEffortResponse,
SetCompactPercentRequest,
+ SetConversationComputerRequest,
SetSystemPromptTemplateRequest,
+ SetWorkspaceDefaultComputerRequest,
SystemPromptTemplateResponse,
SystemPromptVariablesResponse,
+ TestComputerResponse,
ThroughputResponse,
TitleResponse,
WarmResponse,
@@ -53,6 +60,7 @@ import {
} from "./logic.js";
import {
type CompactionService,
+ type ComputerService,
type ConversationStore,
type CredentialStore,
conversationOpened,
@@ -78,6 +86,14 @@ export interface CreateServerOptions {
readonly mcpService?: McpService;
/** Optional — system prompt builder service (GET/PUT template). */
readonly systemPromptService?: SystemPromptService;
+ /**
+ * Optional — computer discovery + live connection service (provided by the
+ * `ssh` extension). When absent (ssh not loaded), the `/computers*` routes
+ * degrade: list returns `[]`, status returns "disconnected", test returns
+ * a not-configured result. The per-conversation / workspace-default computer
+ * endpoints work regardless (they only touch the conversation store).
+ */
+ readonly computerService?: ComputerService;
/** Optional — defaults to a no-op store (recording disabled, empty reports). */
readonly throughputStore?: ThroughputStore;
readonly logger?: Logger;
@@ -282,6 +298,77 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ // ─── Computers (discovery + live state) ───────────────────────────────────
+ // Read-only discovery + connection state is delegated to the ComputerService
+ // (provided by the `ssh` extension). When ssh is NOT loaded the routes
+ // degrade: list → empty, status → "disconnected", test → not-configured.
+
+ app.get("/computers", async (c) => {
+ if (opts.computerService === undefined) {
+ // Graceful: no ssh configured → no computers discovered.
+ const body: ComputerListResponse = { computers: [] };
+ return c.json(body, 200);
+ }
+ try {
+ const computers = await opts.computerService.listComputers();
+ log.info("computers: list", { count: computers.length });
+ const body: ComputerListResponse = { computers };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("computers: list failure", { err });
+ return c.json({ error: "Failed to list computers" }, 500);
+ }
+ });
+
+ app.get("/computers/:alias", async (c) => {
+ const alias = c.req.param("alias");
+ if (opts.computerService === undefined) {
+ // No ssh configured → no computer resolves this alias.
+ return c.json({ error: "Computer not found" }, 404);
+ }
+ try {
+ const computer = await opts.computerService.getComputer(alias);
+ if (computer === null) {
+ return c.json({ error: "Computer not found" }, 404);
+ }
+ const body: ComputerResponse = computer;
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("computers: get failure", { err, alias });
+ return c.json({ error: "Failed to read computer" }, 500);
+ }
+ });
+
+ app.get("/computers/:alias/status", async (c) => {
+ const alias = c.req.param("alias");
+ if (opts.computerService === undefined) {
+ const body: ComputerStatusResponse = { alias, state: "disconnected", knownHost: false };
+ return c.json(body, 200);
+ }
+ try {
+ const body = await opts.computerService.getStatus(alias);
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("computers: status failure", { err, alias });
+ return c.json({ error: "Failed to read computer status" }, 500);
+ }
+ });
+
+ app.post("/computers/:alias/test", async (c) => {
+ const alias = c.req.param("alias");
+ if (opts.computerService === undefined) {
+ const body: TestComputerResponse = { alias, ok: false, error: "SSH not configured" };
+ return c.json(body, 200);
+ }
+ try {
+ const body = await opts.computerService.test(alias);
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("computers: test failure", { err, alias });
+ return c.json({ error: "Failed to test computer" }, 500);
+ }
+ });
+
app.post("/chat", async (c) => {
let body: unknown;
try {
@@ -297,11 +384,13 @@ export function createApp(opts: CreateServerOptions): Hono {
return c.json({ error: result.error }, 400);
}
- const { conversationId, message, model, cwd, reasoningEffort, workspaceId } = result;
+ const { conversationId, message, model, cwd, computerId, reasoningEffort, workspaceId } =
+ result;
log.info("chat: request accepted", {
conversationId,
hasModel: model !== undefined,
hasCwd: cwd !== undefined,
+ hasComputerId: computerId !== undefined,
hasReasoningEffort: reasoningEffort !== undefined,
hasWorkspaceId: workspaceId !== undefined,
});
@@ -351,6 +440,7 @@ export function createApp(opts: CreateServerOptions): Hono {
},
...(model !== undefined ? { modelName: model } : {}),
...(cwd !== undefined ? { cwd } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
};
@@ -578,6 +668,91 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ // ─── Per-conversation computer (mirrors /conversations/:id/cwd) ──────────
+
+ app.get("/conversations/:id/computer", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ const computerId = await opts.conversationStore.getComputerId(conversationId);
+ log.info("conversations: computer read", {
+ conversationId,
+ hasComputerId: computerId !== null,
+ });
+ const body: ConversationComputerResponse = { conversationId, computerId };
+ return c.json(body, 200);
+ } catch (err) {
+ log.error("conversations: computer read failure", { err });
+ return c.json({ error: "Failed to read conversation computer" }, 500);
+ }
+ });
+
+ app.put("/conversations/:id/computer", async (c) => {
+ const conversationId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ log.warn("conversations/computer: invalid JSON body");
+ return c.json({ error: "Invalid JSON body" }, 400);
+ }
+
+ if (body === null || typeof body !== "object") {
+ return c.json({ error: "Request body must be a JSON object" }, 400);
+ }
+ const obj = body as Record<string, unknown>;
+ // `computerId` must be a string (the SSH alias) or null (clear → inherit
+ // the workspace defaultComputerId → local). An empty string is rejected
+ // (unlike cwd, an alias is never "empty"); null is the explicit clear.
+ if (
+ obj.computerId !== null &&
+ (typeof obj.computerId !== "string" || obj.computerId.length === 0)
+ ) {
+ return c.json(
+ { error: "Field 'computerId' is required and must be a non-empty string or null" },
+ 400,
+ );
+ }
+ const { computerId } = obj as unknown as SetConversationComputerRequest;
+
+ // Mirror PUT /conversations/:id/cwd: when a workspaceId is provided,
+ // assign the conversation to that workspace BEFORE persisting the
+ // computer, so a subsequent effective-computer resolution reads the
+ // workspace's defaultComputerId. Omit for unchanged workspace assignment.
+ if (obj.workspaceId !== undefined) {
+ if (typeof obj.workspaceId !== "string" || !isValidWorkspaceSlug(obj.workspaceId)) {
+ return c.json({ error: "Invalid workspaceId" }, 400);
+ }
+ }
+
+ try {
+ if (typeof obj.workspaceId === "string") {
+ await opts.conversationStore.ensureWorkspace(obj.workspaceId);
+ await opts.conversationStore.setWorkspaceId(conversationId, obj.workspaceId);
+ }
+ // null → clear (inherit/local); string → persist the alias.
+ await opts.conversationStore.setComputerId(conversationId, computerId);
+ log.info("conversations: computer set", { conversationId });
+ const response: ConversationComputerResponse = { conversationId, computerId };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: computer set failure", { err });
+ return c.json({ error: "Failed to set conversation computer" }, 500);
+ }
+ });
+
+ app.delete("/conversations/:id/computer", async (c) => {
+ const conversationId = c.req.param("id");
+ try {
+ await opts.conversationStore.clearComputerId(conversationId);
+ log.info("conversations: computer cleared", { conversationId });
+ const response: ConversationComputerResponse = { conversationId, computerId: null };
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("conversations: computer clear failure", { err });
+ return c.json({ error: "Failed to clear conversation computer" }, 500);
+ }
+ });
+
app.get("/conversations/:id/reasoning-effort", async (c) => {
const conversationId = c.req.param("id");
try {
@@ -1119,6 +1294,35 @@ export function createApp(opts: CreateServerOptions): Hono {
}
});
+ // Mirrors PUT /workspaces/:id/default-cwd exactly (the computer analog).
+ app.put("/workspaces/:id/default-computer", async (c) => {
+ const workspaceId = c.req.param("id");
+ let body: unknown;
+ try {
+ body = await c.req.json();
+ } catch {
+ body = {};
+ }
+ const obj = body as Record<string, unknown>;
+ // Mirrors PUT /workspaces/:id/default-cwd: a string → the SSH alias;
+ // anything else (null/absent/non-string) → clear (local).
+ const defaultComputerId: SetWorkspaceDefaultComputerRequest["computerId"] =
+ typeof obj.computerId === "string" ? obj.computerId : null;
+
+ try {
+ const workspace = await opts.conversationStore.setWorkspaceDefaultComputerId(
+ workspaceId,
+ defaultComputerId,
+ );
+ log.info("workspaces: default-computer set", { workspaceId });
+ const response: WorkspaceResponse = workspace;
+ return c.json(response, 200);
+ } catch (err) {
+ log.error("workspaces: default-computer set failure", { err });
+ return c.json({ error: "Failed to set workspace default computer" }, 500);
+ }
+ });
+
app.delete("/workspaces/:id", async (c) => {
const workspaceId = c.req.param("id");
if (workspaceId === "default") {
diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts
index 0f46e6b..4ab43ce 100644
--- a/packages/transport-http/src/extension.ts
+++ b/packages/transport-http/src/extension.ts
@@ -1,8 +1,10 @@
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import { createApp } from "./app.js";
import {
+ type ComputerService,
cacheWarmHandle,
compactionHandle,
+ computerServiceHandle,
conversationStoreHandle,
credentialStoreHandle,
lspServiceHandle,
@@ -31,11 +33,16 @@ export const manifest: Manifest = {
routes: [
"/chat",
"/chat/warm",
+ "/computers",
+ "/computers/:alias",
+ "/computers/:alias/status",
+ "/computers/:alias/test",
"/conversations",
"/conversations/:id",
"/conversations/:id/close",
"/conversations/:id/compact",
"/conversations/:id/compact-percent",
+ "/conversations/:id/computer",
"/conversations/:id/cwd",
"/conversations/:id/last",
"/conversations/:id/lsp",
@@ -55,6 +62,7 @@ export const manifest: Manifest = {
"/workspaces/:id",
"/workspaces/:id/title",
"/workspaces/:id/default-cwd",
+ "/workspaces/:id/default-computer",
],
},
activation: "eager",
@@ -80,6 +88,17 @@ export function createTransportHttpExtension(): Extension & {
const lspService = host.getService(lspServiceHandle);
const mcpService = host.getService(mcpServiceHandle);
const systemPromptService = host.getService(systemPromptHandle);
+ // Optional: the `ssh` extension provides ComputerService. It is NOT in
+ // dependsOn (ssh may be absent), so resolve defensively — when no
+ // provider registered the handle, the computer routes degrade to
+ // empty/disconnected (see app.ts). Wrapped because getService throws
+ // for an unregistered handle.
+ let computerService: ComputerService | undefined;
+ try {
+ computerService = host.getService(computerServiceHandle);
+ } catch {
+ computerService = undefined;
+ }
const logger = host.logger;
const app = createApp({
@@ -92,6 +111,7 @@ export function createTransportHttpExtension(): Extension & {
lspService,
mcpService,
systemPromptService,
+ ...(computerService !== undefined ? { computerService } : {}),
logger,
emit: host.emit.bind(host),
...(process.env.DISPATCH_WEB_DIR !== undefined
diff --git a/packages/transport-http/src/logic.ts b/packages/transport-http/src/logic.ts
index 948afb8..4e099c4 100644
--- a/packages/transport-http/src/logic.ts
+++ b/packages/transport-http/src/logic.ts
@@ -46,6 +46,13 @@ export interface ChatCommand {
readonly message: string;
readonly model?: string;
readonly cwd?: string;
+ /**
+ * Per-turn computer override (SSH `Host` alias). Mirrors `cwd`: forwarded
+ * to the orchestrator verbatim and never part of the model prompt. When
+ * absent, the orchestrator resolves the per-conversation → workspace
+ * default → local chain.
+ */
+ readonly computerId?: string;
readonly reasoningEffort?: ReasoningEffort;
readonly workspaceId?: string;
}
@@ -91,6 +98,13 @@ export function parseChatBody(body: unknown, generateId: () => string): ParseRes
(result as { cwd?: string }).cwd = obj.cwd;
}
+ if (obj.computerId !== undefined) {
+ if (typeof obj.computerId !== "string") {
+ return { error: "Field 'computerId' must be a string" };
+ }
+ (result as { computerId?: string }).computerId = obj.computerId;
+ }
+
if (obj.reasoningEffort !== undefined) {
if (!isValidReasoningEffort(obj.reasoningEffort)) {
return {
diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts
index e9dc4ce..ef28a09 100644
--- a/packages/transport-http/src/seam.ts
+++ b/packages/transport-http/src/seam.ts
@@ -1,3 +1,7 @@
+import { defineService, type ServiceHandle } from "@dispatch/kernel";
+import type { ComputerStatusResponse, TestComputerResponse } from "@dispatch/transport-contract";
+import type { Computer, ComputerEntry } from "@dispatch/wire";
+
export type { ConversationStore } from "@dispatch/conversation-store";
export { conversationStoreHandle, isValidWorkspaceSlug } from "@dispatch/conversation-store";
export type { CredentialStore } from "@dispatch/credential-store";
@@ -21,3 +25,39 @@ export type { SystemPromptService } from "@dispatch/system-prompt";
export { systemPromptHandle } from "@dispatch/system-prompt";
export type { ThroughputStore } from "@dispatch/throughput-store";
export { ThroughputQueryError, throughputStoreHandle } from "@dispatch/throughput-store";
+
+// ─── ComputerService seam ─────────────────────────────────────────────────────
+//
+// The read-only computer discovery + live connection surface. The `ssh`
+// extension provides the real implementation (parses `~/.ssh/config`, pools
+// `ssh2` connections) and registers it via `host.provideService`. Until ssh is
+// loaded, the routes that delegate here DEGRADE: the list route returns an empty
+// `[]` (no computers configured), and the status/test routes return their
+// "disconnected" / not-configured sentinels. The interface + handle are defined
+// HERE (not in `@dispatch/ssh`, which does not exist yet) so the routes can be
+// wired against a typed seam today; when the `ssh` package lands it imports
+// `ComputerService` + `computerServiceHandle` from here (mirroring how a
+// provider implements a contract owned by its consumer seam).
+
+/**
+ * Read-only computer discovery + per-alias live state + one-shot probe. The
+ * transport routes delegate to this; it never throws for "no ssh configured"
+ * — an ABSENT service (ssh extension not loaded) is the graceful-degrade path.
+ */
+export interface ComputerService {
+ /** Every computer discovered from `~/.ssh/config`, sorted by `alias`. */
+ readonly listComputers: () => Promise<readonly ComputerEntry[]>;
+ /** One computer by alias, or `null` when the alias isn't in the config. */
+ readonly getComputer: (alias: string) => Promise<Computer | null>;
+ /** Live connection state for a computer alias. */
+ readonly getStatus: (alias: string) => Promise<ComputerStatusResponse>;
+ /** One-shot connectivity probe (open, run a trivial command, close). */
+ readonly test: (alias: string) => Promise<TestComputerResponse>;
+}
+
+/**
+ * Typed service handle the `ssh` extension provides and the transport routes
+ * consume. Mirrors `lspServiceHandle` / `mcpServiceHandle`.
+ */
+export const computerServiceHandle: ServiceHandle<ComputerService> =
+ defineService<ComputerService>("ssh");
diff --git a/packages/transport-ws/src/extension.ts b/packages/transport-ws/src/extension.ts
index 1e3da27..56bd8e2 100644
--- a/packages/transport-ws/src/extension.ts
+++ b/packages/transport-ws/src/extension.ts
@@ -290,6 +290,7 @@ export function createTransportWsExtension(): Extension {
? { reasoningEffort: result.reasoningEffort }
: {}),
...(result.workspaceId !== undefined ? { workspaceId: result.workspaceId } : {}),
+ ...(result.computerId !== undefined ? { computerId: result.computerId } : {}),
});
if (!startResult.started) {
send(ws, {
diff --git a/packages/transport-ws/src/router.test.ts b/packages/transport-ws/src/router.test.ts
index 66e84cf..6d01823 100644
--- a/packages/transport-ws/src/router.test.ts
+++ b/packages/transport-ws/src/router.test.ts
@@ -379,6 +379,39 @@ describe("routeClientMessage", () => {
expect(result).not.toHaveProperty("workspaceId");
});
+ it("chat.send threads computerId", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ conversationId: "conv-cid",
+ message: "hello computer",
+ computerId: "dev-box",
+ });
+
+ expect(result.kind).toBe("chat");
+ if (result.kind !== "chat") throw new Error("expected chat");
+ expect(result.computerId).toBe("dev-box");
+ });
+
+ it("chat.send omits computerId (absent/undefined) when not sent — backward compatible", () => {
+ const registry = fakeRegistry([]);
+ const connSubs = new Set<string>();
+
+ const result = routeClientMessage(registry, connSubs, {
+ type: "chat.send",
+ message: "hello no computer",
+ });
+
+ expect(result.kind).toBe("chat");
+ if (result.kind !== "chat") throw new Error("expected chat");
+ // computerId is absent (undefined) — the orchestrator receives no
+ // computerId and resolves the inherited chain (conversation →
+ // workspace defaultComputerId → local). Mirrors workspaceId.
+ expect(result).not.toHaveProperty("computerId");
+ });
+
it("rejects a malformed chat.send (empty message)", () => {
const registry = fakeRegistry([]);
const connSubs = new Set<string>();
diff --git a/packages/transport-ws/src/router.ts b/packages/transport-ws/src/router.ts
index d43894d..7e9ba77 100644
--- a/packages/transport-ws/src/router.ts
+++ b/packages/transport-ws/src/router.ts
@@ -49,6 +49,15 @@ export interface ChatRouteResult {
readonly cwd: string | undefined;
readonly reasoningEffort?: ReasoningEffort;
readonly workspaceId?: string;
+ /**
+ * The computer (SSH config alias) to run this turn's tools on — forwarded
+ * verbatim to the orchestrator's `startTurn` (which resolves it via
+ * `getEffectiveComputer`). Mirrors `cwd`/`workspaceId`: an opaque per-turn
+ * override, unvalidated here (validation happens at SSH connect time).
+ * Absent when the client omits it (the orchestrator then inherits the
+ * conversation → workspace → local chain).
+ */
+ readonly computerId?: string;
}
/** A malformed chat.send that should yield a chat.error reply. */
@@ -173,6 +182,7 @@ function handleChatSend(msg: ChatSendMessage): ChatRouteResult | ChatRouteError
cwd: msg.cwd,
...(msg.reasoningEffort !== undefined ? { reasoningEffort: msg.reasoningEffort } : {}),
...(msg.workspaceId !== undefined ? { workspaceId: msg.workspaceId } : {}),
+ ...(msg.computerId !== undefined ? { computerId: msg.computerId } : {}),
};
}
diff --git a/tasks.md b/tasks.md
index 8c58fd6..0916af9 100644
--- a/tasks.md
+++ b/tasks.md
@@ -31,8 +31,10 @@ owner-agents on disjoint packages).
API types). `tsc -b` EXIT 0, biome clean, **1620 vitest** (was 1599).
CR-1 (non-blocking): MCP filter doesn't preserve `computerId` on
ToolAssembly — fix folded into wave 4.
-- [ ] **Wave 4** (parallel): `transport-http` + `transport-ws` (computer
- endpoints + chat threading) + `mcp` (CR-1: preserve computerId in filter).
+- [x] **Wave 4** (parallel): `transport-http` (computer endpoints + `/chat`
+ threading + the `ComputerService` seam the ssh package will provide) +
+ `transport-ws` (computerId through chat.send/queue) + `mcp` (CR-1: preserve
+ computerId in filter). `tsc -b` EXIT 0, biome clean, **1641 vitest** (was 1620).
- [ ] **Wave 5**: `host-bin` wiring + `ssh` package (SshConnectionPool,
SshExecBackend, ~/.ssh/config reader via ssh-config, known_hosts pinning).
- [ ] **DEFERRED — cache-warming**: computerId threading intentionally NOT done