@acpfx/bridge-acpx 0.2.2 → 0.2.4

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 ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2024-2026 acpfx contributors
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @acpfx/bridge-acpx
2
+
3
+ Agent bridge connecting speech events to Claude via ACP (Agent Control Protocol). Forwards transcribed speech to the agent and streams back responses as deltas.
4
+
5
+ ## Usage
6
+
7
+ This package is a pipeline node for [@acpfx/cli](../orchestrator/README.md). See the CLI package for installation and usage.
8
+
9
+ Requires [acpx](https://github.com/anthropics/acpx) to be available (`npx acpx@latest`).
10
+
11
+ ## Manifest
12
+
13
+ - **Consumes:** `speech.partial`, `speech.pause`, `control.interrupt`
14
+ - **Emits:** `agent.submit`, `agent.delta`, `agent.complete`, `agent.thinking`, `agent.tool_start`, `agent.tool_done`, `control.interrupt`, `control.error`, `lifecycle.ready`, `lifecycle.done`
15
+
16
+ ## Settings
17
+
18
+ | Name | Type | Default | Description |
19
+ |------|------|---------|-------------|
20
+ | `agent` | string | **(required)** | Agent to connect to (e.g., `claude`) |
21
+ | `session` | string | | Session type (e.g., `voice`) |
22
+ | `verbose` | boolean | | Enable verbose logging |
23
+
24
+ Additional arguments are passed through to the agent.
25
+
26
+ ## Pipeline Example
27
+
28
+ ```yaml
29
+ nodes:
30
+ bridge:
31
+ use: "@acpfx/bridge-acpx"
32
+ settings:
33
+ agent: claude
34
+ session: voice
35
+ args: { approve-all: true }
36
+ outputs: [tts, player]
37
+ ```
38
+
39
+ ## Credits
40
+
41
+ This node connects to Claude via [acpx](https://github.com/anthropics/acpx), an Agent Communication Protocol (ACP) CLI by Anthropic.
42
+
43
+ ## License
44
+
45
+ ISC
package/dist/index.js ADDED
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { randomUUID } from "node:crypto";
5
+ import { spawn, execSync } from "node:child_process";
6
+ import { appendFileSync, writeFileSync } from "node:fs";
7
+
8
+ // ../node-sdk/src/index.ts
9
+ import { createInterface } from "node:readline";
10
+
11
+ // ../core/src/config.ts
12
+ import { parse as parseYaml } from "yaml";
13
+
14
+ // ../core/src/manifest.ts
15
+ import { readFileSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { z as z2 } from "zod";
18
+
19
+ // ../core/src/acpfx-flags.ts
20
+ import { z } from "zod";
21
+ var SetupCheckResponseSchema = z.object({
22
+ needed: z.boolean(),
23
+ description: z.string().optional()
24
+ });
25
+ var SetupProgressSchema = z.discriminatedUnion("type", [
26
+ z.object({
27
+ type: z.literal("progress"),
28
+ message: z.string(),
29
+ pct: z.number().optional()
30
+ }),
31
+ z.object({ type: z.literal("complete"), message: z.string() }),
32
+ z.object({ type: z.literal("error"), message: z.string() })
33
+ ]);
34
+ var UnsupportedFlagResponseSchema = z.object({
35
+ unsupported: z.boolean(),
36
+ flag: z.string()
37
+ });
38
+
39
+ // ../core/src/manifest.ts
40
+ var ArgumentTypeSchema = z2.enum(["string", "number", "boolean"]);
41
+ var ManifestArgumentSchema = z2.object({
42
+ type: ArgumentTypeSchema,
43
+ default: z2.unknown().optional(),
44
+ description: z2.string().optional(),
45
+ required: z2.boolean().optional(),
46
+ enum: z2.array(z2.unknown()).optional()
47
+ });
48
+ var ManifestEnvFieldSchema = z2.object({
49
+ required: z2.boolean().optional(),
50
+ description: z2.string().optional()
51
+ });
52
+ var NodeManifestSchema = z2.object({
53
+ name: z2.string(),
54
+ description: z2.string().optional(),
55
+ consumes: z2.array(z2.string()),
56
+ emits: z2.array(z2.string()),
57
+ arguments: z2.record(z2.string(), ManifestArgumentSchema).optional(),
58
+ additional_arguments: z2.boolean().optional(),
59
+ env: z2.record(z2.string(), ManifestEnvFieldSchema).optional()
60
+ });
61
+ function handleAcpfxFlags(manifestPath) {
62
+ const acpfxFlag = process.argv.find((a) => a.startsWith("--acpfx-"));
63
+ const legacyManifest = process.argv.includes("--manifest");
64
+ if (!acpfxFlag && !legacyManifest) return;
65
+ const flag = acpfxFlag ?? "--acpfx-manifest";
66
+ switch (flag) {
67
+ case "--acpfx-manifest":
68
+ printManifest(manifestPath);
69
+ break;
70
+ case "--acpfx-setup-check":
71
+ process.stdout.write(JSON.stringify({ needed: false }) + "\n");
72
+ process.exit(0);
73
+ break;
74
+ default:
75
+ process.stdout.write(
76
+ JSON.stringify({ unsupported: true, flag }) + "\n"
77
+ );
78
+ process.exit(0);
79
+ }
80
+ }
81
+ function handleManifestFlag(manifestPath) {
82
+ handleAcpfxFlags(manifestPath);
83
+ }
84
+ function printManifest(manifestPath) {
85
+ if (!manifestPath) {
86
+ const script = process.argv[1];
87
+ const scriptDir = dirname(script);
88
+ const scriptBase = script.replace(/\.[^.]+$/, "");
89
+ const colocated = `${scriptBase}.manifest.json`;
90
+ try {
91
+ readFileSync(colocated);
92
+ manifestPath = colocated;
93
+ } catch {
94
+ manifestPath = join(scriptDir, "manifest.json");
95
+ }
96
+ }
97
+ try {
98
+ const content = readFileSync(manifestPath, "utf8");
99
+ process.stdout.write(content.trim() + "\n");
100
+ process.exit(0);
101
+ } catch (err) {
102
+ process.stderr.write(`Failed to read manifest: ${err}
103
+ `);
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ // ../node-sdk/src/index.ts
109
+ var NODE_NAME = process.env.ACPFX_NODE_NAME ?? "unknown";
110
+ function emit(event) {
111
+ process.stdout.write(JSON.stringify(event) + "\n");
112
+ }
113
+ function log(level, message) {
114
+ emit({ type: "log", level, component: NODE_NAME, message });
115
+ }
116
+ log.info = (message) => log("info", message);
117
+ log.warn = (message) => log("warn", message);
118
+ log.error = (message) => log("error", message);
119
+ log.debug = (message) => log("debug", message);
120
+ function onEvent(handler) {
121
+ const rl = createInterface({ input: process.stdin });
122
+ rl.on("line", (line) => {
123
+ if (!line.trim()) return;
124
+ try {
125
+ const event = JSON.parse(line);
126
+ handler(event);
127
+ } catch {
128
+ }
129
+ });
130
+ return rl;
131
+ }
132
+
133
+ // src/index.ts
134
+ handleManifestFlag();
135
+ var settings = JSON.parse(process.env.ACPFX_SETTINGS || "{}");
136
+ if (!settings.agent) {
137
+ log.error("settings.agent is required");
138
+ process.exit(1);
139
+ }
140
+ var AGENT = settings.agent;
141
+ var VERBOSE = settings.verbose ?? false;
142
+ var NODE_NAME2 = process.env.ACPFX_NODE_NAME ?? "bridge";
143
+ var activeChild = null;
144
+ var interrupted = false;
145
+ var streaming = false;
146
+ var agentResponding = false;
147
+ function buildExtraArgs() {
148
+ const args = [];
149
+ if (!settings.args) return args;
150
+ for (const [key, value] of Object.entries(settings.args)) {
151
+ const flag = key.length === 1 ? `-${key}` : `--${key}`;
152
+ if (value === true) {
153
+ args.push(flag);
154
+ } else if (typeof value === "string") {
155
+ args.push(flag, value);
156
+ }
157
+ }
158
+ return args;
159
+ }
160
+ function ensureSession() {
161
+ log.info(`Ensuring session for "${AGENT}"${settings.session ? ` (session: ${settings.session})` : ""}...`);
162
+ const args = ["acpx@latest", AGENT, "sessions", "ensure"];
163
+ if (settings.session) args.push("--name", settings.session);
164
+ try {
165
+ const output = execSync(["npx", "-y", ...args].join(" "), {
166
+ stdio: ["ignore", "pipe", "pipe"],
167
+ env: process.env,
168
+ timeout: 3e4,
169
+ encoding: "utf8"
170
+ });
171
+ log.info(`Session: ${output.trim()}`);
172
+ } catch (err) {
173
+ log.warn(`sessions ensure failed: ${err instanceof Error ? err.message : err}`);
174
+ }
175
+ }
176
+ function handleSpeechPause(pendingText) {
177
+ if (interrupted) return;
178
+ const requestId = randomUUID();
179
+ streaming = true;
180
+ emit({ type: "agent.submit", requestId, text: pendingText });
181
+ const args = [
182
+ "-y",
183
+ "acpx@latest",
184
+ "--format",
185
+ "json",
186
+ ...buildExtraArgs(),
187
+ AGENT
188
+ ];
189
+ if (settings.session) args.push("-s", settings.session);
190
+ args.push(pendingText);
191
+ const child = spawn("npx", args, {
192
+ stdio: ["ignore", "pipe", "pipe"],
193
+ env: process.env
194
+ });
195
+ activeChild = child;
196
+ let seq = 0;
197
+ let fullText = "";
198
+ let buffer = "";
199
+ let emittedThinking = false;
200
+ const processLine = (line) => {
201
+ let msg;
202
+ try {
203
+ msg = JSON.parse(line);
204
+ } catch {
205
+ return;
206
+ }
207
+ if (msg.method === "session/update") {
208
+ const params = msg.params;
209
+ const update = params?.update;
210
+ if (!update) return;
211
+ const sessionUpdate = update.sessionUpdate;
212
+ if (sessionUpdate !== "agent_message_chunk" && sessionUpdate !== "usage_update" && sessionUpdate !== "available_commands_update") {
213
+ log.debug(`ACP: ${sessionUpdate} ${JSON.stringify(update).slice(0, 200)}`);
214
+ }
215
+ if (sessionUpdate === "agent_thought_chunk") {
216
+ if (!emittedThinking) {
217
+ emittedThinking = true;
218
+ emit({ type: "agent.thinking", requestId });
219
+ }
220
+ return;
221
+ }
222
+ if (sessionUpdate === "tool_call") {
223
+ emit({
224
+ type: "agent.tool_start",
225
+ requestId,
226
+ toolCallId: typeof update.toolCallId === "string" ? update.toolCallId : ""
227
+ });
228
+ return;
229
+ }
230
+ if (sessionUpdate === "tool_call_update") {
231
+ const status = update.status;
232
+ if (status === "completed" || status === "failed") {
233
+ emit({
234
+ type: "agent.tool_done",
235
+ requestId,
236
+ toolCallId: update.toolCallId ?? "",
237
+ status
238
+ });
239
+ }
240
+ return;
241
+ }
242
+ if (sessionUpdate === "agent_message_chunk") {
243
+ const content = update.content;
244
+ if (content?.type === "text" && typeof content.text === "string" && content.text) {
245
+ fullText += content.text;
246
+ agentResponding = true;
247
+ emit({ type: "agent.delta", requestId, delta: content.text, seq: seq++ });
248
+ }
249
+ return;
250
+ }
251
+ return;
252
+ }
253
+ if (Object.hasOwn(msg, "result") && typeof msg.id === "number") {
254
+ const result = msg.result;
255
+ if (result && typeof result.stopReason === "string") {
256
+ if (!interrupted) {
257
+ emit({ type: "agent.complete", requestId, text: fullText });
258
+ }
259
+ }
260
+ }
261
+ };
262
+ writeFileSync("/tmp/acpfx-bridge-raw.log", "");
263
+ child.stdout.setEncoding("utf8");
264
+ child.stdout.on("data", (chunk) => {
265
+ appendFileSync("/tmp/acpfx-bridge-raw.log", chunk);
266
+ buffer += chunk;
267
+ let idx = buffer.indexOf("\n");
268
+ while (idx >= 0) {
269
+ const line = buffer.slice(0, idx).trim();
270
+ buffer = buffer.slice(idx + 1);
271
+ if (line.length > 0) processLine(line);
272
+ idx = buffer.indexOf("\n");
273
+ }
274
+ });
275
+ if (VERBOSE) {
276
+ child.stderr.setEncoding("utf8");
277
+ child.stderr.on("data", (chunk) => {
278
+ log.debug(`acpx stderr: ${chunk.trimEnd()}`);
279
+ });
280
+ }
281
+ child.on("close", (code, _signal) => {
282
+ if (activeChild === child) activeChild = null;
283
+ streaming = false;
284
+ if (code !== 0 && code !== null && !interrupted) {
285
+ log.error(`acpx exited with code ${code}`);
286
+ emit({
287
+ type: "control.error",
288
+ component: "bridge-acpx",
289
+ message: `acpx exited with code ${code}`,
290
+ fatal: false
291
+ });
292
+ }
293
+ });
294
+ }
295
+ function cancelCurrentPrompt() {
296
+ if (activeChild) {
297
+ activeChild.kill("SIGTERM");
298
+ activeChild = null;
299
+ }
300
+ const args = ["-y", "acpx@latest", AGENT, "cancel"];
301
+ if (settings.session) args.push("-s", settings.session);
302
+ const cancel = spawn("npx", args, {
303
+ stdio: "ignore",
304
+ detached: true,
305
+ env: process.env
306
+ });
307
+ cancel.unref();
308
+ streaming = false;
309
+ }
310
+ function main() {
311
+ ensureSession();
312
+ emit({ type: "lifecycle.ready", component: "bridge-acpx" });
313
+ let active = false;
314
+ let interruptedForBargein = false;
315
+ let accumulatedText = "";
316
+ const rl = onEvent((event) => {
317
+ if (event.type === "speech.partial" && active && !interruptedForBargein) {
318
+ log.info("Barge-in detected (speech.partial while active) \u2014 interrupting");
319
+ interruptedForBargein = true;
320
+ emit({ type: "control.interrupt", reason: "user_speech" });
321
+ if (streaming) cancelCurrentPrompt();
322
+ } else if (event.type === "speech.pause") {
323
+ interruptedForBargein = false;
324
+ active = true;
325
+ const text = event.pendingText ?? event.text ?? "";
326
+ if (text) {
327
+ emit({ type: "control.interrupt", reason: "user_speech" });
328
+ if (agentResponding) {
329
+ accumulatedText = text;
330
+ agentResponding = false;
331
+ } else if (streaming) {
332
+ cancelCurrentPrompt();
333
+ accumulatedText = accumulatedText ? accumulatedText + " " + text : text;
334
+ } else {
335
+ accumulatedText = accumulatedText ? accumulatedText + " " + text : text;
336
+ }
337
+ handleSpeechPause(accumulatedText);
338
+ }
339
+ } else if (event.type === "control.interrupt" && event._from !== NODE_NAME2) {
340
+ interrupted = true;
341
+ cancelCurrentPrompt();
342
+ interrupted = false;
343
+ }
344
+ });
345
+ rl.on("close", () => {
346
+ if (streaming) cancelCurrentPrompt();
347
+ emit({ type: "lifecycle.done", component: "bridge-acpx" });
348
+ process.exit(0);
349
+ });
350
+ process.on("SIGTERM", () => {
351
+ if (streaming) cancelCurrentPrompt();
352
+ process.exit(0);
353
+ });
354
+ }
355
+ main();
@@ -0,0 +1 @@
1
+ {"name":"bridge-acpx","description":"Agent bridge connecting to Claude via ACP","consumes":["speech.partial","speech.pause","control.interrupt"],"emits":["agent.submit","agent.delta","agent.complete","agent.thinking","agent.tool_start","agent.tool_done","control.interrupt","control.error","lifecycle.ready","lifecycle.done"],"additional_arguments":true,"arguments":{"agent":{"type":"string","required":true,"description":"Agent to connect to"},"session":{"type":"string","description":"Session type (e.g., voice)"},"verbose":{"type":"boolean","description":"Enable verbose logging"}}}
package/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "@acpfx/bridge-acpx",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "acpfx-bridge-acpx": "./dist/index.js"
7
7
  },
8
8
  "main": "./dist/index.js",
9
+ "files": [
10
+ "dist",
11
+ "manifest.yaml"
12
+ ],
9
13
  "dependencies": {
10
14
  "@acpfx/core": "0.4.0",
11
15
  "@acpfx/node-sdk": "0.3.0"
12
16
  },
13
17
  "scripts": {
14
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external"
18
+ "build": "esbuild src/index.ts --bundle --banner:js=\"#!/usr/bin/env node\" --platform=node --format=esm --outfile=dist/index.js --packages=external && node ../../scripts/copy-manifest.js"
15
19
  }
16
20
  }
package/CHANGELOG.md DELETED
@@ -1,38 +0,0 @@
1
- # @acpfx/bridge-acpx
2
-
3
- ## 0.2.2
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0e6838e]
8
- - @acpfx/core@0.4.0
9
- - @acpfx/node-sdk@0.3.0
10
-
11
- ## 0.2.1
12
-
13
- ### Patch Changes
14
-
15
- - Updated dependencies [79c6694]
16
- - Updated dependencies [a0320a1]
17
- - @acpfx/core@0.3.0
18
- - @acpfx/node-sdk@0.2.1
19
-
20
- ## 0.2.0
21
-
22
- ### Minor Changes
23
-
24
- - d757640: Initial release: type-safe contracts, Rust orchestrator, manifest-driven event filtering
25
-
26
- - Rust schema crate as canonical event type source of truth with codegen to TypeScript + Zod
27
- - Node manifests (manifest.yaml) declaring consumes/emits contracts
28
- - Orchestrator event filtering: nodes only receive declared events
29
- - Rust orchestrator with ratatui TUI (--ui flag)
30
- - node-sdk with structured logging helpers
31
- - CI/CD with GitHub Actions and changesets
32
- - Platform-specific npm packages for Rust binaries (esbuild-style distribution)
33
-
34
- ### Patch Changes
35
-
36
- - Updated dependencies [d757640]
37
- - @acpfx/core@0.2.0
38
- - @acpfx/node-sdk@0.2.0
package/src/acpx-ipc.ts DELETED
@@ -1,533 +0,0 @@
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 DELETED
@@ -1,317 +0,0 @@
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();