/** * system-prompt extension — manifest + activate(host). * * 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"; import { systemPromptHandle } from "./types.js"; export const manifest: Manifest = { id: "system-prompt", name: "System Prompt", version: "0.0.0", apiVersion: "^0.1.0", trust: "bundled", activation: "eager", // 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"] }, }; /** Run a command and capture stdout/stderr (used for git). */ async function realSpawn( command: readonly string[], opts: { readonly cwd: string }, ): Promise { const proc = Bun.spawn([...command], { cwd: opts.cwd, stdout: "pipe", stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([ Bun.readableStreamToText(proc.stdout), Bun.readableStreamToText(proc.stderr), proc.exited, ]); return { stdout, stderr, exitCode }; } function realFs() { return { readText: async (path: string): Promise => Bun.file(path).text(), exists: async (path: string): Promise => Bun.file(path).exists(), }; } 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 { 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 => backend.readFile(path), exists: async (path: string): Promise => 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"); /** * 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 => { 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"); } export const extension: Extension = { manifest, activate, };