summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 14:43:29 +0900
committerAdam Malczewski <[email protected]>2026-06-25 14:43:29 +0900
commiteb6adc405fab9b55590af6b235106dcabab5946e (patch)
treed158e92a01f7beb7dd97a866f78c3282264b361a
parent1ff0eac44cd44751af979c51c746a1774c268e8a (diff)
downloaddispatch-eb6adc405fab9b55590af6b235106dcabab5946e.tar.gz
dispatch-eb6adc405fab9b55590af6b235106dcabab5946e.zip
feat(ssh): wave 3 — session-orchestrator computerId threading + transport-contract API types
Wave 3 of transparent SSH support (2 parallel owner-agents on disjoint packages). - session-orchestrator: thread computerId end-to-end through the turn, mirroring cwd exactly — StartTurnInput/EnqueueInput/handleMessage/TurnLifecyclePayload gain computerId; runTurnDetached resolves effectiveComputerId via conversationStore.getEffectiveComputer(convId, override), persists the override, threads into RunTurnInput + ToolAssembly. Register a remote-degradation tools-filter (filterRemoteIncompatibleTools) that, when assembly.computerId is set (REMOTE), drops the 'lsp' tool + any '__'-namespaced MCP tool (local processes that can't see remote files); LOCAL (computerId undefined) is a passthrough — byte-identical to today. +21 tests. - transport-contract: + computerId on ChatRequest (flows to ChatSendMessage) + computer endpoint API types (ComputerListResponse, ComputerResponse, ComputerStatusResponse, SetConversationComputerRequest, ConversationComputerResponse, SetWorkspaceDefaultComputerRequest, TestComputerResponse) — mirrors the cwd/workspace endpoint types. - CR-1 (non-blocking, folded into wave 4): MCP filter doesn't preserve computerId on the returned ToolAssembly. - cache-warming computerId threading intentionally DEFERRED (user request) — noted as a known performance-only limitation in tasks.md. Verified: tsc -b EXIT 0, biome clean, 1620 vitest pass (was 1599, +21). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
-rw-r--r--packages/session-orchestrator/src/extension.ts14
-rw-r--r--packages/session-orchestrator/src/orchestrator.test.ts288
-rw-r--r--packages/session-orchestrator/src/orchestrator.ts67
-rw-r--r--packages/session-orchestrator/src/queue.test.ts48
-rw-r--r--packages/session-orchestrator/src/tools-filter.test.ts75
-rw-r--r--packages/session-orchestrator/src/tools-filter.ts43
-rw-r--r--packages/transport-contract/src/contract.types.test.ts160
-rw-r--r--packages/transport-contract/src/index.ts91
-rw-r--r--tasks.md18
9 files changed, 774 insertions, 30 deletions
diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts
index 4144827..1a57cc3 100644
--- a/packages/session-orchestrator/src/extension.ts
+++ b/packages/session-orchestrator/src/extension.ts
@@ -13,7 +13,7 @@ import {
sessionOrchestratorHandle,
} from "./orchestrator.js";
import { selectFirstProvider } from "./pure.js";
-import { toolsFilter } from "./tools-filter.js";
+import { filterRemoteIncompatibleTools, toolsFilter } from "./tools-filter.js";
export const manifest: Manifest = {
id: "session-orchestrator",
@@ -97,6 +97,18 @@ export function activate(host: HostAPI): void {
host.provideService(sessionOrchestratorHandle, orchestrator);
+ // Remote-degradation rule (plan §6): when a turn is REMOTE
+ // (`assembly.computerId !== undefined`), drop tools that spawn local
+ // processes and cannot run over SFTP — the `lsp` tool (local LSP servers)
+ // and MCP-namespaced tools (`<serverId>__<toolName>`, local MCP servers).
+ // When LOCAL (`computerId === undefined`), the filter is a passthrough —
+ // byte-identical to today. Registered at default priority (0) with
+ // activation-order tie-breaking: session-orchestrator activates before
+ // MCP (which dependsOn it), so this runs FIRST in the chain — the drops
+ // happen before MCP's filter connects/registers servers. Mirrors how MCP
+ // adds its own filter via host.addFilter.
+ host.addFilter(toolsFilter, filterRemoteIncompatibleTools);
+
const warmService = createWarmService(
{
conversationStore,
diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts
index e2d3b6b..8ff3f5e 100644
--- a/packages/session-orchestrator/src/orchestrator.test.ts
+++ b/packages/session-orchestrator/src/orchestrator.test.ts
@@ -33,6 +33,7 @@ function createInMemoryStore(): ConversationStore & {
readonly data: Map<string, ChatMessage[]>;
readonly metricsData: Map<string, TurnMetrics[]>;
readonly cwdData: Map<string, string>;
+ readonly computerData: Map<string, string>;
readonly effortData: Map<string, ReasoningEffort>;
readonly modelData: Map<string, string>;
readonly workspaceIdData: Map<string, string>;
@@ -40,6 +41,7 @@ function createInMemoryStore(): ConversationStore & {
const data = new Map<string, ChatMessage[]>();
const metricsData = new Map<string, TurnMetrics[]>();
const cwdData = new Map<string, string>();
+ const computerData = new Map<string, string>();
const effortData = new Map<string, ReasoningEffort>();
const modelData = new Map<string, string>();
const workspaceIdData = new Map<string, string>();
@@ -53,6 +55,7 @@ function createInMemoryStore(): ConversationStore & {
data,
metricsData,
cwdData,
+ computerData,
effortData,
modelData,
workspaceIdData,
@@ -91,6 +94,22 @@ function createInMemoryStore(): ConversationStore & {
async setCwd(conversationId, cwd) {
cwdData.set(conversationId, cwd);
},
+ async clearCwd(conversationId) {
+ cwdData.delete(conversationId);
+ },
+ async getComputerId(conversationId) {
+ return computerData.get(conversationId) ?? null;
+ },
+ async setComputerId(conversationId, alias) {
+ if (alias === null) {
+ computerData.delete(conversationId);
+ } else {
+ computerData.set(conversationId, alias);
+ }
+ },
+ async clearComputerId(conversationId) {
+ computerData.delete(conversationId);
+ },
async getReasoningEffort(conversationId) {
return effortData.get(conversationId) ?? null;
},
@@ -149,13 +168,44 @@ function createInMemoryStore(): ConversationStore & {
return null;
},
async ensureWorkspace(id) {
- return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async setWorkspaceTitle(id, title) {
- return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async setWorkspaceDefaultCwd(id, defaultCwd) {
- return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return {
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async deleteWorkspace() {
return { closedCount: 0 };
@@ -173,6 +223,9 @@ function createInMemoryStore(): ConversationStore & {
async getEffectiveCwd(conversationId, overrideCwd) {
return overrideCwd ?? cwdData.get(conversationId) ?? null;
},
+ async getEffectiveComputer(conversationId, overrideAlias) {
+ return overrideAlias ?? computerData.get(conversationId) ?? null;
+ },
};
}
@@ -489,6 +542,114 @@ describe("handleMessage model resolution", () => {
expect(captured[1]?.cwd).toBeUndefined();
});
+ it("computerId is forwarded to RunTurnInput.computerId and absent when not provided", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captured, captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-computer",
+ text: "hi",
+ onEvent: () => {},
+ computerId: "my-ssh-host",
+ });
+
+ expect(captured).toHaveLength(1);
+ expect(captured[0]?.computerId).toBe("my-ssh-host");
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-no-computer",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(captured).toHaveLength(2);
+ expect(captured[1]?.computerId).toBeUndefined();
+ });
+
+ it("computerId override persists via setComputerId (mirrors setCwd-on-override)", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-persist-computer",
+ text: "hi",
+ onEvent: () => {},
+ computerId: "persisted-host",
+ });
+
+ expect(store.computerData.get("conv-persist-computer")).toBe("persisted-host");
+ });
+
+ it("computerId not provided → setComputerId NOT called (no override persisted)", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: identityApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-no-persist",
+ text: "hi",
+ onEvent: () => {},
+ });
+
+ expect(store.computerData.get("conv-no-persist")).toBeUndefined();
+ });
+
+ it("computerId threads into ToolAssembly passed to applyToolsFilter", async () => {
+ const store = createInMemoryStore();
+ const provider: ProviderContract = { id: "p", stream: async function* () {} };
+ const { captureRunTurn } = createCapturingRunTurn();
+
+ const capturedAssemblies: ToolAssembly[] = [];
+ const recordingApplyToolsFilter = (assembly: ToolAssembly): Promise<ToolAssembly> => {
+ capturedAssemblies.push(assembly);
+ return Promise.resolve(assembly);
+ };
+
+ const { orchestrator } = createSessionOrchestrator({
+ conversationStore: store,
+ resolveProvider: () => provider,
+ resolveTools: () => [],
+ applyToolsFilter: recordingApplyToolsFilter,
+ runTurn: captureRunTurn,
+ });
+
+ await orchestrator.handleMessage({
+ conversationId: "conv-assembly",
+ text: "hi",
+ onEvent: () => {},
+ computerId: "remote-host",
+ });
+
+ expect(capturedAssemblies).toHaveLength(1);
+ expect(capturedAssemblies[0]?.computerId).toBe("remote-host");
+ });
+
it("forwards an injected now into the RunTurnInput passed to runTurn", async () => {
const store = createInMemoryStore();
const provider: ProviderContract = { id: "p", stream: async function* () {} };
@@ -643,6 +804,12 @@ describe("turn-sealed event", () => {
return null;
},
async setCwd() {},
+ async clearCwd() {},
+ async getComputerId() {
+ return null;
+ },
+ async setComputerId() {},
+ async clearComputerId() {},
async getReasoningEffort() {
return null;
},
@@ -673,13 +840,44 @@ describe("turn-sealed event", () => {
return null;
},
async ensureWorkspace(id) {
- return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async setWorkspaceTitle(id, title) {
- return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async setWorkspaceDefaultCwd(id, defaultCwd) {
- return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return {
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async deleteWorkspace() {
return { closedCount: 0 };
@@ -694,6 +892,9 @@ describe("turn-sealed event", () => {
async getEffectiveCwd() {
return null;
},
+ async getEffectiveComputer() {
+ return null;
+ },
};
const { orchestrator } = createSessionOrchestrator({
@@ -1034,6 +1235,12 @@ describe("turn metrics persistence", () => {
return null;
},
async setCwd() {},
+ async clearCwd() {},
+ async getComputerId() {
+ return null;
+ },
+ async setComputerId() {},
+ async clearComputerId() {},
async getReasoningEffort() {
return null;
},
@@ -1064,13 +1271,44 @@ describe("turn metrics persistence", () => {
return null;
},
async ensureWorkspace(id) {
- return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async setWorkspaceTitle(id, title) {
- return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async setWorkspaceDefaultCwd(id, defaultCwd) {
- return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return {
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async deleteWorkspace() {
return { closedCount: 0 };
@@ -1085,6 +1323,9 @@ describe("turn metrics persistence", () => {
async getEffectiveCwd() {
return null;
},
+ async getEffectiveComputer() {
+ return null;
+ },
};
const { orchestrator } = createSessionOrchestrator({
@@ -2781,7 +3022,14 @@ describe("workspace integration", () => {
...base,
async ensureWorkspace(id) {
ensureWorkspaceCalls.push(id);
- return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
};
@@ -2999,6 +3247,7 @@ describe("workspace integration", () => {
id,
title: id,
defaultCwd: workspaceDefaultCwds.get(id) ?? null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -3012,7 +3261,14 @@ describe("workspace integration", () => {
},
async getWorkspace(id) {
const defaultCwd = workspaceDefaultCwds.get(id) ?? null;
- return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async getEffectiveCwd(conversationId, overrideCwd) {
// Real algorithm: relative cwd resolved against workspace defaultCwd.
@@ -3089,6 +3345,7 @@ describe("workspace integration", () => {
id,
title: id,
defaultCwd: workspaceDefaultCwds.get(id) ?? null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -3101,7 +3358,14 @@ describe("workspace integration", () => {
},
async getWorkspace(id) {
const defaultCwd = workspaceDefaultCwds.get(id) ?? null;
- return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async getEffectiveCwd(conversationId, overrideCwd) {
const wsId = assignedWorkspaceIds.get(conversationId) ?? "default";
diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts
index a533a16..ae27e59 100644
--- a/packages/session-orchestrator/src/orchestrator.ts
+++ b/packages/session-orchestrator/src/orchestrator.ts
@@ -37,6 +37,14 @@ export interface StartTurnInput {
readonly text: string;
readonly modelName?: string;
readonly cwd?: string;
+ /**
+ * The computer to execute this turn's tools on (SSH config alias). Mirrors
+ * `cwd`: an explicit per-turn override resolved via `getEffectiveComputer`.
+ * Omitted/`undefined` = use the persisted per-conversation / workspace
+ * default (LOCAL when none set). The orchestrator never interprets it — it
+ * forwards the alias string verbatim (like cwd forwards a path).
+ */
+ readonly computerId?: string;
readonly reasoningEffort?: ReasoningEffort;
/**
* The workspace this conversation belongs to. Defaults to `"default"` when
@@ -57,6 +65,12 @@ export interface EnqueueInput {
readonly text: string;
/** Workspace to stamp on a new conversation. Defaults to `"default"`. */
readonly workspaceId?: string;
+ /**
+ * Per-turn computer override (SSH alias), threaded to `startTurn` when the
+ * conversation is idle (the message starts a turn). Additive optional —
+ * mirrors `workspaceId` on this type (enqueue does not carry `cwd`).
+ */
+ readonly computerId?: string;
}
/**
@@ -87,6 +101,8 @@ interface ActiveTurn {
export interface TurnLifecyclePayload {
readonly conversationId: string;
readonly cwd?: string;
+ /** The computer this turn executes on (SSH alias), mirroring `cwd`. */
+ readonly computerId?: string;
readonly modelName?: string;
}
@@ -256,6 +272,7 @@ export interface SessionOrchestrator {
onEvent: (event: AgentEvent) => void;
modelName?: string;
cwd?: string;
+ computerId?: string;
reasoningEffort?: ReasoningEffort;
workspaceId?: string;
}): Promise<void>;
@@ -369,6 +386,7 @@ export function createSessionOrchestrator(
text: string,
modelName: string | undefined,
cwd: string | undefined,
+ computerId: string | undefined,
reasoningEffortOverride: ReasoningEffort | undefined,
workspaceId: string,
): void {
@@ -411,6 +429,19 @@ export function createSessionOrchestrator(
deps.conversationStore.getEffectiveCwd(conversationId, cwd).then((c) => c ?? undefined),
);
+ // Resolve the effective computer the SAME way cwd resolves — pass the
+ // per-turn computerId as the overrideAlias. When computerId is
+ // undefined, getEffectiveComputer reads the persisted per-conversation
+ // computerId → workspace defaultComputerId → null (LOCAL). Chained
+ // after workspaceSetupPromise (same timing invariant as cwd). The
+ // orchestrator never interprets the alias — it forwards the string
+ // verbatim (like cwd forwards a path). Mirrors effectiveCwdPromise.
+ const effectiveComputerIdPromise = workspaceSetupPromise.then(() =>
+ deps.conversationStore
+ .getEffectiveComputer(conversationId, computerId)
+ .then((c) => c ?? undefined),
+ );
+
const storedEffortPromise = deps.conversationStore.getReasoningEffort(conversationId);
// Resolve the persisted model (if any) in parallel with the other
// per-conversation reads. The effective model name is
@@ -420,13 +451,15 @@ export function createSessionOrchestrator(
const payloadPromise = Promise.all([
effectiveCwdPromise,
+ effectiveComputerIdPromise,
storedEffortPromise,
storedModelPromise,
- ]).then(([effectiveCwd, _storedEffort, storedModel]) => {
+ ]).then(([effectiveCwd, effectiveComputerId, _storedEffort, storedModel]) => {
const effectiveModelName = resolveModelName(modelName, storedModel);
return {
conversationId,
...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
...(effectiveModelName !== undefined ? { modelName: effectiveModelName } : {}),
};
});
@@ -448,17 +481,27 @@ export function createSessionOrchestrator(
void (async () => {
let sealed = false;
try {
- const [effectiveCwd, storedEffort, isNewConversation, storedModel] = await Promise.all([
- effectiveCwdPromise,
- storedEffortPromise,
- workspaceSetupPromise,
- storedModelPromise,
- ]);
+ const [effectiveCwd, effectiveComputerId, storedEffort, isNewConversation, storedModel] =
+ await Promise.all([
+ effectiveCwdPromise,
+ effectiveComputerIdPromise,
+ storedEffortPromise,
+ workspaceSetupPromise,
+ storedModelPromise,
+ ]);
if (cwd !== undefined) {
await deps.conversationStore.setCwd(conversationId, cwd);
}
+ // Persist the per-turn computer override, mirroring the cwd
+ // persistence above. Only stamped when a computerId was actually
+ // provided — NOT when it resolved to undefined (LOCAL) via the
+ // workspace default. Idempotent when the value is unchanged.
+ if (computerId !== undefined) {
+ await deps.conversationStore.setComputerId(conversationId, computerId);
+ }
+
const resolvedEffort = resolveReasoningEffort(reasoningEffortOverride, storedEffort);
// Effective model name: per-turn override → persisted → undefined
// (→ default provider). Resolved here so every downstream consumer
@@ -508,6 +551,7 @@ export function createSessionOrchestrator(
tools: baseTools,
conversationId,
...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
});
const dispatch = deps.resolveDispatch?.() ?? defaultDispatchPolicy();
const turnLogger = deps.logger?.child({ conversationId, turnId });
@@ -598,6 +642,7 @@ export function createSessionOrchestrator(
providerOpts,
...(turnLogger !== undefined ? { logger: turnLogger } : {}),
...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}),
+ ...(effectiveComputerId !== undefined ? { computerId: effectiveComputerId } : {}),
...(deps.now !== undefined ? { now: deps.now } : {}),
...(drainSteering !== undefined ? { drainSteering } : {}),
};
@@ -683,7 +728,7 @@ export function createSessionOrchestrator(
}
const orchestrator: SessionOrchestrator = {
- startTurn({ conversationId, text, modelName, cwd, reasoningEffort, workspaceId }) {
+ startTurn({ conversationId, text, modelName, cwd, computerId, reasoningEffort, workspaceId }) {
if (activeTurns.has(conversationId)) {
return { started: false, reason: "already-active" };
}
@@ -692,6 +737,7 @@ export function createSessionOrchestrator(
text,
modelName,
cwd,
+ computerId,
reasoningEffort,
workspaceId ?? "default",
);
@@ -700,11 +746,12 @@ export function createSessionOrchestrator(
return { started: true, turnId };
},
- enqueue({ conversationId, text, workspaceId }) {
+ enqueue({ conversationId, text, workspaceId, computerId }) {
const result = orchestrator.startTurn({
conversationId,
text,
...(workspaceId !== undefined ? { workspaceId } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
});
if (result.started) {
return { startedTurn: true, queue: [] };
@@ -785,6 +832,7 @@ export function createSessionOrchestrator(
onEvent,
modelName,
cwd,
+ computerId,
reasoningEffort,
workspaceId,
}) {
@@ -793,6 +841,7 @@ export function createSessionOrchestrator(
text,
...(modelName !== undefined ? { modelName } : {}),
...(cwd !== undefined ? { cwd } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
...(workspaceId !== undefined ? { workspaceId } : {}),
};
diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts
index 225d1af..adf5d9a 100644
--- a/packages/session-orchestrator/src/queue.test.ts
+++ b/packages/session-orchestrator/src/queue.test.ts
@@ -72,6 +72,14 @@ function createInMemoryStore(): ConversationStore & {
async setCwd(conversationId, cwd) {
cwdData.set(conversationId, cwd);
},
+ async clearCwd(conversationId) {
+ cwdData.delete(conversationId);
+ },
+ async getComputerId() {
+ return null;
+ },
+ async setComputerId() {},
+ async clearComputerId() {},
async getReasoningEffort(conversationId) {
return effortData.get(conversationId) ?? null;
},
@@ -110,13 +118,44 @@ function createInMemoryStore(): ConversationStore & {
return null;
},
async ensureWorkspace(id) {
- return { id, title: id, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async setWorkspaceTitle(id, title) {
- return { id, title, defaultCwd: null, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async setWorkspaceDefaultCwd(id, defaultCwd) {
- return { id, title: id, defaultCwd, createdAt: 0, lastActivityAt: 0 };
+ return {
+ id,
+ title: id,
+ defaultCwd,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ },
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ return {
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId,
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
},
async deleteWorkspace() {
return { closedCount: 0 };
@@ -131,6 +170,9 @@ function createInMemoryStore(): ConversationStore & {
async getEffectiveCwd(conversationId) {
return cwdData.get(conversationId) ?? null;
},
+ async getEffectiveComputer() {
+ return null;
+ },
};
}
diff --git a/packages/session-orchestrator/src/tools-filter.test.ts b/packages/session-orchestrator/src/tools-filter.test.ts
new file mode 100644
index 0000000..3233469
--- /dev/null
+++ b/packages/session-orchestrator/src/tools-filter.test.ts
@@ -0,0 +1,75 @@
+import type { ToolContract } from "@dispatch/kernel";
+import { describe, expect, it } from "vitest";
+import { filterRemoteIncompatibleTools, type ToolAssembly } from "./tools-filter.js";
+
+function fakeTool(name: string): ToolContract {
+ return {
+ name,
+ description: `Fake tool: ${name}`,
+ parameters: { type: "object" },
+ execute: async () => ({ content: "ok" }),
+ };
+}
+
+const baseAssembly: ToolAssembly = {
+ tools: [fakeTool("lsp"), fakeTool("mcp__x"), fakeTool("run_shell")],
+ conversationId: "conv-1",
+};
+
+describe("filterRemoteIncompatibleTools", () => {
+ it("REMOTE (computerId set): drops 'lsp' and any '__' namespaced tool, keeps 'run_shell'", () => {
+ const remote: ToolAssembly = { ...baseAssembly, computerId: "my-server" };
+ const result = filterRemoteIncompatibleTools(remote);
+ const names = result.tools.map((t) => t.name);
+ expect(names).not.toContain("lsp");
+ expect(names).not.toContain("mcp__x");
+ expect(names).toContain("run_shell");
+ expect(result.tools).toHaveLength(1);
+ });
+
+ it("REMOTE: preserves computerId + cwd + conversationId in the returned assembly", () => {
+ const remote: ToolAssembly = {
+ tools: [fakeTool("lsp"), fakeTool("run_shell")],
+ conversationId: "conv-2",
+ cwd: "/work",
+ computerId: "ssh-host",
+ };
+ const result = filterRemoteIncompatibleTools(remote);
+ expect(result.computerId).toBe("ssh-host");
+ expect(result.cwd).toBe("/work");
+ expect(result.conversationId).toBe("conv-2");
+ });
+
+ it("LOCAL (computerId undefined): passthrough — nothing is dropped", () => {
+ const local: ToolAssembly = { ...baseAssembly };
+ const result = filterRemoteIncompatibleTools(local);
+ expect(result.tools).toHaveLength(3);
+ const names = result.tools.map((t) => t.name);
+ expect(names).toContain("lsp");
+ expect(names).toContain("mcp__x");
+ expect(names).toContain("run_shell");
+ });
+
+ it("LOCAL: returns the exact same assembly object (byte-identical)", () => {
+ const local: ToolAssembly = { ...baseAssembly };
+ const result = filterRemoteIncompatibleTools(local);
+ expect(result).toBe(local);
+ });
+
+ it("REMOTE: drops multiple MCP-namespaced tools (serverId__toolName pattern)", () => {
+ const remote: ToolAssembly = {
+ tools: [
+ fakeTool("lsp"),
+ fakeTool("filesystem__read"),
+ fakeTool("github__create_issue"),
+ fakeTool("run_shell"),
+ fakeTool("write_file"),
+ ],
+ conversationId: "conv-3",
+ computerId: "host",
+ };
+ const result = filterRemoteIncompatibleTools(remote);
+ const names = result.tools.map((t) => t.name);
+ expect(names).toEqual(["run_shell", "write_file"]);
+ });
+});
diff --git a/packages/session-orchestrator/src/tools-filter.ts b/packages/session-orchestrator/src/tools-filter.ts
index 19b2eb3..913e574 100644
--- a/packages/session-orchestrator/src/tools-filter.ts
+++ b/packages/session-orchestrator/src/tools-filter.ts
@@ -6,6 +6,13 @@ export interface ToolAssembly {
readonly tools: readonly ToolContract[];
/** This turn's working directory (verbatim from the request), for cwd-aware filters. */
readonly cwd?: string;
+ /**
+ * The computer this turn executes on (SSH alias), for computer-aware
+ * filters. Omitted/`undefined` = LOCAL (today's behavior). When set, the
+ * turn is REMOTE — {@link filterRemoteIncompatibleTools} drops tools that
+ * cannot run over SFTP (local-process servers). Mirrors `cwd?`.
+ */
+ readonly computerId?: string;
/** The conversation this turn belongs to. */
readonly conversationId: string;
}
@@ -14,3 +21,39 @@ export interface ToolAssembly {
export const toolsFilter: FilterDescriptor<ToolAssembly> = defineFilter<ToolAssembly>(
"session-orchestrator/tools",
);
+
+/**
+ * Remote-degradation rule (plan §6). When a turn is REMOTE
+ * (`assembly.computerId !== undefined`), drop tools that cannot execute over
+ * SFTP because they spawn local processes:
+ *
+ * - the tool named exactly `"lsp"` — its servers are local LSP processes that
+ * can't see remote files over SFTP, AND
+ * - any tool whose name includes `"__"` — MCP-namespaced tools
+ * (`<serverId>__<toolName>`); MCP servers spawn local processes.
+ *
+ * When `assembly.computerId` is `undefined` (LOCAL), this is a passthrough —
+ * nothing is dropped (byte-identical to today). Tool-name matching is DATA (the
+ * kernel routes tool-calls by name — that is the sanctioned string-keyed
+ * exception), so a name-based filter is correct here, not a string-keyed
+ * cross-feature code lookup.
+ *
+ * Extracted from the filter handler so it is unit-testable without I/O. Mirrors
+ * MCP's `filterMcpTools` extraction pattern.
+ */
+export function filterRemoteIncompatibleTools(assembly: ToolAssembly): ToolAssembly {
+ // LOCAL — passthrough, byte-identical to today.
+ if (assembly.computerId === undefined) return assembly;
+ // REMOTE — drop lsp + MCP-namespaced tools (local-process servers).
+ const filtered = assembly.tools.filter((tool) => {
+ if (tool.name === "lsp") return false;
+ if (tool.name.includes("__")) return false;
+ return true;
+ });
+ return {
+ tools: filtered,
+ ...(assembly.cwd !== undefined ? { cwd: assembly.cwd } : {}),
+ ...(assembly.computerId !== undefined ? { computerId: assembly.computerId } : {}),
+ conversationId: assembly.conversationId,
+ };
+}
diff --git a/packages/transport-contract/src/contract.types.test.ts b/packages/transport-contract/src/contract.types.test.ts
index 6d0129c..0aad643 100644
--- a/packages/transport-contract/src/contract.types.test.ts
+++ b/packages/transport-contract/src/contract.types.test.ts
@@ -8,12 +8,22 @@
import { describe, expect, it } from "vitest";
import type {
+ ChatRequest,
+ Computer,
+ ComputerEntry,
+ ComputerListResponse,
+ ComputerResponse,
+ ComputerStatusResponse,
+ ConversationComputerResponse,
CwdResponse,
LspServerInfo,
LspServerState,
LspStatusResponse,
McpStatusResponse,
+ SetConversationComputerRequest,
SetCwdRequest,
+ SetWorkspaceDefaultComputerRequest,
+ TestComputerResponse,
} from "./index.js";
// ─── CwdResponse ─────────────────────────────────────────────────────────────
@@ -34,6 +44,89 @@ const _setCwd: SetCwdRequest = {
cwd: "/tmp/workspace",
};
+// ─── ChatRequest.computerId (additive optional) ──────────────────────────────
+
+const _chatWithComputer: ChatRequest = {
+ message: "run the test suite",
+ computerId: "prod-box",
+};
+
+const _chatWithoutComputer: 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,
+};
+
+const _computerEntry: ComputerEntry = {
+ ..._computer,
+ usageCount: 3,
+};
+
+const _computerList: ComputerListResponse = {
+ computers: [_computerEntry],
+};
+
+const _computerResponse: ComputerResponse = _computer;
+
+// ─── Computer status / test probe ────────────────────────────────────────────
+
+const _statusConnected: ComputerStatusResponse = {
+ alias: "prod-box",
+ state: "connected",
+ knownHost: true,
+};
+
+const _statusError: ComputerStatusResponse = {
+ alias: "prod-box",
+ state: "error",
+ error: "connection refused",
+ knownHost: false,
+};
+
+const _testOk: TestComputerResponse = {
+ alias: "prod-box",
+ ok: true,
+};
+
+const _testFail: TestComputerResponse = {
+ alias: "prod-box",
+ ok: false,
+ error: "auth failed",
+};
+
+// ─── Per-conversation + workspace computer ───────────────────────────────────
+
+const _setConvComputer: SetConversationComputerRequest = {
+ computerId: "prod-box",
+};
+
+const _clearConvComputer: SetConversationComputerRequest = {
+ computerId: null,
+};
+
+const _convComputer: ConversationComputerResponse = {
+ conversationId: "conv-1",
+ computerId: "prod-box",
+};
+
+const _convComputerNull: ConversationComputerResponse = {
+ conversationId: "conv-2",
+ computerId: null,
+};
+
+const _setDefaultComputer: SetWorkspaceDefaultComputerRequest = {
+ computerId: null,
+};
+
// ─── LspServerState ──────────────────────────────────────────────────────────
const _stateConnected: LspServerState = "connected";
@@ -151,4 +244,71 @@ describe("transport-contract types compile and are exported", () => {
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();
+ });
});
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index 4e3e7dc..b32c8a0 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -22,6 +22,8 @@
import type { SurfaceClientMessage, SurfaceServerMessage } from "@dispatch/ui-contract";
import type {
AgentEvent,
+ Computer,
+ ComputerEntry,
ConversationMeta,
ConversationStatus,
QueuedMessage,
@@ -35,6 +37,8 @@ import type {
export type {
AgentEvent,
CompactionResult,
+ Computer,
+ ComputerEntry,
ConversationMeta,
ConversationStatus,
QueuedMessage,
@@ -78,6 +82,16 @@ export interface ChatRequest {
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
@@ -817,3 +831,80 @@ export interface DeleteWorkspaceResponse {
/** Conversations that were closed (status → "closed") by this delete. */
readonly closedCount: number;
}
+
+// ─── Computers ───────────────────────────────────────────────────────────────
+
+/**
+ * Response of `GET /computers` — every remote computer discovered from the
+ * system's `~/.ssh/config`, sorted by `alias`. Parallel to
+ * `WorkspaceListResponse`: each entry is a `ComputerEntry` (a `Computer` plus a
+ * usage count). There is no Computer CRUD — to add one, the user adds a `Host`
+ * block to `~/.ssh/config` and Dispatch discovers it on the next read.
+ */
+export interface ComputerListResponse {
+ readonly computers: readonly ComputerEntry[];
+}
+
+/**
+ * Response of `GET /computers/:alias` — a single computer. Parallel to
+ * `WorkspaceResponse` (the entity itself). `alias` is the `computerId` users
+ * select; the remaining fields are resolved from the SSH config.
+ */
+export interface ComputerResponse extends Computer {}
+
+/**
+ * Response of `GET /computers/:alias/status` — the live connection state of a
+ * computer (whether Dispatch currently holds an open SSH session to it). Drives
+ * the frontend connection indicator. `error` is present only when
+ * `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;
+}
+
+/**
+ * Body of `PUT /conversations/:id/computer` — set or clear the conversation's
+ * persisted computer selection (the computer analog of `SetCwdRequest`). Pass
+ * `null` to clear → the conversation inherits the workspace's
+ * `defaultComputerId`, then `null`/local. An unknown alias is not validated here
+ * (the connection resolves at turn time; an unreachable host → turn error, not
+ * a 400). Mirrors the cwd/model PUT clear semantics.
+ */
+export interface SetConversationComputerRequest {
+ readonly computerId: string | null;
+}
+
+/**
+ * Response of `GET /conversations/:id/computer`. `computerId` is the persisted
+ * SSH `Host` alias, or `null` when never set (the conversation then inherits
+ * the workspace default → local). Parallel to `CwdResponse`.
+ */
+export interface ConversationComputerResponse {
+ readonly conversationId: string;
+ readonly computerId: string | null;
+}
+
+/**
+ * Body of `PUT /workspaces/:id/default-computer` — set or clear the workspace's
+ * default computer (the computer analog of `SetWorkspaceDefaultCwdRequest`).
+ * `null` means local (no SSH). Conversations in the workspace with no
+ * `computerId` of their own inherit this.
+ */
+export interface SetWorkspaceDefaultComputerRequest {
+ readonly computerId: string | null;
+}
+
+/**
+ * Response of `POST /computers/:alias/test` — the result of a one-shot
+ * connectivity probe (Dispatch opens an SSH connection to the alias, runs a
+ * trivial command, then closes). `ok` is true on success; `error` carries the
+ * failure reason (e.g. auth refused, host unreachable) when `ok` is false.
+ */
+export interface TestComputerResponse {
+ readonly alias: string;
+ readonly ok: boolean;
+ readonly error?: string;
+}
diff --git a/tasks.md b/tasks.md
index f785d4e..8c58fd6 100644
--- a/tasks.md
+++ b/tasks.md
@@ -25,13 +25,21 @@ owner-agents on disjoint packages).
`edit-file` behind `ExecBackend` (local-only; spawn.ts deleted — logic moved
to exec-backend; edit_file gains forward-compatible remote-diagnostics skip).
`tsc -b` EXIT 0, biome clean, **1599 vitest** (was 1592).
-- [ ] **Wave 3**: `conversation-store` (defaultComputerId + getEffectiveComputer)
- + `session-orchestrator` (resolve + thread computerId; drop lsp/mcp when
- remote) + `transport-contract` (computerId on ChatRequest + computer types).
-- [ ] **Wave 4**: `transport-http` + `transport-ws` (computer endpoints + chat).
+- [x] **Wave 3** (parallel): `session-orchestrator` (thread computerId end-to-end
+ + remote tool-drop filter: drops `lsp` + `__`-namespaced MCP tools when
+ remote) + `transport-contract` (ChatRequest.computerId + computer endpoint
+ 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).
- [ ] **Wave 5**: `host-bin` wiring + `ssh` package (SshConnectionPool,
SshExecBackend, ~/.ssh/config reader via ssh-config, known_hosts pinning).
-- [ ] **Wave 6**: `cache-warming` computerId threading + full verify.
+- [ ] **DEFERRED — cache-warming**: computerId threading intentionally NOT done
+ (user-deferred — cache-warming is not needed right now). Known limitation:
+ a warm probe on a remote turn assembles the tool set WITHOUT the remote-drop
+ → a potential prompt-cache miss (performance-only, not correctness). Revisit
+ when cache-warming is re-enabled.
Key decisions: ssh2 + ssh-config (project-local deps); key-only auth from
`~/.ssh`; auto-trust-and-pin host keys; computers discovered read-only from
`~/.ssh/config` (no CRUD entity); computerId persisted per-conversation; LSP/MCP