@span-io/agent-link 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/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/agent.js +65 -0
- package/dist/config.js +45 -0
- package/dist/crypto-utils.js +49 -0
- package/dist/index.js +276 -0
- package/dist/log-buffer.js +29 -0
- package/dist/process-runner.js +137 -0
- package/dist/protocol.js +6 -0
- package/dist/transport.js +176 -0
- package/package.json +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Span-IO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# AgentLink (Client)
|
|
2
|
+
|
|
3
|
+
**AgentLink** is the secure bridge between your local development environment and **Span**, the remote control plane. It allows you to run powerful AI agent tools (like Codex, Gemini, or Claude) on your own machine—where they have access to your code, compilers, and local tools—while controlling and monitoring them from a centralized web interface.
|
|
4
|
+
|
|
5
|
+
Think of it as a **reverse tunnel for AI agents**: The "brains" and history live in the cloud (or your self-hosted server), but the "hands" are local.
|
|
6
|
+
|
|
7
|
+
## 🚀 How It Works
|
|
8
|
+
|
|
9
|
+
1. **You run this CLI** on your laptop or dev server.
|
|
10
|
+
2. **It connects** to Span via a secure WebSocket.
|
|
11
|
+
3. **It waits for commands.** When you send a prompt in the Web UI, the server signals this client.
|
|
12
|
+
4. **It executes the agent** locally on your machine.
|
|
13
|
+
5. **It streams logs/output** back to the Web UI in real-time.
|
|
14
|
+
|
|
15
|
+
## 📦 Installation & Usage
|
|
16
|
+
|
|
17
|
+
You don't need to install this globally. We recommend running it on-demand via `npx`.
|
|
18
|
+
|
|
19
|
+
### 1. Connect a New Device
|
|
20
|
+
In the Span Web UI, click **+ Connect New Device**. You will be given a pairing code.
|
|
21
|
+
|
|
22
|
+
Run the following command in your terminal:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npx -y @span-io/agent-link connect --server https://your-server.com --pairing-code YOUR-PAIRING-CODE
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
*Replace `https://your-server.com` with the URL of your Span instance.*
|
|
29
|
+
|
|
30
|
+
### 2. Run in Background
|
|
31
|
+
Once paired, the client will save your credentials to `~/.config/remote-agent/client.json`. You can subsequently run it without the pairing code:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx -y @span-io/agent-link connect --server https://your-server.com
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 3. Agent Selection
|
|
38
|
+
By default, the client auto-discovers supported agents (`codex`, `gemini`, `claude`) in your `PATH`.
|
|
39
|
+
You can force a specific binary using the `--agent` flag or environment variables:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Force usage of a specific binary
|
|
43
|
+
npx -y @span-io/agent-link connect --server ... --agent /usr/local/bin/my-custom-codex
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 🔒 Security & Risk Profile
|
|
47
|
+
|
|
48
|
+
**This tool allows a remote server to execute commands on your machine.** It is designed for developers who own both the server and the client.
|
|
49
|
+
|
|
50
|
+
### What it Protects Against
|
|
51
|
+
* **Unauthorized Connection:** Pairing requires a short-lived, cryptographic code. Once paired, connections use a refresh token bound to your device.
|
|
52
|
+
* **Man-in-the-Middle:** All traffic is encrypted via TLS (WebSocket Secure).
|
|
53
|
+
* **Drive-by Attacks:** The client does not listen on any open ports; it makes an outbound connection to the server.
|
|
54
|
+
|
|
55
|
+
### What it Does NOT Protect Against
|
|
56
|
+
* **Compromised Server:** If your Span server is hacked, an attacker can send "spawn" commands to your connected client.
|
|
57
|
+
* **Malicious Agent Output:** If the AI agent (e.g., Gemini) decides to run `rm -rf /`, this client will faithfully execute that command.
|
|
58
|
+
* **Local Privilege Escalation:** The agent runs with the same permissions as the user who ran `npx @span-io/agent-link connect`. Do not run this as root.
|
|
59
|
+
|
|
60
|
+
### ⚠️ Threat Model: "Remote Shell"
|
|
61
|
+
You should treat this client with the same security caution as an **SSH Session**.
|
|
62
|
+
* **Difficulty for Malicious Actors:** If they compromise your Span account, gaining code execution on your local machine is **Trivial (Low Difficulty)**. They just need to send a prompt to the agent telling it to run a shell command.
|
|
63
|
+
* **Mitigation:**
|
|
64
|
+
* Only connect to servers you trust.
|
|
65
|
+
* Run the client inside a Docker container or VM if you are working with untrusted inputs.
|
|
66
|
+
* Use the agent's built-in sandboxing (e.g. `--approval-mode`) to review commands before they run.
|
|
67
|
+
|
|
68
|
+
## 🛠 Configuration
|
|
69
|
+
|
|
70
|
+
Configuration is stored in `~/.config/remote-agent/client.json`.
|
|
71
|
+
|
|
72
|
+
**Environment Variables:**
|
|
73
|
+
* `CODEX_BIN`, `GEMINI_BIN`, `CLAUDE_BIN`: Override the path to specific agent binaries.
|
|
74
|
+
* `CODEX_CWD`: Set the working directory for the agent (defaults to the directory where you ran the client).
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
const AGENT_NAMES = ["codex", "gemini", "claude"];
|
|
5
|
+
export function findAgentsOnPath() {
|
|
6
|
+
const pathEntries = (process.env.PATH ?? "")
|
|
7
|
+
.split(path.delimiter)
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
const results = [];
|
|
10
|
+
for (const name of AGENT_NAMES) {
|
|
11
|
+
for (const dir of pathEntries) {
|
|
12
|
+
const fullPath = path.join(dir, name);
|
|
13
|
+
try {
|
|
14
|
+
fs.accessSync(fullPath, fs.constants.X_OK);
|
|
15
|
+
results.push({ name, path: fullPath });
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// continue searching
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
export function resolveAgentBinary(preferred, discovered) {
|
|
26
|
+
// 1. Check for specific environment variable overrides first
|
|
27
|
+
if (preferred === "codex" && process.env.CODEX_BIN) {
|
|
28
|
+
return { name: "codex", path: process.env.CODEX_BIN };
|
|
29
|
+
}
|
|
30
|
+
if (preferred === "gemini" && process.env.GEMINI_BIN) {
|
|
31
|
+
return { name: "gemini", path: process.env.GEMINI_BIN };
|
|
32
|
+
}
|
|
33
|
+
if (preferred === "claude" && process.env.CLAUDE_BIN) {
|
|
34
|
+
return { name: "claude", path: process.env.CLAUDE_BIN };
|
|
35
|
+
}
|
|
36
|
+
// 2. If preferred is an absolute/relative path that exists
|
|
37
|
+
if (preferred) {
|
|
38
|
+
if (fs.existsSync(preferred)) {
|
|
39
|
+
return {
|
|
40
|
+
name: inferNameFromPath(preferred),
|
|
41
|
+
path: preferred,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const byName = (discovered ?? findAgentsOnPath()).find((agent) => agent.name === preferred);
|
|
45
|
+
return byName ?? null;
|
|
46
|
+
}
|
|
47
|
+
const available = discovered ?? findAgentsOnPath();
|
|
48
|
+
return available[0] ?? null;
|
|
49
|
+
}
|
|
50
|
+
function inferNameFromPath(agentPath) {
|
|
51
|
+
const base = path.basename(agentPath).toLowerCase();
|
|
52
|
+
if (base.includes("gemini")) {
|
|
53
|
+
return "gemini";
|
|
54
|
+
}
|
|
55
|
+
if (base.includes("claude")) {
|
|
56
|
+
return "claude";
|
|
57
|
+
}
|
|
58
|
+
return "codex";
|
|
59
|
+
}
|
|
60
|
+
export function spawnAgentProcess(agent, args) {
|
|
61
|
+
const child = spawn(agent.path, args, {
|
|
62
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
63
|
+
});
|
|
64
|
+
return { name: agent.name, child };
|
|
65
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { encrypt, decrypt } from "./crypto-utils.js";
|
|
5
|
+
const CONFIG_DIR = process.env.REMOTE_AGENT_CLIENT_HOME ??
|
|
6
|
+
path.join(os.homedir(), ".remote-agent-client");
|
|
7
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
8
|
+
function newClientId() {
|
|
9
|
+
if (globalThis.crypto?.randomUUID) {
|
|
10
|
+
return globalThis.crypto.randomUUID();
|
|
11
|
+
}
|
|
12
|
+
return `client-${Date.now().toString(36)}`;
|
|
13
|
+
}
|
|
14
|
+
export function loadConfig() {
|
|
15
|
+
try {
|
|
16
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf8");
|
|
17
|
+
const config = JSON.parse(raw);
|
|
18
|
+
if (config.encryptedRefreshToken && !config.refreshToken) {
|
|
19
|
+
try {
|
|
20
|
+
config.refreshToken = decrypt(config.encryptedRefreshToken);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
console.warn("Failed to decrypt refresh token:", err);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return config;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return { clientId: newClientId() };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function saveConfig(config) {
|
|
33
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
34
|
+
const toSave = { ...config };
|
|
35
|
+
if (toSave.refreshToken) {
|
|
36
|
+
try {
|
|
37
|
+
toSave.encryptedRefreshToken = encrypt(toSave.refreshToken);
|
|
38
|
+
delete toSave.refreshToken;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.warn("Failed to encrypt refresh token:", err);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(toSave, null, 2), "utf8");
|
|
45
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
const CONFIG_DIR = process.env.REMOTE_AGENT_CLIENT_HOME ??
|
|
6
|
+
path.join(os.homedir(), ".remote-agent-client");
|
|
7
|
+
const KEY_FILE = path.join(CONFIG_DIR, "master.key");
|
|
8
|
+
function getMasterKey() {
|
|
9
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
10
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
if (fs.existsSync(KEY_FILE)) {
|
|
13
|
+
return fs.readFileSync(KEY_FILE);
|
|
14
|
+
}
|
|
15
|
+
const key = crypto.randomBytes(32);
|
|
16
|
+
fs.writeFileSync(KEY_FILE, key);
|
|
17
|
+
try {
|
|
18
|
+
fs.chmodSync(KEY_FILE, 0o600);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
console.warn("Failed to set secure permissions on master key:", error);
|
|
22
|
+
}
|
|
23
|
+
return key;
|
|
24
|
+
}
|
|
25
|
+
export function encrypt(text) {
|
|
26
|
+
const key = getMasterKey();
|
|
27
|
+
const iv = crypto.randomBytes(12);
|
|
28
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
29
|
+
let encrypted = cipher.update(text, "utf8", "hex");
|
|
30
|
+
encrypted += cipher.final("hex");
|
|
31
|
+
const authTag = cipher.getAuthTag().toString("hex");
|
|
32
|
+
// Format: iv:authTag:encrypted
|
|
33
|
+
return `${iv.toString("hex")}:${authTag}:${encrypted}`;
|
|
34
|
+
}
|
|
35
|
+
export function decrypt(text) {
|
|
36
|
+
const parts = text.split(":");
|
|
37
|
+
if (parts.length !== 3) {
|
|
38
|
+
throw new Error("Invalid encrypted format");
|
|
39
|
+
}
|
|
40
|
+
const [ivHex, authTagHex, encryptedHex] = parts;
|
|
41
|
+
const key = getMasterKey();
|
|
42
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
43
|
+
const authTag = Buffer.from(authTagHex, "hex");
|
|
44
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
|
45
|
+
decipher.setAuthTag(authTag);
|
|
46
|
+
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
|
47
|
+
decrypted += decipher.final("utf8");
|
|
48
|
+
return decrypted;
|
|
49
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import os from "os";
|
|
3
|
+
import process from "process";
|
|
4
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
5
|
+
import { findAgentsOnPath, resolveAgentBinary } from "./agent.js";
|
|
6
|
+
import { spawnAgentProcess as spawnAgentProcessAdvanced } from "./process-runner.js";
|
|
7
|
+
import { LogBuffer } from "./log-buffer.js";
|
|
8
|
+
import { NoopTransport, WebSocketTransport } from "./transport.js";
|
|
9
|
+
const args = parseArgs(process.argv.slice(2));
|
|
10
|
+
if (args.list) {
|
|
11
|
+
const agents = findAgentsOnPath();
|
|
12
|
+
if (agents.length === 0) {
|
|
13
|
+
console.log("No supported agents found on PATH (codex, gemini, claude).");
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
for (const agent of agents) {
|
|
18
|
+
console.log(`${agent.name}: ${agent.path}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
const config = loadConfig();
|
|
24
|
+
if (args.serverUrl) {
|
|
25
|
+
config.serverUrl = args.serverUrl;
|
|
26
|
+
}
|
|
27
|
+
if (args.pairingCode) {
|
|
28
|
+
if (!config.serverUrl) {
|
|
29
|
+
console.error("Pairing requires --server.");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
await pairWithServer({
|
|
33
|
+
serverUrl: config.serverUrl,
|
|
34
|
+
pairingCode: args.pairingCode,
|
|
35
|
+
label: os.hostname(),
|
|
36
|
+
}).then((res) => {
|
|
37
|
+
config.clientId = res.clientId;
|
|
38
|
+
config.refreshToken = res.refreshToken;
|
|
39
|
+
saveConfig(config);
|
|
40
|
+
console.log("Pairing successful.");
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const logBuffer = new LogBuffer();
|
|
44
|
+
// Update map to hold SpawnedProcess which includes the child
|
|
45
|
+
const activeAgents = new Map();
|
|
46
|
+
let transport;
|
|
47
|
+
try {
|
|
48
|
+
transport = await createTransport({
|
|
49
|
+
config,
|
|
50
|
+
logBuffer,
|
|
51
|
+
onControl: (message) => handleControl(message),
|
|
52
|
+
onAck: (id) => logBuffer.setLastAckedId(id),
|
|
53
|
+
noConnect: args.noConnect ?? false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error(`Connection failed, running offline: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
58
|
+
transport = new NoopTransport();
|
|
59
|
+
}
|
|
60
|
+
function handleControl(message) {
|
|
61
|
+
const { action, agentId, payload } = message;
|
|
62
|
+
if (!agentId)
|
|
63
|
+
return;
|
|
64
|
+
switch (action) {
|
|
65
|
+
case "spawn":
|
|
66
|
+
case "start": {
|
|
67
|
+
let agentProc = activeAgents.get(agentId);
|
|
68
|
+
if (agentProc) {
|
|
69
|
+
if (payload?.prompt && agentProc.child.stdin) {
|
|
70
|
+
agentProc.child.stdin.write(`${payload.prompt}\n`);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const discovered = findAgentsOnPath();
|
|
75
|
+
let preferredAgent = args.agent;
|
|
76
|
+
let targetModel = payload?.model;
|
|
77
|
+
// Fallback: Try to find model in args if not in payload top-level
|
|
78
|
+
if (!targetModel && payload?.args) {
|
|
79
|
+
const argsList = payload.args;
|
|
80
|
+
const modelIndex = argsList.findIndex(a => a === "--model" || a === "-m");
|
|
81
|
+
if (modelIndex >= 0 && modelIndex + 1 < argsList.length) {
|
|
82
|
+
targetModel = argsList[modelIndex + 1];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!preferredAgent && targetModel) {
|
|
86
|
+
if (targetModel.startsWith("gemini-")) {
|
|
87
|
+
preferredAgent = "gemini";
|
|
88
|
+
}
|
|
89
|
+
else if (targetModel.startsWith("claude-")) {
|
|
90
|
+
preferredAgent = "claude";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!preferredAgent) {
|
|
94
|
+
preferredAgent = payload?.name || "codex";
|
|
95
|
+
}
|
|
96
|
+
const agentCandidate = resolveAgentBinary(preferredAgent, discovered);
|
|
97
|
+
if (!agentCandidate) {
|
|
98
|
+
console.error(`No agent found for ${preferredAgent || "any supported agent"}`);
|
|
99
|
+
transport.sendStatus(agentId, "error");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const optionsArgs = [...(payload?.args || args.agentArgs)];
|
|
103
|
+
const proc = spawnAgentProcessAdvanced({
|
|
104
|
+
agent: {
|
|
105
|
+
id: agentId,
|
|
106
|
+
name: agentCandidate.name,
|
|
107
|
+
model: targetModel || "codex-cli"
|
|
108
|
+
},
|
|
109
|
+
prompt: payload?.prompt || "",
|
|
110
|
+
optionsArgs,
|
|
111
|
+
executablePath: agentCandidate.path
|
|
112
|
+
});
|
|
113
|
+
activeAgents.set(agentId, proc);
|
|
114
|
+
console.log(`[${new Date().toLocaleTimeString()}] Spawning agent: ${agentCandidate.name} (${targetModel || "default model"})`);
|
|
115
|
+
transport.sendStatus(agentId, "running");
|
|
116
|
+
setupAgentPiping(agentId, proc);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "stop": {
|
|
120
|
+
const proc = activeAgents.get(agentId);
|
|
121
|
+
if (proc) {
|
|
122
|
+
proc.child.kill();
|
|
123
|
+
activeAgents.delete(agentId);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case "stdin":
|
|
128
|
+
case "prompt": {
|
|
129
|
+
const proc = activeAgents.get(agentId);
|
|
130
|
+
const data = message.data || payload?.prompt;
|
|
131
|
+
if (proc && data && proc.child.stdin) {
|
|
132
|
+
proc.child.stdin.write(data.endsWith("\n") ? data : `${data}\n`);
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function setupAgentPiping(agentId, proc) {
|
|
139
|
+
const setupStream = (stream, name) => {
|
|
140
|
+
if (!stream)
|
|
141
|
+
return;
|
|
142
|
+
let buffer = "";
|
|
143
|
+
stream.setEncoding("utf8");
|
|
144
|
+
stream.on("data", (chunk) => {
|
|
145
|
+
buffer += chunk;
|
|
146
|
+
let index = buffer.indexOf("\n");
|
|
147
|
+
while (index >= 0) {
|
|
148
|
+
const line = buffer.slice(0, index + 1);
|
|
149
|
+
buffer = buffer.slice(index + 1);
|
|
150
|
+
transport.sendLog(agentId, name, line);
|
|
151
|
+
index = buffer.indexOf("\n");
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
stream.on("end", () => {
|
|
155
|
+
if (buffer.length > 0) {
|
|
156
|
+
transport.sendLog(agentId, name, buffer);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
setupStream(proc.child.stdout, "stdout");
|
|
161
|
+
setupStream(proc.child.stderr, "stderr");
|
|
162
|
+
proc.child.on("exit", (code, signal) => {
|
|
163
|
+
transport.sendStatus(agentId, "exited");
|
|
164
|
+
activeAgents.delete(agentId);
|
|
165
|
+
});
|
|
166
|
+
proc.child.on("error", (error) => {
|
|
167
|
+
transport.sendLog(agentId, "stderr", `Process error: ${error.message}\n`);
|
|
168
|
+
transport.sendStatus(agentId, "error");
|
|
169
|
+
activeAgents.delete(agentId);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function parseArgs(argv) {
|
|
173
|
+
const parsed = { agentArgs: [] };
|
|
174
|
+
let startIdx = 0;
|
|
175
|
+
// Handle optional "connect" subcommand
|
|
176
|
+
if (argv[0] === "connect") {
|
|
177
|
+
startIdx = 1;
|
|
178
|
+
}
|
|
179
|
+
for (let i = startIdx; i < argv.length; i += 1) {
|
|
180
|
+
const arg = argv[i];
|
|
181
|
+
switch (arg) {
|
|
182
|
+
case "--server":
|
|
183
|
+
parsed.serverUrl = argv[++i];
|
|
184
|
+
break;
|
|
185
|
+
case "--pairing-code":
|
|
186
|
+
parsed.pairingCode = argv[++i];
|
|
187
|
+
break;
|
|
188
|
+
case "--agent":
|
|
189
|
+
parsed.agent = argv[++i];
|
|
190
|
+
break;
|
|
191
|
+
case "--list":
|
|
192
|
+
parsed.list = true;
|
|
193
|
+
break;
|
|
194
|
+
case "--no-connect":
|
|
195
|
+
parsed.noConnect = true;
|
|
196
|
+
break;
|
|
197
|
+
case "--":
|
|
198
|
+
parsed.agentArgs.push(...argv.slice(i + 1));
|
|
199
|
+
i = argv.length;
|
|
200
|
+
break;
|
|
201
|
+
case "--help":
|
|
202
|
+
printHelp();
|
|
203
|
+
process.exit(0);
|
|
204
|
+
default:
|
|
205
|
+
if (arg.startsWith("--")) {
|
|
206
|
+
console.error(`Unknown argument: ${arg}`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
parsed.agentArgs.push(arg);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return parsed;
|
|
215
|
+
}
|
|
216
|
+
function printHelp() {
|
|
217
|
+
console.log(`remote-agent-client
|
|
218
|
+
|
|
219
|
+
Usage:
|
|
220
|
+
remote-agent-client --server https://host --pairing-code 123-456
|
|
221
|
+
remote-agent-client --server https://host
|
|
222
|
+
|
|
223
|
+
Options:
|
|
224
|
+
--server <url> Remote Agent server URL
|
|
225
|
+
--pairing-code <code> Pairing code for first-time auth
|
|
226
|
+
--agent <name|path> Preferred agent (codex, gemini, claude)
|
|
227
|
+
--list List discovered agents
|
|
228
|
+
--no-connect Run without server connection
|
|
229
|
+
-- Pass remaining args to the agent
|
|
230
|
+
`);
|
|
231
|
+
}
|
|
232
|
+
async function pairWithServer(input) {
|
|
233
|
+
const response = await fetch(new URL("/api/clients/pair", input.serverUrl), {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { "Content-Type": "application/json" },
|
|
236
|
+
body: JSON.stringify({
|
|
237
|
+
code: input.pairingCode,
|
|
238
|
+
label: input.label,
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
if (!response.ok) {
|
|
242
|
+
const details = await response.text().catch(() => "");
|
|
243
|
+
throw new Error(`Pairing failed (${response.status}): ${details}`);
|
|
244
|
+
}
|
|
245
|
+
return (await response.json());
|
|
246
|
+
}
|
|
247
|
+
async function createTransport(input) {
|
|
248
|
+
if (input.noConnect || !input.config.serverUrl || !input.config.refreshToken || !input.config.clientId) {
|
|
249
|
+
return new NoopTransport();
|
|
250
|
+
}
|
|
251
|
+
const transport = new WebSocketTransport({
|
|
252
|
+
serverUrl: input.config.serverUrl,
|
|
253
|
+
tokenProvider: () => requestSessionToken(input.config.serverUrl, input.config.refreshToken),
|
|
254
|
+
clientId: input.config.clientId,
|
|
255
|
+
logBuffer: input.logBuffer,
|
|
256
|
+
onControl: input.onControl,
|
|
257
|
+
onAck: input.onAck,
|
|
258
|
+
});
|
|
259
|
+
await transport.connect();
|
|
260
|
+
return transport;
|
|
261
|
+
}
|
|
262
|
+
async function requestSessionToken(serverUrl, refreshToken) {
|
|
263
|
+
const response = await fetch(new URL("/api/clients/session", serverUrl), {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: {
|
|
266
|
+
"Content-Type": "application/json",
|
|
267
|
+
Authorization: `Bearer ${refreshToken}`,
|
|
268
|
+
},
|
|
269
|
+
body: JSON.stringify({}),
|
|
270
|
+
});
|
|
271
|
+
if (!response.ok) {
|
|
272
|
+
throw new Error(`Session token request failed (${response.status})`);
|
|
273
|
+
}
|
|
274
|
+
const data = (await response.json());
|
|
275
|
+
return data.sessionToken;
|
|
276
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class LogBuffer {
|
|
2
|
+
maxEntries;
|
|
3
|
+
entries = [];
|
|
4
|
+
lastAckedId = 0;
|
|
5
|
+
constructor(maxEntries = 5000) {
|
|
6
|
+
this.maxEntries = maxEntries;
|
|
7
|
+
}
|
|
8
|
+
getLastAckedId() {
|
|
9
|
+
return this.lastAckedId;
|
|
10
|
+
}
|
|
11
|
+
setLastAckedId(id) {
|
|
12
|
+
if (id > this.lastAckedId) {
|
|
13
|
+
this.lastAckedId = id;
|
|
14
|
+
this.prune();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
push(entry) {
|
|
18
|
+
this.entries.push(entry);
|
|
19
|
+
this.prune();
|
|
20
|
+
}
|
|
21
|
+
getUnacked() {
|
|
22
|
+
return this.entries.filter((entry) => entry.id > this.lastAckedId);
|
|
23
|
+
}
|
|
24
|
+
prune() {
|
|
25
|
+
while (this.entries.length > this.maxEntries) {
|
|
26
|
+
this.entries.shift();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
const DEFAULT_CODEX_ARGS = "exec --skip-git-repo-check";
|
|
3
|
+
const DEFAULT_GEMINI_ARGS = "";
|
|
4
|
+
const DEFAULT_GEMINI_PROMPT_FLAG = "-p";
|
|
5
|
+
const DEFAULT_PROMPT_FLAG = "";
|
|
6
|
+
const DEFAULT_TTY_MODE = "auto";
|
|
7
|
+
const DEFAULT_TTY_TERM = "dumb";
|
|
8
|
+
function splitArgs(raw) {
|
|
9
|
+
return raw.trim() === "" ? [] : raw.trim().split(/\s+/g);
|
|
10
|
+
}
|
|
11
|
+
function shellQuote(value) {
|
|
12
|
+
// Simple single-quote wrapping for display purposes
|
|
13
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
14
|
+
}
|
|
15
|
+
export function buildCommand({ agent, prompt, optionsArgs = [], executablePath }, promptModeOverride) {
|
|
16
|
+
// 1. Gemini Models
|
|
17
|
+
if (agent.model.startsWith("gemini-")) {
|
|
18
|
+
const command = executablePath ?? process.env.GEMINI_BIN ?? "gemini";
|
|
19
|
+
const rawArgs = process.env.GEMINI_ARGS ?? DEFAULT_GEMINI_ARGS;
|
|
20
|
+
const promptFlag = process.env.GEMINI_PROMPT_FLAG ?? DEFAULT_GEMINI_PROMPT_FLAG;
|
|
21
|
+
const args = splitArgs(rawArgs);
|
|
22
|
+
if (optionsArgs.length > 0) {
|
|
23
|
+
args.push(...optionsArgs);
|
|
24
|
+
}
|
|
25
|
+
if (!args.includes("--model") && !args.includes("-m")) {
|
|
26
|
+
args.push("--model", agent.model);
|
|
27
|
+
}
|
|
28
|
+
if (!args.includes("--approval-mode")) {
|
|
29
|
+
args.push("--approval-mode", "auto_edit");
|
|
30
|
+
}
|
|
31
|
+
args.push(promptFlag, prompt);
|
|
32
|
+
return { command, args, promptMode: "args" };
|
|
33
|
+
}
|
|
34
|
+
// 2. Claude Models
|
|
35
|
+
if (agent.model.startsWith("claude-")) {
|
|
36
|
+
const command = executablePath ?? process.env.CLAUDE_BIN ?? "claude";
|
|
37
|
+
const args = ["-p", prompt, "--model", agent.model];
|
|
38
|
+
return { command, args, promptMode: "args" };
|
|
39
|
+
}
|
|
40
|
+
// 3. Generic Codex/Other Models
|
|
41
|
+
const command = executablePath ?? process.env.CODEX_BIN ?? "codex";
|
|
42
|
+
const rawArgs = process.env.CODEX_ARGS ?? DEFAULT_CODEX_ARGS;
|
|
43
|
+
const promptFlag = process.env.CODEX_PROMPT_FLAG ?? DEFAULT_PROMPT_FLAG;
|
|
44
|
+
// Determine Prompt Mode
|
|
45
|
+
const envPromptMode = process.env.CODEX_PROMPT_MODE === "stdin" ? "stdin" : "args";
|
|
46
|
+
const promptMode = promptModeOverride ?? envPromptMode;
|
|
47
|
+
const extraArgs = optionsArgs.filter((arg) => arg.trim() !== "");
|
|
48
|
+
const args = splitArgs(rawArgs);
|
|
49
|
+
if (promptMode === "args") {
|
|
50
|
+
if (extraArgs.length) {
|
|
51
|
+
args.push(...extraArgs);
|
|
52
|
+
}
|
|
53
|
+
if (!args.includes("--model") && !args.includes("-m")) {
|
|
54
|
+
args.push("--model", agent.model);
|
|
55
|
+
}
|
|
56
|
+
// Handle {prompt} placeholder or append
|
|
57
|
+
const placeholderIndex = args.findIndex((arg) => arg.includes("{prompt}"));
|
|
58
|
+
if (placeholderIndex >= 0) {
|
|
59
|
+
args[placeholderIndex] = args[placeholderIndex].replace("{prompt}", prompt);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
if (promptFlag) {
|
|
63
|
+
args.push(promptFlag);
|
|
64
|
+
}
|
|
65
|
+
args.push(prompt);
|
|
66
|
+
}
|
|
67
|
+
return { command, args, promptMode: "args" };
|
|
68
|
+
}
|
|
69
|
+
// Stdin Mode
|
|
70
|
+
if (promptMode === "stdin") {
|
|
71
|
+
if (extraArgs.length) {
|
|
72
|
+
args.push(...extraArgs);
|
|
73
|
+
}
|
|
74
|
+
if (!args.includes("--model") && !args.includes("-m")) {
|
|
75
|
+
args.push("--model", agent.model);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { command, args, promptMode };
|
|
79
|
+
}
|
|
80
|
+
export function spawnAgentProcess(config) {
|
|
81
|
+
const { agent, optionsArgs = [] } = config;
|
|
82
|
+
const startedAt = new Date().toISOString();
|
|
83
|
+
const isCodexModel = !agent.model.startsWith("gemini-") && !agent.model.startsWith("claude-");
|
|
84
|
+
const sanitizedOptions = agent.model.startsWith("claude-")
|
|
85
|
+
? []
|
|
86
|
+
: optionsArgs;
|
|
87
|
+
const spawnWithMode = (promptModeOverride) => {
|
|
88
|
+
const { command, args, promptMode } = buildCommand({ ...config, optionsArgs: sanitizedOptions }, promptModeOverride);
|
|
89
|
+
const ttyMode = process.env.CODEX_TTY_MODE ?? DEFAULT_TTY_MODE;
|
|
90
|
+
const hasExec = args.includes("exec");
|
|
91
|
+
const useScriptWrapper = ttyMode === "script" || (ttyMode === "auto" && hasExec);
|
|
92
|
+
const ttyTerm = process.env.CODEX_TTY_TERM ?? DEFAULT_TTY_TERM;
|
|
93
|
+
const defaultCwd = process.cwd(); // Client uses current working dir
|
|
94
|
+
const workingDir = process.env.CODEX_CWD ?? defaultCwd;
|
|
95
|
+
// For logging/display
|
|
96
|
+
const commandString = [command, ...args].map(shellQuote).join(" ");
|
|
97
|
+
const finalCommand = useScriptWrapper ? "script" : command;
|
|
98
|
+
// Script wrapper args: -q (quiet), -c (command), /dev/null (output file)
|
|
99
|
+
const finalArgs = useScriptWrapper
|
|
100
|
+
? ["-q", "-c", commandString, "/dev/null"]
|
|
101
|
+
: args;
|
|
102
|
+
const child = spawn(finalCommand, finalArgs, {
|
|
103
|
+
stdio: [promptMode === "stdin" ? "pipe" : "ignore", "pipe", "pipe"],
|
|
104
|
+
cwd: workingDir,
|
|
105
|
+
env: {
|
|
106
|
+
...process.env,
|
|
107
|
+
CODEX_AGENT_ID: agent.id,
|
|
108
|
+
CODEX_AGENT_NAME: agent.name,
|
|
109
|
+
CODEX_AGENT_MODEL: agent.model,
|
|
110
|
+
...(useScriptWrapper ? { TERM: ttyTerm } : {}),
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
if (promptMode === "stdin") {
|
|
114
|
+
child.stdin?.write(config.prompt);
|
|
115
|
+
child.stdin?.end();
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
child,
|
|
119
|
+
pid: child.pid ?? null,
|
|
120
|
+
startedAt,
|
|
121
|
+
command: finalCommand,
|
|
122
|
+
args: finalArgs,
|
|
123
|
+
promptMode
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
try {
|
|
127
|
+
return spawnWithMode();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const code = error instanceof Error ? error.code : null;
|
|
131
|
+
if (code === "E2BIG" && isCodexModel) {
|
|
132
|
+
console.warn("Spawn args exceeded system limits; retrying via stdin.");
|
|
133
|
+
return spawnWithMode("stdin");
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { encodeEnvelope, nowIso } from "./protocol.js";
|
|
2
|
+
import os from "os";
|
|
3
|
+
export class NoopTransport {
|
|
4
|
+
async connect() {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
sendLog(_agentId, _stream, _message) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
sendStatus(_agentId, _state) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
close() {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class WebSocketTransport {
|
|
18
|
+
options;
|
|
19
|
+
socket = null;
|
|
20
|
+
isExplicitClose = false;
|
|
21
|
+
retryCount = 0;
|
|
22
|
+
retryTimer = null;
|
|
23
|
+
pingTimeout = null;
|
|
24
|
+
pingIntervalTimer = null;
|
|
25
|
+
maxRetryDelay = 30000;
|
|
26
|
+
baseRetryDelay = 1000;
|
|
27
|
+
heartbeatInterval = 30000;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
}
|
|
31
|
+
async connect() {
|
|
32
|
+
this.isExplicitClose = false;
|
|
33
|
+
await this.establishConnection();
|
|
34
|
+
}
|
|
35
|
+
heartbeat() {
|
|
36
|
+
if (this.pingTimeout)
|
|
37
|
+
clearTimeout(this.pingTimeout);
|
|
38
|
+
this.pingTimeout = setTimeout(() => {
|
|
39
|
+
console.warn("Connection timed out (no heartbeat). Reconnecting...");
|
|
40
|
+
this.socket?.close();
|
|
41
|
+
}, this.heartbeatInterval + 5000);
|
|
42
|
+
}
|
|
43
|
+
async establishConnection() {
|
|
44
|
+
const { serverUrl, tokenProvider } = this.options;
|
|
45
|
+
let token;
|
|
46
|
+
try {
|
|
47
|
+
token = await tokenProvider();
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
console.error("Failed to fetch session token:", err);
|
|
51
|
+
this.scheduleReconnect();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const url = new URL("/api/ws", serverUrl);
|
|
55
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
56
|
+
url.searchParams.set("token", token);
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
if (this.socket) {
|
|
59
|
+
this.socket.onclose = null;
|
|
60
|
+
this.socket.onerror = null;
|
|
61
|
+
this.socket.onmessage = null;
|
|
62
|
+
this.socket.onopen = null;
|
|
63
|
+
this.socket.close();
|
|
64
|
+
}
|
|
65
|
+
console.log(`Connecting to ${url.toString()}...`);
|
|
66
|
+
const socket = new WebSocket(url.toString());
|
|
67
|
+
this.socket = socket;
|
|
68
|
+
const onOpen = () => {
|
|
69
|
+
console.log("WebSocket connected.");
|
|
70
|
+
this.retryCount = 0;
|
|
71
|
+
this.heartbeat();
|
|
72
|
+
this.startPingInterval();
|
|
73
|
+
this.socket?.send(encodeEnvelope({
|
|
74
|
+
type: "hello",
|
|
75
|
+
clientId: this.options.clientId,
|
|
76
|
+
ts: nowIso(),
|
|
77
|
+
payload: { device: os.hostname(), platform: os.platform() },
|
|
78
|
+
}));
|
|
79
|
+
socket.onclose = this.handleClose.bind(this);
|
|
80
|
+
socket.onerror = (error) => {
|
|
81
|
+
console.error("WebSocket error:", error);
|
|
82
|
+
};
|
|
83
|
+
resolve();
|
|
84
|
+
};
|
|
85
|
+
const onFail = (err) => {
|
|
86
|
+
console.error("WebSocket connection failed to open.");
|
|
87
|
+
reject(new Error("WebSocket connection failed"));
|
|
88
|
+
};
|
|
89
|
+
socket.onopen = onOpen;
|
|
90
|
+
socket.onerror = onFail;
|
|
91
|
+
socket.onmessage = (event) => {
|
|
92
|
+
this.heartbeat();
|
|
93
|
+
const rawData = event.data;
|
|
94
|
+
const data = typeof rawData === "string" ? rawData : rawData.toString();
|
|
95
|
+
if (data === "pong")
|
|
96
|
+
return;
|
|
97
|
+
if (data === "ping") {
|
|
98
|
+
this.socket?.send("pong");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!data)
|
|
102
|
+
return;
|
|
103
|
+
try {
|
|
104
|
+
const message = JSON.parse(data);
|
|
105
|
+
if (message.type === "control") {
|
|
106
|
+
this.options.onControl(message);
|
|
107
|
+
}
|
|
108
|
+
else if (message.type === "ping") {
|
|
109
|
+
this.socket?.send("pong");
|
|
110
|
+
}
|
|
111
|
+
else if (message.type === "ack" && typeof message.id === "number") {
|
|
112
|
+
this.options.onAck(message.id);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// ignore malformed
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
handleClose() {
|
|
122
|
+
if (this.isExplicitClose)
|
|
123
|
+
return;
|
|
124
|
+
this.scheduleReconnect();
|
|
125
|
+
}
|
|
126
|
+
scheduleReconnect() {
|
|
127
|
+
this.stopPingInterval();
|
|
128
|
+
const delay = Math.min(this.baseRetryDelay * Math.pow(1.5, this.retryCount), this.maxRetryDelay);
|
|
129
|
+
console.log(`Reconnecting in ${delay}ms... (Attempt ${this.retryCount + 1})`);
|
|
130
|
+
this.retryCount++;
|
|
131
|
+
this.retryTimer = setTimeout(() => {
|
|
132
|
+
this.establishConnection().catch(() => this.scheduleReconnect());
|
|
133
|
+
}, delay);
|
|
134
|
+
}
|
|
135
|
+
sendLog(agentId, stream, message) {
|
|
136
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
137
|
+
return;
|
|
138
|
+
this.socket.send(JSON.stringify({
|
|
139
|
+
type: "log",
|
|
140
|
+
sessionId: agentId,
|
|
141
|
+
payload: { stream, message },
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
sendStatus(agentId, state) {
|
|
145
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
|
|
146
|
+
return;
|
|
147
|
+
this.socket.send(JSON.stringify({
|
|
148
|
+
type: "status",
|
|
149
|
+
sessionId: agentId,
|
|
150
|
+
payload: { state },
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
close() {
|
|
154
|
+
this.isExplicitClose = true;
|
|
155
|
+
this.stopPingInterval();
|
|
156
|
+
if (this.retryTimer)
|
|
157
|
+
clearTimeout(this.retryTimer);
|
|
158
|
+
if (this.pingTimeout)
|
|
159
|
+
clearTimeout(this.pingTimeout);
|
|
160
|
+
this.socket?.close();
|
|
161
|
+
}
|
|
162
|
+
startPingInterval() {
|
|
163
|
+
this.stopPingInterval();
|
|
164
|
+
this.pingIntervalTimer = setInterval(() => {
|
|
165
|
+
if (this.socket?.readyState === WebSocket.OPEN) {
|
|
166
|
+
this.socket.send("ping");
|
|
167
|
+
}
|
|
168
|
+
}, 10000);
|
|
169
|
+
}
|
|
170
|
+
stopPingInterval() {
|
|
171
|
+
if (this.pingIntervalTimer) {
|
|
172
|
+
clearInterval(this.pingIntervalTimer);
|
|
173
|
+
this.pingIntervalTimer = null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@span-io/agent-link",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Secure bridge between Span (AI control plane) and local agent CLI tools.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"connect": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+ssh://git@github.com/span-io/AgentLink.git"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"dev": "node --enable-source-maps dist/index.js",
|
|
21
|
+
"lint": "echo 'no lint configured'",
|
|
22
|
+
"start": "node dist/index.js",
|
|
23
|
+
"test": "tsx --test"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^25.1.0",
|
|
27
|
+
"tsx": "^4.19.2",
|
|
28
|
+
"typescript": "^5.5.4"
|
|
29
|
+
}
|
|
30
|
+
}
|