1 import { ChildProcessWithoutNullStreams, spawn } from "child_process";
2 import { EventEmitter } from "events";
3 import { delimiter } from "path";
4 import { Connection } from "./connection";
5 import { augmentedPath, defaultModelCacheEnv, resolveExecutable } from "./resolveCommand";
6
7 /** Thrown when the agent executable cannot be located on the (augmented) PATH. */
8 export class AgentNotFoundError extends Error {
9 readonly code = "AGENT_NOT_FOUND";
10 constructor(readonly command: string) {
11 super(
12 `Could not find the "${command}" executable. Install the agent from ` +
13 `https://code.sigit.si and make sure it is on your PATH, or set an ` +
14 `absolute "command" path in the "sigit.agents" setting.`
15 );
16 this.name = "AgentNotFoundError";
17 }
18 }
19
20 /**
21 * AcpClient — spawns an Agent Client Protocol agent over stdio and drives the
22 * ACP handshake and prompt turns.
23 *
24 * Protocol reference: https://agentclientprotocol.com
25 */
26
27 export interface AgentSpawnConfig {
28 command: string;
29 args?: string[];
30 cwd?: string;
31 env?: Record<string, string>;
32 }
33
34 export interface PermissionRequest {
35 sessionId: string;
36 toolCall?: unknown;
37 options?: Array<{ optionId: string; name: string; kind?: string }>;
38 [key: string]: unknown;
39 }
40
41 export interface PermissionResponse {
42 outcome: { outcome: "selected"; optionId: string } | { outcome: "cancelled" };
43 }
44
45 /** Callbacks the host injects so the agent can act on the workspace. */
46 export interface AcpCallbacks {
47 requestPermission: (request: PermissionRequest) => Promise<PermissionResponse>;
48 readTextFile: (params: { path: string; line?: number; limit?: number }) => Promise<string>;
49 writeTextFile: (params: { path: string; content: string }) => Promise<void>;
50 }
51
52 export interface SessionUpdate {
53 sessionId: string;
54 update: {
55 sessionUpdate: string;
56 [key: string]: unknown;
57 };
58 }
59
60 const PROTOCOL_VERSION = 1;
61
62 export class AcpClient extends EventEmitter {
63 private child: ChildProcessWithoutNullStreams | undefined;
64 private connection: Connection | undefined;
65 private callbacks: AcpCallbacks;
66 private sessionId: string | undefined;
67 private disposed = false;
68
69 constructor(callbacks: AcpCallbacks) {
70 super();
71 this.callbacks = callbacks;
72 }
73
74 get currentSessionId(): string | undefined {
75 return this.sessionId;
76 }
77
78 /** Spawn the agent process and wire up the JSON-RPC connection. */
79 spawn(config: AgentSpawnConfig): void {
80 // GUI-launched VS Code inherits a minimal PATH; augment it so the agent
81 // (and any subprocess it spawns) can be found and can find its own tools.
82 // Model-cache defaults sit *under* the inherited env and the agent config so
83 // a real HF_HOME/HF_HUB_CACHE (or an agent `env` entry) always wins; they
84 // only fill in a writable cache dir when nothing else is set, avoiding the
85 // EPERM that the App Group container would otherwise cause.
86 const path = augmentedPath();
87 const env = {
88 ...defaultModelCacheEnv(),
89 ...process.env,
90 ...(config.env ?? {}),
91 PATH: path
92 };
93 if (config.env?.PATH) {
94 env.PATH = `${config.env.PATH}${delimiter}${path}`;
95 }
96
97 const resolved = resolveExecutable(config.command, env.PATH);
98 if (!resolved) {
99 throw new AgentNotFoundError(config.command);
100 }
101
102 const child = spawn(resolved, config.args ?? [], {
103 cwd: config.cwd,
104 env,
105 stdio: ["pipe", "pipe", "pipe"]
106 });
107
108 child.on("error", (err) => {
109 this.emit("error", err);
110 // Unblock any in-flight request (e.g. initialize) instead of hanging.
111 this.connection?.dispose();
112 });
113 child.on("exit", (code, signal) => this.emit("exit", code, signal));
114 child.stderr.on("data", (chunk: Buffer) => this.emit("stderr", chunk.toString()));
115
116 const connection = new Connection(child.stdout, child.stdin);
117 connection.on("error", (err) => this.emit("error", err));
118
119 connection.onNotification("session/update", (params) => {
120 this.emit("update", params as SessionUpdate);
121 });
122
123 connection.onRequest("session/request_permission", async (params) => {
124 return this.callbacks.requestPermission(params as PermissionRequest);
125 });
126
127 connection.onRequest("fs/read_text_file", async (params) => {
128 const p = params as { path: string; line?: number; limit?: number };
129 const content = await this.callbacks.readTextFile(p);
130 return { content };
131 });
132
133 connection.onRequest("fs/write_text_file", async (params) => {
134 const p = params as { path: string; content: string };
135 await this.callbacks.writeTextFile(p);
136 return null;
137 });
138
139 this.child = child;
140 this.connection = connection;
141 }
142
143 /** Run the ACP handshake: initialize → session/new. Returns the session id. */
144 async initialize(cwd: string): Promise<string> {
145 const connection = this.requireConnection();
146
147 await connection.request("initialize", {
148 protocolVersion: PROTOCOL_VERSION,
149 clientCapabilities: {
150 fs: {
151 readTextFile: true,
152 writeTextFile: true
153 }
154 }
155 });
156
157 const session = await connection.request<{ sessionId: string }>("session/new", {
158 cwd,
159 mcpServers: []
160 });
161
162 this.sessionId = session.sessionId;
163 return session.sessionId;
164 }
165
166 /** Send a text prompt and resolve with the stop reason for the turn. */
167 async prompt(text: string): Promise<string> {
168 const connection = this.requireConnection();
169 if (!this.sessionId) {
170 throw new Error("No active session; call initialize() first");
171 }
172 const result = await connection.request<{ stopReason: string }>("session/prompt", {
173 sessionId: this.sessionId,
174 prompt: [{ type: "text", text }]
175 });
176 return result.stopReason;
177 }
178
179 /** Ask the agent to cancel the current turn. */
180 cancel(): void {
181 if (!this.connection || !this.sessionId) {
182 return;
183 }
184 this.connection.notify("session/cancel", { sessionId: this.sessionId });
185 }
186
187 dispose(): void {
188 if (this.disposed) {
189 return;
190 }
191 this.disposed = true;
192 this.connection?.dispose();
193 this.connection = undefined;
194 if (this.child) {
195 try {
196 this.child.kill();
197 } catch {
198 // ignore
199 }
200 this.child = undefined;
201 }
202 this.sessionId = undefined;
203 this.removeAllListeners();
204 }
205
206 private requireConnection(): Connection {
207 if (!this.connection) {
208 throw new Error("Agent not spawned; call spawn() first");
209 }
210 return this.connection;
211 }
212 }