summaryrefslogtreecommitdiffhomepage
path: root/packages/session-orchestrator/src
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 /packages/session-orchestrator/src
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.
Diffstat (limited to 'packages/session-orchestrator/src')
-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
6 files changed, 510 insertions, 25 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,
+ };
+}