@bbigbang/runtime-acp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp/client.d.ts +101 -0
- package/dist/acp/client.js +747 -0
- package/dist/acp/jsonrpc.d.ts +31 -0
- package/dist/acp/jsonrpc.js +18 -0
- package/dist/acp/stdio.d.ts +15 -0
- package/dist/acp/stdio.js +201 -0
- package/dist/acp/types.d.ts +171 -0
- package/dist/acp/types.js +1 -0
- package/dist/db/db.d.ts +3 -0
- package/dist/db/db.js +10 -0
- package/dist/db/deliveryCheckpointStore.d.ts +21 -0
- package/dist/db/deliveryCheckpointStore.js +28 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.js +6196 -0
- package/dist/db/uiPrefStore.d.ts +4 -0
- package/dist/db/uiPrefStore.js +18 -0
- package/dist/gateway/bindingRuntime.d.ts +113 -0
- package/dist/gateway/bindingRuntime.js +1267 -0
- package/dist/gateway/history.d.ts +7 -0
- package/dist/gateway/history.js +146 -0
- package/dist/gateway/sessionStore.d.ts +79 -0
- package/dist/gateway/sessionStore.js +126 -0
- package/dist/gateway/toolAuth.d.ts +36 -0
- package/dist/gateway/toolAuth.js +372 -0
- package/dist/gateway/types.d.ts +79 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +19 -0
- package/dist/logging.d.ts +7 -0
- package/dist/logging.js +28 -0
- package/dist/runtime/lock.d.ts +5 -0
- package/dist/runtime/lock.js +87 -0
- package/dist/runtime/workspaceLockManager.d.ts +23 -0
- package/dist/runtime/workspaceLockManager.js +141 -0
- package/dist/tools/workspace.d.ts +1 -0
- package/dist/tools/workspace.js +13 -0
- package/package.json +38 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { log } from '../logging.js';
|
|
6
|
+
import { WorkspaceLockManager } from '../runtime/workspaceLockManager.js';
|
|
7
|
+
import { resolveWorkspacePath } from '../tools/workspace.js';
|
|
8
|
+
import { isNotification, isRequest, isResponse, } from './jsonrpc.js';
|
|
9
|
+
import { spawnAcpAgent } from './stdio.js';
|
|
10
|
+
const ACP_BOOTSTRAP_TIMEOUT_MS = 60_000;
|
|
11
|
+
export class AcpClient {
|
|
12
|
+
db;
|
|
13
|
+
workspaceRoot;
|
|
14
|
+
agentCommand;
|
|
15
|
+
agentArgs;
|
|
16
|
+
toolAuth;
|
|
17
|
+
defaultAllowTools;
|
|
18
|
+
disabledToolKinds;
|
|
19
|
+
workspaceLockManager;
|
|
20
|
+
rpc;
|
|
21
|
+
nextId = 1;
|
|
22
|
+
pending = new Map();
|
|
23
|
+
// run-scoped state
|
|
24
|
+
currentRun = null;
|
|
25
|
+
runSeq = new Map();
|
|
26
|
+
pendingLocalPermissions = new Map();
|
|
27
|
+
events;
|
|
28
|
+
constructor(params) {
|
|
29
|
+
this.db = params.db;
|
|
30
|
+
this.workspaceRoot = params.workspaceRoot;
|
|
31
|
+
this.agentCommand = params.agentCommand;
|
|
32
|
+
this.agentArgs = params.agentArgs;
|
|
33
|
+
this.toolAuth = params.toolAuth ?? null;
|
|
34
|
+
this.defaultAllowTools = params.defaultAllowTools ?? false;
|
|
35
|
+
this.disabledToolKinds = new Set(params.disabledToolKinds ?? []);
|
|
36
|
+
this.events = params.events ?? {};
|
|
37
|
+
this.workspaceLockManager = params.workspaceLockManager ?? new WorkspaceLockManager();
|
|
38
|
+
this.rpc =
|
|
39
|
+
params.rpc ?? spawnAcpAgent(this.agentCommand, this.agentArgs, params.env, this.workspaceRoot);
|
|
40
|
+
this.rpc.onMessage((m) => this.handleMessage(m));
|
|
41
|
+
this.rpc.onStderr((line) => this.events.onAgentStderr?.(line));
|
|
42
|
+
this.rpc.onExit?.((info) => {
|
|
43
|
+
this.rejectAllPending(this.makeTransportError(info.error
|
|
44
|
+
? `ACP agent exited: ${info.error}`
|
|
45
|
+
: 'ACP agent exited (code=' +
|
|
46
|
+
String(info.code) +
|
|
47
|
+
', signal=' +
|
|
48
|
+
String(info.signal) +
|
|
49
|
+
')'));
|
|
50
|
+
this.rejectAllLocalPermissions(this.makeTransportError(info.error
|
|
51
|
+
? `ACP agent exited while waiting for permission response: ${info.error}`
|
|
52
|
+
: 'ACP agent exited while waiting for permission response'));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
close() {
|
|
56
|
+
this.rejectAllPending(this.makeTransportError('ACP client closed'));
|
|
57
|
+
this.rejectAllLocalPermissions(this.makeTransportError('ACP client closed'));
|
|
58
|
+
for (const [terminalId, state] of this.terminals.entries()) {
|
|
59
|
+
state.releaseLock?.();
|
|
60
|
+
state.releaseLock = undefined;
|
|
61
|
+
this.killChild(state.child);
|
|
62
|
+
this.terminals.delete(terminalId);
|
|
63
|
+
}
|
|
64
|
+
this.rpc.kill();
|
|
65
|
+
}
|
|
66
|
+
setDisabledToolKinds(disabledToolKinds) {
|
|
67
|
+
this.disabledToolKinds = new Set(disabledToolKinds ?? []);
|
|
68
|
+
}
|
|
69
|
+
initPromise = null;
|
|
70
|
+
async initialize() {
|
|
71
|
+
if (this.initPromise)
|
|
72
|
+
return this.initPromise;
|
|
73
|
+
const params = {
|
|
74
|
+
protocolVersion: 1,
|
|
75
|
+
clientCapabilities: {
|
|
76
|
+
fs: { readTextFile: true, writeTextFile: true },
|
|
77
|
+
terminal: true,
|
|
78
|
+
},
|
|
79
|
+
clientInfo: {
|
|
80
|
+
name: 'cli-gateway',
|
|
81
|
+
title: 'cli-gateway',
|
|
82
|
+
version: '0.1.0',
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
this.initPromise = this.request('initialize', params, ACP_BOOTSTRAP_TIMEOUT_MS);
|
|
86
|
+
return this.initPromise;
|
|
87
|
+
}
|
|
88
|
+
async newSession(params) {
|
|
89
|
+
return this.request('session/new', params, ACP_BOOTSTRAP_TIMEOUT_MS);
|
|
90
|
+
}
|
|
91
|
+
async prompt(run, params, timeoutMs) {
|
|
92
|
+
this.currentRun = run;
|
|
93
|
+
this.runSeq.set(run.runId, this.getExistingRunSeq(run.runId));
|
|
94
|
+
try {
|
|
95
|
+
const result = await this.request('session/prompt', params, timeoutMs);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
this.currentRun = null;
|
|
100
|
+
this.runSeq.delete(run.runId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
notifyCancel(sessionId) {
|
|
104
|
+
this.rpc.write({
|
|
105
|
+
jsonrpc: '2.0',
|
|
106
|
+
method: 'session/cancel',
|
|
107
|
+
params: { sessionId },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async respondPermission(req, decision) {
|
|
111
|
+
const local = this.pendingLocalPermissions.get(req.requestId);
|
|
112
|
+
if (local) {
|
|
113
|
+
this.pendingLocalPermissions.delete(req.requestId);
|
|
114
|
+
local.resolve(decision);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const outcome = decision.kind === 'cancelled'
|
|
118
|
+
? { outcome: 'cancelled' }
|
|
119
|
+
: { outcome: 'selected', optionId: decision.optionId };
|
|
120
|
+
const msg = {
|
|
121
|
+
jsonrpc: '2.0',
|
|
122
|
+
id: req.requestId,
|
|
123
|
+
result: { outcome },
|
|
124
|
+
};
|
|
125
|
+
this.rpc.write(msg);
|
|
126
|
+
}
|
|
127
|
+
handleMessage(message) {
|
|
128
|
+
if (isResponse(message)) {
|
|
129
|
+
const pending = this.pending.get(message.id);
|
|
130
|
+
if (pending) {
|
|
131
|
+
this.pending.delete(message.id);
|
|
132
|
+
if (pending.timer) {
|
|
133
|
+
clearTimeout(pending.timer);
|
|
134
|
+
}
|
|
135
|
+
pending.resolve(message);
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (isNotification(message)) {
|
|
140
|
+
if (message.method === 'session/update') {
|
|
141
|
+
const params = message.params;
|
|
142
|
+
const sessionId = params?.sessionId;
|
|
143
|
+
const update = params?.update;
|
|
144
|
+
if (this.currentRun && sessionId) {
|
|
145
|
+
const eventSeq = this.appendEvent(this.currentRun.runId, 'session/update', params);
|
|
146
|
+
void this.events.onSessionUpdate?.(this.currentRun, sessionId, update, eventSeq);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (isRequest(message)) {
|
|
152
|
+
// Agent -> Client requests
|
|
153
|
+
void this.handleAgentRequest(message);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async handleAgentRequest(req) {
|
|
158
|
+
const run = this.currentRun;
|
|
159
|
+
const emitTool = (event) => {
|
|
160
|
+
if (!run)
|
|
161
|
+
return;
|
|
162
|
+
this.events.onClientTool?.(run, event);
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
switch (req.method) {
|
|
166
|
+
case 'session/request_permission': {
|
|
167
|
+
const params = req.params;
|
|
168
|
+
const sessionKey = this.currentRun?.sessionKey ?? 'unknown';
|
|
169
|
+
const pr = {
|
|
170
|
+
requestId: req.id,
|
|
171
|
+
sessionKey,
|
|
172
|
+
sessionId: params.sessionId,
|
|
173
|
+
params,
|
|
174
|
+
createdAtMs: Date.now(),
|
|
175
|
+
};
|
|
176
|
+
this.events.onPermissionRequest?.(pr);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
case 'fs/read_text_file': {
|
|
180
|
+
const params = req.params;
|
|
181
|
+
emitTool({ phase: 'start', method: req.method, params });
|
|
182
|
+
await this.ensureAuthorized({
|
|
183
|
+
kind: 'read',
|
|
184
|
+
method: req.method,
|
|
185
|
+
params,
|
|
186
|
+
});
|
|
187
|
+
const resolvedPath = resolveWorkspacePath(this.workspaceRoot, params.path);
|
|
188
|
+
const content = readTextFileWithLimit(resolvedPath, params.line, params.limit);
|
|
189
|
+
this.respond(req.id, { content });
|
|
190
|
+
emitTool({
|
|
191
|
+
phase: 'end',
|
|
192
|
+
method: req.method,
|
|
193
|
+
params,
|
|
194
|
+
result: { bytes: content.length },
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
case 'fs/write_text_file': {
|
|
199
|
+
const params = req.params;
|
|
200
|
+
emitTool({ phase: 'start', method: req.method, params: { path: params.path } });
|
|
201
|
+
await this.ensureAuthorized({
|
|
202
|
+
kind: 'edit',
|
|
203
|
+
method: req.method,
|
|
204
|
+
params,
|
|
205
|
+
});
|
|
206
|
+
const resolvedPath = resolveWorkspacePath(this.workspaceRoot, params.path);
|
|
207
|
+
const memoryWriteGuard = createMemoryWriteGuard(this.workspaceRoot, resolvedPath);
|
|
208
|
+
const lease = await this.acquireWorkspaceWriteLock(req.method, params);
|
|
209
|
+
const resolvedContent = memoryWriteGuard?.resolveContent(params.content, lease.waited) ?? params.content;
|
|
210
|
+
try {
|
|
211
|
+
fs.mkdirSync(path.dirname(resolvedPath), { recursive: true });
|
|
212
|
+
fs.writeFileSync(resolvedPath, resolvedContent, 'utf8');
|
|
213
|
+
this.respond(req.id, {});
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
lease.release();
|
|
217
|
+
}
|
|
218
|
+
emitTool({
|
|
219
|
+
phase: 'end',
|
|
220
|
+
method: req.method,
|
|
221
|
+
params: { path: params.path },
|
|
222
|
+
result: { bytes: resolvedContent.length },
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
case 'terminal/create': {
|
|
227
|
+
const params = req.params;
|
|
228
|
+
emitTool({
|
|
229
|
+
phase: 'start',
|
|
230
|
+
method: req.method,
|
|
231
|
+
params: {
|
|
232
|
+
command: params.command,
|
|
233
|
+
args: params.args,
|
|
234
|
+
cwd: params.cwd,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
await this.ensureAuthorized({
|
|
238
|
+
kind: 'execute',
|
|
239
|
+
method: req.method,
|
|
240
|
+
params,
|
|
241
|
+
});
|
|
242
|
+
const lease = await this.acquireWorkspaceWriteLock(req.method, params);
|
|
243
|
+
let terminalId = null;
|
|
244
|
+
try {
|
|
245
|
+
terminalId = await this.terminalCreate(params, lease);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
lease.release();
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
this.respond(req.id, { terminalId });
|
|
252
|
+
emitTool({
|
|
253
|
+
phase: 'end',
|
|
254
|
+
method: req.method,
|
|
255
|
+
params: {
|
|
256
|
+
command: params.command,
|
|
257
|
+
args: params.args,
|
|
258
|
+
cwd: params.cwd,
|
|
259
|
+
},
|
|
260
|
+
result: { terminalId },
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
case 'terminal/output': {
|
|
265
|
+
const params = req.params;
|
|
266
|
+
const out = this.terminalOutput(params);
|
|
267
|
+
this.respond(req.id, out);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
case 'terminal/wait_for_exit': {
|
|
271
|
+
const params = req.params;
|
|
272
|
+
emitTool({
|
|
273
|
+
phase: 'start',
|
|
274
|
+
method: req.method,
|
|
275
|
+
params: { terminalId: params.terminalId },
|
|
276
|
+
});
|
|
277
|
+
const res = await this.terminalWaitForExit(params);
|
|
278
|
+
this.respond(req.id, res);
|
|
279
|
+
emitTool({
|
|
280
|
+
phase: 'end',
|
|
281
|
+
method: req.method,
|
|
282
|
+
params: { terminalId: params.terminalId },
|
|
283
|
+
result: res,
|
|
284
|
+
});
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
case 'terminal/kill': {
|
|
288
|
+
const params = req.params;
|
|
289
|
+
await this.ensureAuthorized({
|
|
290
|
+
kind: 'execute',
|
|
291
|
+
method: req.method,
|
|
292
|
+
params,
|
|
293
|
+
});
|
|
294
|
+
this.terminalKill(params);
|
|
295
|
+
this.respond(req.id, {});
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
case 'terminal/release': {
|
|
299
|
+
const params = req.params;
|
|
300
|
+
this.terminalRelease(params);
|
|
301
|
+
this.respond(req.id, {});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
default: {
|
|
305
|
+
this.respondError(req.id, -32601, `Method not found: ${req.method}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
log.error('Agent request handler error', req.method, error);
|
|
311
|
+
emitTool({
|
|
312
|
+
phase: 'error',
|
|
313
|
+
method: req.method,
|
|
314
|
+
params: req.params,
|
|
315
|
+
error: String(error?.message ?? error),
|
|
316
|
+
});
|
|
317
|
+
this.respondError(req.id, -32000, String(error?.message ?? error));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
request(method, params, timeoutMs) {
|
|
321
|
+
const id = this.nextId++;
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
const timer = timeoutMs && timeoutMs > 0
|
|
324
|
+
? setTimeout(() => {
|
|
325
|
+
this.rejectPendingRequest(id, this.makeTransportError('ACP request timed out: ' + method + ' (' + String(timeoutMs) + 'ms)'));
|
|
326
|
+
}, timeoutMs)
|
|
327
|
+
: null;
|
|
328
|
+
this.pending.set(id, {
|
|
329
|
+
method,
|
|
330
|
+
resolve: (res) => {
|
|
331
|
+
if ('error' in res) {
|
|
332
|
+
const code = typeof res.error?.code === 'number'
|
|
333
|
+
? ' (code ' + String(res.error.code) + ')'
|
|
334
|
+
: '';
|
|
335
|
+
const data = res.error?.data !== undefined
|
|
336
|
+
? '; data=' + formatJsonRpcErrorData(res.error.data)
|
|
337
|
+
: '';
|
|
338
|
+
reject(new Error(String(res.error.message) + code + data));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
resolve(res.result);
|
|
342
|
+
},
|
|
343
|
+
reject,
|
|
344
|
+
timer,
|
|
345
|
+
});
|
|
346
|
+
try {
|
|
347
|
+
const req = { jsonrpc: '2.0', id, method, params };
|
|
348
|
+
this.rpc.write(req);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
this.rejectPendingRequest(id, this.makeTransportError(String(error?.message ?? error)));
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
rejectPendingRequest(id, error) {
|
|
356
|
+
const pending = this.pending.get(id);
|
|
357
|
+
if (!pending)
|
|
358
|
+
return;
|
|
359
|
+
this.pending.delete(id);
|
|
360
|
+
if (pending.timer) {
|
|
361
|
+
clearTimeout(pending.timer);
|
|
362
|
+
}
|
|
363
|
+
pending.reject(error);
|
|
364
|
+
}
|
|
365
|
+
rejectAllPending(error) {
|
|
366
|
+
for (const id of this.pending.keys()) {
|
|
367
|
+
const detail = this.makeTransportError(error.message + '; pending_id=' + String(id));
|
|
368
|
+
this.rejectPendingRequest(id, detail);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
rejectAllLocalPermissions(error) {
|
|
372
|
+
for (const [id, pending] of this.pendingLocalPermissions.entries()) {
|
|
373
|
+
this.pendingLocalPermissions.delete(id);
|
|
374
|
+
const detail = this.makeTransportError(error.message + '; permission_request_id=' + String(id));
|
|
375
|
+
pending.reject(detail);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
makeTransportError(message) {
|
|
379
|
+
const err = new Error(message);
|
|
380
|
+
err.name = 'AcpTransportError';
|
|
381
|
+
return err;
|
|
382
|
+
}
|
|
383
|
+
respond(id, result) {
|
|
384
|
+
this.rpc.write({ jsonrpc: '2.0', id, result });
|
|
385
|
+
}
|
|
386
|
+
respondError(id, code, message) {
|
|
387
|
+
this.rpc.write({ jsonrpc: '2.0', id, error: { code, message } });
|
|
388
|
+
}
|
|
389
|
+
appendEvent(runId, method, payload) {
|
|
390
|
+
const prev = this.runSeq.get(runId) ?? 0;
|
|
391
|
+
const seq = prev + 1;
|
|
392
|
+
this.runSeq.set(runId, seq);
|
|
393
|
+
this.db
|
|
394
|
+
.prepare('INSERT INTO events(run_id, seq, method, payload_json, created_at) VALUES(?, ?, ?, ?, ?)')
|
|
395
|
+
.run(runId, seq, method, JSON.stringify(payload), Date.now());
|
|
396
|
+
return seq;
|
|
397
|
+
}
|
|
398
|
+
getExistingRunSeq(runId) {
|
|
399
|
+
const row = this.db
|
|
400
|
+
.prepare('SELECT COALESCE(MAX(seq), 0) as maxSeq FROM events WHERE run_id = ?')
|
|
401
|
+
.get(runId);
|
|
402
|
+
return typeof row?.maxSeq === 'number' ? row.maxSeq : 0;
|
|
403
|
+
}
|
|
404
|
+
async ensureAuthorized(params) {
|
|
405
|
+
const { kind } = params;
|
|
406
|
+
const sessionKey = this.currentRun?.sessionKey;
|
|
407
|
+
// Tool calls should only occur within a prompt turn.
|
|
408
|
+
if (!sessionKey) {
|
|
409
|
+
throw new Error(`Tool call not allowed outside prompt turn (kind=${kind})`);
|
|
410
|
+
}
|
|
411
|
+
if (this.disabledToolKinds.has(kind)) {
|
|
412
|
+
throw new Error(`Tool call denied by agent settings: ${kind} disabled.`);
|
|
413
|
+
}
|
|
414
|
+
if (this.defaultAllowTools) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
// If auth is not wired, default deny (secure by default).
|
|
418
|
+
if (!this.toolAuth) {
|
|
419
|
+
throw new Error(`Tool call denied (no ToolAuth): ${kind}`);
|
|
420
|
+
}
|
|
421
|
+
if (this.toolAuth.consume(sessionKey, kind, {
|
|
422
|
+
method: params.method,
|
|
423
|
+
params: params.params,
|
|
424
|
+
workspaceRoot: this.workspaceRoot,
|
|
425
|
+
})) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (!this.events.onPermissionRequest) {
|
|
429
|
+
throw new Error(`Tool call denied by policy: ${kind}. Approve in permission UI (Allow) or use /allow <n>.`);
|
|
430
|
+
}
|
|
431
|
+
const req = buildLocalPermissionRequest({
|
|
432
|
+
sessionKey,
|
|
433
|
+
kind,
|
|
434
|
+
method: params.method,
|
|
435
|
+
params: params.params,
|
|
436
|
+
});
|
|
437
|
+
const decision = await new Promise((resolve, reject) => {
|
|
438
|
+
this.pendingLocalPermissions.set(req.requestId, { resolve, reject });
|
|
439
|
+
try {
|
|
440
|
+
this.events.onPermissionRequest?.(req);
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
this.pendingLocalPermissions.delete(req.requestId);
|
|
444
|
+
reject(new Error(String(error?.message ?? error)));
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
if (decision.kind === 'cancelled') {
|
|
448
|
+
throw new Error(`Tool call denied by policy: ${kind}. Approve in permission UI (Allow) or use /allow <n>.`);
|
|
449
|
+
}
|
|
450
|
+
if (this.toolAuth.consume(sessionKey, kind, {
|
|
451
|
+
method: params.method,
|
|
452
|
+
params: params.params,
|
|
453
|
+
workspaceRoot: this.workspaceRoot,
|
|
454
|
+
})) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
throw new Error(`Tool call denied by policy: ${kind}. Approve in permission UI (Allow) or use /allow <n>.`);
|
|
458
|
+
}
|
|
459
|
+
// terminal management (minimal)
|
|
460
|
+
terminals = new Map();
|
|
461
|
+
async withWorkspaceWriteLock(method, params, action) {
|
|
462
|
+
const lease = await this.acquireWorkspaceWriteLock(method, params);
|
|
463
|
+
try {
|
|
464
|
+
return await action();
|
|
465
|
+
}
|
|
466
|
+
finally {
|
|
467
|
+
lease.release();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async acquireWorkspaceWriteLock(method, params) {
|
|
471
|
+
const run = this.currentRun;
|
|
472
|
+
return this.workspaceLockManager.acquire(this.workspaceRoot, {
|
|
473
|
+
onWaitStart: () => {
|
|
474
|
+
if (!run)
|
|
475
|
+
return;
|
|
476
|
+
this.events.onTaskUpdate?.(run, {
|
|
477
|
+
title: 'waiting for workspace lock',
|
|
478
|
+
detail: buildWorkspaceLockWaitDetail(method, params),
|
|
479
|
+
silent: true,
|
|
480
|
+
});
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
async terminalCreate(params, lease) {
|
|
485
|
+
const terminalId = randomUUID();
|
|
486
|
+
const cwd = params.cwd
|
|
487
|
+
? resolveWorkspacePath(this.workspaceRoot, params.cwd)
|
|
488
|
+
: this.workspaceRoot;
|
|
489
|
+
const byteLimit = params.outputByteLimit ?? 256_000;
|
|
490
|
+
// Claude Code ACP may pass shell commands with operators (&&, |, etc.) as a single string.
|
|
491
|
+
// Wrap in sh -c so the shell can interpret them correctly.
|
|
492
|
+
const hasShellOperators = /[&|;<>$`\\!]/.test(params.command) || (params.args ?? []).length === 0;
|
|
493
|
+
const [spawnCmd, spawnArgs] = hasShellOperators
|
|
494
|
+
? ['sh', ['-c', params.command, ...(params.args ?? [])]]
|
|
495
|
+
: [params.command, params.args ?? []];
|
|
496
|
+
const child = spawn(spawnCmd, spawnArgs, {
|
|
497
|
+
cwd,
|
|
498
|
+
env: {
|
|
499
|
+
...process.env,
|
|
500
|
+
...(params.env ?? []).reduce((acc, kv) => {
|
|
501
|
+
acc[kv.name] = kv.value;
|
|
502
|
+
return acc;
|
|
503
|
+
}, {}),
|
|
504
|
+
},
|
|
505
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
506
|
+
});
|
|
507
|
+
const state = {
|
|
508
|
+
child,
|
|
509
|
+
output: '',
|
|
510
|
+
truncated: false,
|
|
511
|
+
byteLimit,
|
|
512
|
+
releaseLock: () => lease.release(),
|
|
513
|
+
};
|
|
514
|
+
this.terminals.set(terminalId, state);
|
|
515
|
+
const onData = (buf) => {
|
|
516
|
+
const chunk = buf.toString('utf8');
|
|
517
|
+
state.output += chunk;
|
|
518
|
+
if (state.output.length > state.byteLimit) {
|
|
519
|
+
state.output = state.output.slice(state.output.length - state.byteLimit);
|
|
520
|
+
state.truncated = true;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
child.stdout?.on('data', onData);
|
|
524
|
+
child.stderr?.on('data', onData);
|
|
525
|
+
child.once('exit', () => {
|
|
526
|
+
state.releaseLock?.();
|
|
527
|
+
state.releaseLock = undefined;
|
|
528
|
+
});
|
|
529
|
+
return terminalId;
|
|
530
|
+
}
|
|
531
|
+
terminalOutput(params) {
|
|
532
|
+
const state = this.terminals.get(params.terminalId);
|
|
533
|
+
if (!state)
|
|
534
|
+
throw new Error(`Unknown terminalId: ${params.terminalId}`);
|
|
535
|
+
const exitStatus = state.child.exitCode !== null || state.child.signalCode !== null
|
|
536
|
+
? { exitCode: state.child.exitCode, signal: state.child.signalCode }
|
|
537
|
+
: null;
|
|
538
|
+
return {
|
|
539
|
+
output: state.output,
|
|
540
|
+
truncated: state.truncated,
|
|
541
|
+
exitStatus,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
terminalWaitForExit(params) {
|
|
545
|
+
const state = this.terminals.get(params.terminalId);
|
|
546
|
+
if (!state)
|
|
547
|
+
throw new Error(`Unknown terminalId: ${params.terminalId}`);
|
|
548
|
+
return new Promise((resolve) => {
|
|
549
|
+
state.child.once('exit', (code, signal) => {
|
|
550
|
+
resolve({ exitCode: code, signal });
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
terminalKill(params) {
|
|
555
|
+
const state = this.terminals.get(params.terminalId);
|
|
556
|
+
if (!state)
|
|
557
|
+
throw new Error(`Unknown terminalId: ${params.terminalId}`);
|
|
558
|
+
this.killChild(state.child);
|
|
559
|
+
}
|
|
560
|
+
terminalRelease(params) {
|
|
561
|
+
const state = this.terminals.get(params.terminalId);
|
|
562
|
+
if (!state)
|
|
563
|
+
return;
|
|
564
|
+
this.terminals.delete(params.terminalId);
|
|
565
|
+
}
|
|
566
|
+
killChild(child) {
|
|
567
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
568
|
+
return;
|
|
569
|
+
child.kill('SIGKILL');
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function buildWorkspaceLockWaitDetail(method, params) {
|
|
573
|
+
if (method === 'fs/write_text_file') {
|
|
574
|
+
const pathValue = typeof params?.path === 'string'
|
|
575
|
+
? params.path
|
|
576
|
+
: 'file';
|
|
577
|
+
return `Waiting to write ${pathValue}.`;
|
|
578
|
+
}
|
|
579
|
+
if (method === 'terminal/create') {
|
|
580
|
+
const command = typeof params?.command === 'string'
|
|
581
|
+
? params.command
|
|
582
|
+
: 'command';
|
|
583
|
+
return `Waiting to execute ${command}.`;
|
|
584
|
+
}
|
|
585
|
+
return 'Waiting for another run to finish mutating this workspace.';
|
|
586
|
+
}
|
|
587
|
+
function formatJsonRpcErrorData(value) {
|
|
588
|
+
if (typeof value === 'string')
|
|
589
|
+
return value;
|
|
590
|
+
try {
|
|
591
|
+
return JSON.stringify(value);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
return String(value);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function buildLocalPermissionRequest(params) {
|
|
598
|
+
const sessionId = typeof params.params?.sessionId ===
|
|
599
|
+
'string'
|
|
600
|
+
? String(params.params.sessionId)
|
|
601
|
+
: 'unknown';
|
|
602
|
+
return {
|
|
603
|
+
requestId: `localperm-${randomUUID()}`,
|
|
604
|
+
sessionKey: params.sessionKey,
|
|
605
|
+
sessionId,
|
|
606
|
+
createdAtMs: Date.now(),
|
|
607
|
+
params: {
|
|
608
|
+
sessionId,
|
|
609
|
+
toolCall: {
|
|
610
|
+
title: buildLocalToolTitle(params.method, params.params),
|
|
611
|
+
kind: params.kind,
|
|
612
|
+
name: params.method,
|
|
613
|
+
arguments: buildLocalPermissionArgs(params.params),
|
|
614
|
+
},
|
|
615
|
+
options: [
|
|
616
|
+
{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
|
|
617
|
+
{
|
|
618
|
+
optionId: 'allow_always',
|
|
619
|
+
name: 'Always allow',
|
|
620
|
+
kind: 'allow_always',
|
|
621
|
+
},
|
|
622
|
+
{ optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
|
|
623
|
+
{
|
|
624
|
+
optionId: 'reject_always',
|
|
625
|
+
name: 'Always reject',
|
|
626
|
+
kind: 'reject_always',
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
function buildLocalToolTitle(method, rawParams) {
|
|
633
|
+
const params = (rawParams ?? {});
|
|
634
|
+
if (method === 'fs/read_text_file') {
|
|
635
|
+
const target = stringOrFallback(params.path, '<path>');
|
|
636
|
+
return truncateInline(`read: ${target}`, 180);
|
|
637
|
+
}
|
|
638
|
+
if (method === 'fs/write_text_file') {
|
|
639
|
+
const target = stringOrFallback(params.path, '<path>');
|
|
640
|
+
return truncateInline(`edit: ${target}`, 180);
|
|
641
|
+
}
|
|
642
|
+
if (method === 'terminal/create') {
|
|
643
|
+
const command = stringOrFallback(params.command, '<command>');
|
|
644
|
+
const args = Array.isArray(params.args)
|
|
645
|
+
? params.args
|
|
646
|
+
.filter((item) => typeof item === 'string')
|
|
647
|
+
.join(' ')
|
|
648
|
+
: '';
|
|
649
|
+
const full = args ? `${command} ${args}` : command;
|
|
650
|
+
return truncateInline(`run: ${full}`, 180);
|
|
651
|
+
}
|
|
652
|
+
if (method === 'terminal/kill') {
|
|
653
|
+
const terminalId = stringOrFallback(params.terminalId, '<terminal_id>');
|
|
654
|
+
return truncateInline(`run: kill terminal ${terminalId}`, 180);
|
|
655
|
+
}
|
|
656
|
+
return truncateInline(method, 180);
|
|
657
|
+
}
|
|
658
|
+
function buildLocalPermissionArgs(rawParams) {
|
|
659
|
+
if (!rawParams || typeof rawParams !== 'object' || Array.isArray(rawParams)) {
|
|
660
|
+
return rawParams;
|
|
661
|
+
}
|
|
662
|
+
const source = rawParams;
|
|
663
|
+
const args = {};
|
|
664
|
+
for (const [key, value] of Object.entries(source)) {
|
|
665
|
+
if (key === 'sessionId')
|
|
666
|
+
continue;
|
|
667
|
+
args[key] = sanitizePermissionArgValue(key, value);
|
|
668
|
+
}
|
|
669
|
+
return args;
|
|
670
|
+
}
|
|
671
|
+
function sanitizePermissionArgValue(key, value) {
|
|
672
|
+
if (key === 'content' && typeof value === 'string') {
|
|
673
|
+
return value.length > 240
|
|
674
|
+
? `${value.slice(0, 237)}... (${value.length} chars)`
|
|
675
|
+
: value;
|
|
676
|
+
}
|
|
677
|
+
if (key === 'env' && Array.isArray(value)) {
|
|
678
|
+
return value.map((item) => {
|
|
679
|
+
if (!item || typeof item !== 'object')
|
|
680
|
+
return '<env>';
|
|
681
|
+
const name = item.name;
|
|
682
|
+
return typeof name === 'string' && name.trim() ? name.trim() : '<env>';
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
return value;
|
|
686
|
+
}
|
|
687
|
+
function truncateInline(text, maxLen) {
|
|
688
|
+
const clean = text.replace(/\s+/g, ' ').trim();
|
|
689
|
+
if (clean.length <= maxLen)
|
|
690
|
+
return clean;
|
|
691
|
+
return clean.slice(0, maxLen - 3) + '...';
|
|
692
|
+
}
|
|
693
|
+
function stringOrFallback(value, fallback) {
|
|
694
|
+
if (typeof value !== 'string')
|
|
695
|
+
return fallback;
|
|
696
|
+
const trimmed = value.trim();
|
|
697
|
+
return trimmed || fallback;
|
|
698
|
+
}
|
|
699
|
+
function createMemoryWriteGuard(workspaceRoot, resolvedPath) {
|
|
700
|
+
const memoryPath = path.resolve(workspaceRoot, 'MEMORY.md');
|
|
701
|
+
if (path.resolve(resolvedPath) !== memoryPath)
|
|
702
|
+
return null;
|
|
703
|
+
const before = snapshotFileVersion(memoryPath);
|
|
704
|
+
return {
|
|
705
|
+
resolveContent(intendedContent, waited) {
|
|
706
|
+
if (!waited)
|
|
707
|
+
return intendedContent;
|
|
708
|
+
const after = snapshotFileVersion(memoryPath);
|
|
709
|
+
if (before.exists !== after.exists ||
|
|
710
|
+
before.size !== after.size ||
|
|
711
|
+
before.mtimeMs !== after.mtimeMs) {
|
|
712
|
+
// MEMORY.md was changed by another agent while we waited for the lock.
|
|
713
|
+
// Re-read the latest version and merge by appending our intended content,
|
|
714
|
+
// so both agents' updates are preserved and no data is lost.
|
|
715
|
+
log.warn('MEMORY.md changed while waiting for workspace lock; merging with latest content.');
|
|
716
|
+
try {
|
|
717
|
+
const latest = fs.readFileSync(memoryPath, 'utf8');
|
|
718
|
+
return `${latest.trimEnd()}\n\n${intendedContent.trimStart()}`;
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// Best-effort: if we can't read the latest, proceed with the intended content.
|
|
722
|
+
return intendedContent;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return intendedContent;
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
function snapshotFileVersion(filePath) {
|
|
730
|
+
const stat = fs.statSync(filePath, { throwIfNoEntry: false });
|
|
731
|
+
if (!stat) {
|
|
732
|
+
return { exists: false, size: null, mtimeMs: null };
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
exists: true,
|
|
736
|
+
size: stat.size,
|
|
737
|
+
mtimeMs: Math.floor(stat.mtimeMs),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
function readTextFileWithLimit(filePath, line, limit) {
|
|
741
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
742
|
+
if (!line || !limit)
|
|
743
|
+
return content;
|
|
744
|
+
const lines = content.split(/\r?\n/);
|
|
745
|
+
const startIndex = Math.max(0, line - 1);
|
|
746
|
+
return lines.slice(startIndex, startIndex + limit).join('\n');
|
|
747
|
+
}
|