acpx 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/LICENSE +15 -0
- package/README.md +102 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1452 -0
- package/package.json +48 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1452 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command, CommanderError, InvalidArgumentError } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/output.ts
|
|
7
|
+
function nowIso() {
|
|
8
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
9
|
+
}
|
|
10
|
+
function asStatus(status) {
|
|
11
|
+
return status ?? "unknown";
|
|
12
|
+
}
|
|
13
|
+
var TextOutputFormatter = class {
|
|
14
|
+
stdout;
|
|
15
|
+
constructor(stdout) {
|
|
16
|
+
this.stdout = stdout;
|
|
17
|
+
}
|
|
18
|
+
onSessionUpdate(notification) {
|
|
19
|
+
const update = notification.update;
|
|
20
|
+
switch (update.sessionUpdate) {
|
|
21
|
+
case "agent_message_chunk": {
|
|
22
|
+
if (update.content.type === "text") {
|
|
23
|
+
this.stdout.write(update.content.text);
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
case "agent_thought_chunk": {
|
|
28
|
+
if (update.content.type === "text") {
|
|
29
|
+
this.stdout.write(`
|
|
30
|
+
[thought] ${update.content.text}
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
case "tool_call": {
|
|
36
|
+
this.stdout.write(`
|
|
37
|
+
[tool] ${update.title} (${asStatus(update.status)})
|
|
38
|
+
`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
case "tool_call_update": {
|
|
42
|
+
const title = update.title ?? update.toolCallId;
|
|
43
|
+
this.stdout.write(`
|
|
44
|
+
[tool] ${title} (${asStatus(update.status)})
|
|
45
|
+
`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
case "plan": {
|
|
49
|
+
this.stdout.write("\n[plan]\n");
|
|
50
|
+
for (const entry of update.entries) {
|
|
51
|
+
this.stdout.write(`- (${entry.status}) ${entry.content}
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
onDone(stopReason) {
|
|
61
|
+
this.stdout.write(`
|
|
62
|
+
[done] ${stopReason}
|
|
63
|
+
`);
|
|
64
|
+
}
|
|
65
|
+
flush() {
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
var JsonOutputFormatter = class {
|
|
69
|
+
stdout;
|
|
70
|
+
constructor(stdout) {
|
|
71
|
+
this.stdout = stdout;
|
|
72
|
+
}
|
|
73
|
+
onSessionUpdate(notification) {
|
|
74
|
+
const update = notification.update;
|
|
75
|
+
const timestamp = nowIso();
|
|
76
|
+
switch (update.sessionUpdate) {
|
|
77
|
+
case "agent_message_chunk": {
|
|
78
|
+
if (update.content.type === "text") {
|
|
79
|
+
this.emit({
|
|
80
|
+
type: "text",
|
|
81
|
+
content: update.content.text,
|
|
82
|
+
timestamp
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
case "agent_thought_chunk": {
|
|
88
|
+
if (update.content.type === "text") {
|
|
89
|
+
this.emit({
|
|
90
|
+
type: "thought",
|
|
91
|
+
content: update.content.text,
|
|
92
|
+
timestamp
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
case "tool_call": {
|
|
98
|
+
this.emit({
|
|
99
|
+
type: "tool_call",
|
|
100
|
+
title: update.title,
|
|
101
|
+
toolCallId: update.toolCallId,
|
|
102
|
+
status: update.status,
|
|
103
|
+
timestamp
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
case "tool_call_update": {
|
|
108
|
+
this.emit({
|
|
109
|
+
type: "tool_call",
|
|
110
|
+
title: update.title ?? void 0,
|
|
111
|
+
toolCallId: update.toolCallId,
|
|
112
|
+
status: update.status ?? void 0,
|
|
113
|
+
timestamp
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
case "plan": {
|
|
118
|
+
this.emit({
|
|
119
|
+
type: "plan",
|
|
120
|
+
entries: update.entries.map((entry) => ({
|
|
121
|
+
content: entry.content,
|
|
122
|
+
status: entry.status,
|
|
123
|
+
priority: entry.priority
|
|
124
|
+
})),
|
|
125
|
+
timestamp
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
default: {
|
|
130
|
+
this.emit({
|
|
131
|
+
type: "update",
|
|
132
|
+
update: update.sessionUpdate,
|
|
133
|
+
timestamp
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
onDone(stopReason) {
|
|
139
|
+
this.emit({
|
|
140
|
+
type: "done",
|
|
141
|
+
stopReason,
|
|
142
|
+
timestamp: nowIso()
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
flush() {
|
|
146
|
+
}
|
|
147
|
+
emit(event) {
|
|
148
|
+
this.stdout.write(`${JSON.stringify(event)}
|
|
149
|
+
`);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
var QuietOutputFormatter = class {
|
|
153
|
+
stdout;
|
|
154
|
+
chunks = [];
|
|
155
|
+
constructor(stdout) {
|
|
156
|
+
this.stdout = stdout;
|
|
157
|
+
}
|
|
158
|
+
onSessionUpdate(notification) {
|
|
159
|
+
const update = notification.update;
|
|
160
|
+
if (update.sessionUpdate !== "agent_message_chunk") {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (update.content.type !== "text") {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.chunks.push(update.content.text);
|
|
167
|
+
}
|
|
168
|
+
onDone(_stopReason) {
|
|
169
|
+
const text = this.chunks.join("");
|
|
170
|
+
this.stdout.write(text.endsWith("\n") ? text : `${text}
|
|
171
|
+
`);
|
|
172
|
+
}
|
|
173
|
+
flush() {
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
function createOutputFormatter(format, options = {}) {
|
|
177
|
+
const stdout = options.stdout ?? process.stdout;
|
|
178
|
+
switch (format) {
|
|
179
|
+
case "text":
|
|
180
|
+
return new TextOutputFormatter(stdout);
|
|
181
|
+
case "json":
|
|
182
|
+
return new JsonOutputFormatter(stdout);
|
|
183
|
+
case "quiet":
|
|
184
|
+
return new QuietOutputFormatter(stdout);
|
|
185
|
+
default: {
|
|
186
|
+
const exhaustive = format;
|
|
187
|
+
throw new Error(`Unsupported output format: ${exhaustive}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/session.ts
|
|
193
|
+
import fs2 from "fs/promises";
|
|
194
|
+
import os from "os";
|
|
195
|
+
import path2 from "path";
|
|
196
|
+
|
|
197
|
+
// src/client.ts
|
|
198
|
+
import {
|
|
199
|
+
ClientSideConnection,
|
|
200
|
+
PROTOCOL_VERSION,
|
|
201
|
+
ndJsonStream
|
|
202
|
+
} from "@agentclientprotocol/sdk";
|
|
203
|
+
import {
|
|
204
|
+
spawn
|
|
205
|
+
} from "child_process";
|
|
206
|
+
import { randomUUID } from "crypto";
|
|
207
|
+
import fs from "fs/promises";
|
|
208
|
+
import path from "path";
|
|
209
|
+
import { Readable, Writable } from "stream";
|
|
210
|
+
|
|
211
|
+
// src/permissions.ts
|
|
212
|
+
import readline from "readline/promises";
|
|
213
|
+
function selected(optionId) {
|
|
214
|
+
return { outcome: { outcome: "selected", optionId } };
|
|
215
|
+
}
|
|
216
|
+
function cancelled() {
|
|
217
|
+
return { outcome: { outcome: "cancelled" } };
|
|
218
|
+
}
|
|
219
|
+
function pickOption(options, kinds) {
|
|
220
|
+
for (const kind of kinds) {
|
|
221
|
+
const match = options.find((option) => option.kind === kind);
|
|
222
|
+
if (match) {
|
|
223
|
+
return match;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return void 0;
|
|
227
|
+
}
|
|
228
|
+
function inferToolKind(params) {
|
|
229
|
+
if (params.toolCall.kind) {
|
|
230
|
+
return params.toolCall.kind;
|
|
231
|
+
}
|
|
232
|
+
const title = params.toolCall.title?.trim().toLowerCase();
|
|
233
|
+
if (!title) {
|
|
234
|
+
return void 0;
|
|
235
|
+
}
|
|
236
|
+
const head = title.split(":", 1)[0]?.trim();
|
|
237
|
+
if (!head) {
|
|
238
|
+
return void 0;
|
|
239
|
+
}
|
|
240
|
+
if (head.includes("read") || head.includes("cat")) {
|
|
241
|
+
return "read";
|
|
242
|
+
}
|
|
243
|
+
if (head.includes("search") || head.includes("find") || head.includes("grep")) {
|
|
244
|
+
return "search";
|
|
245
|
+
}
|
|
246
|
+
if (head.includes("write") || head.includes("edit") || head.includes("patch")) {
|
|
247
|
+
return "edit";
|
|
248
|
+
}
|
|
249
|
+
if (head.includes("delete") || head.includes("remove")) {
|
|
250
|
+
return "delete";
|
|
251
|
+
}
|
|
252
|
+
if (head.includes("move") || head.includes("rename")) {
|
|
253
|
+
return "move";
|
|
254
|
+
}
|
|
255
|
+
if (head.includes("run") || head.includes("execute") || head.includes("bash")) {
|
|
256
|
+
return "execute";
|
|
257
|
+
}
|
|
258
|
+
if (head.includes("fetch") || head.includes("http") || head.includes("url")) {
|
|
259
|
+
return "fetch";
|
|
260
|
+
}
|
|
261
|
+
if (head.includes("think")) {
|
|
262
|
+
return "think";
|
|
263
|
+
}
|
|
264
|
+
return "other";
|
|
265
|
+
}
|
|
266
|
+
function isAutoApprovedReadKind(kind) {
|
|
267
|
+
return kind === "read" || kind === "search";
|
|
268
|
+
}
|
|
269
|
+
async function promptForPermission(params) {
|
|
270
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
const toolName = params.toolCall.title ?? "tool";
|
|
274
|
+
const toolKind = inferToolKind(params) ?? "other";
|
|
275
|
+
const rl = readline.createInterface({
|
|
276
|
+
input: process.stdin,
|
|
277
|
+
output: process.stderr
|
|
278
|
+
});
|
|
279
|
+
try {
|
|
280
|
+
const answer = await rl.question(
|
|
281
|
+
`
|
|
282
|
+
[permission] Allow ${toolName} [${toolKind}]? (y/N) `
|
|
283
|
+
);
|
|
284
|
+
const normalized = answer.trim().toLowerCase();
|
|
285
|
+
return normalized === "y" || normalized === "yes";
|
|
286
|
+
} finally {
|
|
287
|
+
rl.close();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function resolvePermissionRequest(params, mode) {
|
|
291
|
+
const options = params.options ?? [];
|
|
292
|
+
if (options.length === 0) {
|
|
293
|
+
return cancelled();
|
|
294
|
+
}
|
|
295
|
+
const allowOption = pickOption(options, ["allow_once", "allow_always"]);
|
|
296
|
+
const rejectOption = pickOption(options, ["reject_once", "reject_always"]);
|
|
297
|
+
if (mode === "approve-all") {
|
|
298
|
+
if (allowOption) {
|
|
299
|
+
return selected(allowOption.optionId);
|
|
300
|
+
}
|
|
301
|
+
return selected(options[0].optionId);
|
|
302
|
+
}
|
|
303
|
+
if (mode === "deny-all") {
|
|
304
|
+
if (rejectOption) {
|
|
305
|
+
return selected(rejectOption.optionId);
|
|
306
|
+
}
|
|
307
|
+
return cancelled();
|
|
308
|
+
}
|
|
309
|
+
const kind = inferToolKind(params);
|
|
310
|
+
if (isAutoApprovedReadKind(kind) && allowOption) {
|
|
311
|
+
return selected(allowOption.optionId);
|
|
312
|
+
}
|
|
313
|
+
const approved = await promptForPermission(params);
|
|
314
|
+
if (approved && allowOption) {
|
|
315
|
+
return selected(allowOption.optionId);
|
|
316
|
+
}
|
|
317
|
+
if (!approved && rejectOption) {
|
|
318
|
+
return selected(rejectOption.optionId);
|
|
319
|
+
}
|
|
320
|
+
return cancelled();
|
|
321
|
+
}
|
|
322
|
+
function classifyPermissionDecision(params, response) {
|
|
323
|
+
if (response.outcome.outcome !== "selected") {
|
|
324
|
+
return "cancelled";
|
|
325
|
+
}
|
|
326
|
+
const selectedOptionId = response.outcome.optionId;
|
|
327
|
+
const selectedOption = params.options.find(
|
|
328
|
+
(option) => option.optionId === selectedOptionId
|
|
329
|
+
);
|
|
330
|
+
if (!selectedOption) {
|
|
331
|
+
return "cancelled";
|
|
332
|
+
}
|
|
333
|
+
if (selectedOption.kind === "allow_once" || selectedOption.kind === "allow_always") {
|
|
334
|
+
return "approved";
|
|
335
|
+
}
|
|
336
|
+
return "denied";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// src/client.ts
|
|
340
|
+
var DEFAULT_TERMINAL_OUTPUT_LIMIT_BYTES = 64 * 1024;
|
|
341
|
+
var REPLAY_IDLE_MS = 80;
|
|
342
|
+
var REPLAY_DRAIN_TIMEOUT_MS = 5e3;
|
|
343
|
+
var DRAIN_POLL_INTERVAL_MS = 20;
|
|
344
|
+
function waitForSpawn(child) {
|
|
345
|
+
return new Promise((resolve, reject) => {
|
|
346
|
+
const onSpawn = () => {
|
|
347
|
+
child.off("error", onError);
|
|
348
|
+
resolve();
|
|
349
|
+
};
|
|
350
|
+
const onError = (error) => {
|
|
351
|
+
child.off("spawn", onSpawn);
|
|
352
|
+
reject(error);
|
|
353
|
+
};
|
|
354
|
+
child.once("spawn", onSpawn);
|
|
355
|
+
child.once("error", onError);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
function splitCommandLine(value) {
|
|
359
|
+
const parts = [];
|
|
360
|
+
let current = "";
|
|
361
|
+
let quote = null;
|
|
362
|
+
let escaping = false;
|
|
363
|
+
for (const ch of value) {
|
|
364
|
+
if (escaping) {
|
|
365
|
+
current += ch;
|
|
366
|
+
escaping = false;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (ch === "\\" && quote !== "'") {
|
|
370
|
+
escaping = true;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (quote) {
|
|
374
|
+
if (ch === quote) {
|
|
375
|
+
quote = null;
|
|
376
|
+
} else {
|
|
377
|
+
current += ch;
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (ch === "'" || ch === '"') {
|
|
382
|
+
quote = ch;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (/\s/.test(ch)) {
|
|
386
|
+
if (current.length > 0) {
|
|
387
|
+
parts.push(current);
|
|
388
|
+
current = "";
|
|
389
|
+
}
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
current += ch;
|
|
393
|
+
}
|
|
394
|
+
if (escaping) {
|
|
395
|
+
current += "\\";
|
|
396
|
+
}
|
|
397
|
+
if (quote) {
|
|
398
|
+
throw new Error("Invalid --agent command: unterminated quote");
|
|
399
|
+
}
|
|
400
|
+
if (current.length > 0) {
|
|
401
|
+
parts.push(current);
|
|
402
|
+
}
|
|
403
|
+
if (parts.length === 0) {
|
|
404
|
+
throw new Error("Invalid --agent command: empty command");
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
command: parts[0],
|
|
408
|
+
args: parts.slice(1)
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function asAbsoluteCwd(cwd) {
|
|
412
|
+
return path.resolve(cwd);
|
|
413
|
+
}
|
|
414
|
+
function toEnvObject(env) {
|
|
415
|
+
if (!env || env.length === 0) {
|
|
416
|
+
return void 0;
|
|
417
|
+
}
|
|
418
|
+
const merged = { ...process.env };
|
|
419
|
+
for (const entry of env) {
|
|
420
|
+
merged[entry.name] = entry.value;
|
|
421
|
+
}
|
|
422
|
+
return merged;
|
|
423
|
+
}
|
|
424
|
+
var AcpClient = class {
|
|
425
|
+
options;
|
|
426
|
+
connection;
|
|
427
|
+
agent;
|
|
428
|
+
initResult;
|
|
429
|
+
permissionStats = {
|
|
430
|
+
requested: 0,
|
|
431
|
+
approved: 0,
|
|
432
|
+
denied: 0,
|
|
433
|
+
cancelled: 0
|
|
434
|
+
};
|
|
435
|
+
terminals = /* @__PURE__ */ new Map();
|
|
436
|
+
sessionUpdateChain = Promise.resolve();
|
|
437
|
+
observedSessionUpdates = 0;
|
|
438
|
+
processedSessionUpdates = 0;
|
|
439
|
+
suppressSessionUpdates = false;
|
|
440
|
+
constructor(options) {
|
|
441
|
+
this.options = {
|
|
442
|
+
...options,
|
|
443
|
+
cwd: asAbsoluteCwd(options.cwd)
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
get initializeResult() {
|
|
447
|
+
return this.initResult;
|
|
448
|
+
}
|
|
449
|
+
getAgentPid() {
|
|
450
|
+
return this.agent?.pid ?? void 0;
|
|
451
|
+
}
|
|
452
|
+
getPermissionStats() {
|
|
453
|
+
return { ...this.permissionStats };
|
|
454
|
+
}
|
|
455
|
+
supportsLoadSession() {
|
|
456
|
+
return Boolean(this.initResult?.agentCapabilities?.loadSession);
|
|
457
|
+
}
|
|
458
|
+
async start() {
|
|
459
|
+
if (this.connection && this.agent) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const { command, args } = splitCommandLine(this.options.agentCommand);
|
|
463
|
+
this.log(`spawning agent: ${command} ${args.join(" ")}`);
|
|
464
|
+
const child = spawn(command, args, {
|
|
465
|
+
cwd: this.options.cwd,
|
|
466
|
+
stdio: ["pipe", "pipe", "inherit"]
|
|
467
|
+
});
|
|
468
|
+
await waitForSpawn(child);
|
|
469
|
+
const input = Writable.toWeb(child.stdin);
|
|
470
|
+
const output = Readable.toWeb(child.stdout);
|
|
471
|
+
const stream = ndJsonStream(input, output);
|
|
472
|
+
const connection = new ClientSideConnection(
|
|
473
|
+
() => ({
|
|
474
|
+
sessionUpdate: async (params) => {
|
|
475
|
+
await this.handleSessionUpdate(params);
|
|
476
|
+
},
|
|
477
|
+
requestPermission: async (params) => {
|
|
478
|
+
return this.handlePermissionRequest(params);
|
|
479
|
+
},
|
|
480
|
+
readTextFile: async (params) => {
|
|
481
|
+
return this.handleReadTextFile(params);
|
|
482
|
+
},
|
|
483
|
+
writeTextFile: async (params) => {
|
|
484
|
+
return this.handleWriteTextFile(params);
|
|
485
|
+
},
|
|
486
|
+
createTerminal: async (params) => {
|
|
487
|
+
return this.handleCreateTerminal(params);
|
|
488
|
+
},
|
|
489
|
+
terminalOutput: async (params) => {
|
|
490
|
+
return this.handleTerminalOutput(params);
|
|
491
|
+
},
|
|
492
|
+
waitForTerminalExit: async (params) => {
|
|
493
|
+
return this.handleWaitForTerminalExit(params);
|
|
494
|
+
},
|
|
495
|
+
killTerminal: async (params) => {
|
|
496
|
+
return this.handleKillTerminal(params);
|
|
497
|
+
},
|
|
498
|
+
releaseTerminal: async (params) => {
|
|
499
|
+
return this.handleReleaseTerminal(params);
|
|
500
|
+
}
|
|
501
|
+
}),
|
|
502
|
+
stream
|
|
503
|
+
);
|
|
504
|
+
try {
|
|
505
|
+
const initResult = await connection.initialize({
|
|
506
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
507
|
+
clientCapabilities: {
|
|
508
|
+
fs: {
|
|
509
|
+
readTextFile: true,
|
|
510
|
+
writeTextFile: true
|
|
511
|
+
},
|
|
512
|
+
terminal: true
|
|
513
|
+
},
|
|
514
|
+
clientInfo: {
|
|
515
|
+
name: "acpx",
|
|
516
|
+
version: "0.1.0"
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
this.connection = connection;
|
|
520
|
+
this.agent = child;
|
|
521
|
+
this.initResult = initResult;
|
|
522
|
+
this.log(`initialized protocol version ${initResult.protocolVersion}`);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
child.kill();
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async createSession(cwd = this.options.cwd) {
|
|
529
|
+
const connection = this.getConnection();
|
|
530
|
+
const result = await connection.newSession({
|
|
531
|
+
cwd: asAbsoluteCwd(cwd),
|
|
532
|
+
mcpServers: []
|
|
533
|
+
});
|
|
534
|
+
return result.sessionId;
|
|
535
|
+
}
|
|
536
|
+
async loadSession(sessionId, cwd = this.options.cwd) {
|
|
537
|
+
this.getConnection();
|
|
538
|
+
await this.loadSessionWithOptions(sessionId, cwd, {});
|
|
539
|
+
}
|
|
540
|
+
async loadSessionWithOptions(sessionId, cwd = this.options.cwd, options = {}) {
|
|
541
|
+
const connection = this.getConnection();
|
|
542
|
+
const previousSuppression = this.suppressSessionUpdates;
|
|
543
|
+
this.suppressSessionUpdates = previousSuppression || Boolean(options.suppressReplayUpdates);
|
|
544
|
+
try {
|
|
545
|
+
await connection.loadSession({
|
|
546
|
+
sessionId,
|
|
547
|
+
cwd: asAbsoluteCwd(cwd),
|
|
548
|
+
mcpServers: []
|
|
549
|
+
});
|
|
550
|
+
await this.waitForSessionUpdateDrain(
|
|
551
|
+
options.replayIdleMs ?? REPLAY_IDLE_MS,
|
|
552
|
+
options.replayDrainTimeoutMs ?? REPLAY_DRAIN_TIMEOUT_MS
|
|
553
|
+
);
|
|
554
|
+
} finally {
|
|
555
|
+
this.suppressSessionUpdates = previousSuppression;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async prompt(sessionId, text) {
|
|
559
|
+
const connection = this.getConnection();
|
|
560
|
+
return connection.prompt({
|
|
561
|
+
sessionId,
|
|
562
|
+
prompt: [
|
|
563
|
+
{
|
|
564
|
+
type: "text",
|
|
565
|
+
text
|
|
566
|
+
}
|
|
567
|
+
]
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
async close() {
|
|
571
|
+
for (const terminalId of [...this.terminals.keys()]) {
|
|
572
|
+
await this.releaseTerminalById(terminalId);
|
|
573
|
+
}
|
|
574
|
+
if (this.agent && !this.agent.killed) {
|
|
575
|
+
this.agent.kill();
|
|
576
|
+
}
|
|
577
|
+
this.sessionUpdateChain = Promise.resolve();
|
|
578
|
+
this.observedSessionUpdates = 0;
|
|
579
|
+
this.processedSessionUpdates = 0;
|
|
580
|
+
this.suppressSessionUpdates = false;
|
|
581
|
+
this.connection = void 0;
|
|
582
|
+
this.agent = void 0;
|
|
583
|
+
}
|
|
584
|
+
getConnection() {
|
|
585
|
+
if (!this.connection) {
|
|
586
|
+
throw new Error("ACP client not started");
|
|
587
|
+
}
|
|
588
|
+
return this.connection;
|
|
589
|
+
}
|
|
590
|
+
log(message) {
|
|
591
|
+
if (!this.options.verbose) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
process.stderr.write(`[acpx] ${message}
|
|
595
|
+
`);
|
|
596
|
+
}
|
|
597
|
+
async handlePermissionRequest(params) {
|
|
598
|
+
const response = await resolvePermissionRequest(
|
|
599
|
+
params,
|
|
600
|
+
this.options.permissionMode
|
|
601
|
+
);
|
|
602
|
+
const decision = classifyPermissionDecision(params, response);
|
|
603
|
+
this.permissionStats.requested += 1;
|
|
604
|
+
if (decision === "approved") {
|
|
605
|
+
this.permissionStats.approved += 1;
|
|
606
|
+
} else if (decision === "denied") {
|
|
607
|
+
this.permissionStats.denied += 1;
|
|
608
|
+
} else {
|
|
609
|
+
this.permissionStats.cancelled += 1;
|
|
610
|
+
}
|
|
611
|
+
return response;
|
|
612
|
+
}
|
|
613
|
+
async handleReadTextFile(params) {
|
|
614
|
+
const content = await fs.readFile(params.path, "utf8");
|
|
615
|
+
if (params.line == null && params.limit == null) {
|
|
616
|
+
return { content };
|
|
617
|
+
}
|
|
618
|
+
const lines = content.split("\n");
|
|
619
|
+
const start = Math.max(0, (params.line ?? 1) - 1);
|
|
620
|
+
const end = params.limit == null ? lines.length : start + Math.max(params.limit, 0);
|
|
621
|
+
return {
|
|
622
|
+
content: lines.slice(start, end).join("\n")
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
async handleWriteTextFile(params) {
|
|
626
|
+
await fs.mkdir(path.dirname(params.path), { recursive: true });
|
|
627
|
+
await fs.writeFile(params.path, params.content, "utf8");
|
|
628
|
+
return {};
|
|
629
|
+
}
|
|
630
|
+
async handleCreateTerminal(params) {
|
|
631
|
+
const outputByteLimit = Math.max(
|
|
632
|
+
1,
|
|
633
|
+
params.outputByteLimit ?? DEFAULT_TERMINAL_OUTPUT_LIMIT_BYTES
|
|
634
|
+
);
|
|
635
|
+
const proc = spawn(params.command, params.args ?? [], {
|
|
636
|
+
cwd: params.cwd ?? this.options.cwd,
|
|
637
|
+
env: toEnvObject(params.env),
|
|
638
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
639
|
+
});
|
|
640
|
+
await waitForSpawn(proc);
|
|
641
|
+
const terminalId = randomUUID();
|
|
642
|
+
const terminal = {
|
|
643
|
+
process: proc,
|
|
644
|
+
output: Buffer.alloc(0),
|
|
645
|
+
truncated: false,
|
|
646
|
+
outputByteLimit,
|
|
647
|
+
exitCode: void 0,
|
|
648
|
+
signal: void 0,
|
|
649
|
+
waiters: []
|
|
650
|
+
};
|
|
651
|
+
const appendOutput = (chunk) => {
|
|
652
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
653
|
+
terminal.output = Buffer.concat([terminal.output, buf]);
|
|
654
|
+
if (terminal.output.length > terminal.outputByteLimit) {
|
|
655
|
+
terminal.output = terminal.output.subarray(
|
|
656
|
+
terminal.output.length - terminal.outputByteLimit
|
|
657
|
+
);
|
|
658
|
+
terminal.truncated = true;
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
proc.stdout.on("data", appendOutput);
|
|
662
|
+
proc.stderr.on("data", appendOutput);
|
|
663
|
+
proc.once("exit", (exitCode, signal) => {
|
|
664
|
+
terminal.exitCode = exitCode;
|
|
665
|
+
terminal.signal = signal;
|
|
666
|
+
const response = {
|
|
667
|
+
exitCode: exitCode ?? null,
|
|
668
|
+
signal: signal ?? null
|
|
669
|
+
};
|
|
670
|
+
for (const waiter of terminal.waiters.splice(0)) {
|
|
671
|
+
waiter(response);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
this.terminals.set(terminalId, terminal);
|
|
675
|
+
return { terminalId };
|
|
676
|
+
}
|
|
677
|
+
async handleTerminalOutput(params) {
|
|
678
|
+
const terminal = this.getTerminal(params.terminalId);
|
|
679
|
+
if (!terminal) {
|
|
680
|
+
throw new Error(`Unknown terminal: ${params.terminalId}`);
|
|
681
|
+
}
|
|
682
|
+
const hasExitStatus = terminal.exitCode !== void 0 || terminal.signal !== void 0;
|
|
683
|
+
return {
|
|
684
|
+
output: terminal.output.toString("utf8"),
|
|
685
|
+
truncated: terminal.truncated,
|
|
686
|
+
exitStatus: hasExitStatus ? {
|
|
687
|
+
exitCode: terminal.exitCode ?? null,
|
|
688
|
+
signal: terminal.signal ?? null
|
|
689
|
+
} : void 0
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
async handleWaitForTerminalExit(params) {
|
|
693
|
+
const terminal = this.getTerminal(params.terminalId);
|
|
694
|
+
if (!terminal) {
|
|
695
|
+
throw new Error(`Unknown terminal: ${params.terminalId}`);
|
|
696
|
+
}
|
|
697
|
+
if (terminal.exitCode !== void 0 || terminal.signal !== void 0) {
|
|
698
|
+
return {
|
|
699
|
+
exitCode: terminal.exitCode ?? null,
|
|
700
|
+
signal: terminal.signal ?? null
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return new Promise((resolve) => {
|
|
704
|
+
terminal.waiters.push(resolve);
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
async handleKillTerminal(params) {
|
|
708
|
+
const terminal = this.getTerminal(params.terminalId);
|
|
709
|
+
if (!terminal) {
|
|
710
|
+
throw new Error(`Unknown terminal: ${params.terminalId}`);
|
|
711
|
+
}
|
|
712
|
+
if (!terminal.process.killed) {
|
|
713
|
+
terminal.process.kill();
|
|
714
|
+
}
|
|
715
|
+
return {};
|
|
716
|
+
}
|
|
717
|
+
async handleReleaseTerminal(params) {
|
|
718
|
+
await this.releaseTerminalById(params.terminalId);
|
|
719
|
+
return {};
|
|
720
|
+
}
|
|
721
|
+
async releaseTerminalById(terminalId) {
|
|
722
|
+
const terminal = this.getTerminal(terminalId);
|
|
723
|
+
if (!terminal) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (!terminal.process.killed) {
|
|
727
|
+
terminal.process.kill();
|
|
728
|
+
}
|
|
729
|
+
this.terminals.delete(terminalId);
|
|
730
|
+
}
|
|
731
|
+
getTerminal(terminalId) {
|
|
732
|
+
return this.terminals.get(terminalId);
|
|
733
|
+
}
|
|
734
|
+
async handleSessionUpdate(notification) {
|
|
735
|
+
const sequence = ++this.observedSessionUpdates;
|
|
736
|
+
this.sessionUpdateChain = this.sessionUpdateChain.then(async () => {
|
|
737
|
+
try {
|
|
738
|
+
if (!this.suppressSessionUpdates) {
|
|
739
|
+
this.options.onSessionUpdate?.(notification);
|
|
740
|
+
}
|
|
741
|
+
} catch (error) {
|
|
742
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
743
|
+
this.log(`session update handler failed: ${message}`);
|
|
744
|
+
} finally {
|
|
745
|
+
this.processedSessionUpdates = sequence;
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
await this.sessionUpdateChain;
|
|
749
|
+
}
|
|
750
|
+
async waitForSessionUpdateDrain(idleMs, timeoutMs) {
|
|
751
|
+
const normalizedIdleMs = Math.max(0, idleMs);
|
|
752
|
+
const normalizedTimeoutMs = Math.max(normalizedIdleMs, timeoutMs);
|
|
753
|
+
const deadline = Date.now() + normalizedTimeoutMs;
|
|
754
|
+
let lastObserved = this.observedSessionUpdates;
|
|
755
|
+
let idleSince = Date.now();
|
|
756
|
+
while (Date.now() <= deadline) {
|
|
757
|
+
const observed = this.observedSessionUpdates;
|
|
758
|
+
if (observed !== lastObserved) {
|
|
759
|
+
lastObserved = observed;
|
|
760
|
+
idleSince = Date.now();
|
|
761
|
+
}
|
|
762
|
+
if (this.processedSessionUpdates === this.observedSessionUpdates && Date.now() - idleSince >= normalizedIdleMs) {
|
|
763
|
+
await this.sessionUpdateChain;
|
|
764
|
+
if (this.processedSessionUpdates === this.observedSessionUpdates) {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
await new Promise((resolve) => {
|
|
769
|
+
setTimeout(resolve, DRAIN_POLL_INTERVAL_MS);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
throw new Error(
|
|
773
|
+
`Timed out waiting for session replay drain after ${normalizedTimeoutMs}ms`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
// src/session.ts
|
|
779
|
+
var SESSION_BASE_DIR = path2.join(os.homedir(), ".acpx", "sessions");
|
|
780
|
+
var PROCESS_EXIT_GRACE_MS = 1500;
|
|
781
|
+
var PROCESS_POLL_MS = 50;
|
|
782
|
+
var TimeoutError = class extends Error {
|
|
783
|
+
constructor(timeoutMs) {
|
|
784
|
+
super(`Timed out after ${timeoutMs}ms`);
|
|
785
|
+
this.name = "TimeoutError";
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
var InterruptedError = class extends Error {
|
|
789
|
+
constructor() {
|
|
790
|
+
super("Interrupted");
|
|
791
|
+
this.name = "InterruptedError";
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
function sessionFilePath(id) {
|
|
795
|
+
const safeId = encodeURIComponent(id);
|
|
796
|
+
return path2.join(SESSION_BASE_DIR, `${safeId}.json`);
|
|
797
|
+
}
|
|
798
|
+
async function ensureSessionDir() {
|
|
799
|
+
await fs2.mkdir(SESSION_BASE_DIR, { recursive: true });
|
|
800
|
+
}
|
|
801
|
+
async function withTimeout(promise, timeoutMs) {
|
|
802
|
+
if (!timeoutMs || timeoutMs <= 0) {
|
|
803
|
+
return promise;
|
|
804
|
+
}
|
|
805
|
+
let timer;
|
|
806
|
+
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
807
|
+
timer = setTimeout(() => {
|
|
808
|
+
reject(new TimeoutError(timeoutMs));
|
|
809
|
+
}, timeoutMs);
|
|
810
|
+
});
|
|
811
|
+
try {
|
|
812
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
813
|
+
} finally {
|
|
814
|
+
if (timer) {
|
|
815
|
+
clearTimeout(timer);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
async function withInterrupt(run, onInterrupt) {
|
|
820
|
+
return new Promise((resolve, reject) => {
|
|
821
|
+
let settled = false;
|
|
822
|
+
const finish = (cb) => {
|
|
823
|
+
if (settled) {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
settled = true;
|
|
827
|
+
process.off("SIGINT", onSigint);
|
|
828
|
+
process.off("SIGTERM", onSigterm);
|
|
829
|
+
cb();
|
|
830
|
+
};
|
|
831
|
+
const onSigint = () => {
|
|
832
|
+
void onInterrupt().finally(() => {
|
|
833
|
+
finish(() => reject(new InterruptedError()));
|
|
834
|
+
});
|
|
835
|
+
};
|
|
836
|
+
const onSigterm = () => {
|
|
837
|
+
void onInterrupt().finally(() => {
|
|
838
|
+
finish(() => reject(new InterruptedError()));
|
|
839
|
+
});
|
|
840
|
+
};
|
|
841
|
+
process.once("SIGINT", onSigint);
|
|
842
|
+
process.once("SIGTERM", onSigterm);
|
|
843
|
+
void run().then(
|
|
844
|
+
(result) => finish(() => resolve(result)),
|
|
845
|
+
(error) => finish(() => reject(error))
|
|
846
|
+
);
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
function parseSessionRecord(raw) {
|
|
850
|
+
if (!raw || typeof raw !== "object") {
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
const record = raw;
|
|
854
|
+
const pid = record.pid == null ? void 0 : Number.isInteger(record.pid) && record.pid > 0 ? record.pid : null;
|
|
855
|
+
if (typeof record.id !== "string" || typeof record.sessionId !== "string" || typeof record.agentCommand !== "string" || typeof record.cwd !== "string" || typeof record.createdAt !== "string" || typeof record.lastUsedAt !== "string" || pid === null) {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
...record,
|
|
860
|
+
id: record.id,
|
|
861
|
+
sessionId: record.sessionId,
|
|
862
|
+
agentCommand: record.agentCommand,
|
|
863
|
+
cwd: record.cwd,
|
|
864
|
+
createdAt: record.createdAt,
|
|
865
|
+
lastUsedAt: record.lastUsedAt,
|
|
866
|
+
pid
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
async function writeSessionRecord(record) {
|
|
870
|
+
await ensureSessionDir();
|
|
871
|
+
const file = sessionFilePath(record.id);
|
|
872
|
+
const tempFile = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
873
|
+
const payload = JSON.stringify(record, null, 2);
|
|
874
|
+
await fs2.writeFile(tempFile, `${payload}
|
|
875
|
+
`, "utf8");
|
|
876
|
+
await fs2.rename(tempFile, file);
|
|
877
|
+
}
|
|
878
|
+
async function resolveSessionRecord(sessionId) {
|
|
879
|
+
await ensureSessionDir();
|
|
880
|
+
const directPath = sessionFilePath(sessionId);
|
|
881
|
+
try {
|
|
882
|
+
const directPayload = await fs2.readFile(directPath, "utf8");
|
|
883
|
+
const directRecord = parseSessionRecord(JSON.parse(directPayload));
|
|
884
|
+
if (directRecord) {
|
|
885
|
+
return directRecord;
|
|
886
|
+
}
|
|
887
|
+
} catch {
|
|
888
|
+
}
|
|
889
|
+
const sessions = await listSessions();
|
|
890
|
+
const exact = sessions.filter(
|
|
891
|
+
(session) => session.id === sessionId || session.sessionId === sessionId
|
|
892
|
+
);
|
|
893
|
+
if (exact.length === 1) {
|
|
894
|
+
return exact[0];
|
|
895
|
+
}
|
|
896
|
+
if (exact.length > 1) {
|
|
897
|
+
throw new Error(`Multiple sessions match id: ${sessionId}`);
|
|
898
|
+
}
|
|
899
|
+
const suffixMatches = sessions.filter(
|
|
900
|
+
(session) => session.id.endsWith(sessionId) || session.sessionId.endsWith(sessionId)
|
|
901
|
+
);
|
|
902
|
+
if (suffixMatches.length === 1) {
|
|
903
|
+
return suffixMatches[0];
|
|
904
|
+
}
|
|
905
|
+
if (suffixMatches.length > 1) {
|
|
906
|
+
throw new Error(`Session id is ambiguous: ${sessionId}`);
|
|
907
|
+
}
|
|
908
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
909
|
+
}
|
|
910
|
+
function toPromptResult(stopReason, sessionId, client) {
|
|
911
|
+
return {
|
|
912
|
+
stopReason,
|
|
913
|
+
sessionId,
|
|
914
|
+
permissionStats: client.getPermissionStats()
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
function absolutePath(value) {
|
|
918
|
+
return path2.resolve(value);
|
|
919
|
+
}
|
|
920
|
+
function isoNow() {
|
|
921
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
922
|
+
}
|
|
923
|
+
function formatError(error) {
|
|
924
|
+
if (error instanceof Error) {
|
|
925
|
+
return error.message;
|
|
926
|
+
}
|
|
927
|
+
if (error && typeof error === "object") {
|
|
928
|
+
const maybeMessage = error.message;
|
|
929
|
+
if (typeof maybeMessage === "string" && maybeMessage.length > 0) {
|
|
930
|
+
return maybeMessage;
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
return JSON.stringify(error);
|
|
934
|
+
} catch {
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return String(error);
|
|
938
|
+
}
|
|
939
|
+
function shouldFallbackToNewSession(error) {
|
|
940
|
+
if (error instanceof TimeoutError || error instanceof InterruptedError) {
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
const message = formatError(error).toLowerCase();
|
|
944
|
+
if (message.includes("resource_not_found") || message.includes("resource not found") || message.includes("session not found") || message.includes("unknown session") || message.includes("invalid session")) {
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : void 0;
|
|
948
|
+
return code === -32001 || code === -32002;
|
|
949
|
+
}
|
|
950
|
+
function isProcessAlive(pid) {
|
|
951
|
+
if (!pid || !Number.isInteger(pid) || pid <= 0 || pid === process.pid) {
|
|
952
|
+
return false;
|
|
953
|
+
}
|
|
954
|
+
try {
|
|
955
|
+
process.kill(pid, 0);
|
|
956
|
+
return true;
|
|
957
|
+
} catch {
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
962
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
963
|
+
while (Date.now() <= deadline) {
|
|
964
|
+
if (!isProcessAlive(pid)) {
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
await new Promise((resolve) => {
|
|
968
|
+
setTimeout(resolve, PROCESS_POLL_MS);
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
return !isProcessAlive(pid);
|
|
972
|
+
}
|
|
973
|
+
async function terminateProcess(pid) {
|
|
974
|
+
if (!isProcessAlive(pid)) {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
try {
|
|
978
|
+
process.kill(pid, "SIGTERM");
|
|
979
|
+
} catch {
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
if (await waitForProcessExit(pid, PROCESS_EXIT_GRACE_MS)) {
|
|
983
|
+
return true;
|
|
984
|
+
}
|
|
985
|
+
try {
|
|
986
|
+
process.kill(pid, "SIGKILL");
|
|
987
|
+
} catch {
|
|
988
|
+
return false;
|
|
989
|
+
}
|
|
990
|
+
await waitForProcessExit(pid, PROCESS_EXIT_GRACE_MS);
|
|
991
|
+
return true;
|
|
992
|
+
}
|
|
993
|
+
function firstAgentCommandToken(command) {
|
|
994
|
+
const trimmed = command.trim();
|
|
995
|
+
if (!trimmed) {
|
|
996
|
+
return void 0;
|
|
997
|
+
}
|
|
998
|
+
const token = trimmed.split(/\s+/, 1)[0];
|
|
999
|
+
return token.length > 0 ? token : void 0;
|
|
1000
|
+
}
|
|
1001
|
+
async function isLikelyMatchingProcess(pid, agentCommand) {
|
|
1002
|
+
const expectedToken = firstAgentCommandToken(agentCommand);
|
|
1003
|
+
if (!expectedToken) {
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
const procCmdline = `/proc/${pid}/cmdline`;
|
|
1007
|
+
try {
|
|
1008
|
+
const payload = await fs2.readFile(procCmdline, "utf8");
|
|
1009
|
+
const argv = payload.split("\0").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
1010
|
+
if (argv.length === 0) {
|
|
1011
|
+
return false;
|
|
1012
|
+
}
|
|
1013
|
+
const executableBase = path2.basename(argv[0]);
|
|
1014
|
+
const expectedBase = path2.basename(expectedToken);
|
|
1015
|
+
return executableBase === expectedBase || argv.some((entry) => path2.basename(entry) === expectedBase);
|
|
1016
|
+
} catch {
|
|
1017
|
+
return true;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
async function runOnce(options) {
|
|
1021
|
+
const output = options.outputFormatter;
|
|
1022
|
+
const client = new AcpClient({
|
|
1023
|
+
agentCommand: options.agentCommand,
|
|
1024
|
+
cwd: absolutePath(options.cwd),
|
|
1025
|
+
permissionMode: options.permissionMode,
|
|
1026
|
+
verbose: options.verbose,
|
|
1027
|
+
onSessionUpdate: (notification) => output.onSessionUpdate(notification)
|
|
1028
|
+
});
|
|
1029
|
+
try {
|
|
1030
|
+
return await withInterrupt(
|
|
1031
|
+
async () => {
|
|
1032
|
+
await withTimeout(client.start(), options.timeoutMs);
|
|
1033
|
+
const sessionId = await withTimeout(
|
|
1034
|
+
client.createSession(absolutePath(options.cwd)),
|
|
1035
|
+
options.timeoutMs
|
|
1036
|
+
);
|
|
1037
|
+
const response = await withTimeout(
|
|
1038
|
+
client.prompt(sessionId, options.message),
|
|
1039
|
+
options.timeoutMs
|
|
1040
|
+
);
|
|
1041
|
+
output.onDone(response.stopReason);
|
|
1042
|
+
output.flush();
|
|
1043
|
+
return toPromptResult(response.stopReason, sessionId, client);
|
|
1044
|
+
},
|
|
1045
|
+
async () => {
|
|
1046
|
+
await client.close();
|
|
1047
|
+
}
|
|
1048
|
+
);
|
|
1049
|
+
} finally {
|
|
1050
|
+
await client.close();
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async function createSession(options) {
|
|
1054
|
+
const client = new AcpClient({
|
|
1055
|
+
agentCommand: options.agentCommand,
|
|
1056
|
+
cwd: absolutePath(options.cwd),
|
|
1057
|
+
permissionMode: options.permissionMode,
|
|
1058
|
+
verbose: options.verbose
|
|
1059
|
+
});
|
|
1060
|
+
try {
|
|
1061
|
+
return await withInterrupt(
|
|
1062
|
+
async () => {
|
|
1063
|
+
await withTimeout(client.start(), options.timeoutMs);
|
|
1064
|
+
const sessionId = await withTimeout(
|
|
1065
|
+
client.createSession(absolutePath(options.cwd)),
|
|
1066
|
+
options.timeoutMs
|
|
1067
|
+
);
|
|
1068
|
+
const now = isoNow();
|
|
1069
|
+
const record = {
|
|
1070
|
+
id: sessionId,
|
|
1071
|
+
sessionId,
|
|
1072
|
+
agentCommand: options.agentCommand,
|
|
1073
|
+
cwd: absolutePath(options.cwd),
|
|
1074
|
+
createdAt: now,
|
|
1075
|
+
lastUsedAt: now,
|
|
1076
|
+
pid: client.getAgentPid(),
|
|
1077
|
+
protocolVersion: client.initializeResult?.protocolVersion,
|
|
1078
|
+
agentCapabilities: client.initializeResult?.agentCapabilities
|
|
1079
|
+
};
|
|
1080
|
+
await writeSessionRecord(record);
|
|
1081
|
+
return record;
|
|
1082
|
+
},
|
|
1083
|
+
async () => {
|
|
1084
|
+
await client.close();
|
|
1085
|
+
}
|
|
1086
|
+
);
|
|
1087
|
+
} finally {
|
|
1088
|
+
await client.close();
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
async function sendSession(options) {
|
|
1092
|
+
const output = options.outputFormatter;
|
|
1093
|
+
const record = await resolveSessionRecord(options.sessionId);
|
|
1094
|
+
const storedProcessAlive = isProcessAlive(record.pid);
|
|
1095
|
+
if (storedProcessAlive && options.verbose) {
|
|
1096
|
+
process.stderr.write(
|
|
1097
|
+
`[acpx] saved session pid ${record.pid} is running; reconnecting with loadSession
|
|
1098
|
+
`
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
const client = new AcpClient({
|
|
1102
|
+
agentCommand: record.agentCommand,
|
|
1103
|
+
cwd: absolutePath(record.cwd),
|
|
1104
|
+
permissionMode: options.permissionMode,
|
|
1105
|
+
verbose: options.verbose,
|
|
1106
|
+
onSessionUpdate: (notification) => output.onSessionUpdate(notification)
|
|
1107
|
+
});
|
|
1108
|
+
try {
|
|
1109
|
+
return await withInterrupt(
|
|
1110
|
+
async () => {
|
|
1111
|
+
await withTimeout(client.start(), options.timeoutMs);
|
|
1112
|
+
record.pid = client.getAgentPid();
|
|
1113
|
+
let resumed = false;
|
|
1114
|
+
let loadError;
|
|
1115
|
+
let activeSessionId = record.sessionId;
|
|
1116
|
+
if (client.supportsLoadSession()) {
|
|
1117
|
+
try {
|
|
1118
|
+
await withTimeout(
|
|
1119
|
+
client.loadSessionWithOptions(record.sessionId, record.cwd, {
|
|
1120
|
+
suppressReplayUpdates: true
|
|
1121
|
+
}),
|
|
1122
|
+
options.timeoutMs
|
|
1123
|
+
);
|
|
1124
|
+
resumed = true;
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
loadError = formatError(error);
|
|
1127
|
+
if (!shouldFallbackToNewSession(error)) {
|
|
1128
|
+
throw error;
|
|
1129
|
+
}
|
|
1130
|
+
activeSessionId = await withTimeout(
|
|
1131
|
+
client.createSession(record.cwd),
|
|
1132
|
+
options.timeoutMs
|
|
1133
|
+
);
|
|
1134
|
+
record.sessionId = activeSessionId;
|
|
1135
|
+
}
|
|
1136
|
+
} else {
|
|
1137
|
+
activeSessionId = await withTimeout(
|
|
1138
|
+
client.createSession(record.cwd),
|
|
1139
|
+
options.timeoutMs
|
|
1140
|
+
);
|
|
1141
|
+
record.sessionId = activeSessionId;
|
|
1142
|
+
}
|
|
1143
|
+
const response = await withTimeout(
|
|
1144
|
+
client.prompt(activeSessionId, options.message),
|
|
1145
|
+
options.timeoutMs
|
|
1146
|
+
);
|
|
1147
|
+
output.onDone(response.stopReason);
|
|
1148
|
+
output.flush();
|
|
1149
|
+
record.lastUsedAt = isoNow();
|
|
1150
|
+
record.protocolVersion = client.initializeResult?.protocolVersion;
|
|
1151
|
+
record.agentCapabilities = client.initializeResult?.agentCapabilities;
|
|
1152
|
+
await writeSessionRecord(record);
|
|
1153
|
+
return {
|
|
1154
|
+
...toPromptResult(response.stopReason, record.id, client),
|
|
1155
|
+
record,
|
|
1156
|
+
resumed,
|
|
1157
|
+
loadError
|
|
1158
|
+
};
|
|
1159
|
+
},
|
|
1160
|
+
async () => {
|
|
1161
|
+
await client.close();
|
|
1162
|
+
}
|
|
1163
|
+
);
|
|
1164
|
+
} finally {
|
|
1165
|
+
await client.close();
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
async function listSessions() {
|
|
1169
|
+
await ensureSessionDir();
|
|
1170
|
+
const entries = await fs2.readdir(SESSION_BASE_DIR, { withFileTypes: true });
|
|
1171
|
+
const records = [];
|
|
1172
|
+
for (const entry of entries) {
|
|
1173
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
const fullPath = path2.join(SESSION_BASE_DIR, entry.name);
|
|
1177
|
+
try {
|
|
1178
|
+
const payload = await fs2.readFile(fullPath, "utf8");
|
|
1179
|
+
const parsed = parseSessionRecord(JSON.parse(payload));
|
|
1180
|
+
if (parsed) {
|
|
1181
|
+
records.push(parsed);
|
|
1182
|
+
}
|
|
1183
|
+
} catch {
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
records.sort((a, b) => b.lastUsedAt.localeCompare(a.lastUsedAt));
|
|
1187
|
+
return records;
|
|
1188
|
+
}
|
|
1189
|
+
async function closeSession(sessionId) {
|
|
1190
|
+
const record = await resolveSessionRecord(sessionId);
|
|
1191
|
+
if (record.pid != null && isProcessAlive(record.pid) && await isLikelyMatchingProcess(record.pid, record.agentCommand)) {
|
|
1192
|
+
await terminateProcess(record.pid);
|
|
1193
|
+
}
|
|
1194
|
+
const file = sessionFilePath(record.id);
|
|
1195
|
+
await fs2.unlink(file);
|
|
1196
|
+
return record;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// src/types.ts
|
|
1200
|
+
var EXIT_CODES = {
|
|
1201
|
+
SUCCESS: 0,
|
|
1202
|
+
ERROR: 1,
|
|
1203
|
+
USAGE: 2,
|
|
1204
|
+
TIMEOUT: 3,
|
|
1205
|
+
PERMISSION_DENIED: 4,
|
|
1206
|
+
INTERRUPTED: 130
|
|
1207
|
+
};
|
|
1208
|
+
var OUTPUT_FORMATS = ["text", "json", "quiet"];
|
|
1209
|
+
|
|
1210
|
+
// src/cli.ts
|
|
1211
|
+
function parseOutputFormat(value) {
|
|
1212
|
+
if (!OUTPUT_FORMATS.includes(value)) {
|
|
1213
|
+
throw new InvalidArgumentError(
|
|
1214
|
+
`Invalid format "${value}". Expected one of: ${OUTPUT_FORMATS.join(", ")}`
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
return value;
|
|
1218
|
+
}
|
|
1219
|
+
function parseTimeoutSeconds(value) {
|
|
1220
|
+
const parsed = Number(value);
|
|
1221
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1222
|
+
throw new InvalidArgumentError("Timeout must be a positive number of seconds");
|
|
1223
|
+
}
|
|
1224
|
+
return Math.round(parsed * 1e3);
|
|
1225
|
+
}
|
|
1226
|
+
function resolvePermissionMode(flags) {
|
|
1227
|
+
const selected2 = [flags.approveAll, flags.approveReads, flags.denyAll].filter(
|
|
1228
|
+
Boolean
|
|
1229
|
+
).length;
|
|
1230
|
+
if (selected2 > 1) {
|
|
1231
|
+
throw new InvalidArgumentError(
|
|
1232
|
+
"Use only one permission mode: --approve-all, --approve-reads, or --deny-all"
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
if (flags.approveAll) {
|
|
1236
|
+
return "approve-all";
|
|
1237
|
+
}
|
|
1238
|
+
if (flags.denyAll) {
|
|
1239
|
+
return "deny-all";
|
|
1240
|
+
}
|
|
1241
|
+
return "approve-reads";
|
|
1242
|
+
}
|
|
1243
|
+
function addPermissionFlags(command) {
|
|
1244
|
+
return command.option("--approve-all", "Auto-approve all permission requests").option(
|
|
1245
|
+
"--approve-reads",
|
|
1246
|
+
"Auto-approve read/search requests and prompt for writes"
|
|
1247
|
+
).option("--deny-all", "Deny all permission requests");
|
|
1248
|
+
}
|
|
1249
|
+
function addFormatFlag(command) {
|
|
1250
|
+
return command.option(
|
|
1251
|
+
"--format <fmt>",
|
|
1252
|
+
"Output format: text, json, quiet",
|
|
1253
|
+
parseOutputFormat,
|
|
1254
|
+
"text"
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
async function readPrompt(promptParts) {
|
|
1258
|
+
const joined = promptParts.join(" ").trim();
|
|
1259
|
+
if (joined.length > 0) {
|
|
1260
|
+
return joined;
|
|
1261
|
+
}
|
|
1262
|
+
if (process.stdin.isTTY) {
|
|
1263
|
+
throw new InvalidArgumentError(
|
|
1264
|
+
"Prompt is required (pass as argument or pipe via stdin)"
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
let data = "";
|
|
1268
|
+
for await (const chunk of process.stdin) {
|
|
1269
|
+
data += String(chunk);
|
|
1270
|
+
}
|
|
1271
|
+
const prompt = data.trim();
|
|
1272
|
+
if (!prompt) {
|
|
1273
|
+
throw new InvalidArgumentError("Prompt from stdin is empty");
|
|
1274
|
+
}
|
|
1275
|
+
return prompt;
|
|
1276
|
+
}
|
|
1277
|
+
function applyPermissionExitCode(result) {
|
|
1278
|
+
const stats = result.permissionStats;
|
|
1279
|
+
const deniedOrCancelled = stats.denied + stats.cancelled;
|
|
1280
|
+
if (stats.requested > 0 && stats.approved === 0 && deniedOrCancelled > 0) {
|
|
1281
|
+
process.exitCode = EXIT_CODES.PERMISSION_DENIED;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
function printSessionRecordByFormat(record, format) {
|
|
1285
|
+
if (format === "json") {
|
|
1286
|
+
process.stdout.write(
|
|
1287
|
+
`${JSON.stringify({
|
|
1288
|
+
type: "session",
|
|
1289
|
+
...record
|
|
1290
|
+
})}
|
|
1291
|
+
`
|
|
1292
|
+
);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
process.stdout.write(`${record.id}
|
|
1296
|
+
`);
|
|
1297
|
+
}
|
|
1298
|
+
function printSessionsByFormat(sessions, format) {
|
|
1299
|
+
if (format === "json") {
|
|
1300
|
+
process.stdout.write(`${JSON.stringify(sessions)}
|
|
1301
|
+
`);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (format === "quiet") {
|
|
1305
|
+
for (const session of sessions) {
|
|
1306
|
+
process.stdout.write(`${session.id}
|
|
1307
|
+
`);
|
|
1308
|
+
}
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (sessions.length === 0) {
|
|
1312
|
+
process.stdout.write("No sessions\n");
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
for (const session of sessions) {
|
|
1316
|
+
process.stdout.write(
|
|
1317
|
+
`${session.id} ${session.cwd} ${session.lastUsedAt}
|
|
1318
|
+
`
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
async function main() {
|
|
1323
|
+
const program = new Command();
|
|
1324
|
+
program.name("acpx").description("Headless CLI client for the Agent Client Protocol").showHelpAfterError();
|
|
1325
|
+
program.command("run").description("Run a one-shot prompt").argument("[prompt...]", "Prompt text").requiredOption("--agent <command>", "ACP adapter command").option("--cwd <dir>", "Working directory", process.cwd()).option(
|
|
1326
|
+
"--timeout <seconds>",
|
|
1327
|
+
"Maximum time to wait for agent response",
|
|
1328
|
+
parseTimeoutSeconds
|
|
1329
|
+
).option("--verbose", "Enable verbose debug logs").allowUnknownOption(false);
|
|
1330
|
+
const runCommand = program.commands.find((cmd) => cmd.name() === "run");
|
|
1331
|
+
if (!runCommand) {
|
|
1332
|
+
throw new Error("Failed to build run command");
|
|
1333
|
+
}
|
|
1334
|
+
addPermissionFlags(runCommand);
|
|
1335
|
+
addFormatFlag(runCommand);
|
|
1336
|
+
runCommand.action(async (promptParts, flags) => {
|
|
1337
|
+
const prompt = await readPrompt(promptParts);
|
|
1338
|
+
const permissionMode = resolvePermissionMode(flags);
|
|
1339
|
+
const formatter = createOutputFormatter(flags.format);
|
|
1340
|
+
const result = await runOnce({
|
|
1341
|
+
agentCommand: flags.agent,
|
|
1342
|
+
cwd: flags.cwd,
|
|
1343
|
+
message: prompt,
|
|
1344
|
+
permissionMode,
|
|
1345
|
+
outputFormatter: formatter,
|
|
1346
|
+
timeoutMs: flags.timeout,
|
|
1347
|
+
verbose: flags.verbose
|
|
1348
|
+
});
|
|
1349
|
+
applyPermissionExitCode(result);
|
|
1350
|
+
});
|
|
1351
|
+
const session = program.command("session").description("Session management");
|
|
1352
|
+
const sessionCreate = session.command("create").description("Create a persistent session").requiredOption("--agent <command>", "ACP adapter command").option("--cwd <dir>", "Working directory", process.cwd()).option(
|
|
1353
|
+
"--timeout <seconds>",
|
|
1354
|
+
"Maximum time to wait for agent response",
|
|
1355
|
+
parseTimeoutSeconds
|
|
1356
|
+
).option("--verbose", "Enable verbose debug logs");
|
|
1357
|
+
addPermissionFlags(sessionCreate);
|
|
1358
|
+
addFormatFlag(sessionCreate);
|
|
1359
|
+
sessionCreate.action(async (flags) => {
|
|
1360
|
+
const permissionMode = resolvePermissionMode(flags);
|
|
1361
|
+
const record = await createSession({
|
|
1362
|
+
agentCommand: flags.agent,
|
|
1363
|
+
cwd: flags.cwd,
|
|
1364
|
+
permissionMode,
|
|
1365
|
+
timeoutMs: flags.timeout,
|
|
1366
|
+
verbose: flags.verbose
|
|
1367
|
+
});
|
|
1368
|
+
printSessionRecordByFormat(record, flags.format);
|
|
1369
|
+
});
|
|
1370
|
+
const sessionSend = session.command("send").description("Send a prompt to an existing session").argument("<sessionId>", "Session ID").argument("[prompt...]", "Prompt text").option(
|
|
1371
|
+
"--timeout <seconds>",
|
|
1372
|
+
"Maximum time to wait for agent response",
|
|
1373
|
+
parseTimeoutSeconds
|
|
1374
|
+
).option("--verbose", "Enable verbose debug logs");
|
|
1375
|
+
addPermissionFlags(sessionSend);
|
|
1376
|
+
addFormatFlag(sessionSend);
|
|
1377
|
+
sessionSend.action(
|
|
1378
|
+
async (sessionId, promptParts, flags) => {
|
|
1379
|
+
const prompt = await readPrompt(promptParts);
|
|
1380
|
+
const permissionMode = resolvePermissionMode(flags);
|
|
1381
|
+
const formatter = createOutputFormatter(flags.format);
|
|
1382
|
+
const result = await sendSession({
|
|
1383
|
+
sessionId,
|
|
1384
|
+
message: prompt,
|
|
1385
|
+
permissionMode,
|
|
1386
|
+
outputFormatter: formatter,
|
|
1387
|
+
timeoutMs: flags.timeout,
|
|
1388
|
+
verbose: flags.verbose
|
|
1389
|
+
});
|
|
1390
|
+
applyPermissionExitCode(result);
|
|
1391
|
+
if (flags.verbose && result.loadError) {
|
|
1392
|
+
process.stderr.write(
|
|
1393
|
+
`[acpx] loadSession failed, started fresh session: ${result.loadError}
|
|
1394
|
+
`
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
);
|
|
1399
|
+
const sessionList = session.command("list").description("List saved sessions");
|
|
1400
|
+
addFormatFlag(sessionList);
|
|
1401
|
+
sessionList.action(async (flags) => {
|
|
1402
|
+
const sessions = await listSessions();
|
|
1403
|
+
printSessionsByFormat(sessions, flags.format);
|
|
1404
|
+
});
|
|
1405
|
+
const sessionClose = session.command("close").description("Close and remove a saved session").argument("<sessionId>", "Session ID");
|
|
1406
|
+
addFormatFlag(sessionClose);
|
|
1407
|
+
sessionClose.action(async (sessionId, flags) => {
|
|
1408
|
+
const record = await closeSession(sessionId);
|
|
1409
|
+
if (flags.format === "json") {
|
|
1410
|
+
process.stdout.write(
|
|
1411
|
+
`${JSON.stringify({
|
|
1412
|
+
type: "session_closed",
|
|
1413
|
+
id: record.id,
|
|
1414
|
+
sessionId: record.sessionId
|
|
1415
|
+
})}
|
|
1416
|
+
`
|
|
1417
|
+
);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
if (flags.format === "quiet") {
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
process.stdout.write(`${record.id}
|
|
1424
|
+
`);
|
|
1425
|
+
});
|
|
1426
|
+
program.exitOverride((error) => {
|
|
1427
|
+
throw error;
|
|
1428
|
+
});
|
|
1429
|
+
try {
|
|
1430
|
+
await program.parseAsync(process.argv);
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
if (error instanceof CommanderError) {
|
|
1433
|
+
if (error.code === "commander.helpDisplayed" || error.code === "commander.version") {
|
|
1434
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
1435
|
+
}
|
|
1436
|
+
process.exit(EXIT_CODES.USAGE);
|
|
1437
|
+
}
|
|
1438
|
+
if (error instanceof InterruptedError) {
|
|
1439
|
+
process.exit(EXIT_CODES.INTERRUPTED);
|
|
1440
|
+
}
|
|
1441
|
+
if (error instanceof TimeoutError) {
|
|
1442
|
+
process.stderr.write(`${error.message}
|
|
1443
|
+
`);
|
|
1444
|
+
process.exit(EXIT_CODES.TIMEOUT);
|
|
1445
|
+
}
|
|
1446
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1447
|
+
process.stderr.write(`${message}
|
|
1448
|
+
`);
|
|
1449
|
+
process.exit(EXIT_CODES.ERROR);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
void main();
|