1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
|
/**
* 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<GitSpawnResult> {
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<string> => Bun.file(path).text(),
exists: async (path: string): Promise<boolean> => 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<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");
/**
* 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");
}
export const extension: Extension = {
manifest,
activate,
};
|