ghosty-acp 0.0.1

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.
Files changed (3) hide show
  1. package/README.md +146 -0
  2. package/bridge.mjs +128 -0
  3. package/package.json +13 -0
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # ghosty-acp
2
+
3
+ Connect your editor to an [ACP](https://agentclientprotocol.com) agent running on another
4
+ machine.
5
+
6
+ Editors launch a **command** and talk to it over standard input/output. A remote agent
7
+ lives behind a **WebSocket**. This swaps the cable — nothing else. No translation, no
8
+ state, no dependencies.
9
+
10
+ Not tied to any agent or vendor: if the other end speaks ACP over WebSocket, this works.
11
+
12
+ ## Install
13
+
14
+ Nothing to install — `npx` fetches it:
15
+
16
+ ```
17
+ npx -y ghosty-acp wss://your-agent.example.com/acp
18
+ ```
19
+
20
+ ## Zed
21
+
22
+ `settings.json`:
23
+
24
+ ```jsonc
25
+ {
26
+ "agent_servers": {
27
+ "My agent": {
28
+ "type": "custom",
29
+ "command": "npx",
30
+ "args": ["-y", "ghosty-acp", "wss://your-agent.example.com/acp"],
31
+ "env": { "GHOSTY_ACP_TOKEN": "…" }
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ ## VS Code
38
+
39
+ Install the **ACP Client** extension, then in `settings.json`:
40
+
41
+ ```jsonc
42
+ {
43
+ "acp.agents": {
44
+ "My agent": {
45
+ "command": "npx",
46
+ "args": ["-y", "ghosty-acp", "wss://your-agent.example.com/acp"],
47
+ "env": { "GHOSTY_ACP_TOKEN": "…" }
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ The token is optional — omit it if the other end doesn't require one.
54
+
55
+ ## ⚠️ Put the token in `env`, never in `args`
56
+
57
+ Any process on your machine can read another process's arguments with `ps`. The token is
58
+ read from `GHOSTY_ACP_TOKEN` and appended to the URL inside the process.
59
+
60
+ ## Troubleshooting
61
+
62
+ Diagnostics go to **stderr**; stdout carries only the JSON-RPC stream, because anything
63
+ else there breaks the editor.
64
+
65
+ | Message | Meaning |
66
+ |---|---|
67
+ | `rechazado (1006)` | Bad credential, or the server refused the connection |
68
+ | `la máquina estaba en reposo…` | The agent was asleep; connecting wakes it |
69
+ | `conexión cerrada (…)` | The other end closed; the bridge exits so the editor notices |
70
+
71
+ Requires Node 22+. MIT.
72
+
73
+ ---
74
+
75
+ # ghosty-acp — en español
76
+
77
+ Conecta tu editor a un agente [ACP](https://agentclientprotocol.com) que corre en otra
78
+ máquina.
79
+
80
+ Los editores lanzan un **comando** y le hablan por entrada y salida estándar. Un agente
81
+ remoto vive detrás de un **WebSocket**. Esto cambia el cable, y nada más: no traduce, no
82
+ guarda nada, no tiene dependencias.
83
+
84
+ No es de ningún agente ni de ningún proveedor en particular: si el otro extremo habla ACP
85
+ sobre WebSocket, sirve.
86
+
87
+ ## Instalación
88
+
89
+ Nada que instalar — `npx` lo descarga:
90
+
91
+ ```
92
+ npx -y ghosty-acp wss://tu-agente.example.com/acp
93
+ ```
94
+
95
+ ## Zed
96
+
97
+ En `settings.json`:
98
+
99
+ ```jsonc
100
+ {
101
+ "agent_servers": {
102
+ "Mi agente": {
103
+ "type": "custom",
104
+ "command": "npx",
105
+ "args": ["-y", "ghosty-acp", "wss://tu-agente.example.com/acp"],
106
+ "env": { "GHOSTY_ACP_TOKEN": "…" }
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ ## VS Code
113
+
114
+ Instala la extensión **ACP Client** y en `settings.json`:
115
+
116
+ ```jsonc
117
+ {
118
+ "acp.agents": {
119
+ "Mi agente": {
120
+ "command": "npx",
121
+ "args": ["-y", "ghosty-acp", "wss://tu-agente.example.com/acp"],
122
+ "env": { "GHOSTY_ACP_TOKEN": "…" }
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ El token es opcional: si el otro extremo no pide credencial, omítelo.
129
+
130
+ ## ⚠️ El token va en `env`, nunca en `args`
131
+
132
+ Los argumentos de un proceso los puede leer cualquier otro proceso de la máquina con un
133
+ `ps`. Por eso se lee de `GHOSTY_ACP_TOKEN` y se añade a la URL ya dentro del proceso.
134
+
135
+ ## Cuando algo falla
136
+
137
+ Todo lo que el puente cuenta va a la **salida de error**; la salida estándar es sólo el
138
+ flujo JSON-RPC, porque cualquier otra cosa ahí rompe al editor.
139
+
140
+ | Mensaje | Qué pasa |
141
+ |---|---|
142
+ | `rechazado (1006)` | La credencial no vale, o el servidor no aceptó la conexión |
143
+ | `la máquina estaba en reposo…` | El agente estaba dormido; la conexión lo despierta |
144
+ | `conexión cerrada (…)` | El otro extremo cerró; el puente sale para que el editor se entere |
145
+
146
+ Necesita Node 22 o superior. MIT.
package/bridge.mjs ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // Puente ACP: el editor de un lado, la caja del agente del otro.
3
+ //
4
+ // node bridge.mjs wss://acp-<agentId>.sandboxes.easybits.cloud/acp
5
+ // GHOSTY_ACP_TOKEN=gat_… node bridge.mjs <url>
6
+ //
7
+ // POR QUÉ EXISTE. Un agente de ghosty.studio vive en una microVM y se habla por
8
+ // WebSocket. Los editores —Zed, JetBrains, los plugins de neovim— no consumen una URL:
9
+ // ejecutan un COMANDO y le escriben JSON-RPC por stdin. Sin esta pieza, la dirección
10
+ // `wss://` que reparte el panel no la puede usar ningún editor, por muy correcta que sea.
11
+ //
12
+ // No traduce nada: mueve líneas. ACP es JSON-RPC delimitado por saltos de línea, así que
13
+ // lo único que hay aquí es el cambio de cable.
14
+ //
15
+ // Node puro y CERO dependencias: Node 22 trae `WebSocket` global. Un puente que exija
16
+ // `npm install` deja de ser una línea en el settings.json de alguien.
17
+
18
+ const url = process.argv[2];
19
+ const token = process.env.GHOSTY_ACP_TOKEN || "";
20
+
21
+ if (!url) {
22
+ console.error("uso: node bridge.mjs <wss://…/acp> (token en GHOSTY_ACP_TOKEN)");
23
+ process.exit(2);
24
+ }
25
+
26
+ // ⚠️ El token va por ENV y nunca por argv: la línea de comandos de un proceso la lee
27
+ // cualquiera con un `ps` en esa máquina, y esto es una credencial de acceso al agente.
28
+ const target = new URL(url);
29
+ if (token) target.searchParams.set("token", token);
30
+
31
+ // stdout es SÓLO el flujo JSON-RPC. Todo diagnóstico va a stderr o el editor se
32
+ // atraganta con lo que no esperaba — el mismo criterio con el que el relé no le manda al
33
+ // cliente el stderr del agente.
34
+ const diag = (m) => process.stderr.write(`[ghosty-acp] ${m}\n`);
35
+
36
+ diag(`conectando a ${target.origin}${target.pathname}…`);
37
+ // La caja hiberna a los 15 minutos de ocio; la primera conexión la despierta y eso tarda
38
+ // unos segundos. Se avisa para que el silencio no se lea como un cuelgue.
39
+ const avisoLento = setTimeout(() => diag("la máquina estaba en reposo, despertándola…"), 3000);
40
+
41
+ const ws = new WebSocket(target);
42
+
43
+ // ⚠️ COLA DE SALIDA, y no es un lujo. El editor escribe `initialize` en cuanto lanza el
44
+ // proceso —antes de que el WebSocket haya abierto, y más aún si la caja estaba dormida y
45
+ // tarda segundos—. Descartar esas líneas deja al editor esperando una respuesta que nadie
46
+ // llegó a oír: se ve como un agente que no arranca, sin un solo error. Se guardan y se
47
+ // sueltan en orden al abrir.
48
+ let cola = [];
49
+
50
+ ws.onopen = () => {
51
+ clearTimeout(avisoLento);
52
+ diag("listo");
53
+ for (const linea of cola) ws.send(linea + "\n");
54
+ cola = [];
55
+ };
56
+
57
+ ws.onerror = (e) => {
58
+ clearTimeout(avisoLento);
59
+ // El evento de error de esta API trae poco; el `onclose` que viene detrás trae el
60
+ // código, y ahí está el diagnóstico bueno. Se imprime lo que haya por si acaso.
61
+ diag(`error de conexión${e?.message ? `: ${e.message}` : ""}`);
62
+ };
63
+
64
+ ws.onclose = (e) => {
65
+ clearTimeout(avisoLento);
66
+ // 1006 sin motivo es lo que se ve cuando el servidor rechaza el upgrade: casi siempre
67
+ // el token. Decirlo aquí evita el rato de mirar el editor pensando que es cosa suya.
68
+ if (e.code === 1006 && !e.reason) {
69
+ diag(token ? "rechazado (1006): revisa el token de este agente" : "rechazado (1006): falta GHOSTY_ACP_TOKEN");
70
+ } else {
71
+ diag(`conexión cerrada (${e.code}${e.reason ? `: ${e.reason}` : ""})`);
72
+ }
73
+ // Salir es parte del contrato con el editor: un proceso que sigue vivo con el cable
74
+ // muerto se ve como un agente que dejó de contestar, sin decir nunca que murió.
75
+ process.exit(e.code === 1000 ? 0 : 1);
76
+ };
77
+
78
+ // ── caja → editor ────────────────────────────────────────────────────────────────
79
+ //
80
+ // ⚠️ EL SALTO DE LÍNEA NO ES DECORACIÓN. El relé manda cada mensaje SIN `\n` final,
81
+ // porque en un WebSocket el frame YA es la frontera. Pero el editor lee un FLUJO de
82
+ // bytes y espera el delimitador: si se vuelca el frame tal cual, se queda con el mensaje
83
+ // entero en su buffer esperando un salto que no llega, y **cuelga sin un solo error**.
84
+ // Es el primer bug que tuvo este transporte y no se ve en ninguna traza.
85
+ //
86
+ // Se parte por si vinieran varios mensajes en un frame; no se guarda nada entre frames.
87
+ ws.onmessage = (e) => {
88
+ for (const linea of String(e.data).split("\n")) {
89
+ const t = linea.trim();
90
+ if (t) process.stdout.write(t + "\n");
91
+ }
92
+ };
93
+
94
+ // ── editor → caja ────────────────────────────────────────────────────────────────
95
+ //
96
+ // Aquí SÍ hace falta acumular: stdin es un flujo y un mensaje puede llegar partido en
97
+ // dos chunks. Se emite sólo lo que ya tiene su salto de línea.
98
+ let buffer = "";
99
+ process.stdin.setEncoding("utf8");
100
+ process.stdin.on("data", (chunk) => {
101
+ buffer += chunk;
102
+ let nl;
103
+ while ((nl = buffer.indexOf("\n")) !== -1) {
104
+ const linea = buffer.slice(0, nl).trim();
105
+ buffer = buffer.slice(nl + 1);
106
+ if (!linea) continue;
107
+ // Si el socket aún no está abierto la línea se descarta y se dice: mandarla al vacío
108
+ // en silencio dejaría al editor esperando para siempre una respuesta que nadie oyó.
109
+ if (ws.readyState === WebSocket.CONNECTING) {
110
+ cola.push(linea);
111
+ continue;
112
+ }
113
+ if (ws.readyState !== WebSocket.OPEN) {
114
+ diag("mensaje descartado: la conexión está cerrada");
115
+ continue;
116
+ }
117
+ ws.send(linea + "\n");
118
+ }
119
+ });
120
+
121
+ // El editor cierra stdin cuando termina con el agente. Cerrar limpio (1000) para que la
122
+ // caja libere su sesión en vez de esperar a que expire el socket.
123
+ // ⚠️ Con la cola aún pendiente, cerrar aquí tiraría lo que no ha salido. Sólo se cierra
124
+ // si ya no hay nada esperando; si lo hay, lo hará el `onclose` de la caja o el propio
125
+ // editor al matar el proceso.
126
+ process.stdin.on("end", () => {
127
+ if (ws.readyState === WebSocket.OPEN && cola.length === 0) ws.close(1000, "stdin cerrado");
128
+ });
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "ghosty-acp",
3
+ "version": "0.0.1",
4
+ "description": "Conecta tu editor a un agente ACP remoto. Puente entre entrada/salida estándar y WebSocket, sin dependencias.",
5
+ "type": "module",
6
+ "bin": { "ghosty-acp": "bridge.mjs" },
7
+ "files": ["bridge.mjs", "README.md"],
8
+ "engines": { "node": ">=22" },
9
+ "keywords": ["acp", "agent-client-protocol", "zed", "vscode", "neovim", "agent"],
10
+ "license": "MIT",
11
+ "repository": { "type": "git", "url": "git+https://github.com/blissito/ghosty-studio.git", "directory": "packages/acp-bridge" },
12
+ "homepage": "https://www.ghosty.studio/docs/acp"
13
+ }