summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/conversation-store/src/keys.ts4
-rw-r--r--packages/conversation-store/src/store-workspace.test.ts241
-rw-r--r--packages/conversation-store/src/store.ts162
-rw-r--r--packages/exec-backend/package.json11
-rw-r--r--packages/exec-backend/src/backend.test.ts63
-rw-r--r--packages/exec-backend/src/backend.ts78
-rw-r--r--packages/exec-backend/src/extension.ts48
-rw-r--r--packages/exec-backend/src/index.ts5
-rw-r--r--packages/exec-backend/src/local.test.ts199
-rw-r--r--packages/exec-backend/src/local.ts146
-rw-r--r--packages/exec-backend/src/service.ts27
-rw-r--r--packages/exec-backend/tsconfig.json6
-rw-r--r--packages/kernel/src/runtime/dispatch.ts4
-rw-r--r--packages/kernel/src/runtime/run-turn.test.ts65
-rw-r--r--packages/kernel/src/runtime/run-turn.ts3
-rw-r--r--packages/wire/src/index.test.ts59
-rw-r--r--packages/wire/src/index.ts49
17 files changed, 1166 insertions, 4 deletions
diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts
index 1fd1237..061871e 100644
--- a/packages/conversation-store/src/keys.ts
+++ b/packages/conversation-store/src/keys.ts
@@ -50,6 +50,10 @@ export function cwdKey(conversationId: string): string {
return `conv:${conversationId}:cwd`;
}
+export function computerKey(conversationId: string): string {
+ return `conv:${conversationId}:computer`;
+}
+
export function reasoningEffortKey(conversationId: string): string {
return `conv:${conversationId}:reasoning-effort`;
}
diff --git a/packages/conversation-store/src/store-workspace.test.ts b/packages/conversation-store/src/store-workspace.test.ts
index 48c63e5..3926c94 100644
--- a/packages/conversation-store/src/store-workspace.test.ts
+++ b/packages/conversation-store/src/store-workspace.test.ts
@@ -46,6 +46,7 @@ describe("WorkspaceStore", () => {
id: "my-work",
title: "my-work",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 1000,
lastActivityAt: 1000,
});
@@ -64,6 +65,7 @@ describe("WorkspaceStore", () => {
id: "my-work",
title: "my-work",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 1000,
lastActivityAt: 1000,
});
@@ -80,6 +82,7 @@ describe("WorkspaceStore", () => {
id: "my-work",
title: "Custom",
defaultCwd: "/projects/dispatch",
+ defaultComputerId: null,
createdAt: 3000,
lastActivityAt: 3000,
});
@@ -92,6 +95,7 @@ describe("WorkspaceStore", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
});
@@ -112,6 +116,7 @@ describe("WorkspaceStore", () => {
id: "my-work",
title: "Renamed",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 1000,
lastActivityAt: 1000,
});
@@ -208,6 +213,7 @@ describe("WorkspaceStore", () => {
id: "default",
title: "default",
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
conversationCount: 0,
@@ -417,6 +423,241 @@ describe("WorkspaceStore", () => {
});
});
+describe("ComputerStore", () => {
+ let storage: StorageNamespace;
+ let clock: number;
+
+ beforeEach(() => {
+ storage = createMemoryStorage();
+ clock = 1000;
+ });
+
+ function makeStore() {
+ return createConversationStore(storage, undefined, () => clock);
+ }
+
+ // --- per-conversation computerId (mirror getCwd/setCwd/clearCwd) ---
+
+ it("setComputerId/getComputerId round-trips an alias", async () => {
+ const store = makeStore();
+ expect(await store.getComputerId("conv1")).toBeNull();
+ await store.setComputerId("conv1", "myserver");
+ expect(await store.getComputerId("conv1")).toBe("myserver");
+ });
+
+ it("setComputerId(null) clears (is idempotent local sentinel, like clearComputerId)", async () => {
+ const store = makeStore();
+ await store.setComputerId("conv1", "myserver");
+ expect(await store.getComputerId("conv1")).toBe("myserver");
+ // null is the "local" sentinel: it clears the persisted key so it does
+ // NOT linger to shadow the workspace defaultComputerId.
+ await store.setComputerId("conv1", null);
+ expect(await store.getComputerId("conv1")).toBeNull();
+ // idempotent — clearing an already-absent key is a no-op.
+ await store.setComputerId("conv1", null);
+ expect(await store.getComputerId("conv1")).toBeNull();
+ });
+
+ it("clearComputerId is idempotent and un-shadows the workspace default", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" });
+ await store.setWorkspaceId("conv1", "my-work");
+ await store.setComputerId("conv1", "per-conv-host");
+ expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host");
+ // After clear: the workspace defaultComputerId is used (fall-through).
+ await store.clearComputerId("conv1");
+ expect(await store.getComputerId("conv1")).toBeNull();
+ expect(await store.getEffectiveComputer("conv1")).toBe("ws-host");
+ // idempotent — deleting an already-absent key is a no-op.
+ await store.clearComputerId("conv1");
+ expect(await store.getComputerId("conv1")).toBeNull();
+ });
+
+ // --- setWorkspaceDefaultComputerId (mirror setWorkspaceDefaultCwd) ---
+
+ it("setWorkspaceDefaultComputerId sets and clears", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.ensureWorkspace("my-work");
+ clock = 2000;
+ const setWs = await store.setWorkspaceDefaultComputerId("my-work", "remote-host");
+ expect(setWs.defaultComputerId).toBe("remote-host");
+ // does not bump lastActivityAt on defaultComputerId change (mirrors defaultCwd).
+ expect(setWs.lastActivityAt).toBe(1000);
+ const cleared = await store.setWorkspaceDefaultComputerId("my-work", null);
+ expect(cleared.defaultComputerId).toBeNull();
+ });
+
+ it("setWorkspaceDefaultComputerId creates the workspace if missing", async () => {
+ const store = makeStore();
+ clock = 5000;
+ const ws = await store.setWorkspaceDefaultComputerId("brand-new", "remote-host");
+ expect(ws).toEqual({
+ id: "brand-new",
+ title: "brand-new",
+ defaultCwd: null,
+ defaultComputerId: "remote-host",
+ createdAt: 5000,
+ lastActivityAt: 5000,
+ });
+ });
+
+ it("setWorkspaceDefaultComputerId preserves defaultCwd on an existing workspace", async () => {
+ const store = makeStore();
+ clock = 1000;
+ await store.ensureWorkspace("my-work", { defaultCwd: "/workspace/root" });
+ clock = 2000;
+ const ws = await store.setWorkspaceDefaultComputerId("my-work", "remote-host");
+ expect(ws.defaultCwd).toBe("/workspace/root");
+ expect(ws.defaultComputerId).toBe("remote-host");
+ });
+
+ it("the synthesized 'default' workspace still returns defaultComputerId: null (local)", async () => {
+ const store = makeStore();
+ const ws = await store.getWorkspace("default");
+ expect(ws).toEqual({
+ id: "default",
+ title: "default",
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: 0,
+ lastActivityAt: 0,
+ });
+ // And it surfaces null in listWorkspaces too.
+ const list = await store.listWorkspaces();
+ const defaultWs = list.find((w) => w.id === "default");
+ expect(defaultWs?.defaultComputerId).toBeNull();
+ });
+
+ // --- getEffectiveComputer resolution ladder (mirror getEffectiveCwd) ---
+
+ it("getEffectiveComputer: per-conversation computerId overrides workspace defaultComputerId", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" });
+ await store.setWorkspaceId("conv1", "my-work");
+ await store.setComputerId("conv1", "per-conv-host");
+ expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host");
+ });
+
+ it("getEffectiveComputer: workspace defaultComputerId used when conversation computerId is unset", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" });
+ await store.setWorkspaceId("conv1", "my-work");
+ expect(await store.getEffectiveComputer("conv1")).toBe("ws-host");
+ });
+
+ it("getEffectiveComputer: null (LOCAL) when both conversation and workspace computerId are unset", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work");
+ await store.setWorkspaceId("conv1", "my-work");
+ expect(await store.getEffectiveComputer("conv1")).toBeNull();
+ });
+
+ it("getEffectiveComputer: default workspace (no defaultComputerId) falls through to null (local)", async () => {
+ const store = makeStore();
+ // No explicit workspace assignment — defaults to "default" workspace
+ // which has defaultComputerId null.
+ expect(await store.getEffectiveComputer("conv1")).toBeNull();
+ });
+
+ it("getEffectiveComputer: clearComputerId falls through to workspace defaultComputerId (un-shadows it)", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" });
+ await store.setWorkspaceId("conv1", "my-work");
+ await store.setComputerId("conv1", "per-conv-host");
+ // Before clear: the conversation computerId shadows the workspace default.
+ expect(await store.getEffectiveComputer("conv1")).toBe("per-conv-host");
+ // After clear: the workspace defaultComputerId is used (fall-through).
+ await store.clearComputerId("conv1");
+ expect(await store.getEffectiveComputer("conv1")).toBe("ws-host");
+ });
+
+ // --- overrideAlias (per-turn computer override, mirror overrideCwd) ---
+
+ it("getEffectiveComputer: overrideAlias string wins outright, overriding workspace defaultComputerId", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" });
+ await store.setWorkspaceId("conv1", "my-work");
+ // A string override wins outright, even over a workspace defaultComputerId.
+ expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host");
+ });
+
+ it("getEffectiveComputer: overrideAlias string wins over the persisted per-conversation computerId", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" });
+ await store.setWorkspaceId("conv1", "my-work");
+ await store.setComputerId("conv1", "persisted-host");
+ // The override must win over the persisted computerId.
+ expect(await store.getEffectiveComputer("conv1", "override-host")).toBe("override-host");
+ });
+
+ it("getEffectiveComputer: overrideAlias null is explicitly local and does NOT fall through", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" });
+ await store.setWorkspaceId("conv1", "my-work");
+ await store.setComputerId("conv1", "persisted-host");
+ // An explicit null override = "local for this turn": it wins outright and
+ // does NOT fall through to the persisted value or the workspace default.
+ expect(await store.getEffectiveComputer("conv1", null)).toBeNull();
+ });
+
+ it("getEffectiveComputer: overrideAlias omitted behaves as today (uses persisted computerId)", async () => {
+ const store = makeStore();
+ await store.ensureWorkspace("my-work", { defaultComputerId: "ws-host" });
+ await store.setWorkspaceId("conv1", "my-work");
+ await store.setComputerId("conv1", "persisted-host");
+ // No second arg — persisted computerId is used.
+ expect(await store.getEffectiveComputer("conv1")).toBe("persisted-host");
+ });
+
+ // --- round-trip through persistence (parse/toWorkspace) ---
+
+ it("a Workspace with defaultComputerId round-trips through parse/toWorkspace", async () => {
+ const store = makeStore();
+ clock = 1000;
+ // Create with a defaultComputerId via ensureWorkspace, then read it back
+ // (exercises parseWorkspaceRow -> toWorkspace round-trip).
+ const created = await store.ensureWorkspace("remote-work", {
+ title: "Remote",
+ defaultComputerId: "prod-server",
+ });
+ expect(created.defaultComputerId).toBe("prod-server");
+ const roundTripped = await store.getWorkspace("remote-work");
+ expect(roundTripped).toEqual({
+ id: "remote-work",
+ title: "Remote",
+ defaultCwd: null,
+ defaultComputerId: "prod-server",
+ createdAt: 1000,
+ lastActivityAt: 1000,
+ });
+ });
+
+ it("a legacy WorkspaceRow without defaultComputerId reads back as null (local)", async () => {
+ const store = makeStore();
+ // Simulate a legacy row persisted before defaultComputerId existed:
+ // write a raw WorkspaceRow JSON lacking the field, then read it back.
+ await storage.set(
+ "workspace:legacy",
+ JSON.stringify({
+ title: "legacy",
+ defaultCwd: "/legacy/cwd",
+ createdAt: 100,
+ lastActivityAt: 200,
+ }),
+ );
+ const ws = await store.getWorkspace("legacy");
+ expect(ws).toEqual({
+ id: "legacy",
+ title: "legacy",
+ defaultCwd: "/legacy/cwd",
+ defaultComputerId: null,
+ createdAt: 100,
+ lastActivityAt: 200,
+ });
+ });
+});
+
describe("isValidWorkspaceSlug", () => {
it("accepts valid slugs", () => {
expect(isValidWorkspaceSlug("my-work")).toBe(true);
diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts
index 26d5ed4..2fd0a0c 100644
--- a/packages/conversation-store/src/store.ts
+++ b/packages/conversation-store/src/store.ts
@@ -18,6 +18,7 @@ import {
chunkKey,
chunkPrefix,
compactThresholdKey,
+ computerKey,
cwdKey,
metaKey,
metricsKey,
@@ -70,6 +71,20 @@ export interface ConversationStore {
readonly setCwd: (conversationId: string, cwd: string) => Promise<void>;
/** Clear (delete) the persisted working directory for a conversation. */
readonly clearCwd: (conversationId: string) => Promise<void>;
+ /**
+ * The persisted computer (SSH config `Host` alias) for a conversation, or
+ * `null` if never set (local). The computer analog of `getCwd`.
+ */
+ readonly getComputerId: (conversationId: string) => Promise<string | null>;
+ /**
+ * Persist (upsert) the computer for a conversation. Passing `null` clears
+ * the persisted selection (idempotent) — `null` is the "local" sentinel
+ * (no SSH), so it must NOT linger to shadow the workspace default. Mirrors
+ * `setModel`'s clear-on-sentinel pattern (the computer analog of `setCwd`).
+ */
+ readonly setComputerId: (conversationId: string, alias: string | null) => Promise<void>;
+ /** Clear (delete) the persisted computer for a conversation. */
+ readonly clearComputerId: (conversationId: string) => Promise<void>;
/** The persisted reasoning-effort level for a conversation, or null if never set. */
readonly getReasoningEffort: (conversationId: string) => Promise<ReasoningEffort | null>;
/** Persist (upsert) the reasoning-effort level for a conversation. */
@@ -145,13 +160,26 @@ export interface ConversationStore {
*/
readonly ensureWorkspace: (
id: string,
- opts?: { readonly title?: string; readonly defaultCwd?: string | null },
+ opts?: {
+ readonly title?: string;
+ readonly defaultCwd?: string | null;
+ readonly defaultComputerId?: string | null;
+ },
) => Promise<Workspace>;
/** Rename a workspace. Creates the workspace if missing. */
readonly setWorkspaceTitle: (id: string, title: string) => Promise<Workspace>;
/** Set/clear a workspace's default cwd. Creates the workspace if missing. */
readonly setWorkspaceDefaultCwd: (id: string, defaultCwd: string | null) => Promise<Workspace>;
/**
+ * Set/clear a workspace's default computer (SSH alias). Creates the
+ * workspace if missing. The computer analog of `setWorkspaceDefaultCwd`.
+ * `null` = local (no SSH).
+ */
+ readonly setWorkspaceDefaultComputerId: (
+ id: string,
+ defaultComputerId: string | null,
+ ) => Promise<Workspace>;
+ /**
* Delete a workspace: (1) find all conversations with `workspaceId === id`,
* (2) set each to `status = "closed"` and reassign `workspaceId = "default"`,
* (3) delete the workspace entity. Returns `closedCount`. Throws if `id
@@ -205,6 +233,34 @@ export interface ConversationStore {
conversationId: string,
overrideCwd?: string,
) => Promise<string | null>;
+ /**
+ * Resolve the effective computer (SSH alias) for a conversation — the
+ * computer analog of `getEffectiveCwd`. Resolution ladder:
+ *
+ * 1. **overrideAlias** — an explicit per-turn alias (from `chat.send`)
+ * wins outright, EVEN when `null` (explicitly local for this turn — it
+ * does NOT fall through).
+ * 2. **Persisted per-conversation `computerId`** — `getComputerId`.
+ * 3. **Workspace `defaultComputerId`** — resolved via `getWorkspaceId`
+ * (falling back to `"default"`) + `getWorkspace`.
+ * 4. **None of the above** — `null` (LOCAL: no SSH, today's behavior).
+ *
+ * Returns the alias STRING (or `null`); it does NOT validate the alias
+ * exists in `~/.ssh/config` (validation happens at connect time — a stale
+ * alias yields a clear connect error rather than silently falling back to
+ * local).
+ *
+ * @param overrideAlias — an explicit alias to resolve INSTEAD of the
+ * persisted `getComputerId` value. When provided (not `undefined`), it
+ * is returned as-is (string or `null`), short-circuiting the rest of the
+ * ladder. Used by the session-orchestrator for a per-turn computer
+ * override (sent by the client on `chat.send`). When omitted, the
+ * persisted `getComputerId` is read as today.
+ */
+ readonly getEffectiveComputer: (
+ conversationId: string,
+ overrideAlias?: string | null,
+ ) => Promise<string | null>;
}
export const conversationStoreHandle = defineService<ConversationStore>("conversation-store/store");
@@ -265,6 +321,12 @@ interface ConversationMetaRow {
interface WorkspaceRow {
readonly title: string;
readonly defaultCwd: string | null;
+ /**
+ * The workspace's default computer (SSH config `Host` alias) — the computer
+ * analog of `defaultCwd`. `null` = local (no SSH). Conversations in this
+ * workspace inherit it when they set no `computerId` of their own.
+ */
+ readonly defaultComputerId: string | null;
readonly createdAt: number;
readonly lastActivityAt: number;
}
@@ -373,9 +435,14 @@ function parseWorkspaceRow(raw: string): WorkspaceRow | null {
const row = parsed as WorkspaceRow;
// `defaultCwd` may be null OR a string; treat anything else as null.
const defaultCwd = typeof row.defaultCwd === "string" ? row.defaultCwd : null;
+ // `defaultComputerId` may be null OR a string; treat anything else as null
+ // (mirrors `defaultCwd`). Absent on legacy rows → null (local).
+ const defaultComputerId =
+ typeof row.defaultComputerId === "string" ? row.defaultComputerId : null;
return {
title: row.title,
defaultCwd,
+ defaultComputerId,
createdAt: row.createdAt,
lastActivityAt: row.lastActivityAt,
};
@@ -386,6 +453,7 @@ function toWorkspace(id: string, row: WorkspaceRow): Workspace {
id,
title: row.title,
defaultCwd: row.defaultCwd,
+ defaultComputerId: row.defaultComputerId,
createdAt: row.createdAt,
lastActivityAt: row.lastActivityAt,
};
@@ -442,10 +510,17 @@ export function createConversationStore(
const existing = await readWorkspaceRow(workspaceId);
const row: WorkspaceRow =
existing === null
- ? { title: workspaceId, defaultCwd: null, createdAt: ts, lastActivityAt: ts }
+ ? {
+ title: workspaceId,
+ defaultCwd: null,
+ defaultComputerId: null,
+ createdAt: ts,
+ lastActivityAt: ts,
+ }
: {
title: existing.title,
defaultCwd: existing.defaultCwd,
+ defaultComputerId: existing.defaultComputerId,
createdAt: existing.createdAt,
lastActivityAt: ts,
};
@@ -662,6 +737,36 @@ export function createConversationStore(
}
},
+ async getComputerId(conversationId) {
+ return await storage.get(computerKey(conversationId));
+ },
+
+ async setComputerId(conversationId, alias) {
+ // `null` is the "local" sentinel: clear the persisted key so it does
+ // NOT linger to shadow the workspace defaultComputerId. Idempotent
+ // (deleting an already-absent key is a no-op). Mirrors `setModel`'s
+ // clear-on-sentinel pattern.
+ if (alias === null) {
+ await storage.delete(computerKey(conversationId));
+ if (logger !== undefined) {
+ logger.debug("computer cleared", { conversationId });
+ }
+ return;
+ }
+ await storage.set(computerKey(conversationId), alias);
+ if (logger !== undefined) {
+ logger.debug("computer set", { conversationId });
+ }
+ },
+
+ async clearComputerId(conversationId) {
+ // Idempotent: deleting an already-absent key is a no-op (no error).
+ await storage.delete(computerKey(conversationId));
+ if (logger !== undefined) {
+ logger.debug("computer cleared", { conversationId });
+ }
+ },
+
async getReasoningEffort(conversationId) {
return (await storage.get(reasoningEffortKey(conversationId))) as ReasoningEffort | null;
},
@@ -874,13 +979,15 @@ export function createConversationStore(
}
await ensureInIndex(targetId);
- // Copy cwd + reasoning-effort + model (so the archive is self-contained).
+ // Copy cwd + reasoning-effort + model + computer (so the archive is self-contained).
const cwd = await storage.get(cwdKey(sourceId));
if (cwd !== null) await storage.set(cwdKey(targetId), cwd);
const effort = await storage.get(reasoningEffortKey(sourceId));
if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort);
const model = await storage.get(modelKey(sourceId));
if (model !== null) await storage.set(modelKey(targetId), model);
+ const computerId = await storage.get(computerKey(sourceId));
+ if (computerId !== null) await storage.set(computerKey(targetId), computerId);
},
async getCompactPercent(conversationId) {
@@ -917,12 +1024,14 @@ export function createConversationStore(
const row = await readWorkspaceRow(id);
if (row !== null) return toWorkspace(id, row);
// Synthesize the always-present "default" workspace when it was
- // never persisted (title "default", defaultCwd null, timestamps 0).
+ // never persisted (title "default", defaultCwd null, defaultComputerId
+ // null [local], timestamps 0).
if (id === DEFAULT_WORKSPACE_ID) {
return {
id: DEFAULT_WORKSPACE_ID,
title: DEFAULT_WORKSPACE_ID,
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
};
@@ -939,6 +1048,7 @@ export function createConversationStore(
const row: WorkspaceRow = {
title: opts?.title ?? id,
defaultCwd: opts?.defaultCwd ?? null,
+ defaultComputerId: opts?.defaultComputerId ?? null,
createdAt: ts,
lastActivityAt: ts,
};
@@ -954,6 +1064,7 @@ export function createConversationStore(
? {
title: id,
defaultCwd: null as string | null,
+ defaultComputerId: null as string | null,
createdAt: ts,
lastActivityAt: ts,
}
@@ -961,6 +1072,7 @@ export function createConversationStore(
const row: WorkspaceRow = {
title,
defaultCwd: base.defaultCwd,
+ defaultComputerId: base.defaultComputerId,
createdAt: base.createdAt,
lastActivityAt: base.lastActivityAt,
};
@@ -976,6 +1088,7 @@ export function createConversationStore(
? {
title: id,
defaultCwd: null as string | null,
+ defaultComputerId: null as string | null,
createdAt: ts,
lastActivityAt: ts,
}
@@ -983,6 +1096,31 @@ export function createConversationStore(
const row: WorkspaceRow = {
title: base.title,
defaultCwd,
+ defaultComputerId: base.defaultComputerId,
+ createdAt: base.createdAt,
+ lastActivityAt: base.lastActivityAt,
+ };
+ await storage.set(workspaceKey(id), JSON.stringify(row));
+ return toWorkspace(id, row);
+ },
+
+ async setWorkspaceDefaultComputerId(id, defaultComputerId) {
+ const existing = await readWorkspaceRow(id);
+ const ts = now();
+ const base =
+ existing === null
+ ? {
+ title: id,
+ defaultCwd: null as string | null,
+ defaultComputerId: null as string | null,
+ createdAt: ts,
+ lastActivityAt: ts,
+ }
+ : existing;
+ const row: WorkspaceRow = {
+ title: base.title,
+ defaultCwd: base.defaultCwd,
+ defaultComputerId,
createdAt: base.createdAt,
lastActivityAt: base.lastActivityAt,
};
@@ -1053,6 +1191,7 @@ export function createConversationStore(
id: DEFAULT_WORKSPACE_ID,
title: DEFAULT_WORKSPACE_ID,
defaultCwd: null,
+ defaultComputerId: null,
createdAt: 0,
lastActivityAt: 0,
});
@@ -1155,5 +1294,20 @@ export function createConversationStore(
}
return pathResolve(workspaceCwd ?? serverDefaultCwd, conversationCwd);
},
+
+ async getEffectiveComputer(conversationId, overrideAlias) {
+ const workspaceId = await this.getWorkspaceId(conversationId);
+ const workspace = await this.getWorkspace(workspaceId);
+ const workspaceComputerId = workspace?.defaultComputerId ?? null;
+ // When an explicit override is given, it wins outright — even `null`
+ // (explicitly local for this turn) does NOT fall through to the
+ // persisted / workspace values.
+ if (overrideAlias !== undefined) {
+ return overrideAlias;
+ }
+ // Persisted per-conversation computerId → workspace defaultComputerId → null (LOCAL).
+ const computerId = await this.getComputerId(conversationId);
+ return computerId ?? workspaceComputerId;
+ },
};
}
diff --git a/packages/exec-backend/package.json b/packages/exec-backend/package.json
new file mode 100644
index 0000000..19a8f9b
--- /dev/null
+++ b/packages/exec-backend/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@dispatch/exec-backend",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*"
+ }
+}
diff --git a/packages/exec-backend/src/backend.test.ts b/packages/exec-backend/src/backend.test.ts
new file mode 100644
index 0000000..30458e7
--- /dev/null
+++ b/packages/exec-backend/src/backend.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js";
+
+/**
+ * ExecBackend type conformance — a fake backend satisfies the interface.
+ * (Pure compile-time + runtime check; zero internal mocks.)
+ */
+describe("ExecBackend type conformance", () => {
+ it("a minimal fake satisfies the ExecBackend interface", () => {
+ const fake: ExecBackend = {
+ spawn: async (_params: SpawnParams): Promise<ExecResult> => ({
+ exitCode: 0,
+ timedOut: false,
+ aborted: false,
+ }),
+ readFile: async (_path: string): Promise<string> => "",
+ writeFile: async (_path: string, _content: string): Promise<void> => {},
+ stat: async (_path: string): Promise<StatResult> => ({ isFile: true, isDirectory: false }),
+ readdir: async (_path: string): Promise<readonly DirEntry[]> => [],
+ exists: async (_path: string): Promise<boolean> => true,
+ };
+
+ // Runtime sanity: every method is present and callable.
+ expect(typeof fake.spawn).toBe("function");
+ expect(typeof fake.readFile).toBe("function");
+ expect(typeof fake.writeFile).toBe("function");
+ expect(typeof fake.stat).toBe("function");
+ expect(typeof fake.readdir).toBe("function");
+ expect(typeof fake.exists).toBe("function");
+ });
+
+ it("ExecResult is { exitCode, timedOut, aborted }", () => {
+ const result: ExecResult = { exitCode: null, timedOut: true, aborted: false };
+ expect(result.exitCode).toBeNull();
+ expect(result.timedOut).toBe(true);
+ expect(result.aborted).toBe(false);
+ });
+
+ it("SpawnParams carries the shell-tool seam fields", () => {
+ const params: SpawnParams = {
+ command: "echo",
+ cwd: "/tmp",
+ signal: new AbortController().signal,
+ timeout: 1000,
+ onOutput: () => {},
+ };
+ expect(params.command).toBe("echo");
+ expect(params.timeout).toBe(1000);
+ });
+
+ it("StatResult distinguishes file vs directory", () => {
+ const fileStat: StatResult = { isFile: true, isDirectory: false };
+ const dirStat: StatResult = { isFile: false, isDirectory: true };
+ expect(fileStat.isFile && !fileStat.isDirectory).toBe(true);
+ expect(!dirStat.isFile && dirStat.isDirectory).toBe(true);
+ });
+
+ it("DirEntry carries name + isDirectory", () => {
+ const entry: DirEntry = { name: "sub", isDirectory: true };
+ expect(entry.name).toBe("sub");
+ expect(entry.isDirectory).toBe(true);
+ });
+});
diff --git a/packages/exec-backend/src/backend.ts b/packages/exec-backend/src/backend.ts
new file mode 100644
index 0000000..f6a807f
--- /dev/null
+++ b/packages/exec-backend/src/backend.ts
@@ -0,0 +1,78 @@
+/**
+ * ExecBackend — the transport-agnostic spawn + minimal filesystem surface.
+ *
+ * Tools (tool-shell, tool-read-file, tool-write-file, tool-edit-file) program
+ * against THIS abstraction instead of `node:fs` / `node:child_process` directly.
+ * Two implementations exist:
+ *
+ * - `LocalExecBackend` — wraps today's node calls (behavior-identical).
+ * - `SshExecBackend` — wraps ssh2 `exec` + `sftp` (added later by the `ssh`
+ * package; not this package's concern — but THIS interface is the seam it
+ * implements).
+ *
+ * The surface is deliberately SMALL (only what the bundled tools use) so a
+ * remote implementation is tractable. New operations are added here, not ad hoc.
+ *
+ * Resolved per-call from `ToolExecuteContext.computerId` via the injected
+ * `ExecBackendResolver` (see `./service.js`). `computerId` undefined → local.
+ *
+ * Error contract: `readFile`/`stat`/`readdir`/`writeFile` throw node:fs-style
+ * errors carrying a `.code` property (e.g. `"ENOENT"`) so the tools' existing
+ * error branches work unchanged. `exists` never throws (returns `false` on
+ * missing). The SshExecBackend maps ssh2 errors onto these same shapes.
+ */
+
+/** A spawned process's result. Mirrors tool-shell's `SpawnResult` exactly. */
+export interface ExecResult {
+ readonly exitCode: number | null;
+ readonly timedOut: boolean;
+ readonly aborted: boolean;
+}
+
+/** Parameters for spawning a shell command. Mirrors tool-shell's `SpawnShell` params. */
+export interface SpawnParams {
+ readonly command: string;
+ readonly cwd: string;
+ readonly signal: AbortSignal;
+ readonly timeout: number;
+ readonly onOutput: (data: string, stream: "stdout" | "stderr") => void;
+}
+
+/** Stat result — the subset read_file / write_file / edit_file need. */
+export interface StatResult {
+ readonly isFile: boolean;
+ readonly isDirectory: boolean;
+}
+
+/** A directory entry — the subset read_file lists. */
+export interface DirEntry {
+ readonly name: string;
+ readonly isDirectory: boolean;
+}
+
+/**
+ * The execution backend: spawn + a minimal filesystem surface.
+ * Tools program against THIS, never against `node:fs`. Resolved per-call from
+ * `ToolExecuteContext.computerId` via the injected resolver.
+ */
+export interface ExecBackend {
+ /** Run a shell command, streaming stdout/stderr. The shell-tool seam. */
+ readonly spawn: (params: SpawnParams) => Promise<ExecResult>;
+
+ // --- filesystem (the read_file / write_file / edit_file surface) ---
+
+ /** Read a file as utf8 text. Throws node:fs-style errors with `.code`. */
+ readonly readFile: (path: string) => Promise<string>;
+
+ /** Write utf8 text to a file. Throws on failure (e.g. missing parent dir). */
+ readonly writeFile: (path: string, content: string) => Promise<void>;
+
+ /** Stat a path. Throws node:fs-style errors with `.code` (e.g. `"ENOENT"`). */
+ readonly stat: (path: string) => Promise<StatResult>;
+
+ /** List directory entries. Throws node:fs-style errors with `.code`. */
+ readonly readdir: (path: string) => Promise<readonly DirEntry[]>;
+
+ /** Check existence without throwing (returns `false` when the path is missing). */
+ readonly exists: (path: string) => Promise<boolean>;
+}
diff --git a/packages/exec-backend/src/extension.ts b/packages/exec-backend/src/extension.ts
new file mode 100644
index 0000000..c07b7a8
--- /dev/null
+++ b/packages/exec-backend/src/extension.ts
@@ -0,0 +1,48 @@
+import type { Extension, Manifest } from "@dispatch/kernel";
+import type { ExecBackend } from "./backend.js";
+import { localExecBackend } from "./local.js";
+import type { ExecBackendResolver } from "./service.js";
+import { execBackendHandle } from "./service.js";
+
+export const manifest: Manifest = {
+ id: "exec-backend",
+ name: "Exec Backend",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ contributes: { services: ["exec-backend/resolver"] },
+};
+
+/**
+ * The resolver provided by this extension.
+ *
+ * - `computerId` undefined → `LocalExecBackend` (today's local behavior).
+ * - `computerId` set → throws. Remote execution is wired by `host-bin` + the
+ * `ssh` package in a later wave (`SshExecBackend` implements the same
+ * `ExecBackend` interface). For now only the local path exists — failing
+ * loudly here is safer than silently running locally when remote was requested.
+ */
+function resolveBackend(computerId?: string): ExecBackend {
+ if (computerId === undefined) return localExecBackend;
+ throw new Error(
+ `Remote execution (computerId="${computerId}") is not yet configured. ` +
+ "The SSH backend will be wired by the ssh package.",
+ );
+}
+
+/**
+ * Factory: create the `exec-backend` core extension.
+ *
+ * `activate` provides the local-only `ExecBackendResolver` via the typed
+ * service handle. Remote resolution is added in a later wave.
+ */
+export function createExecBackendExtension(): Extension {
+ return {
+ manifest,
+ activate(host) {
+ const resolver: ExecBackendResolver = resolveBackend;
+ host.provideService(execBackendHandle, resolver);
+ },
+ };
+}
diff --git a/packages/exec-backend/src/index.ts b/packages/exec-backend/src/index.ts
new file mode 100644
index 0000000..3135f79
--- /dev/null
+++ b/packages/exec-backend/src/index.ts
@@ -0,0 +1,5 @@
+export type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js";
+export { createExecBackendExtension, manifest } from "./extension.js";
+export { createLocalExecBackend, localExecBackend } from "./local.js";
+export type { ExecBackendResolver } from "./service.js";
+export { execBackendHandle } from "./service.js";
diff --git a/packages/exec-backend/src/local.test.ts b/packages/exec-backend/src/local.test.ts
new file mode 100644
index 0000000..5357d6f
--- /dev/null
+++ b/packages/exec-backend/src/local.test.ts
@@ -0,0 +1,199 @@
+import { writeFile as fsWriteFile, mkdir, mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { ExecBackend } from "./backend.js";
+import { createLocalExecBackend, localExecBackend } from "./local.js";
+
+/**
+ * LocalExecBackend — integration tests against the OUTERMOST real edge
+ * (real fs/spawn). Zero internal mocks; no mocking of @dispatch/*.
+ */
+describe("LocalExecBackend", () => {
+ const backend: ExecBackend = createLocalExecBackend();
+ let tmpDir: string;
+
+ beforeEach(async () => {
+ tmpDir = await mkdtemp(join(tmpdir(), "exec-backend-test-"));
+ });
+
+ afterEach(async () => {
+ await rm(tmpDir, { recursive: true, force: true });
+ });
+
+ describe("spawn", () => {
+ it("runs a real `sh -c 'echo hi'` and returns exitCode 0 + captured stdout", async () => {
+ let output = "";
+ const result = await backend.spawn({
+ command: "echo hi",
+ cwd: tmpDir,
+ signal: AbortSignal.timeout(5000),
+ timeout: 5000,
+ onOutput: (data) => {
+ output += data;
+ },
+ });
+ expect(result.exitCode).toBe(0);
+ expect(result.timedOut).toBe(false);
+ expect(result.aborted).toBe(false);
+ expect(output).toContain("hi");
+ });
+
+ it("returns a non-zero exit code for a failing command", async () => {
+ const result = await backend.spawn({
+ command: "false",
+ cwd: tmpDir,
+ signal: AbortSignal.timeout(5000),
+ timeout: 5000,
+ onOutput: () => {},
+ });
+ expect(result.exitCode).toBe(1);
+ expect(result.aborted).toBe(false);
+ expect(result.timedOut).toBe(false);
+ });
+
+ it("streams stderr separately from stdout", async () => {
+ const streams: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
+ const result = await backend.spawn({
+ command: "echo out; echo err 1>&2",
+ cwd: tmpDir,
+ signal: AbortSignal.timeout(5000),
+ timeout: 5000,
+ onOutput: (data, stream) => streams.push({ data, stream }),
+ });
+ expect(result.exitCode).toBe(0);
+ expect(streams.some((s) => s.stream === "stdout" && s.data.includes("out"))).toBe(true);
+ expect(streams.some((s) => s.stream === "stderr" && s.data.includes("err"))).toBe(true);
+ });
+
+ it("resolves with aborted: true when the signal fires", async () => {
+ const controller = new AbortController();
+ const promise = backend.spawn({
+ command: "sleep 30",
+ cwd: tmpDir,
+ signal: controller.signal,
+ timeout: 60_000,
+ onOutput: () => {},
+ });
+ // Let the sleep actually start.
+ await new Promise((r) => setTimeout(r, 300));
+ controller.abort();
+ const result = await promise;
+ expect(result.aborted).toBe(true);
+ expect(result.timedOut).toBe(false);
+ });
+
+ it("resolves with timedOut: true when the timeout elapses", async () => {
+ const start = Date.now();
+ const result = await backend.spawn({
+ command: "sleep 30",
+ cwd: tmpDir,
+ signal: AbortSignal.timeout(60_000),
+ timeout: 300,
+ onOutput: () => {},
+ });
+ const elapsed = Date.now() - start;
+ expect(result.timedOut).toBe(true);
+ expect(result.aborted).toBe(false);
+ // Should resolve shortly after the 300ms timeout, well under 30s.
+ expect(elapsed).toBeLessThan(10_000);
+ });
+ });
+
+ describe("stat", () => {
+ it("distinguishes file vs directory", async () => {
+ await fsWriteFile(join(tmpDir, "file.txt"), "hello");
+ await mkdir(join(tmpDir, "subdir"));
+
+ const fileStat = await backend.stat(join(tmpDir, "file.txt"));
+ expect(fileStat.isFile).toBe(true);
+ expect(fileStat.isDirectory).toBe(false);
+
+ const dirStat = await backend.stat(join(tmpDir, "subdir"));
+ expect(dirStat.isFile).toBe(false);
+ expect(dirStat.isDirectory).toBe(true);
+ });
+
+ it("throws ENOENT with .code for a missing path", async () => {
+ try {
+ await backend.stat(join(tmpDir, "nope"));
+ expect.fail("stat should have thrown for a missing path");
+ } catch (err: unknown) {
+ expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
+ }
+ });
+ });
+
+ describe("readFile / writeFile / readdir / exists round-trip", () => {
+ it("writes then reads a file (utf8 round-trip)", async () => {
+ const filePath = join(tmpDir, "round.txt");
+ await backend.writeFile(filePath, "round-trip content");
+ const content = await backend.readFile(filePath);
+ expect(content).toBe("round-trip content");
+ });
+
+ it("readdir lists entries with correct isDirectory flags", async () => {
+ await fsWriteFile(join(tmpDir, "a.txt"), "a");
+ await mkdir(join(tmpDir, "sub"));
+
+ const entries = await backend.readdir(tmpDir);
+ const names = entries.map((e) => e.name).sort();
+ expect(names).toEqual(["a.txt", "sub"]);
+
+ const sub = entries.find((e) => e.name === "sub");
+ expect(sub?.isDirectory).toBe(true);
+
+ const file = entries.find((e) => e.name === "a.txt");
+ expect(file?.isDirectory).toBe(false);
+ });
+
+ it("exists returns true for an existing file, false for a missing one", async () => {
+ const filePath = join(tmpDir, "exists.txt");
+ await fsWriteFile(filePath, "x");
+ expect(await backend.exists(filePath)).toBe(true);
+ expect(await backend.exists(join(tmpDir, "missing"))).toBe(false);
+ });
+
+ it("exists returns true for an existing directory", async () => {
+ await mkdir(join(tmpDir, "adir"));
+ expect(await backend.exists(join(tmpDir, "adir"))).toBe(true);
+ });
+
+ it("readFile throws ENOENT with .code for a missing file", async () => {
+ try {
+ await backend.readFile(join(tmpDir, "missing.txt"));
+ expect.fail("readFile should have thrown for a missing file");
+ } catch (err: unknown) {
+ expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
+ }
+ });
+
+ it("readdir throws ENOENT with .code for a missing directory", async () => {
+ try {
+ await backend.readdir(join(tmpDir, "missingdir"));
+ expect.fail("readdir should have thrown for a missing directory");
+ } catch (err: unknown) {
+ expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
+ }
+ });
+
+ it("writeFile throws an error with .code when the parent dir is missing", async () => {
+ try {
+ await backend.writeFile(join(tmpDir, "missing-parent", "child.txt"), "x");
+ expect.fail("writeFile should have thrown for a missing parent dir");
+ } catch (err: unknown) {
+ expect((err as NodeJS.ErrnoException).code).toBe("ENOENT");
+ }
+ });
+ });
+
+ describe("singleton", () => {
+ it("localExecBackend singleton satisfies ExecBackend and behaves identically", async () => {
+ expect(typeof localExecBackend.spawn).toBe("function");
+ expect(typeof localExecBackend.readFile).toBe("function");
+ const filePath = join(tmpDir, "singleton.txt");
+ await localExecBackend.writeFile(filePath, "singleton");
+ expect(await localExecBackend.readFile(filePath)).toBe("singleton");
+ });
+ });
+});
diff --git a/packages/exec-backend/src/local.ts b/packages/exec-backend/src/local.ts
new file mode 100644
index 0000000..ca88a11
--- /dev/null
+++ b/packages/exec-backend/src/local.ts
@@ -0,0 +1,146 @@
+import { spawn as nodeSpawn } from "node:child_process";
+import { access, readdir, readFile, stat, writeFile } from "node:fs/promises";
+import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js";
+
+/**
+ * LocalExecBackend — wraps `node:fs/promises` + `node:child_process`.
+ *
+ * Behavior is IDENTICAL to today's local tools:
+ * - `spawn` mirrors `realSpawn` in `packages/tool-shell/src/spawn.ts` — same
+ * `sh -c` invocation, detached process-group kill on abort/timeout,
+ * close-based resolution, and spawn-error → `{ exitCode: 1 }`.
+ * - `readFile`/`writeFile`/`stat`/`readdir` use the same `node:fs/promises`
+ * calls (utf8, `withFileTypes`) the tools make inline today, and throw the
+ * same node errors (carrying `.code`) so the tools' existing error branches
+ * work unchanged.
+ * - `exists` swallows all errors and returns `false` (an existence check).
+ *
+ * This factors the inline node calls out behind the `ExecBackend` interface so
+ * a remote (SshExecBackend) can swap in transparently. Stateless — safe to
+ * share as a singleton.
+ */
+export function createLocalExecBackend(): ExecBackend {
+ return {
+ spawn: localSpawn,
+
+ readFile: (path) => readFile(path, "utf8"),
+
+ writeFile: (path, content) => writeFile(path, content, "utf8"),
+
+ stat: async (path): Promise<StatResult> => {
+ const s = await stat(path);
+ return { isFile: s.isFile(), isDirectory: s.isDirectory() };
+ },
+
+ readdir: async (path): Promise<readonly DirEntry[]> => {
+ const entries = await readdir(path, { encoding: "utf8", withFileTypes: true });
+ return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() }));
+ },
+
+ exists: async (path): Promise<boolean> => {
+ try {
+ await access(path);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ };
+}
+
+/** Default singleton — stateless, safe to share across calls. */
+export const localExecBackend: ExecBackend = createLocalExecBackend();
+
+/**
+ * Run a shell command locally via `node:child_process`.
+ *
+ * Ported verbatim from `packages/tool-shell/src/spawn.ts` (`realSpawn`) so
+ * behavior is byte-identical: `sh -c <command>`, `detached: true` (own process
+ * group), process-group `SIGKILL` on abort/timeout so a backgrounded grandchild
+ * cannot hold the stdio pipes open, and resolve-once-with-cleanup to avoid
+ * listener/timer leaks.
+ */
+function localSpawn(params: SpawnParams): Promise<ExecResult> {
+ return new Promise<ExecResult>((resolve) => {
+ // detached: true puts the child in its own process group (pgid = child.pid).
+ // This lets us kill the entire group (child + any grandchildren that inherit
+ // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a
+ // backgrounded grandchild can't keep the stdio pipes open and stall the
+ // promise on child.on("close").
+ const child = nodeSpawn("sh", ["-c", params.command], {
+ cwd: params.cwd,
+ stdio: ["ignore", "pipe", "pipe"],
+ detached: true,
+ });
+
+ let settled = false;
+ let timedOut = false;
+ let timer: ReturnType<typeof setTimeout> | undefined;
+
+ /** Kill the entire child process group (best-effort — group may be gone). */
+ const killGroup = () => {
+ if (child.pid !== undefined) {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {
+ // Process group may already be gone — ignore.
+ }
+ }
+ };
+
+ /** Remove the abort listener and clear the timeout timer (no leaks). */
+ const cleanup = () => {
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ timer = undefined;
+ }
+ params.signal.removeEventListener("abort", onAbort);
+ };
+
+ /** Resolve once, then clean up so listeners/timers never leak. */
+ const settle = (result: ExecResult) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ resolve(result);
+ };
+
+ const onAbort = () => {
+ if (settled) return;
+ killGroup();
+ // Resolve immediately — do NOT wait for child.on("close"), which may
+ // never fire if a grandchild holds the pipes open.
+ settle({ exitCode: null, timedOut: false, aborted: true });
+ };
+ params.signal.addEventListener("abort", onAbort, { once: true });
+
+ timer = setTimeout(() => {
+ if (settled) return;
+ timedOut = true;
+ killGroup();
+ // Resolve immediately — same reasoning as abort.
+ settle({ exitCode: null, timedOut: true, aborted: false });
+ }, params.timeout);
+
+ child.stdout.on("data", (chunk: Buffer) => {
+ params.onOutput(chunk.toString(), "stdout");
+ });
+
+ child.stderr.on("data", (chunk: Buffer) => {
+ params.onOutput(chunk.toString(), "stderr");
+ });
+
+ // Normal-completion path: wait for "close" so all stdout/stderr is captured.
+ // If abort/timeout already settled, this is a no-op (settled === true).
+ child.on("close", (code) => {
+ settle({ exitCode: code, timedOut, aborted: false });
+ });
+
+ // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case
+ // and resolve — never leave the promise pending.
+ child.on("error", () => {
+ killGroup();
+ settle({ exitCode: 1, timedOut: false, aborted: false });
+ });
+ });
+}
diff --git a/packages/exec-backend/src/service.ts b/packages/exec-backend/src/service.ts
new file mode 100644
index 0000000..81ea5fa
--- /dev/null
+++ b/packages/exec-backend/src/service.ts
@@ -0,0 +1,27 @@
+import { defineService } from "@dispatch/kernel";
+import type { ExecBackend } from "./backend.js";
+
+/**
+ * Resolve an `ExecBackend` for a given computer.
+ *
+ * - `computerId` **undefined** → local (today's behavior; `LocalExecBackend`).
+ * - `computerId` **set** → remote (SSH; wired by `host-bin` + the `ssh` package
+ * in a later wave — the `SshExecBackend` implements the same `ExecBackend`
+ * interface).
+ *
+ * The resolver is SYNCHRONOUS by design: it returns a backend whose methods are
+ * async, so any remote connection acquisition happens lazily inside the first
+ * backend method call, not at resolver-call time. This keeps the resolver
+ * side-effect-free — merely resolving a backend never opens a connection; only
+ * when a tool actually executes does the (remote) backend connect.
+ */
+export type ExecBackendResolver = (computerId?: string) => ExecBackend;
+
+/**
+ * Typed service handle for the `ExecBackend` resolver.
+ *
+ * The `exec-backend` extension provides this via `host.provideService`.
+ * Tool extensions resolve their per-call backend from it (injected at
+ * activation by `host-bin`).
+ */
+export const execBackendHandle = defineService<ExecBackendResolver>("exec-backend/resolver");
diff --git a/packages/exec-backend/tsconfig.json b/packages/exec-backend/tsconfig.json
new file mode 100644
index 0000000..ff99a43
--- /dev/null
+++ b/packages/exec-backend/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }]
+}
diff --git a/packages/kernel/src/runtime/dispatch.ts b/packages/kernel/src/runtime/dispatch.ts
index e0be1b4..01f0043 100644
--- a/packages/kernel/src/runtime/dispatch.ts
+++ b/packages/kernel/src/runtime/dispatch.ts
@@ -18,6 +18,7 @@ export async function executeToolCall(
turnId: string,
toolSpan?: Span,
cwd?: string,
+ computerId?: string,
): Promise<ToolResult> {
if (tool === undefined) {
return { content: `Unknown tool: ${call.name}`, isError: true };
@@ -34,6 +35,7 @@ export async function executeToolCall(
log: toolSpan?.log ?? createNoopLogger(),
conversationId,
...(cwd !== undefined ? { cwd } : {}),
+ ...(computerId !== undefined ? { computerId } : {}),
};
// Race the tool's execute promise against the abort signal so a tool
// that hangs (ignores ctx.signal, or blocks on something the signal
@@ -74,6 +76,7 @@ export function createStepDispatcher(
turnId: string,
toolSpans: Map<string, Span>,
cwd?: string,
+ computerId?: string,
): StepDispatcher {
let activeCount = 0;
let unsafeRunning = false;
@@ -112,6 +115,7 @@ export function createStepDispatcher(
turnId,
tcSpan,
cwd,
+ computerId,
);
activeCount--;
if (entry.tool?.concurrencySafe === false) unsafeRunning = false;
diff --git a/packages/kernel/src/runtime/run-turn.test.ts b/packages/kernel/src/runtime/run-turn.test.ts
index 0d4c59d..08d3055 100644
--- a/packages/kernel/src/runtime/run-turn.test.ts
+++ b/packages/kernel/src/runtime/run-turn.test.ts
@@ -835,6 +835,71 @@ describe("runTurn", () => {
expect(capturedCwd).toBeUndefined();
});
+ it("forwards computerId from RunTurnInput to ToolExecuteContext", async () => {
+ let capturedComputerId: string | undefined = "SENTINEL_NOT_SET";
+
+ const tool = createFakeTool("computercheck", async (_input, ctx) => {
+ capturedComputerId = ctx.computerId;
+ return { content: "ok" };
+ });
+
+ const provider = createFakeProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "tab-test",
+ turnId: "turn-test",
+ emit: () => {},
+ computerId: "ssh-host-alias",
+ });
+
+ expect(capturedComputerId).toBe("ssh-host-alias");
+ });
+
+ it("forwards undefined computerId when RunTurnInput has no computerId", async () => {
+ let capturedComputerId: string | undefined = "SENTINEL_NOT_SET";
+
+ const tool = createFakeTool("computercheck", async (_input, ctx) => {
+ capturedComputerId = ctx.computerId;
+ return { content: "ok" };
+ });
+
+ const provider = createFakeProvider([
+ [
+ { type: "tool-call", toolCallId: "tc1", toolName: "computercheck", input: {} },
+ { type: "finish", reason: "tool-calls" },
+ ],
+ [
+ { type: "text-delta", delta: "done" },
+ { type: "finish", reason: "stop" },
+ ],
+ ]);
+
+ await runTurn({
+ provider,
+ messages: [userMessage],
+ tools: [tool],
+ dispatch: { maxConcurrent: 1, eager: false },
+ conversationId: "tab-test",
+ turnId: "turn-test",
+ emit: () => {},
+ });
+
+ expect(capturedComputerId).toBeUndefined();
+ });
+
it("aggregates usage across multiple steps", async () => {
const provider = createFakeProvider([
[
diff --git a/packages/kernel/src/runtime/run-turn.ts b/packages/kernel/src/runtime/run-turn.ts
index f5d80d3..940c77f 100644
--- a/packages/kernel/src/runtime/run-turn.ts
+++ b/packages/kernel/src/runtime/run-turn.ts
@@ -117,6 +117,7 @@ interface StepContext {
readonly turnSpan: Span | undefined;
readonly toolSpans: Map<string, Span>;
readonly cwd: string | undefined;
+ readonly computerId: string | undefined;
readonly now: (() => number) | undefined;
/** Per-turn provider options (model, systemPrompt, …) threaded to stream(). */
readonly providerOpts: ProviderStreamOptions | undefined;
@@ -295,6 +296,7 @@ async function executeStep(ctx: StepContext): Promise<StepResult> {
ctx.turnId,
ctx.toolSpans,
ctx.cwd,
+ ctx.computerId,
);
const timing: TimingState = {
@@ -522,6 +524,7 @@ export async function runTurn(input: RunTurnInput): Promise<RunTurnResult> {
turnSpan,
toolSpans,
cwd: input.cwd,
+ computerId: input.computerId,
now,
providerOpts: input.providerOpts,
});
diff --git a/packages/wire/src/index.test.ts b/packages/wire/src/index.test.ts
new file mode 100644
index 0000000..cd297b7
--- /dev/null
+++ b/packages/wire/src/index.test.ts
@@ -0,0 +1,59 @@
+/**
+ * Conformance test for the wire ABI's type-only surface. The wire package ships
+ * no runtime, so these tests assert that the public shapes COMPILE and round-trip
+ * — a `Computer` literal satisfies its type, `ComputerEntry` extends `Computer`,
+ * and a `Workspace` carries the new `defaultComputerId`. The `ComputerEntry →
+ * Computer` assignment is a genuine compile-time check (it would fail to typecheck
+ * if the `extends` relationship broke); the runtime assertions are sanity echo.
+ */
+
+import { describe, expect, it } from "vitest";
+import type { Computer, ComputerEntry, Workspace } from "./index.js";
+
+describe("@dispatch/wire — Computer / Workspace shapes", () => {
+ it("a Computer literal satisfies the Computer type", () => {
+ const c: Computer = {
+ alias: "myserver",
+ hostName: "myserver.example.com",
+ port: 22,
+ user: "deploy",
+ identityFile: null,
+ knownHost: true,
+ };
+ expect(c.alias).toBe("myserver");
+ expect(c.port).toBe(22);
+ expect(c.identityFile).toBeNull();
+ expect(c.knownHost).toBe(true);
+ });
+
+ it("ComputerEntry extends Computer and carries usageCount", () => {
+ const entry: ComputerEntry = {
+ alias: "buildbox",
+ hostName: "buildbox",
+ port: 2222,
+ user: "root",
+ identityFile: "/home/u/.ssh/id_ed25519",
+ knownHost: false,
+ usageCount: 3,
+ };
+ // Compile-time proof that ComputerEntry is assignable to Computer.
+ const asComputer: Computer = entry;
+ expect(asComputer.alias).toBe("buildbox");
+ expect(entry.usageCount).toBe(3);
+ });
+
+ it("a Workspace carries defaultComputerId (null = local)", () => {
+ const remote: Workspace = {
+ id: "default",
+ title: "Default",
+ defaultCwd: null,
+ defaultComputerId: "myserver",
+ createdAt: 0,
+ lastActivityAt: 0,
+ };
+ expect(remote.defaultComputerId).toBe("myserver");
+
+ const local: Workspace = { ...remote, defaultComputerId: null };
+ expect(local.defaultComputerId).toBeNull();
+ });
+});
diff --git a/packages/wire/src/index.ts b/packages/wire/src/index.ts
index bade977..f6a95cf 100644
--- a/packages/wire/src/index.ts
+++ b/packages/wire/src/index.ts
@@ -570,6 +570,14 @@ export interface Workspace {
readonly title: string;
/** The workspace's default cwd, or `null` (fall through to server default). */
readonly defaultCwd: string | null;
+ /**
+ * The workspace's default computer — an SSH config `Host` alias that
+ * conversations in this workspace inherit when they set no `computerId` of
+ * their own. `null` means local (no SSH; today's behavior). The computer
+ * analog of `defaultCwd`. Resolved per-conversation by `getEffectiveComputer`
+ * (per-conv `computerId` → this → `null`/local).
+ */
+ readonly defaultComputerId: string | null;
/** Epoch-ms when the workspace was first created. */
readonly createdAt: number;
/** Epoch-ms of the most recent conversation activity in this workspace. */
@@ -584,3 +592,44 @@ export interface WorkspaceEntry extends Workspace {
/** Number of conversations assigned to this workspace. */
readonly conversationCount: number;
}
+
+// ─── Computers ───────────────────────────────────────────────────────────────
+
+/**
+ * A read-only view of a remote computer discovered from the system's
+ * `~/.ssh/config` — a "computer" is a `Host` alias, NOT an editable entity
+ * (there is no Computer CRUD store). To add a computer, the user adds a `Host`
+ * block to `~/.ssh/config`; Dispatch discovers it on the next `listComputers()`
+ * read. Every field below is resolved from the config (first-match-wins for
+ * `HostName`/`User`/`Port`/`IdentityFile`).
+ *
+ * `alias` is the `computerId` users select — the string persisted per
+ * conversation and per workspace (the computer analog of `cwd`). `knownHost`
+ * drives the frontend "known/new" indicator and is read-only.
+ */
+export interface Computer {
+ /** The SSH config `Host` alias — also the `computerId` users select. */
+ readonly alias: string;
+ /** Resolved `HostName`/IP from the config (falls back to the alias itself). */
+ readonly hostName: string;
+ /** Resolved port (config `Port`, default 22). */
+ readonly port: number;
+ /** Resolved user (config `User`, default the current user). */
+ readonly user: string;
+ /** Resolved `IdentityFile` path (from the config, or `null` = default `~/.ssh/id_*`). */
+ readonly identityFile: string | null;
+ /**
+ * Whether the host's key is already in `~/.ssh/known_hosts` (i.e. previously
+ * connected). Drives the frontend "known/new" indicator. Read-only.
+ */
+ readonly knownHost: boolean;
+}
+
+/**
+ * A computer entry in the list response (`GET /computers`) — a `Computer` plus
+ * a usage count. Parallel to `WorkspaceEntry`.
+ */
+export interface ComputerEntry extends Computer {
+ /** Number of conversations/workspaces whose `computerId` resolves to this alias. */
+ readonly usageCount: number;
+}