@openagents-org/agent-launcher 0.2.143 → 0.2.146
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/package.json +1 -1
- package/registry.json +274 -66
- package/src/adapters/aider.js +634 -0
- package/src/adapters/amp.js +477 -0
- package/src/adapters/base.js +112 -115
- package/src/adapters/claude.js +36 -34
- package/src/adapters/cline-stream.js +632 -0
- package/src/adapters/cline.js +914 -0
- package/src/adapters/codex.js +14 -16
- package/src/adapters/copilot-stream-parser.js +380 -0
- package/src/adapters/copilot.js +796 -0
- package/src/adapters/cursor.js +9 -15
- package/src/adapters/gemini.js +44 -12
- package/src/adapters/goose-stream.js +268 -0
- package/src/adapters/goose.js +649 -0
- package/src/adapters/health-status.js +166 -0
- package/src/adapters/hermes.js +8 -17
- package/src/adapters/index.js +16 -1
- package/src/adapters/nanoclaw-bridge.js +264 -0
- package/src/adapters/nanoclaw-channel-install.js +411 -0
- package/src/adapters/nanoclaw-control.js +491 -0
- package/src/adapters/nanoclaw-delivery-store.js +0 -0
- package/src/adapters/nanoclaw-protocol.js +349 -0
- package/src/adapters/nanoclaw.js +661 -9
- package/src/adapters/opencode.js +541 -75
- package/src/adapters/workspace-prompt.js +0 -79
- package/src/agent-rows.js +13 -1
- package/src/auth-guidance.js +98 -0
- package/src/cli.js +36 -7
- package/src/config.js +8 -2
- package/src/daemon.js +165 -51
- package/src/installer.js +572 -10
- package/src/paths.js +125 -2
- package/src/tui.js +29 -2
- package/src/update-check.js +26 -14
- package/src/workspace-client.js +31 -29
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single source of truth for agent readiness / runtime-failure classification.
|
|
5
|
+
*
|
|
6
|
+
* The Install page, the Agents list, the daemon and the TUI must all speak the
|
|
7
|
+
* same vocabulary about WHY an agent is or isn't usable. Mixing "not installed"
|
|
8
|
+
* (a genuinely missing executable) with "installed but signed out" or "spawn
|
|
9
|
+
* failed" is exactly the bug this module exists to prevent.
|
|
10
|
+
*
|
|
11
|
+
* Hard rule: REASON.NOT_INSTALLED is ONLY for a binary that cannot be resolved
|
|
12
|
+
* at all. An installed-but-signed-out agent is REASON.LOGIN_REQUIRED. A binary
|
|
13
|
+
* that resolved at install time but fails to start at run time is
|
|
14
|
+
* REASON.RUNTIME_MISSING / REASON.SPAWN_FAILED — never NOT_INSTALLED.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is pure and dependency-free so it can be required from the
|
|
17
|
+
* daemon, the adapters AND the Electron launcher (via
|
|
18
|
+
* `@openagents-org/agent-launcher/src/adapters/health-status`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const REASON = {
|
|
22
|
+
READY: 'ready',
|
|
23
|
+
NOT_INSTALLED: 'not_installed', // executable cannot be resolved anywhere
|
|
24
|
+
LOGIN_REQUIRED: 'login_required', // installed, but no usable login / API key
|
|
25
|
+
VERSION_INCOMPATIBLE: 'version_incompatible',
|
|
26
|
+
RUNTIME_MISSING: 'runtime_missing', // resolved at install time, gone at run time
|
|
27
|
+
SPAWN_FAILED: 'spawn_failed', // child_process spawn errored (ENOENT/EACCES/…)
|
|
28
|
+
WORKSPACE_JOIN_FAILED: 'workspace_join_failed',
|
|
29
|
+
HEARTBEAT_FAILED: 'heartbeat_failed',
|
|
30
|
+
SESSION_REVOKED: 'session_revoked',
|
|
31
|
+
ADAPTER_CRASHED: 'adapter_crashed',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Reasons that represent a genuine failure the UI should surface as an error
|
|
35
|
+
// (red dot / X) rather than a benign "needs configuration" state.
|
|
36
|
+
const ERROR_REASONS = new Set([
|
|
37
|
+
REASON.RUNTIME_MISSING,
|
|
38
|
+
REASON.SPAWN_FAILED,
|
|
39
|
+
REASON.WORKSPACE_JOIN_FAILED,
|
|
40
|
+
REASON.HEARTBEAT_FAILED,
|
|
41
|
+
REASON.SESSION_REVOKED,
|
|
42
|
+
REASON.ADAPTER_CRASHED,
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** True when a reason should render as a hard error (daemon state 'error'). */
|
|
46
|
+
function isErrorReason(reason) {
|
|
47
|
+
return ERROR_REASONS.has(reason);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Readiness reason from an (installed, ready) pair. The ONLY place that decides
|
|
52
|
+
* "not_installed vs login_required", so the Install page and the Agents list
|
|
53
|
+
* can never disagree.
|
|
54
|
+
*/
|
|
55
|
+
function readinessReason(installed, ready) {
|
|
56
|
+
if (!installed) return REASON.NOT_INSTALLED;
|
|
57
|
+
return ready ? REASON.READY : REASON.LOGIN_REQUIRED;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a resolved binary path must be launched through a shell. On Windows,
|
|
62
|
+
* `.cmd`/`.bat` shims (npm/pnpm/yarn global bins, and amp.cmd) are NOT directly
|
|
63
|
+
* executable via CreateProcess — Node's spawn needs shell:true or it throws
|
|
64
|
+
* EINVAL/ENOENT. Mirrors the amp adapter's own _spawnAmp handling so the
|
|
65
|
+
* launcher probe, the Test-connection path and the daemon all behave the same.
|
|
66
|
+
*/
|
|
67
|
+
function shouldUseShellForBinary(bin, platform) {
|
|
68
|
+
const plat = platform || process.platform;
|
|
69
|
+
return plat === 'win32' && /\.(cmd|bat)$/i.test(String(bin || ''));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Strip secrets from a diagnostic string before it reaches a log, the daemon
|
|
74
|
+
* status file or the UI. Removes tokens / api keys / bearer headers / cookies /
|
|
75
|
+
* URL credentials / long opaque tokens, and caps the length. Never throws.
|
|
76
|
+
*/
|
|
77
|
+
function redactDiagnostic(input, maxLen = 300) {
|
|
78
|
+
let s = String(input == null ? '' : input);
|
|
79
|
+
try {
|
|
80
|
+
s = s
|
|
81
|
+
// key=value / key: value for sensitive keys (token, api_key, secret, …)
|
|
82
|
+
.replace(
|
|
83
|
+
/((?:access[_-]?|api[_-]?|auth[_-]?)?(?:tokens?|keys?|secrets?|passwords?|cookies?)\s*[=:]\s*)(\S+)/gi,
|
|
84
|
+
'$1<redacted>',
|
|
85
|
+
)
|
|
86
|
+
// Authorization: Bearer xxxxx
|
|
87
|
+
.replace(/(bearer\s+)[A-Za-z0-9._\-]+/gi, '$1<redacted>')
|
|
88
|
+
// URL userinfo scheme://user:pass@host
|
|
89
|
+
.replace(/(\b[a-z][a-z0-9+.\-]*:\/\/)[^/@\s:]+:[^/@\s]+@/gi, '$1<redacted>@')
|
|
90
|
+
// Known opaque token prefixes
|
|
91
|
+
.replace(/\b(?:sgp_|sk-|ghp_|gho_|xox[baprs]-)[A-Za-z0-9._\-]+/g, '<redacted>')
|
|
92
|
+
// Generic long opaque blobs (>= 40 chars)
|
|
93
|
+
.replace(/\b[A-Za-z0-9_\-]{40,}\b/g, '<redacted>');
|
|
94
|
+
} catch {
|
|
95
|
+
/* keep best-effort */
|
|
96
|
+
}
|
|
97
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
98
|
+
if (s.length > maxLen) s = s.slice(0, maxLen) + '…';
|
|
99
|
+
return s;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Pull a numeric HTTP status off a workspace-client error, if present. */
|
|
103
|
+
function httpStatusOf(err) {
|
|
104
|
+
if (!err) return null;
|
|
105
|
+
const sc = err.statusCode != null ? err.statusCode : err.status;
|
|
106
|
+
return typeof sc === 'number' ? sc : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Classify a workspace JOIN failure → { reason, message } (redacted). */
|
|
110
|
+
function classifyJoinError(err) {
|
|
111
|
+
const sc = httpStatusOf(err);
|
|
112
|
+
let detail;
|
|
113
|
+
if (sc === 401 || sc === 403) detail = `authentication rejected (HTTP ${sc})`;
|
|
114
|
+
else if (sc === 404) detail = 'workspace not found (HTTP 404)';
|
|
115
|
+
else if (typeof sc === 'number') detail = `HTTP ${sc}`;
|
|
116
|
+
else detail = redactDiagnostic(err && err.message) || 'network error';
|
|
117
|
+
return {
|
|
118
|
+
reason: REASON.WORKSPACE_JOIN_FAILED,
|
|
119
|
+
message: `Workspace join failed: ${detail}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Classify a workspace HEARTBEAT failure → { reason, message } (redacted). */
|
|
124
|
+
function classifyHeartbeatError(err) {
|
|
125
|
+
const sc = httpStatusOf(err);
|
|
126
|
+
const detail =
|
|
127
|
+
typeof sc === 'number'
|
|
128
|
+
? `HTTP ${sc}`
|
|
129
|
+
: redactDiagnostic(err && err.message) || 'network error';
|
|
130
|
+
return {
|
|
131
|
+
reason: REASON.HEARTBEAT_FAILED,
|
|
132
|
+
message: `Workspace heartbeat failed: ${detail}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Classify a child_process spawn failure (the CLI could not be started) →
|
|
138
|
+
* { reason, message } (redacted). `label` names the agent ("Amp"), `bin` is the
|
|
139
|
+
* resolved path included for diagnostics (it is a filesystem path, not a secret).
|
|
140
|
+
*/
|
|
141
|
+
function classifySpawnError(err, opts) {
|
|
142
|
+
const { label = 'Agent', bin = null } = opts || {};
|
|
143
|
+
const code = (err && (err.code || err.errno)) || '';
|
|
144
|
+
const where = bin ? ` [${bin}]` : '';
|
|
145
|
+
let detail;
|
|
146
|
+
if (code === 'ENOENT') detail = `executable not found${where}`;
|
|
147
|
+
else if (code === 'EACCES' || code === 'EPERM')
|
|
148
|
+
detail = `permission denied${where}`;
|
|
149
|
+
else detail = (redactDiagnostic((err && err.message) || code) || 'spawn error') + where;
|
|
150
|
+
return {
|
|
151
|
+
reason: REASON.SPAWN_FAILED,
|
|
152
|
+
message: `${label} process failed to start: ${detail}`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
REASON,
|
|
158
|
+
isErrorReason,
|
|
159
|
+
readinessReason,
|
|
160
|
+
shouldUseShellForBinary,
|
|
161
|
+
redactDiagnostic,
|
|
162
|
+
httpStatusOf,
|
|
163
|
+
classifyJoinError,
|
|
164
|
+
classifyHeartbeatError,
|
|
165
|
+
classifySpawnError,
|
|
166
|
+
};
|
package/src/adapters/hermes.js
CHANGED
|
@@ -21,7 +21,7 @@ const { execSync, spawn } = require('child_process');
|
|
|
21
21
|
|
|
22
22
|
const BaseAdapter = require('./base');
|
|
23
23
|
const { buildOpenclawSystemPrompt } = require('./workspace-prompt');
|
|
24
|
-
const { whichBinary,
|
|
24
|
+
const { whichBinary, whereBinary } = require('../paths');
|
|
25
25
|
|
|
26
26
|
const IS_WINDOWS = process.platform === 'win32';
|
|
27
27
|
const HERMES_INSTALL_HINT = IS_WINDOWS
|
|
@@ -76,22 +76,13 @@ class HermesAdapter extends BaseAdapter {
|
|
|
76
76
|
// Tier 1: PATH (enriched env so we see the dirs the launcher adds; a fresh
|
|
77
77
|
// install updates the user PATH, which the running daemon won't pick up).
|
|
78
78
|
// windowsHide stops a console window from flashing.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
encoding: 'utf-8', timeout: 5000, windowsHide: true, env,
|
|
87
|
-
});
|
|
88
|
-
const found = r.split(/\r?\n/)[0].trim();
|
|
89
|
-
if (found) return found;
|
|
90
|
-
} else {
|
|
91
|
-
const found = execSync('which hermes', { encoding: 'utf-8', timeout: 5000, windowsHide: true, env }).trim();
|
|
92
|
-
if (found) return found;
|
|
93
|
-
}
|
|
94
|
-
} catch {}
|
|
79
|
+
// Codepage-safe lookup (whereBinary forces UTF-8 output + verifies existence
|
|
80
|
+
// so a non-ASCII/Chinese username isn't mangled into an ENOENT). Native
|
|
81
|
+
// Windows is supported (install.ps1); the installer adds venv\Scripts to the
|
|
82
|
+
// user PATH, which a running daemon won't see — hence the enriched env
|
|
83
|
+
// (whereBinary's default) + the explicit candidates in Tier 2.
|
|
84
|
+
const viaWhere = whereBinary('hermes');
|
|
85
|
+
if (viaWhere) return viaWhere;
|
|
95
86
|
|
|
96
87
|
// Tier 2: Common install locations. The native Windows installer
|
|
97
88
|
// (install.ps1) provisions a portable venv and drops hermes.exe under
|
package/src/adapters/index.js
CHANGED
|
@@ -14,6 +14,11 @@ const CursorAdapter = require('./cursor');
|
|
|
14
14
|
const HermesAdapter = require('./hermes');
|
|
15
15
|
const GeminiAdapter = require('./gemini');
|
|
16
16
|
const KimiAdapter = require('./kimi');
|
|
17
|
+
const AiderAdapter = require('./aider');
|
|
18
|
+
const GooseAdapter = require('./goose');
|
|
19
|
+
const CopilotAdapter = require('./copilot');
|
|
20
|
+
const ClineAdapter = require('./cline');
|
|
21
|
+
const AmpAdapter = require('./amp');
|
|
17
22
|
|
|
18
23
|
const ADAPTER_MAP = {
|
|
19
24
|
openclaw: OpenClawAdapter,
|
|
@@ -25,11 +30,16 @@ const ADAPTER_MAP = {
|
|
|
25
30
|
hermes: HermesAdapter,
|
|
26
31
|
gemini: GeminiAdapter,
|
|
27
32
|
kimi: KimiAdapter,
|
|
33
|
+
aider: AiderAdapter,
|
|
34
|
+
goose: GooseAdapter,
|
|
35
|
+
copilot: CopilotAdapter,
|
|
36
|
+
cline: ClineAdapter,
|
|
37
|
+
amp: AmpAdapter,
|
|
28
38
|
};
|
|
29
39
|
|
|
30
40
|
/**
|
|
31
41
|
* Create an adapter instance for the given agent type.
|
|
32
|
-
* @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi)
|
|
42
|
+
* @param {string} type - Agent type (openclaw, claude, codex, opencode, nanoclaw, cursor, hermes, gemini, kimi, aider, goose, copilot, cline, amp)
|
|
33
43
|
* @param {object} opts - Adapter constructor options
|
|
34
44
|
* @returns {BaseAdapter}
|
|
35
45
|
*/
|
|
@@ -52,6 +62,11 @@ module.exports = {
|
|
|
52
62
|
HermesAdapter,
|
|
53
63
|
GeminiAdapter,
|
|
54
64
|
KimiAdapter,
|
|
65
|
+
AiderAdapter,
|
|
66
|
+
GooseAdapter,
|
|
67
|
+
CopilotAdapter,
|
|
68
|
+
ClineAdapter,
|
|
69
|
+
AmpAdapter,
|
|
55
70
|
createAdapter,
|
|
56
71
|
ADAPTER_MAP,
|
|
57
72
|
};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NanoclawBridge — client side of the local IPC to the native NanoClaw
|
|
3
|
+
* `openagents` channel.
|
|
4
|
+
*
|
|
5
|
+
* The channel (running inside the NanoClaw host) owns a Unix-socket SERVER at
|
|
6
|
+
* `<home>/data/openagents.sock`; this bridge is the CLIENT. We send `inbound`
|
|
7
|
+
* frames (a workspace message addressed to an OpenAgents channel) and receive
|
|
8
|
+
* `outbound` / `status` / `error` frames (the agent container's replies and
|
|
9
|
+
* liveness, correlated by `platformId`). The channel does the native
|
|
10
|
+
* router→session→container routing; we never touch NanoClaw's database.
|
|
11
|
+
*
|
|
12
|
+
* Connection is resilient: a dropped socket triggers exponential-backoff
|
|
13
|
+
* reconnect (the NanoClaw host may restart, or the channel may load late).
|
|
14
|
+
* The workspace token is never sent over this socket.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const net = require('net');
|
|
20
|
+
const { EventEmitter } = require('events');
|
|
21
|
+
|
|
22
|
+
const proto = require('./nanoclaw-protocol');
|
|
23
|
+
const { isPathSafe } = require('./nanoclaw-control');
|
|
24
|
+
|
|
25
|
+
const noop = () => {};
|
|
26
|
+
|
|
27
|
+
// Upper bound on the inbound line buffer. The bridge speaks newline-delimited
|
|
28
|
+
// JSON control frames (small); a peer that streams data without a newline would
|
|
29
|
+
// otherwise grow the buffer unbounded. The trust boundary is the same OS user,
|
|
30
|
+
// so this guards against a malfunctioning/compromised local peer, not a remote
|
|
31
|
+
// attacker. 8 MiB is far above any legitimate frame.
|
|
32
|
+
const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
class NanoclawBridge extends EventEmitter {
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} socketPath
|
|
37
|
+
* @param {object} opts
|
|
38
|
+
* @param {string} opts.workspace
|
|
39
|
+
* @param {string} opts.agent
|
|
40
|
+
* @param {()=>(string|null)} [opts.secretProvider] read the current handshake secret (fresh per connect)
|
|
41
|
+
* @param {(m:string)=>void} [opts.log]
|
|
42
|
+
* @param {number} [opts.minBackoffMs]
|
|
43
|
+
* @param {number} [opts.maxBackoffMs]
|
|
44
|
+
*/
|
|
45
|
+
constructor(socketPath, opts = {}) {
|
|
46
|
+
super();
|
|
47
|
+
this.socketPath = socketPath;
|
|
48
|
+
this.workspace = opts.workspace || '';
|
|
49
|
+
this.agent = opts.agent || '';
|
|
50
|
+
this._secretProvider = typeof opts.secretProvider === 'function' ? opts.secretProvider : () => null;
|
|
51
|
+
this._log = opts.log || noop;
|
|
52
|
+
this._minBackoff = opts.minBackoffMs || 500;
|
|
53
|
+
this._maxBackoff = opts.maxBackoffMs || 8000;
|
|
54
|
+
|
|
55
|
+
this._sock = null;
|
|
56
|
+
this._buffer = '';
|
|
57
|
+
this._connected = false;
|
|
58
|
+
this._ready = false;
|
|
59
|
+
this._closed = false;
|
|
60
|
+
this._attempt = 0;
|
|
61
|
+
this._reconnectTimer = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isConnected() {
|
|
65
|
+
return this._connected && !this._closed;
|
|
66
|
+
}
|
|
67
|
+
isReady() {
|
|
68
|
+
return this._ready && !this._closed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Begin connecting (idempotent). Resolves on first successful TCP connect. */
|
|
72
|
+
connect() {
|
|
73
|
+
this._closed = false;
|
|
74
|
+
if (this._sock) return;
|
|
75
|
+
this._openOnce();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_openOnce() {
|
|
79
|
+
if (this._closed) return;
|
|
80
|
+
this._buffer = '';
|
|
81
|
+
|
|
82
|
+
// Refuse a symlinked socket path (path-escape / redirection guard).
|
|
83
|
+
const safe = isPathSafe(this.socketPath);
|
|
84
|
+
if (!safe.ok) {
|
|
85
|
+
this._log(`bridge refusing unsafe socket path: ${safe.reason}`);
|
|
86
|
+
this.emit('socket-error', Object.assign(new Error(safe.reason), { code: 'UNSAFE_PATH' }));
|
|
87
|
+
this._scheduleReconnect();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Read the handshake secret fresh every connect (handles host restart /
|
|
92
|
+
// secret rotation, and re-auth on reconnect).
|
|
93
|
+
const secret = this._secretProvider();
|
|
94
|
+
if (!secret) {
|
|
95
|
+
this._log('bridge: handshake secret not available yet — will retry');
|
|
96
|
+
this.emit('auth-pending');
|
|
97
|
+
this._scheduleReconnect();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const sock = net.createConnection(this.socketPath);
|
|
102
|
+
this._sock = sock;
|
|
103
|
+
|
|
104
|
+
sock.on('connect', () => {
|
|
105
|
+
this._connected = true;
|
|
106
|
+
this._attempt = 0;
|
|
107
|
+
this._log(`bridge connected: ${this.socketPath}`);
|
|
108
|
+
// Handshake — identify ourselves + present the local secret. The channel
|
|
109
|
+
// replies 'ready' only on a valid secret + matching protocol.
|
|
110
|
+
try {
|
|
111
|
+
sock.write(proto.buildHello(this.workspace, this.agent, secret));
|
|
112
|
+
} catch (e) {
|
|
113
|
+
this._log(`bridge hello write failed: ${e.message}`);
|
|
114
|
+
}
|
|
115
|
+
this.emit('connect');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
sock.on('data', (chunk) => {
|
|
119
|
+
this._buffer += chunk.toString('utf8');
|
|
120
|
+
const { frames, rest } = proto.parseFrames(this._buffer);
|
|
121
|
+
this._buffer = rest;
|
|
122
|
+
// Guard against an unbounded line with no frame delimiter — drop the
|
|
123
|
+
// buffer and reset the connection rather than growing memory without limit.
|
|
124
|
+
if (this._buffer.length > MAX_BUFFER_BYTES) {
|
|
125
|
+
this._log(`bridge buffer exceeded ${MAX_BUFFER_BYTES} bytes without a complete frame — resetting connection`);
|
|
126
|
+
this._buffer = '';
|
|
127
|
+
try { sock.destroy(); } catch { /* best-effort */ }
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const frame of frames) this._onFrame(frame);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Always attach an error listener: a SIGKILL'd/closed peer can emit
|
|
134
|
+
// 'error' (EPIPE/ECONNRESET) which, unhandled, crashes the process.
|
|
135
|
+
sock.on('error', (err) => {
|
|
136
|
+
this._log(`bridge socket error: ${err.code || err.message}`);
|
|
137
|
+
this.emit('socket-error', err);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
sock.on('close', () => {
|
|
141
|
+
const wasReady = this._ready;
|
|
142
|
+
this._connected = false;
|
|
143
|
+
this._ready = false;
|
|
144
|
+
this._sock = null;
|
|
145
|
+
if (wasReady || this._attempt === 0) this.emit('disconnect');
|
|
146
|
+
this._scheduleReconnect();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_onFrame(frame) {
|
|
151
|
+
switch (frame.op) {
|
|
152
|
+
case 'ready':
|
|
153
|
+
this._ready = true;
|
|
154
|
+
this._log(`bridge ready (channel=${frame.channelType || 'openagents'}, proto=${frame.protocol})`);
|
|
155
|
+
this.emit('ready', frame);
|
|
156
|
+
break;
|
|
157
|
+
case 'outbound':
|
|
158
|
+
this.emit('outbound', frame);
|
|
159
|
+
break;
|
|
160
|
+
case 'status':
|
|
161
|
+
this.emit('status', frame);
|
|
162
|
+
break;
|
|
163
|
+
case 'error':
|
|
164
|
+
if (frame.code === 'auth_failed' || frame.code === 'incompatible_version' || frame.code === 'single_connection') {
|
|
165
|
+
this._log(`bridge handshake rejected: ${frame.code}`);
|
|
166
|
+
this.emit('auth-failed', frame.code);
|
|
167
|
+
}
|
|
168
|
+
this.emit('error-frame', frame);
|
|
169
|
+
break;
|
|
170
|
+
case 'dropped':
|
|
171
|
+
// A queued outbound the channel could not retain (overflow / expiry).
|
|
172
|
+
this.emit('dropped', frame);
|
|
173
|
+
break;
|
|
174
|
+
case 'pong':
|
|
175
|
+
this.emit('pong', frame);
|
|
176
|
+
break;
|
|
177
|
+
default:
|
|
178
|
+
// Forward-compat: ignore unknown ops.
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_scheduleReconnect() {
|
|
184
|
+
if (this._closed) return;
|
|
185
|
+
if (this._reconnectTimer) return;
|
|
186
|
+
this._attempt += 1;
|
|
187
|
+
const delay = Math.min(this._maxBackoff, this._minBackoff * 2 ** Math.min(this._attempt - 1, 6));
|
|
188
|
+
this.emit('reconnecting', this._attempt, delay);
|
|
189
|
+
this._reconnectTimer = setTimeout(() => {
|
|
190
|
+
this._reconnectTimer = null;
|
|
191
|
+
this._openOnce();
|
|
192
|
+
}, delay);
|
|
193
|
+
if (this._reconnectTimer.unref) this._reconnectTimer.unref();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Write an already-encoded frame string. Returns true if written.
|
|
198
|
+
* Throws if not connected (caller decides whether to surface or wait).
|
|
199
|
+
*/
|
|
200
|
+
_write(frameStr) {
|
|
201
|
+
if (!this._sock || !this._connected) {
|
|
202
|
+
const e = new Error('NanoClaw bridge not connected');
|
|
203
|
+
e.code = 'NOT_CONNECTED';
|
|
204
|
+
throw e;
|
|
205
|
+
}
|
|
206
|
+
return this._sock.write(frameStr);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Inject a workspace message into NanoClaw. `fields` per proto.buildInbound. */
|
|
210
|
+
sendInbound(fields) {
|
|
211
|
+
return this._write(proto.buildInbound(fields));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
sendCancel(platformId, msgId) {
|
|
215
|
+
try {
|
|
216
|
+
return this._write(proto.buildCancel(platformId, msgId));
|
|
217
|
+
} catch {
|
|
218
|
+
return false; // cancel is best-effort
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Acknowledge a delivered outbound so the channel stops holding/replaying it. */
|
|
223
|
+
sendAck(outId, platformId) {
|
|
224
|
+
if (!outId) return false;
|
|
225
|
+
try {
|
|
226
|
+
return this._write(proto.buildAck(outId, platformId));
|
|
227
|
+
} catch {
|
|
228
|
+
return false; // will be re-driven by the channel's replay on reconnect
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
ping() {
|
|
233
|
+
try {
|
|
234
|
+
return this._write(proto.buildPing());
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Stop reconnecting and tear down the socket. */
|
|
241
|
+
close() {
|
|
242
|
+
this._closed = true;
|
|
243
|
+
if (this._reconnectTimer) {
|
|
244
|
+
clearTimeout(this._reconnectTimer);
|
|
245
|
+
this._reconnectTimer = null;
|
|
246
|
+
}
|
|
247
|
+
const sock = this._sock;
|
|
248
|
+
this._sock = null;
|
|
249
|
+
this._connected = false;
|
|
250
|
+
this._ready = false;
|
|
251
|
+
if (sock) {
|
|
252
|
+
try {
|
|
253
|
+
sock.removeAllListeners('close');
|
|
254
|
+
sock.on('error', noop); // swallow teardown EPIPE
|
|
255
|
+
sock.end();
|
|
256
|
+
sock.destroy();
|
|
257
|
+
} catch {
|
|
258
|
+
/* best-effort */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { NanoclawBridge };
|