@acpfx/bridge-acpx 0.2.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/CHANGELOG.md +21 -0
- package/manifest.yaml +17 -0
- package/package.json +16 -0
- package/src/acpx-ipc.ts +533 -0
- package/src/index.ts +317 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @acpfx/bridge-acpx
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d757640: Initial release: type-safe contracts, Rust orchestrator, manifest-driven event filtering
|
|
8
|
+
|
|
9
|
+
- Rust schema crate as canonical event type source of truth with codegen to TypeScript + Zod
|
|
10
|
+
- Node manifests (manifest.yaml) declaring consumes/emits contracts
|
|
11
|
+
- Orchestrator event filtering: nodes only receive declared events
|
|
12
|
+
- Rust orchestrator with ratatui TUI (--ui flag)
|
|
13
|
+
- node-sdk with structured logging helpers
|
|
14
|
+
- CI/CD with GitHub Actions and changesets
|
|
15
|
+
- Platform-specific npm packages for Rust binaries (esbuild-style distribution)
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- Updated dependencies [d757640]
|
|
20
|
+
- @acpfx/core@0.2.0
|
|
21
|
+
- @acpfx/node-sdk@0.2.0
|
package/manifest.yaml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: bridge-acpx
|
|
2
|
+
description: Agent bridge connecting to Claude via ACP
|
|
3
|
+
consumes:
|
|
4
|
+
- speech.partial
|
|
5
|
+
- speech.pause
|
|
6
|
+
- control.interrupt
|
|
7
|
+
emits:
|
|
8
|
+
- agent.submit
|
|
9
|
+
- agent.delta
|
|
10
|
+
- agent.complete
|
|
11
|
+
- agent.thinking
|
|
12
|
+
- agent.tool_start
|
|
13
|
+
- agent.tool_done
|
|
14
|
+
- control.interrupt
|
|
15
|
+
- control.error
|
|
16
|
+
- lifecycle.ready
|
|
17
|
+
- lifecycle.done
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@acpfx/bridge-acpx",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"acpfx-bridge-acpx": "./dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@acpfx/core": "0.2.0",
|
|
11
|
+
"@acpfx/node-sdk": "0.2.0"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external"
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/acpx-ipc.ts
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* acpx Queue IPC client.
|
|
3
|
+
*
|
|
4
|
+
* Connects to acpx's queue owner Unix socket, submits prompts,
|
|
5
|
+
* streams responses (ACP JSON-RPC messages), and cancels.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
import * as net from "node:net";
|
|
11
|
+
import { log } from "@acpfx/node-sdk";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
|
|
15
|
+
const SOCKET_CONNECT_TIMEOUT_MS = 5_000;
|
|
16
|
+
const CONNECT_RETRY_MS = 50;
|
|
17
|
+
const CONNECT_MAX_ATTEMPTS = 40;
|
|
18
|
+
|
|
19
|
+
// --- Queue path resolution (mirrors acpx/src/queue-paths.ts) ---
|
|
20
|
+
|
|
21
|
+
function shortHash(value: string, length: number): string {
|
|
22
|
+
return createHash("sha256").update(value).digest("hex").slice(0, length);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function queueKeyForSession(sessionId: string): string {
|
|
26
|
+
return shortHash(sessionId, 24);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function queueBaseDir(): string {
|
|
30
|
+
return path.join(os.homedir(), ".acpx", "queues");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function queueSocketPath(sessionId: string): string {
|
|
34
|
+
const key = queueKeyForSession(sessionId);
|
|
35
|
+
if (process.platform === "win32") {
|
|
36
|
+
return `\\\\.\\pipe\\acpx-${key}`;
|
|
37
|
+
}
|
|
38
|
+
const socketBase = path.join("/tmp", `acpx-${shortHash(os.homedir(), 10)}`);
|
|
39
|
+
return path.join(socketBase, `${key}.sock`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function queueLockFilePath(sessionId: string): string {
|
|
43
|
+
return path.join(queueBaseDir(), `${queueKeyForSession(sessionId)}.lock`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Queue owner record ---
|
|
47
|
+
|
|
48
|
+
type QueueOwnerRecord = {
|
|
49
|
+
pid: number;
|
|
50
|
+
sessionId: string;
|
|
51
|
+
socketPath: string;
|
|
52
|
+
ownerGeneration?: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
async function readQueueOwnerRecord(
|
|
56
|
+
sessionId: string,
|
|
57
|
+
): Promise<QueueOwnerRecord | undefined> {
|
|
58
|
+
const lockPath = queueLockFilePath(sessionId);
|
|
59
|
+
try {
|
|
60
|
+
const payload = await fs.readFile(lockPath, "utf8");
|
|
61
|
+
const parsed = JSON.parse(payload);
|
|
62
|
+
if (
|
|
63
|
+
!parsed ||
|
|
64
|
+
typeof parsed !== "object" ||
|
|
65
|
+
typeof parsed.pid !== "number" ||
|
|
66
|
+
typeof parsed.sessionId !== "string" ||
|
|
67
|
+
typeof parsed.socketPath !== "string"
|
|
68
|
+
) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
pid: parsed.pid,
|
|
73
|
+
sessionId: parsed.sessionId,
|
|
74
|
+
socketPath: parsed.socketPath,
|
|
75
|
+
ownerGeneration:
|
|
76
|
+
typeof parsed.ownerGeneration === "number"
|
|
77
|
+
? parsed.ownerGeneration
|
|
78
|
+
: undefined,
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- Socket connection ---
|
|
86
|
+
|
|
87
|
+
function connectToSocket(
|
|
88
|
+
socketPath: string,
|
|
89
|
+
timeoutMs = SOCKET_CONNECT_TIMEOUT_MS,
|
|
90
|
+
): Promise<net.Socket> {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const socket = net.createConnection(socketPath);
|
|
93
|
+
let settled = false;
|
|
94
|
+
|
|
95
|
+
const timeout = setTimeout(() => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
settled = true;
|
|
98
|
+
socket.destroy();
|
|
99
|
+
reject(
|
|
100
|
+
new Error(
|
|
101
|
+
`Connection to ${socketPath} timed out after ${timeoutMs}ms`,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
}, timeoutMs);
|
|
105
|
+
|
|
106
|
+
socket.once("connect", () => {
|
|
107
|
+
if (settled) return;
|
|
108
|
+
settled = true;
|
|
109
|
+
clearTimeout(timeout);
|
|
110
|
+
resolve(socket);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
socket.once("error", (err) => {
|
|
114
|
+
if (settled) return;
|
|
115
|
+
settled = true;
|
|
116
|
+
clearTimeout(timeout);
|
|
117
|
+
reject(err);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function connectToQueueOwner(
|
|
123
|
+
owner: QueueOwnerRecord,
|
|
124
|
+
): Promise<net.Socket | undefined> {
|
|
125
|
+
let lastError: unknown;
|
|
126
|
+
|
|
127
|
+
for (let attempt = 0; attempt < CONNECT_MAX_ATTEMPTS; attempt++) {
|
|
128
|
+
try {
|
|
129
|
+
return await connectToSocket(owner.socketPath);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
lastError = error;
|
|
132
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
133
|
+
if (code !== "ENOENT" && code !== "ECONNREFUSED") {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
await new Promise((r) => setTimeout(r, CONNECT_RETRY_MS));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- ACP message parsing ---
|
|
144
|
+
|
|
145
|
+
type AcpTextDelta = {
|
|
146
|
+
type: "text_delta";
|
|
147
|
+
text: string;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
type AcpStopReason = {
|
|
151
|
+
type: "stop_reason";
|
|
152
|
+
reason: string;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type AcpEvent = AcpTextDelta | AcpStopReason | { type: "other" };
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Extracts meaningful data from an ACP JSON-RPC message streamed from the queue owner.
|
|
159
|
+
* The queue owner wraps ACP messages in: { type: "event", requestId, message: AcpJsonRpcMessage }
|
|
160
|
+
* where AcpJsonRpcMessage is a JSON-RPC notification like:
|
|
161
|
+
* { method: "session/update", params: { update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "..." } } } }
|
|
162
|
+
*/
|
|
163
|
+
export function extractAcpEvent(acpMessage: Record<string, unknown>): AcpEvent {
|
|
164
|
+
// Check for session/update notifications with agent_message_chunk
|
|
165
|
+
if (acpMessage.method === "session/update") {
|
|
166
|
+
const params = acpMessage.params as Record<string, unknown> | undefined;
|
|
167
|
+
if (!params) return { type: "other" };
|
|
168
|
+
const update = params.update as Record<string, unknown> | undefined;
|
|
169
|
+
if (!update) return { type: "other" };
|
|
170
|
+
|
|
171
|
+
if (update.sessionUpdate === "agent_message_chunk") {
|
|
172
|
+
const content = update.content as Record<string, unknown> | undefined;
|
|
173
|
+
if (content && content.type === "text" && typeof content.text === "string") {
|
|
174
|
+
return { type: "text_delta", text: content.text };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { type: "other" };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check for prompt completion (JSON-RPC response with result)
|
|
181
|
+
if (Object.hasOwn(acpMessage, "result")) {
|
|
182
|
+
const result = acpMessage.result as Record<string, unknown> | undefined;
|
|
183
|
+
if (result && typeof result.stopReason === "string") {
|
|
184
|
+
return { type: "stop_reason", reason: result.stopReason };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { type: "other" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- IPC client ---
|
|
192
|
+
|
|
193
|
+
export type SubmitOptions = {
|
|
194
|
+
sessionId: string;
|
|
195
|
+
text: string;
|
|
196
|
+
onTextDelta: (delta: string, seq: number) => void;
|
|
197
|
+
onComplete: (fullText: string) => void;
|
|
198
|
+
onError: (error: Error) => void;
|
|
199
|
+
signal?: AbortSignal;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export type CancelResult = {
|
|
203
|
+
cancelled: boolean;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export class AcpxIpcClient {
|
|
207
|
+
private _sessionId: string;
|
|
208
|
+
private _verbose: boolean;
|
|
209
|
+
|
|
210
|
+
constructor(sessionId: string, opts?: { verbose?: boolean }) {
|
|
211
|
+
this._sessionId = sessionId;
|
|
212
|
+
this._verbose = opts?.verbose ?? false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
get sessionId(): string {
|
|
216
|
+
return this._sessionId;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Submit a prompt to the acpx queue owner and stream text deltas back.
|
|
221
|
+
* Returns the requestId. Calls onTextDelta for each chunk, onComplete when done.
|
|
222
|
+
*/
|
|
223
|
+
async submitPrompt(opts: SubmitOptions): Promise<string> {
|
|
224
|
+
const owner = await readQueueOwnerRecord(this._sessionId);
|
|
225
|
+
if (!owner) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`No active acpx session found for "${this._sessionId}". ` +
|
|
228
|
+
`Start one with: acpx ${this._sessionId} "hello"`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const socket = await connectToQueueOwner(owner);
|
|
233
|
+
if (!socket) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Could not connect to acpx queue owner for session "${this._sessionId}"`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const requestId = randomUUID();
|
|
240
|
+
let seq = 0;
|
|
241
|
+
let fullText = "";
|
|
242
|
+
let acknowledged = false;
|
|
243
|
+
let buffer = "";
|
|
244
|
+
|
|
245
|
+
const request = {
|
|
246
|
+
type: "submit_prompt",
|
|
247
|
+
requestId,
|
|
248
|
+
ownerGeneration: owner.ownerGeneration,
|
|
249
|
+
message: opts.text,
|
|
250
|
+
permissionMode: "approve-all",
|
|
251
|
+
waitForCompletion: true,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
socket.setEncoding("utf8");
|
|
255
|
+
|
|
256
|
+
return new Promise<string>((resolve, reject) => {
|
|
257
|
+
let settled = false;
|
|
258
|
+
|
|
259
|
+
const cleanup = () => {
|
|
260
|
+
if (!socket.destroyed) {
|
|
261
|
+
socket.end();
|
|
262
|
+
}
|
|
263
|
+
socket.removeAllListeners();
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const finish = (error?: Error) => {
|
|
267
|
+
if (settled) return;
|
|
268
|
+
settled = true;
|
|
269
|
+
cleanup();
|
|
270
|
+
if (error) {
|
|
271
|
+
opts.onError(error);
|
|
272
|
+
reject(error);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Handle abort signal
|
|
277
|
+
if (opts.signal) {
|
|
278
|
+
if (opts.signal.aborted) {
|
|
279
|
+
cleanup();
|
|
280
|
+
reject(new Error("Aborted"));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
opts.signal.addEventListener("abort", () => {
|
|
284
|
+
finish(new Error("Aborted"));
|
|
285
|
+
}, { once: true });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const processLine = (line: string) => {
|
|
289
|
+
let parsed: Record<string, unknown>;
|
|
290
|
+
try {
|
|
291
|
+
parsed = JSON.parse(line);
|
|
292
|
+
} catch {
|
|
293
|
+
return; // Skip malformed lines
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (typeof parsed.type !== "string") return;
|
|
297
|
+
|
|
298
|
+
// Check requestId matches
|
|
299
|
+
if (parsed.requestId !== requestId) return;
|
|
300
|
+
|
|
301
|
+
if (parsed.type === "accepted") {
|
|
302
|
+
acknowledged = true;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!acknowledged) {
|
|
307
|
+
finish(new Error("Queue owner sent data before acknowledging request"));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (parsed.type === "error") {
|
|
312
|
+
const msg = typeof parsed.message === "string"
|
|
313
|
+
? parsed.message
|
|
314
|
+
: "Queue owner error";
|
|
315
|
+
finish(new Error(msg));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (parsed.type === "event") {
|
|
320
|
+
const acpMessage = parsed.message as Record<string, unknown> | undefined;
|
|
321
|
+
if (!acpMessage) return;
|
|
322
|
+
|
|
323
|
+
const event = extractAcpEvent(acpMessage);
|
|
324
|
+
if (event.type === "text_delta") {
|
|
325
|
+
fullText += event.text;
|
|
326
|
+
opts.onTextDelta(event.text, seq++);
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (parsed.type === "result") {
|
|
332
|
+
opts.onComplete(fullText);
|
|
333
|
+
if (!settled) {
|
|
334
|
+
settled = true;
|
|
335
|
+
cleanup();
|
|
336
|
+
resolve(requestId);
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (parsed.type === "cancel_result") {
|
|
342
|
+
opts.onComplete(fullText);
|
|
343
|
+
if (!settled) {
|
|
344
|
+
settled = true;
|
|
345
|
+
cleanup();
|
|
346
|
+
resolve(requestId);
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
socket.on("data", (chunk: string) => {
|
|
353
|
+
buffer += chunk;
|
|
354
|
+
let idx = buffer.indexOf("\n");
|
|
355
|
+
while (idx >= 0) {
|
|
356
|
+
const line = buffer.slice(0, idx).trim();
|
|
357
|
+
buffer = buffer.slice(idx + 1);
|
|
358
|
+
if (line.length > 0) {
|
|
359
|
+
processLine(line);
|
|
360
|
+
}
|
|
361
|
+
idx = buffer.indexOf("\n");
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
socket.once("error", (err: Error) => {
|
|
366
|
+
finish(err);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
socket.once("close", () => {
|
|
370
|
+
if (!settled) {
|
|
371
|
+
if (!acknowledged) {
|
|
372
|
+
finish(new Error("Queue owner disconnected before acknowledging request"));
|
|
373
|
+
} else {
|
|
374
|
+
// Connection closed after acknowledgement, treat as completion
|
|
375
|
+
opts.onComplete(fullText);
|
|
376
|
+
settled = true;
|
|
377
|
+
cleanup();
|
|
378
|
+
resolve(requestId);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Send the request
|
|
384
|
+
socket.write(JSON.stringify(request) + "\n");
|
|
385
|
+
|
|
386
|
+
if (this._verbose) {
|
|
387
|
+
log.debug(`submitted prompt to session ${this._sessionId} (requestId: ${requestId})`);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Cancel a running prompt on the acpx queue owner.
|
|
394
|
+
*/
|
|
395
|
+
async cancelPrompt(): Promise<CancelResult> {
|
|
396
|
+
const owner = await readQueueOwnerRecord(this._sessionId);
|
|
397
|
+
if (!owner) {
|
|
398
|
+
return { cancelled: false };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const socket = await connectToQueueOwner(owner);
|
|
402
|
+
if (!socket) {
|
|
403
|
+
return { cancelled: false };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const requestId = randomUUID();
|
|
407
|
+
const request = {
|
|
408
|
+
type: "cancel_prompt",
|
|
409
|
+
requestId,
|
|
410
|
+
ownerGeneration: owner.ownerGeneration,
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
socket.setEncoding("utf8");
|
|
414
|
+
|
|
415
|
+
return new Promise<CancelResult>((resolve) => {
|
|
416
|
+
let settled = false;
|
|
417
|
+
let buffer = "";
|
|
418
|
+
|
|
419
|
+
const timeout = setTimeout(() => {
|
|
420
|
+
if (settled) return;
|
|
421
|
+
settled = true;
|
|
422
|
+
socket.destroy();
|
|
423
|
+
resolve({ cancelled: false });
|
|
424
|
+
}, SOCKET_CONNECT_TIMEOUT_MS);
|
|
425
|
+
|
|
426
|
+
socket.on("data", (chunk: string) => {
|
|
427
|
+
buffer += chunk;
|
|
428
|
+
let idx = buffer.indexOf("\n");
|
|
429
|
+
while (idx >= 0) {
|
|
430
|
+
const line = buffer.slice(0, idx).trim();
|
|
431
|
+
buffer = buffer.slice(idx + 1);
|
|
432
|
+
if (line.length > 0) {
|
|
433
|
+
try {
|
|
434
|
+
const parsed = JSON.parse(line);
|
|
435
|
+
if (parsed.requestId === requestId) {
|
|
436
|
+
if (parsed.type === "accepted") {
|
|
437
|
+
// Wait for cancel_result
|
|
438
|
+
} else if (parsed.type === "cancel_result") {
|
|
439
|
+
if (!settled) {
|
|
440
|
+
settled = true;
|
|
441
|
+
clearTimeout(timeout);
|
|
442
|
+
socket.end();
|
|
443
|
+
resolve({ cancelled: parsed.cancelled === true });
|
|
444
|
+
}
|
|
445
|
+
} else if (parsed.type === "error") {
|
|
446
|
+
if (!settled) {
|
|
447
|
+
settled = true;
|
|
448
|
+
clearTimeout(timeout);
|
|
449
|
+
socket.end();
|
|
450
|
+
resolve({ cancelled: false });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
// Skip malformed
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
idx = buffer.indexOf("\n");
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
socket.once("error", () => {
|
|
463
|
+
if (!settled) {
|
|
464
|
+
settled = true;
|
|
465
|
+
clearTimeout(timeout);
|
|
466
|
+
resolve({ cancelled: false });
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
socket.once("close", () => {
|
|
471
|
+
if (!settled) {
|
|
472
|
+
settled = true;
|
|
473
|
+
clearTimeout(timeout);
|
|
474
|
+
resolve({ cancelled: false });
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
socket.write(JSON.stringify(request) + "\n");
|
|
479
|
+
|
|
480
|
+
if (this._verbose) {
|
|
481
|
+
log.debug(`cancel request sent to session ${this._sessionId}`);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// --- Session resolution ---
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Find the session ID for a given agent name.
|
|
491
|
+
* Looks at ~/.acpx/sessions/ index to find the most recent open session
|
|
492
|
+
* for the given agent command.
|
|
493
|
+
*/
|
|
494
|
+
export async function resolveSessionId(
|
|
495
|
+
agentName: string,
|
|
496
|
+
sessionName?: string,
|
|
497
|
+
): Promise<string | undefined> {
|
|
498
|
+
const indexPath = path.join(os.homedir(), ".acpx", "sessions", "index.json");
|
|
499
|
+
try {
|
|
500
|
+
const data = await fs.readFile(indexPath, "utf8");
|
|
501
|
+
const raw = JSON.parse(data);
|
|
502
|
+
|
|
503
|
+
const entries: unknown[] = Array.isArray(raw) ? raw : Array.isArray(raw?.entries) ? raw.entries : [];
|
|
504
|
+
if (entries.length === 0) return undefined;
|
|
505
|
+
|
|
506
|
+
type IndexEntry = {
|
|
507
|
+
acpxRecordId?: string;
|
|
508
|
+
acpSessionId?: string;
|
|
509
|
+
agentCommand?: string;
|
|
510
|
+
name?: string;
|
|
511
|
+
closed?: boolean;
|
|
512
|
+
lastUsedAt?: string;
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const matches = (entries as IndexEntry[])
|
|
516
|
+
.filter(
|
|
517
|
+
(entry) =>
|
|
518
|
+
entry.agentCommand &&
|
|
519
|
+
entry.agentCommand.includes(agentName) &&
|
|
520
|
+
!entry.closed &&
|
|
521
|
+
(entry.acpxRecordId || entry.acpSessionId) &&
|
|
522
|
+
// If sessionName specified, match it; otherwise match unnamed sessions
|
|
523
|
+
(sessionName ? entry.name === sessionName : !entry.name || entry.name === ""),
|
|
524
|
+
)
|
|
525
|
+
.sort((a, b) =>
|
|
526
|
+
(b.lastUsedAt ?? "").localeCompare(a.lastUsedAt ?? ""),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
return matches[0]?.acpxRecordId ?? matches[0]?.acpSessionId;
|
|
530
|
+
} catch {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bridge-acpx node — reads speech.pause events, submits to acpx CLI,
|
|
3
|
+
* streams agent.submit/agent.delta/agent.complete events.
|
|
4
|
+
* Handles control.interrupt by killing the active process + acpx cancel.
|
|
5
|
+
*
|
|
6
|
+
* Uses `acpx --format json` for structured ACP output instead of
|
|
7
|
+
* direct socket IPC. acpx handles session management, queue owner
|
|
8
|
+
* lifecycle, and agent reconnection.
|
|
9
|
+
*
|
|
10
|
+
* Settings:
|
|
11
|
+
* agent: string (required) — agent name (claude, codex, pi, etc.)
|
|
12
|
+
* session?: string — named session (maps to acpx -s <name>)
|
|
13
|
+
* args?: Record<string, string | boolean> — extra acpx CLI flags
|
|
14
|
+
* verbose?: boolean
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
|
19
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { emit, log, onEvent, handleManifestFlag } from "@acpfx/node-sdk";
|
|
21
|
+
|
|
22
|
+
handleManifestFlag();
|
|
23
|
+
|
|
24
|
+
type Settings = {
|
|
25
|
+
agent: string;
|
|
26
|
+
session?: string;
|
|
27
|
+
args?: Record<string, string | boolean>;
|
|
28
|
+
verbose?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const settings: Settings = JSON.parse(process.env.ACPFX_SETTINGS || "{}");
|
|
32
|
+
|
|
33
|
+
if (!settings.agent) {
|
|
34
|
+
log.error("settings.agent is required");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const AGENT = settings.agent;
|
|
39
|
+
const VERBOSE = settings.verbose ?? false;
|
|
40
|
+
const NODE_NAME = process.env.ACPFX_NODE_NAME ?? "bridge";
|
|
41
|
+
|
|
42
|
+
let activeChild: ChildProcess | null = null;
|
|
43
|
+
let interrupted = false;
|
|
44
|
+
let streaming = false;
|
|
45
|
+
let agentResponding = false;
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
/** Build CLI args from settings.args */
|
|
49
|
+
function buildExtraArgs(): string[] {
|
|
50
|
+
const args: string[] = [];
|
|
51
|
+
if (!settings.args) return args;
|
|
52
|
+
for (const [key, value] of Object.entries(settings.args)) {
|
|
53
|
+
const flag = key.length === 1 ? `-${key}` : `--${key}`;
|
|
54
|
+
if (value === true) {
|
|
55
|
+
args.push(flag);
|
|
56
|
+
} else if (typeof value === "string") {
|
|
57
|
+
args.push(flag, value);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return args;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ensure an acpx session exists.
|
|
65
|
+
* `sessions ensure` creates the record; the first prompt bootstraps the queue owner.
|
|
66
|
+
*/
|
|
67
|
+
function ensureSession(): void {
|
|
68
|
+
log.info(`Ensuring session for "${AGENT}"${settings.session ? ` (session: ${settings.session})` : ""}...`);
|
|
69
|
+
|
|
70
|
+
const args = ["acpx@latest", AGENT, "sessions", "ensure"];
|
|
71
|
+
if (settings.session) args.push("--name", settings.session);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const output = execSync(["npx", "-y", ...args].join(" "), {
|
|
75
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
76
|
+
env: process.env,
|
|
77
|
+
timeout: 30000,
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
});
|
|
80
|
+
log.info(`Session: ${output.trim()}`);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
log.warn(`sessions ensure failed: ${err instanceof Error ? err.message : err}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Submit a prompt via `acpx --format json` and stream deltas.
|
|
88
|
+
*/
|
|
89
|
+
function handleSpeechPause(pendingText: string): void {
|
|
90
|
+
if (interrupted) return;
|
|
91
|
+
|
|
92
|
+
const requestId = randomUUID();
|
|
93
|
+
streaming = true;
|
|
94
|
+
|
|
95
|
+
emit({ type: "agent.submit", requestId, text: pendingText });
|
|
96
|
+
|
|
97
|
+
// Build acpx command: npx -y acpx@latest --format json [extra-args] <agent> -s <session> "text"
|
|
98
|
+
const args = [
|
|
99
|
+
"-y", "acpx@latest",
|
|
100
|
+
"--format", "json",
|
|
101
|
+
...buildExtraArgs(),
|
|
102
|
+
AGENT,
|
|
103
|
+
];
|
|
104
|
+
if (settings.session) args.push("-s", settings.session);
|
|
105
|
+
args.push(pendingText);
|
|
106
|
+
|
|
107
|
+
const child = spawn("npx", args, {
|
|
108
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
109
|
+
env: process.env,
|
|
110
|
+
});
|
|
111
|
+
activeChild = child;
|
|
112
|
+
|
|
113
|
+
let seq = 0;
|
|
114
|
+
let fullText = "";
|
|
115
|
+
let buffer = "";
|
|
116
|
+
|
|
117
|
+
let emittedThinking = false;
|
|
118
|
+
|
|
119
|
+
const processLine = (line: string) => {
|
|
120
|
+
let msg: Record<string, unknown>;
|
|
121
|
+
try {
|
|
122
|
+
msg = JSON.parse(line);
|
|
123
|
+
} catch {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Handle session/update events
|
|
128
|
+
if (msg.method === "session/update") {
|
|
129
|
+
const params = msg.params as Record<string, unknown> | undefined;
|
|
130
|
+
const update = params?.update as Record<string, unknown> | undefined;
|
|
131
|
+
if (!update) return;
|
|
132
|
+
|
|
133
|
+
const sessionUpdate = update.sessionUpdate as string;
|
|
134
|
+
// Log all non-chunk session updates to debug event flow
|
|
135
|
+
if (sessionUpdate !== "agent_message_chunk" && sessionUpdate !== "usage_update" && sessionUpdate !== "available_commands_update") {
|
|
136
|
+
log.debug(`ACP: ${sessionUpdate} ${JSON.stringify(update).slice(0, 200)}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Thinking chunks
|
|
140
|
+
if (sessionUpdate === "agent_thought_chunk") {
|
|
141
|
+
if (!emittedThinking) {
|
|
142
|
+
emittedThinking = true;
|
|
143
|
+
emit({ type: "agent.thinking", requestId });
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Tool call started — tool_call event means a new tool invocation
|
|
149
|
+
if (sessionUpdate === "tool_call") {
|
|
150
|
+
emit({
|
|
151
|
+
type: "agent.tool_start",
|
|
152
|
+
requestId,
|
|
153
|
+
toolCallId: (typeof update.toolCallId === "string" ? update.toolCallId : ""),
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Tool call completed/failed
|
|
159
|
+
if (sessionUpdate === "tool_call_update") {
|
|
160
|
+
const status = update.status as string | undefined;
|
|
161
|
+
if (status === "completed" || status === "failed") {
|
|
162
|
+
emit({
|
|
163
|
+
type: "agent.tool_done",
|
|
164
|
+
requestId,
|
|
165
|
+
toolCallId: (update.toolCallId as string) ?? "",
|
|
166
|
+
status,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Text generation
|
|
173
|
+
if (sessionUpdate === "agent_message_chunk") {
|
|
174
|
+
const content = update.content as Record<string, unknown> | undefined;
|
|
175
|
+
if (content?.type === "text" && typeof content.text === "string" && content.text) {
|
|
176
|
+
fullText += content.text;
|
|
177
|
+
agentResponding = true;
|
|
178
|
+
emit({ type: "agent.delta", requestId, delta: content.text, seq: seq++ });
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Look for completion: JSON-RPC result with stopReason
|
|
187
|
+
if (Object.hasOwn(msg, "result") && typeof msg.id === "number") {
|
|
188
|
+
const result = msg.result as Record<string, unknown> | undefined;
|
|
189
|
+
if (result && typeof result.stopReason === "string") {
|
|
190
|
+
if (!interrupted) {
|
|
191
|
+
emit({ type: "agent.complete", requestId, text: fullText });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
writeFileSync("/tmp/acpfx-bridge-raw.log", "");
|
|
198
|
+
child.stdout!.setEncoding("utf8");
|
|
199
|
+
child.stdout!.on("data", (chunk: string) => {
|
|
200
|
+
appendFileSync("/tmp/acpfx-bridge-raw.log", chunk);
|
|
201
|
+
buffer += chunk;
|
|
202
|
+
let idx = buffer.indexOf("\n");
|
|
203
|
+
while (idx >= 0) {
|
|
204
|
+
const line = buffer.slice(0, idx).trim();
|
|
205
|
+
buffer = buffer.slice(idx + 1);
|
|
206
|
+
if (line.length > 0) processLine(line);
|
|
207
|
+
idx = buffer.indexOf("\n");
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if (VERBOSE) {
|
|
212
|
+
child.stderr!.setEncoding("utf8");
|
|
213
|
+
child.stderr!.on("data", (chunk: string) => {
|
|
214
|
+
log.debug(`acpx stderr: ${chunk.trimEnd()}`);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
child.on("close", (code, _signal) => {
|
|
219
|
+
if (activeChild === child) activeChild = null;
|
|
220
|
+
streaming = false;
|
|
221
|
+
|
|
222
|
+
// null code + signal means we killed it (SIGTERM on cancel) — not an error
|
|
223
|
+
if (code !== 0 && code !== null && !interrupted) {
|
|
224
|
+
log.error(`acpx exited with code ${code}`);
|
|
225
|
+
emit({
|
|
226
|
+
type: "control.error",
|
|
227
|
+
component: "bridge-acpx",
|
|
228
|
+
message: `acpx exited with code ${code}`,
|
|
229
|
+
fatal: false,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Cancel the active prompt: kill process + acpx cancel command.
|
|
237
|
+
*/
|
|
238
|
+
function cancelCurrentPrompt(): void {
|
|
239
|
+
if (activeChild) {
|
|
240
|
+
activeChild.kill("SIGTERM");
|
|
241
|
+
activeChild = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Fire-and-forget cancel to the queue owner
|
|
245
|
+
const args = ["-y", "acpx@latest", AGENT, "cancel"];
|
|
246
|
+
if (settings.session) args.push("-s", settings.session);
|
|
247
|
+
|
|
248
|
+
const cancel = spawn("npx", args, {
|
|
249
|
+
stdio: "ignore",
|
|
250
|
+
detached: true,
|
|
251
|
+
env: process.env,
|
|
252
|
+
});
|
|
253
|
+
cancel.unref();
|
|
254
|
+
|
|
255
|
+
streaming = false;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- Main ---
|
|
259
|
+
|
|
260
|
+
function main(): void {
|
|
261
|
+
ensureSession();
|
|
262
|
+
|
|
263
|
+
emit({ type: "lifecycle.ready", component: "bridge-acpx" });
|
|
264
|
+
|
|
265
|
+
let active = false;
|
|
266
|
+
let interruptedForBargein = false;
|
|
267
|
+
let accumulatedText = "";
|
|
268
|
+
|
|
269
|
+
const rl = onEvent((event) => {
|
|
270
|
+
if (event.type === "speech.partial" && active && !interruptedForBargein) {
|
|
271
|
+
log.info("Barge-in detected (speech.partial while active) — interrupting");
|
|
272
|
+
interruptedForBargein = true;
|
|
273
|
+
emit({ type: "control.interrupt", reason: "user_speech" });
|
|
274
|
+
if (streaming) cancelCurrentPrompt();
|
|
275
|
+
} else if (event.type === "speech.pause") {
|
|
276
|
+
interruptedForBargein = false;
|
|
277
|
+
active = true;
|
|
278
|
+
const text = (event.pendingText as string) ?? (event.text as string) ?? "";
|
|
279
|
+
if (text) {
|
|
280
|
+
emit({ type: "control.interrupt", reason: "user_speech" });
|
|
281
|
+
|
|
282
|
+
if (agentResponding) {
|
|
283
|
+
// Agent already responded — this is a new turn, clear accumulator
|
|
284
|
+
accumulatedText = text;
|
|
285
|
+
agentResponding = false;
|
|
286
|
+
} else if (streaming) {
|
|
287
|
+
// Agent hasn't responded yet — append to accumulator and resubmit
|
|
288
|
+
cancelCurrentPrompt();
|
|
289
|
+
accumulatedText = accumulatedText ? accumulatedText + " " + text : text;
|
|
290
|
+
} else {
|
|
291
|
+
// Fresh submission
|
|
292
|
+
accumulatedText = accumulatedText ? accumulatedText + " " + text : text;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
handleSpeechPause(accumulatedText);
|
|
296
|
+
}
|
|
297
|
+
} else if (event.type === "control.interrupt" && event._from !== NODE_NAME) {
|
|
298
|
+
// Ignore our own interrupts that cycled back through the graph
|
|
299
|
+
interrupted = true;
|
|
300
|
+
cancelCurrentPrompt();
|
|
301
|
+
interrupted = false;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
rl.on("close", () => {
|
|
306
|
+
if (streaming) cancelCurrentPrompt();
|
|
307
|
+
emit({ type: "lifecycle.done", component: "bridge-acpx" });
|
|
308
|
+
process.exit(0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
process.on("SIGTERM", () => {
|
|
312
|
+
if (streaming) cancelCurrentPrompt();
|
|
313
|
+
process.exit(0);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
main();
|