summaryrefslogtreecommitdiffhomepage
path: root/packages/system-prompt/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 21:45:58 +0900
committerAdam Malczewski <[email protected]>2026-06-25 21:45:58 +0900
commit2cc9ddfb590dc60557bba3ed76a6c4639df5f596 (patch)
treeb0ced1ecb5f899e6a2b835d41603c4040a49bbce /packages/system-prompt/src
parent087ce142247637bb10351ab7815144b720836153 (diff)
downloaddispatch-2cc9ddfb590dc60557bba3ed76a6c4639df5f596.tar.gz
dispatch-2cc9ddfb590dc60557bba3ed76a6c4639df5f596.zip
feat(ssh): discover computers from ~/.ssh/known_hosts + remote system-prompt
Two improvements to the SSH support feature: 1. KNOWN_HOSTS DISCOVERY (packages/ssh): Computers are now auto-discovered from ~/.ssh/known_hosts (every hostname you've ever connected to) in ADDITION to ~/.ssh/config (explicit Host aliases). Config entries take precedence (full params); known_hosts entries get defaulted params (User=defaultUser, IdentityFile=null→pool probes default keys, Port from [host]:port or 22, knownHost=true). Zero-config — no ~/.ssh/config file needed; hosts just appear. Reject list: dispatch.toml [ssh].reject = [...] (glob patterns like github.com, *.ts.net) filters noise from the catalog. Read from both the global ~/.config/dispatch/dispatch.toml and the project dispatch.toml. Parsed with Bun.TOML.parse (zero deps). Only filters discovery (catalog); specific lookups (getComputer/getStatus/test/connect) ignore the reject list (it's a visibility filter, not access control). New pure functions: parseKnownHosts(), isRejected(), globMatch(). +26 tests. tsc EXIT 0, biome clean, 1756 tests pass. 2. REMOTE SYSTEM-PROMPT AWARENESS (packages/system-prompt): When a conversation has a computerId set (remote turn), the system prompt now resolves system:os, system:hostname, git:branch/git:status, and file: reads against the REMOTE machine — not the local host. Previously the prompt always said 'Arch Linux (WSL)' + local hostname even when the agent was connected to a remote Artix Linux machine. The ResolverAdapters' hostname()/platform() are now async (so a remote adapter can run 'hostname'/'uname -s' over SSH). The system-prompt extension builds remote adapters from the ExecBackend (readFile→SFTP, spawn→SSH exec). Cache invalidation now checks computerId (switching computers rebuilds the prompt). The compaction path also threads computerId. @dispatch/system-prompt now depends on @dispatch/exec-backend.
Diffstat (limited to 'packages/system-prompt/src')
-rw-r--r--packages/system-prompt/src/extension.ts101
-rw-r--r--packages/system-prompt/src/resolver.test.ts18
-rw-r--r--packages/system-prompt/src/resolver.ts18
-rw-r--r--packages/system-prompt/src/service.test.ts6
-rw-r--r--packages/system-prompt/src/service.ts33
-rw-r--r--packages/system-prompt/src/types.ts26
6 files changed, 169 insertions, 33 deletions
diff --git a/packages/system-prompt/src/extension.ts b/packages/system-prompt/src/extension.ts
index 7b6575d..5cb9125 100644
--- a/packages/system-prompt/src/extension.ts
+++ b/packages/system-prompt/src/extension.ts
@@ -4,7 +4,13 @@
* Builds the service with real Bun-backed adapters (Bun.file for fs,
* Bun.spawn for git), persists the template + resolved prompts via a namespaced
* storage, and provides the service through `systemPromptHandle`.
+ *
+ * When a conversation has a `computerId` set (remote turn), the service calls
+ * `resolveRemoteAdapters` to obtain ExecBackend-backed adapters that read
+ * files / run commands on the REMOTE machine (via SSH), so the system prompt
+ * reflects the remote OS/hostname/cwd — not the local host's.
*/
+import { type ExecBackend, execBackendHandle } from "@dispatch/exec-backend";
import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import type { GitSpawnResult, ResolverAdapters } from "./resolver.js";
import { createSystemPromptService } from "./service.js";
@@ -17,7 +23,10 @@ export const manifest: Manifest = {
apiVersion: "^0.1.0",
trust: "bundled",
activation: "eager",
- dependsOn: [],
+ // exec-backend provides the resolver used to obtain a remote ExecBackend
+ // when computerId is set. The lookup is lazy (at construct time, not
+ // activation), but declaring the dep keeps the DAG honest.
+ dependsOn: ["exec-backend"],
capabilities: { fs: true, spawn: true },
contributes: { services: ["system-prompt"] },
};
@@ -47,11 +56,97 @@ function realFs() {
};
}
-const adapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() };
+const localAdapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() };
+
+/**
+ * Run a single command on a remote ExecBackend and capture stdout. Returns
+ * `null` on any error (the resolver treats null as "unavailable").
+ */
+async function remoteCommand(
+ backend: ExecBackend,
+ command: string,
+ cwd: string,
+): Promise<string | null> {
+ let stdout = "";
+ try {
+ const result = await backend.spawn({
+ command,
+ cwd,
+ signal: new AbortController().signal,
+ timeout: 10_000,
+ onOutput: (data: string, stream: "stdout" | "stderr") => {
+ if (stream === "stdout") stdout += data;
+ },
+ });
+ return result.exitCode === 0 ? stdout.trim() : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Build `ResolverAdapters` backed by a remote `ExecBackend` (SSH). File reads
+ * go through SFTP; git/hostname/uname run over SSH exec. This makes the system
+ * prompt reflect the REMOTE machine's OS, hostname, and git state.
+ */
+function buildRemoteAdapters(backend: ExecBackend, cwd: string): ResolverAdapters {
+ return {
+ spawn: async (command, opts) => {
+ let stdout = "";
+ let stderr = "";
+ const result = await backend.spawn({
+ command: command.join(" "),
+ cwd: opts.cwd,
+ signal: new AbortController().signal,
+ timeout: 10_000,
+ onOutput: (data: string, stream: "stdout" | "stderr") => {
+ if (stream === "stdout") stdout += data;
+ else stderr += data;
+ },
+ });
+ return { stdout, stderr, exitCode: result.exitCode };
+ },
+ fs: {
+ readText: async (path: string): Promise<string> => backend.readFile(path),
+ exists: async (path: string): Promise<boolean> => backend.exists(path),
+ },
+ // Run hostname/uname on the REMOTE machine. These are resolved once per
+ // construct call (cached by the service's cwd+computerId cache). If the
+ // remote command fails, fall back to a generic value (the resolver will
+ // still read /etc/os-release via SFTP for the distro name).
+ hostname: async () => (await remoteCommand(backend, "hostname", cwd)) ?? "remote",
+ platform: async () => (await remoteCommand(backend, "uname -s", cwd)) ?? "linux",
+ };
+}
export function activate(host: HostAPI): void {
const storage = host.storage("system-prompt");
- const service = createSystemPromptService({ storage, adapters });
+
+ /**
+ * Resolve remote-backed adapters for a given computerId. Looks up the
+ * ExecBackendResolver (provided by exec-backend, which delegates to ssh's
+ * remote factory when computerId is set) and wraps it in ResolverAdapters.
+ * Falls back to local adapters if the resolver or backend is unavailable.
+ */
+ const resolveRemoteAdapters = async (
+ computerId: string,
+ cwd: string,
+ ): Promise<ResolverAdapters> => {
+ try {
+ const resolver = host.getService(execBackendHandle);
+ const backend = resolver(computerId);
+ return buildRemoteAdapters(backend, cwd);
+ } catch {
+ // exec-backend not loaded or resolver unavailable → local.
+ return localAdapters;
+ }
+ };
+
+ const service = createSystemPromptService({
+ storage,
+ adapters: localAdapters,
+ resolveRemoteAdapters,
+ });
host.provideService(systemPromptHandle, service);
host.logger.info("system-prompt: activated");
}
diff --git a/packages/system-prompt/src/resolver.test.ts b/packages/system-prompt/src/resolver.test.ts
index 474c79a..d55af07 100644
--- a/packages/system-prompt/src/resolver.test.ts
+++ b/packages/system-prompt/src/resolver.test.ts
@@ -37,8 +37,8 @@ describe("resolver", () => {
spawn: failSpawn(),
fs: fakeFs(new Map()),
now: () => fixedNow,
- platform: () => "linux",
- hostname: () => "myhost",
+ platform: async () => "linux",
+ hostname: async () => "myhost",
});
expect(map.get("system:time")).toBe("2024-06-15T12:30:00.000Z");
@@ -83,7 +83,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(files),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS");
});
@@ -95,7 +95,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(files),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("Debian 12");
});
@@ -108,7 +108,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(files),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)");
});
@@ -121,7 +121,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(files),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)");
});
@@ -131,7 +131,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(files),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("Linux (WSL)");
});
@@ -140,7 +140,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(new Map()),
- platform: () => "linux",
+ platform: async () => "linux",
});
expect(map.get("system:os")).toBe("linux");
});
@@ -149,7 +149,7 @@ describe("resolver", () => {
const map = await resolveVariables("/proj", {
spawn: failSpawn(),
fs: fakeFs(new Map()),
- platform: () => "darwin",
+ platform: async () => "darwin",
});
expect(map.get("system:os")).toBe("darwin");
});
diff --git a/packages/system-prompt/src/resolver.ts b/packages/system-prompt/src/resolver.ts
index f271f47..e864554 100644
--- a/packages/system-prompt/src/resolver.ts
+++ b/packages/system-prompt/src/resolver.ts
@@ -45,10 +45,16 @@ export interface ResolverAdapters {
readonly fs: ResolverFs;
/** Override the current time (defaults to `new Date()`). */
readonly now?: () => Date;
- /** Override `process.platform` (defaults to the real platform). */
- readonly platform?: () => string;
- /** Override the hostname (defaults to `os.hostname()`). */
- readonly hostname?: () => string;
+ /**
+ * Override `process.platform` (defaults to the real platform). Async so a
+ * remote adapter can run `uname -s` over SSH.
+ */
+ readonly platform?: () => Promise<string>;
+ /**
+ * Override the hostname (defaults to `os.hostname()`). Async so a remote
+ * adapter can run `hostname` over SSH.
+ */
+ readonly hostname?: () => Promise<string>;
}
/** Per-construction context forwarded by the session-orchestrator. */
@@ -157,9 +163,9 @@ export async function resolveVariables(
// ── system:* ────────────────────────────────────────────────────────────
vars.set("system:time", now.toISOString());
vars.set("system:date", now.toISOString().slice(0, 10));
- const platform = adapters.platform?.() ?? process.platform;
+ const platform = (await adapters.platform?.()) ?? process.platform;
vars.set("system:os", await resolveOs(platform, adapters.fs));
- vars.set("system:hostname", adapters.hostname?.() ?? osHostname());
+ vars.set("system:hostname", (await adapters.hostname?.()) ?? osHostname());
// ── prompt:* ────────────────────────────────────────────────────────────
vars.set("prompt:cwd", cwd);
diff --git a/packages/system-prompt/src/service.test.ts b/packages/system-prompt/src/service.test.ts
index cd850e3..37c1c0d 100644
--- a/packages/system-prompt/src/service.test.ts
+++ b/packages/system-prompt/src/service.test.ts
@@ -157,7 +157,7 @@ describe("system-prompt service", () => {
});
const meta = await service.getWithMeta("never-constructed");
- expect(meta).toEqual({ prompt: null, cwd: null });
+ expect(meta).toEqual({ prompt: null, cwd: null, computerId: null });
});
it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => {
@@ -217,11 +217,11 @@ describe("system-prompt service", () => {
const first = await service.construct("conv-second", "/dir-a");
const firstMeta = await service.getWithMeta("conv-second");
- expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a" });
+ expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null });
const second = await service.construct("conv-second", "/dir-b");
const secondMeta = await service.getWithMeta("conv-second");
- expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b" });
+ expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null });
expect(secondMeta.cwd).not.toBe("/dir-a");
});
});
diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts
index 60977bf..8d6ede5 100644
--- a/packages/system-prompt/src/service.ts
+++ b/packages/system-prompt/src/service.ts
@@ -28,12 +28,21 @@ The current working directory is [prompt:cwd].
const TEMPLATE_KEY = "template";
const resolvedKey = (conversationId: string): string => `resolved:${conversationId}`;
const resolvedCwdKey = (conversationId: string): string => `resolved-cwd:${conversationId}`;
+const resolvedComputerIdKey = (conversationId: string): string =>
+ `resolved-computer:${conversationId}`;
export interface SystemPromptServiceDeps {
/** Namespaced KV (`host.storage("system-prompt")`). */
readonly storage: StorageNamespace;
- /** Injected effects for variable resolution. */
+ /** Injected effects for variable resolution (local). */
readonly adapters: ResolverAdapters;
+ /**
+ * Optional: build remote-backed adapters for a given computerId. When
+ * `construct` is called with a `computerId`, this is invoked to obtain
+ * adapters that read/run commands on the REMOTE machine (via the
+ * ExecBackend/SSH). Absent → falls back to the local `adapters`.
+ */
+ readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise<ResolverAdapters>;
}
/**
@@ -52,7 +61,17 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System
...(context?.model !== undefined ? { model: context.model } : {}),
...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}),
};
- const vars = await resolveVariables(cwd, deps.adapters, {
+
+ // Select adapters: when computerId is set, use remote-backed adapters
+ // (read files / run commands on the REMOTE machine via SSH). Otherwise
+ // use the local adapters.
+ const computerId = context?.computerId;
+ const adapters =
+ computerId !== undefined && deps.resolveRemoteAdapters !== undefined
+ ? await deps.resolveRemoteAdapters(computerId, cwd)
+ : deps.adapters;
+
+ const vars = await resolveVariables(cwd, adapters, {
context: resolverContext,
referencedKeys,
});
@@ -60,6 +79,9 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System
await deps.storage.set(resolvedKey(conversationId), result);
await deps.storage.set(resolvedCwdKey(conversationId), cwd);
+ // Store the computerId (or empty string for local) so the cache can be
+ // invalidated when the computer changes.
+ await deps.storage.set(resolvedComputerIdKey(conversationId), computerId ?? "");
return result;
},
@@ -68,11 +90,14 @@ export function createSystemPromptService(deps: SystemPromptServiceDeps): System
},
async getWithMeta(conversationId) {
- const [prompt, cwd] = await Promise.all([
+ const [prompt, cwd, computerIdStored] = await Promise.all([
deps.storage.get(resolvedKey(conversationId)),
deps.storage.get(resolvedCwdKey(conversationId)),
+ deps.storage.get(resolvedComputerIdKey(conversationId)),
]);
- return { prompt, cwd };
+ // Empty string → null (local, no computerId). Non-empty → the alias.
+ const computerId = computerIdStored === null ? null : computerIdStored || null;
+ return { prompt, cwd, computerId };
},
async getTemplate() {
diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts
index 4e1db0b..a9fe3ca 100644
--- a/packages/system-prompt/src/types.ts
+++ b/packages/system-prompt/src/types.ts
@@ -20,25 +20,35 @@ export interface SystemPromptService {
* result under `resolved:<conversationId>`. Returns the resolved string.
* When no template is stored, the built-in default template is used. An
* empty template yields an empty string.
+ *
+ * When `context.computerId` is set, the resolver uses remote-backed adapters
+ * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via
+ * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine.
*/
construct(
conversationId: string,
cwd: string,
- context?: { readonly model?: string; readonly workspaceId?: string },
+ context?: {
+ readonly model?: string;
+ readonly workspaceId?: string;
+ readonly computerId?: string;
+ },
): Promise<string>;
/** Read the persisted resolved system prompt, or `null` if never constructed. */
get(conversationId: string): Promise<string | null>;
/**
- * Read the persisted resolved system prompt AND the cwd it was built
- * against. Returns `{ prompt: null, cwd: null }` if never constructed.
- * Consumers use this to detect whether the cached prompt is stale
- * relative to the current effective cwd.
+ * Read the persisted resolved system prompt AND the cwd + computerId it was
+ * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if
+ * never constructed. Consumers use this to detect whether the cached prompt
+ * is stale relative to the current effective cwd or computerId.
*/
- getWithMeta(
- conversationId: string,
- ): Promise<{ readonly prompt: string | null; readonly cwd: string | null }>;
+ getWithMeta(conversationId: string): Promise<{
+ readonly prompt: string | null;
+ readonly cwd: string | null;
+ readonly computerId: string | null;
+ }>;
/** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */
getTemplate(): Promise<string>;