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.
- package/README.md +146 -0
- package/bridge.mjs +128 -0
- 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
|
+
}
|