@agent-relay/wrapper 0.1.0
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/dist/__fixtures__/claude-outputs.d.ts +49 -0
- package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/claude-outputs.js +443 -0
- package/dist/__fixtures__/claude-outputs.js.map +1 -0
- package/dist/__fixtures__/codex-outputs.d.ts +9 -0
- package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/codex-outputs.js +94 -0
- package/dist/__fixtures__/codex-outputs.js.map +1 -0
- package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
- package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/gemini-outputs.js +144 -0
- package/dist/__fixtures__/gemini-outputs.js.map +1 -0
- package/dist/__fixtures__/index.d.ts +68 -0
- package/dist/__fixtures__/index.d.ts.map +1 -0
- package/dist/__fixtures__/index.js +44 -0
- package/dist/__fixtures__/index.js.map +1 -0
- package/dist/auth-detection.d.ts +49 -0
- package/dist/auth-detection.d.ts.map +1 -0
- package/dist/auth-detection.js +199 -0
- package/dist/auth-detection.js.map +1 -0
- package/dist/base-wrapper.d.ts +225 -0
- package/dist/base-wrapper.d.ts.map +1 -0
- package/dist/base-wrapper.js +572 -0
- package/dist/base-wrapper.js.map +1 -0
- package/dist/client.d.ts +254 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +801 -0
- package/dist/client.js.map +1 -0
- package/dist/id-generator.d.ts +35 -0
- package/dist/id-generator.d.ts.map +1 -0
- package/dist/id-generator.js +60 -0
- package/dist/id-generator.js.map +1 -0
- package/dist/idle-detector.d.ts +110 -0
- package/dist/idle-detector.d.ts.map +1 -0
- package/dist/idle-detector.js +304 -0
- package/dist/idle-detector.js.map +1 -0
- package/dist/inbox.d.ts +37 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +73 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +236 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +1238 -0
- package/dist/parser.js.map +1 -0
- package/dist/prompt-composer.d.ts +67 -0
- package/dist/prompt-composer.d.ts.map +1 -0
- package/dist/prompt-composer.js +168 -0
- package/dist/prompt-composer.js.map +1 -0
- package/dist/relay-pty-orchestrator.d.ts +407 -0
- package/dist/relay-pty-orchestrator.d.ts.map +1 -0
- package/dist/relay-pty-orchestrator.js +1885 -0
- package/dist/relay-pty-orchestrator.js.map +1 -0
- package/dist/shared.d.ts +201 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +341 -0
- package/dist/shared.js.map +1 -0
- package/dist/stuck-detector.d.ts +161 -0
- package/dist/stuck-detector.d.ts.map +1 -0
- package/dist/stuck-detector.js +402 -0
- package/dist/stuck-detector.js.map +1 -0
- package/dist/tmux-resolver.d.ts +55 -0
- package/dist/tmux-resolver.d.ts.map +1 -0
- package/dist/tmux-resolver.js +175 -0
- package/dist/tmux-resolver.js.map +1 -0
- package/dist/tmux-wrapper.d.ts +345 -0
- package/dist/tmux-wrapper.d.ts.map +1 -0
- package/dist/tmux-wrapper.js +1747 -0
- package/dist/tmux-wrapper.js.map +1 -0
- package/dist/trajectory-integration.d.ts +292 -0
- package/dist/trajectory-integration.d.ts.map +1 -0
- package/dist/trajectory-integration.js +979 -0
- package/dist/trajectory-integration.js.map +1 -0
- package/dist/wrapper-types.d.ts +41 -0
- package/dist/wrapper-types.d.ts.map +1 -0
- package/dist/wrapper-types.js +7 -0
- package/dist/wrapper-types.js.map +1 -0
- package/package.json +63 -0
- package/src/__fixtures__/claude-outputs.ts +471 -0
- package/src/__fixtures__/codex-outputs.ts +99 -0
- package/src/__fixtures__/gemini-outputs.ts +151 -0
- package/src/__fixtures__/index.ts +47 -0
- package/src/auth-detection.ts +244 -0
- package/src/base-wrapper.test.ts +540 -0
- package/src/base-wrapper.ts +741 -0
- package/src/client.test.ts +262 -0
- package/src/client.ts +984 -0
- package/src/id-generator.test.ts +71 -0
- package/src/id-generator.ts +69 -0
- package/src/idle-detector.test.ts +390 -0
- package/src/idle-detector.ts +370 -0
- package/src/inbox.test.ts +233 -0
- package/src/inbox.ts +89 -0
- package/src/index.ts +170 -0
- package/src/parser.regression.test.ts +251 -0
- package/src/parser.test.ts +1359 -0
- package/src/parser.ts +1477 -0
- package/src/prompt-composer.test.ts +219 -0
- package/src/prompt-composer.ts +231 -0
- package/src/relay-pty-orchestrator.test.ts +1027 -0
- package/src/relay-pty-orchestrator.ts +2270 -0
- package/src/shared.test.ts +221 -0
- package/src/shared.ts +454 -0
- package/src/stuck-detector.test.ts +303 -0
- package/src/stuck-detector.ts +511 -0
- package/src/tmux-resolver.test.ts +104 -0
- package/src/tmux-resolver.ts +207 -0
- package/src/tmux-wrapper.test.ts +316 -0
- package/src/tmux-wrapper.ts +2010 -0
- package/src/trajectory-detection.test.ts +151 -0
- package/src/trajectory-integration.ts +1261 -0
- package/src/wrapper-types.ts +45 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay Client
|
|
3
|
+
* Connects to the daemon and handles message sending/receiving.
|
|
4
|
+
*
|
|
5
|
+
* @deprecated **MIGRATION REQUIRED** - This module will be removed in a future version.
|
|
6
|
+
*
|
|
7
|
+
* ## Migration Path
|
|
8
|
+
*
|
|
9
|
+
* Replace imports from `@agent-relay/wrapper` with `@agent-relay/sdk`:
|
|
10
|
+
*
|
|
11
|
+
* ```typescript
|
|
12
|
+
* // BEFORE (deprecated)
|
|
13
|
+
* import { RelayClient } from '@agent-relay/wrapper';
|
|
14
|
+
*
|
|
15
|
+
* // AFTER (recommended)
|
|
16
|
+
* import { RelayClient } from '@agent-relay/sdk';
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The `@agent-relay/sdk` provides the same API with:
|
|
20
|
+
* - Identical method signatures
|
|
21
|
+
* - Same configuration options
|
|
22
|
+
* - Compatible event handlers
|
|
23
|
+
*
|
|
24
|
+
* ## Timeline
|
|
25
|
+
*
|
|
26
|
+
* - Current: Deprecation warning on first use
|
|
27
|
+
* - Next minor: TypeScript deprecation errors
|
|
28
|
+
* - Next major: This module will be removed
|
|
29
|
+
*
|
|
30
|
+
* ## Internal Use Only
|
|
31
|
+
*
|
|
32
|
+
* This client is retained only for internal daemon/wrapper integration.
|
|
33
|
+
* External consumers should always use `@agent-relay/sdk`.
|
|
34
|
+
*
|
|
35
|
+
* Optimizations:
|
|
36
|
+
* - Monotonic ID generation (faster than UUID)
|
|
37
|
+
* - Write coalescing (batch socket writes)
|
|
38
|
+
* - Circular dedup cache (O(1) eviction)
|
|
39
|
+
*/
|
|
40
|
+
import net from 'node:net';
|
|
41
|
+
import { randomUUID } from 'node:crypto';
|
|
42
|
+
import { generateId } from './id-generator.js';
|
|
43
|
+
// Import types from SDK (re-exported via protocol for compatibility)
|
|
44
|
+
import { PROTOCOL_VERSION, } from '@agent-relay/protocol/types';
|
|
45
|
+
import { encodeFrameLegacy, FrameParser } from '@agent-relay/protocol/framing';
|
|
46
|
+
import { DEFAULT_SOCKET_PATH } from '@agent-relay/config/relay-config';
|
|
47
|
+
const DEFAULT_CLIENT_CONFIG = {
|
|
48
|
+
socketPath: DEFAULT_SOCKET_PATH,
|
|
49
|
+
agentName: 'agent',
|
|
50
|
+
cli: undefined,
|
|
51
|
+
quiet: false,
|
|
52
|
+
reconnect: true,
|
|
53
|
+
maxReconnectAttempts: 10,
|
|
54
|
+
reconnectDelayMs: 100,
|
|
55
|
+
reconnectMaxDelayMs: 30000,
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Circular buffer for O(1) deduplication with bounded memory.
|
|
59
|
+
*/
|
|
60
|
+
class CircularDedupeCache {
|
|
61
|
+
ids = new Set();
|
|
62
|
+
ring;
|
|
63
|
+
head = 0;
|
|
64
|
+
capacity;
|
|
65
|
+
constructor(capacity = 2000) {
|
|
66
|
+
this.capacity = capacity;
|
|
67
|
+
this.ring = new Array(capacity);
|
|
68
|
+
}
|
|
69
|
+
/** Returns true if duplicate (already seen) */
|
|
70
|
+
check(id) {
|
|
71
|
+
if (this.ids.has(id))
|
|
72
|
+
return true;
|
|
73
|
+
// Evict oldest if at capacity
|
|
74
|
+
if (this.ids.size >= this.capacity) {
|
|
75
|
+
const oldest = this.ring[this.head];
|
|
76
|
+
if (oldest)
|
|
77
|
+
this.ids.delete(oldest);
|
|
78
|
+
}
|
|
79
|
+
// Add new ID
|
|
80
|
+
this.ring[this.head] = id;
|
|
81
|
+
this.ids.add(id);
|
|
82
|
+
this.head = (this.head + 1) % this.capacity;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
clear() {
|
|
86
|
+
this.ids.clear();
|
|
87
|
+
this.ring = new Array(this.capacity);
|
|
88
|
+
this.head = 0;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* @deprecated Use `RelayClient` from `@agent-relay/sdk` instead.
|
|
93
|
+
* This class will be removed in a future major version.
|
|
94
|
+
*/
|
|
95
|
+
export class RelayClient {
|
|
96
|
+
/** Track if deprecation warning has been shown (warn once per process) */
|
|
97
|
+
static _deprecationWarningShown = false;
|
|
98
|
+
config;
|
|
99
|
+
socket;
|
|
100
|
+
parser;
|
|
101
|
+
_state = 'DISCONNECTED';
|
|
102
|
+
sessionId;
|
|
103
|
+
resumeToken;
|
|
104
|
+
reconnectAttempts = 0;
|
|
105
|
+
reconnectDelay;
|
|
106
|
+
reconnectTimer;
|
|
107
|
+
_destroyed = false;
|
|
108
|
+
// Circular dedup cache (O(1) eviction vs O(n) array shift)
|
|
109
|
+
dedupeCache = new CircularDedupeCache(2000);
|
|
110
|
+
// Write coalescing: batch multiple writes into single syscall
|
|
111
|
+
writeQueue = [];
|
|
112
|
+
writeScheduled = false;
|
|
113
|
+
pendingSyncAcks = new Map();
|
|
114
|
+
// Event handlers
|
|
115
|
+
/**
|
|
116
|
+
* Handler for incoming messages.
|
|
117
|
+
* @param from - The sender agent name
|
|
118
|
+
* @param payload - The message payload
|
|
119
|
+
* @param messageId - Unique message ID
|
|
120
|
+
* @param meta - Optional message metadata
|
|
121
|
+
* @param originalTo - Original 'to' field from sender (e.g., '*' for broadcasts)
|
|
122
|
+
*/
|
|
123
|
+
onMessage;
|
|
124
|
+
/**
|
|
125
|
+
* Callback for channel messages.
|
|
126
|
+
* @param from - Sender name
|
|
127
|
+
* @param channel - Channel name
|
|
128
|
+
* @param body - Message content
|
|
129
|
+
* @param envelope - Full envelope for additional data
|
|
130
|
+
*/
|
|
131
|
+
onChannelMessage;
|
|
132
|
+
onStateChange;
|
|
133
|
+
onError;
|
|
134
|
+
constructor(config = {}) {
|
|
135
|
+
// Show deprecation warning once per process (skip for internal wrapper usage)
|
|
136
|
+
if (!RelayClient._deprecationWarningShown && !config._internal) {
|
|
137
|
+
RelayClient._deprecationWarningShown = true;
|
|
138
|
+
console.warn('\x1b[33m[DEPRECATION WARNING]\x1b[0m RelayClient from @agent-relay/wrapper is deprecated.\n' +
|
|
139
|
+
' Migrate to @agent-relay/sdk:\n' +
|
|
140
|
+
' import { RelayClient } from \'@agent-relay/sdk\';\n' +
|
|
141
|
+
' This module will be removed in a future major version.');
|
|
142
|
+
}
|
|
143
|
+
this.config = { ...DEFAULT_CLIENT_CONFIG, ...config };
|
|
144
|
+
this.parser = new FrameParser();
|
|
145
|
+
this.parser.setLegacyMode(true); // Use 4-byte header for backwards compatibility
|
|
146
|
+
this.reconnectDelay = this.config.reconnectDelayMs;
|
|
147
|
+
}
|
|
148
|
+
get state() {
|
|
149
|
+
return this._state;
|
|
150
|
+
}
|
|
151
|
+
get agentName() {
|
|
152
|
+
return this.config.agentName;
|
|
153
|
+
}
|
|
154
|
+
/** Get the session ID assigned by the server */
|
|
155
|
+
get currentSessionId() {
|
|
156
|
+
return this.sessionId;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Connect to the relay daemon.
|
|
160
|
+
*/
|
|
161
|
+
connect() {
|
|
162
|
+
if (this._state !== 'DISCONNECTED' && this._state !== 'BACKOFF') {
|
|
163
|
+
return Promise.resolve();
|
|
164
|
+
}
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
let settled = false;
|
|
167
|
+
const settleResolve = () => {
|
|
168
|
+
if (settled)
|
|
169
|
+
return;
|
|
170
|
+
settled = true;
|
|
171
|
+
resolve();
|
|
172
|
+
};
|
|
173
|
+
const settleReject = (err) => {
|
|
174
|
+
if (settled)
|
|
175
|
+
return;
|
|
176
|
+
settled = true;
|
|
177
|
+
reject(err);
|
|
178
|
+
};
|
|
179
|
+
this.setState('CONNECTING');
|
|
180
|
+
this.socket = net.createConnection(this.config.socketPath, () => {
|
|
181
|
+
this.setState('HANDSHAKING');
|
|
182
|
+
this.sendHello();
|
|
183
|
+
});
|
|
184
|
+
this.socket.on('data', (data) => this.handleData(data));
|
|
185
|
+
this.socket.on('close', () => {
|
|
186
|
+
this.handleDisconnect();
|
|
187
|
+
});
|
|
188
|
+
this.socket.on('error', (err) => {
|
|
189
|
+
if (this._state === 'CONNECTING') {
|
|
190
|
+
settleReject(err);
|
|
191
|
+
}
|
|
192
|
+
this.handleError(err);
|
|
193
|
+
});
|
|
194
|
+
// Wait for WELCOME
|
|
195
|
+
const checkReady = setInterval(() => {
|
|
196
|
+
if (this._state === 'READY') {
|
|
197
|
+
clearInterval(checkReady);
|
|
198
|
+
clearTimeout(timeout);
|
|
199
|
+
settleResolve();
|
|
200
|
+
}
|
|
201
|
+
}, 10);
|
|
202
|
+
// Timeout
|
|
203
|
+
const timeout = setTimeout(() => {
|
|
204
|
+
if (this._state !== 'READY') {
|
|
205
|
+
clearInterval(checkReady);
|
|
206
|
+
this.socket?.destroy();
|
|
207
|
+
settleReject(new Error('Connection timeout'));
|
|
208
|
+
}
|
|
209
|
+
}, 5000);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Disconnect from the relay daemon.
|
|
214
|
+
*/
|
|
215
|
+
disconnect() {
|
|
216
|
+
if (this.reconnectTimer) {
|
|
217
|
+
clearTimeout(this.reconnectTimer);
|
|
218
|
+
this.reconnectTimer = undefined;
|
|
219
|
+
}
|
|
220
|
+
if (this.socket) {
|
|
221
|
+
this.send({
|
|
222
|
+
v: PROTOCOL_VERSION,
|
|
223
|
+
type: 'BYE',
|
|
224
|
+
id: generateId(),
|
|
225
|
+
ts: Date.now(),
|
|
226
|
+
payload: {},
|
|
227
|
+
});
|
|
228
|
+
this.socket.end();
|
|
229
|
+
this.socket = undefined;
|
|
230
|
+
}
|
|
231
|
+
this.setState('DISCONNECTED');
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Permanently destroy the client. Disconnects and prevents any reconnection.
|
|
235
|
+
*/
|
|
236
|
+
destroy() {
|
|
237
|
+
this._destroyed = true;
|
|
238
|
+
this.disconnect();
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Send a message to another agent.
|
|
242
|
+
* @param to - Target agent name or '*' for broadcast
|
|
243
|
+
* @param body - Message body
|
|
244
|
+
* @param kind - Message type (default: 'message')
|
|
245
|
+
* @param data - Optional structured data
|
|
246
|
+
* @param thread - Optional thread ID for grouping related messages
|
|
247
|
+
* @param meta - Optional message metadata (importance, replyTo, etc.)
|
|
248
|
+
*/
|
|
249
|
+
sendMessage(to, body, kind = 'message', data, thread, meta) {
|
|
250
|
+
if (this._state !== 'READY') {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const envelope = {
|
|
254
|
+
v: PROTOCOL_VERSION,
|
|
255
|
+
type: 'SEND',
|
|
256
|
+
id: generateId(),
|
|
257
|
+
ts: Date.now(),
|
|
258
|
+
to,
|
|
259
|
+
payload: {
|
|
260
|
+
kind,
|
|
261
|
+
body,
|
|
262
|
+
data,
|
|
263
|
+
thread,
|
|
264
|
+
},
|
|
265
|
+
payload_meta: meta,
|
|
266
|
+
};
|
|
267
|
+
return this.send(envelope);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Send an ACK for a delivered message.
|
|
271
|
+
*/
|
|
272
|
+
sendAck(payload) {
|
|
273
|
+
if (this._state !== 'READY') {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
const envelope = {
|
|
277
|
+
v: PROTOCOL_VERSION,
|
|
278
|
+
type: 'ACK',
|
|
279
|
+
id: generateId(),
|
|
280
|
+
ts: Date.now(),
|
|
281
|
+
payload,
|
|
282
|
+
};
|
|
283
|
+
return this.send(envelope);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Send a message and wait for a correlated ACK response.
|
|
287
|
+
*/
|
|
288
|
+
async sendAndWait(to, body, options = {}) {
|
|
289
|
+
if (this._state !== 'READY') {
|
|
290
|
+
throw new Error('Client not ready');
|
|
291
|
+
}
|
|
292
|
+
const correlationId = randomUUID();
|
|
293
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
294
|
+
const kind = options.kind ?? 'message';
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const timeoutHandle = setTimeout(() => {
|
|
297
|
+
this.pendingSyncAcks.delete(correlationId);
|
|
298
|
+
reject(new Error(`ACK timeout after ${timeoutMs}ms`));
|
|
299
|
+
}, timeoutMs);
|
|
300
|
+
this.pendingSyncAcks.set(correlationId, { resolve, reject, timeoutHandle });
|
|
301
|
+
const envelope = {
|
|
302
|
+
v: PROTOCOL_VERSION,
|
|
303
|
+
type: 'SEND',
|
|
304
|
+
id: generateId(),
|
|
305
|
+
ts: Date.now(),
|
|
306
|
+
to,
|
|
307
|
+
payload: {
|
|
308
|
+
kind,
|
|
309
|
+
body,
|
|
310
|
+
data: options.data,
|
|
311
|
+
thread: options.thread,
|
|
312
|
+
},
|
|
313
|
+
payload_meta: {
|
|
314
|
+
sync: {
|
|
315
|
+
correlationId,
|
|
316
|
+
timeoutMs,
|
|
317
|
+
blocking: true,
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
const sent = this.send(envelope);
|
|
322
|
+
if (!sent) {
|
|
323
|
+
clearTimeout(timeoutHandle);
|
|
324
|
+
this.pendingSyncAcks.delete(correlationId);
|
|
325
|
+
reject(new Error('Failed to send message'));
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Broadcast a message to all agents.
|
|
331
|
+
*/
|
|
332
|
+
broadcast(body, kind = 'message', data) {
|
|
333
|
+
return this.sendMessage('*', body, kind, data);
|
|
334
|
+
}
|
|
335
|
+
// =============================================================================
|
|
336
|
+
// Channel Operations
|
|
337
|
+
// =============================================================================
|
|
338
|
+
/**
|
|
339
|
+
* Join a channel.
|
|
340
|
+
* @param channel - Channel name (e.g., '#general', 'dm:alice:bob')
|
|
341
|
+
* @param displayName - Optional display name for this member
|
|
342
|
+
*/
|
|
343
|
+
joinChannel(channel, displayName) {
|
|
344
|
+
if (this._state !== 'READY') {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
const envelope = {
|
|
348
|
+
v: PROTOCOL_VERSION,
|
|
349
|
+
type: 'CHANNEL_JOIN',
|
|
350
|
+
id: generateId(),
|
|
351
|
+
ts: Date.now(),
|
|
352
|
+
payload: {
|
|
353
|
+
channel,
|
|
354
|
+
displayName,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
return this.send(envelope);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Admin join: Add any member to a channel (does not require member to be connected).
|
|
361
|
+
* Used by dashboard to sync channel memberships for agents.
|
|
362
|
+
* @param channel - Channel name (e.g., '#general')
|
|
363
|
+
* @param member - Name of the member to add
|
|
364
|
+
*/
|
|
365
|
+
adminJoinChannel(channel, member) {
|
|
366
|
+
if (this._state !== 'READY') {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
const envelope = {
|
|
370
|
+
v: PROTOCOL_VERSION,
|
|
371
|
+
type: 'CHANNEL_JOIN',
|
|
372
|
+
id: generateId(),
|
|
373
|
+
ts: Date.now(),
|
|
374
|
+
payload: {
|
|
375
|
+
channel,
|
|
376
|
+
member, // Admin mode: specify member to add
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
return this.send(envelope);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Leave a channel.
|
|
383
|
+
* @param channel - Channel name to leave
|
|
384
|
+
* @param reason - Optional reason for leaving
|
|
385
|
+
*/
|
|
386
|
+
leaveChannel(channel, reason) {
|
|
387
|
+
if (this._state !== 'READY')
|
|
388
|
+
return false;
|
|
389
|
+
const envelope = {
|
|
390
|
+
v: PROTOCOL_VERSION,
|
|
391
|
+
type: 'CHANNEL_LEAVE',
|
|
392
|
+
id: generateId(),
|
|
393
|
+
ts: Date.now(),
|
|
394
|
+
payload: {
|
|
395
|
+
channel,
|
|
396
|
+
reason,
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
return this.send(envelope);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Admin remove: Remove any member from a channel (does not require member to be connected).
|
|
403
|
+
* Used by dashboard to remove channel members.
|
|
404
|
+
* @param channel - Channel name (e.g., '#general')
|
|
405
|
+
* @param member - Name of the member to remove
|
|
406
|
+
*/
|
|
407
|
+
adminRemoveMember(channel, member) {
|
|
408
|
+
if (this._state !== 'READY') {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
const envelope = {
|
|
412
|
+
v: PROTOCOL_VERSION,
|
|
413
|
+
type: 'CHANNEL_LEAVE',
|
|
414
|
+
id: generateId(),
|
|
415
|
+
ts: Date.now(),
|
|
416
|
+
payload: {
|
|
417
|
+
channel,
|
|
418
|
+
member, // Admin mode: specify member to remove
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
return this.send(envelope);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Send a message to a channel.
|
|
425
|
+
* @param channel - Channel name
|
|
426
|
+
* @param body - Message content
|
|
427
|
+
* @param options - Optional thread, mentions, attachments
|
|
428
|
+
*/
|
|
429
|
+
sendChannelMessage(channel, body, options) {
|
|
430
|
+
if (this._state !== 'READY') {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
const envelope = {
|
|
434
|
+
v: PROTOCOL_VERSION,
|
|
435
|
+
type: 'CHANNEL_MESSAGE',
|
|
436
|
+
id: generateId(),
|
|
437
|
+
ts: Date.now(),
|
|
438
|
+
payload: {
|
|
439
|
+
channel,
|
|
440
|
+
body,
|
|
441
|
+
thread: options?.thread,
|
|
442
|
+
mentions: options?.mentions,
|
|
443
|
+
attachments: options?.attachments,
|
|
444
|
+
data: options?.data,
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
return this.send(envelope);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Subscribe to a topic.
|
|
451
|
+
*/
|
|
452
|
+
subscribe(topic) {
|
|
453
|
+
if (this._state !== 'READY')
|
|
454
|
+
return false;
|
|
455
|
+
return this.send({
|
|
456
|
+
v: PROTOCOL_VERSION,
|
|
457
|
+
type: 'SUBSCRIBE',
|
|
458
|
+
id: generateId(),
|
|
459
|
+
ts: Date.now(),
|
|
460
|
+
topic,
|
|
461
|
+
payload: {},
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Unsubscribe from a topic.
|
|
466
|
+
*/
|
|
467
|
+
unsubscribe(topic) {
|
|
468
|
+
if (this._state !== 'READY')
|
|
469
|
+
return false;
|
|
470
|
+
return this.send({
|
|
471
|
+
v: PROTOCOL_VERSION,
|
|
472
|
+
type: 'UNSUBSCRIBE',
|
|
473
|
+
id: generateId(),
|
|
474
|
+
ts: Date.now(),
|
|
475
|
+
topic,
|
|
476
|
+
payload: {},
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Bind this agent as a shadow to a primary agent.
|
|
481
|
+
* As a shadow, this agent will receive copies of messages to/from the primary.
|
|
482
|
+
* @param primaryAgent - The agent to shadow
|
|
483
|
+
* @param options - Shadow configuration options
|
|
484
|
+
*/
|
|
485
|
+
bindAsShadow(primaryAgent, options = {}) {
|
|
486
|
+
if (this._state !== 'READY')
|
|
487
|
+
return false;
|
|
488
|
+
return this.send({
|
|
489
|
+
v: PROTOCOL_VERSION,
|
|
490
|
+
type: 'SHADOW_BIND',
|
|
491
|
+
id: generateId(),
|
|
492
|
+
ts: Date.now(),
|
|
493
|
+
payload: {
|
|
494
|
+
primaryAgent,
|
|
495
|
+
speakOn: options.speakOn,
|
|
496
|
+
receiveIncoming: options.receiveIncoming,
|
|
497
|
+
receiveOutgoing: options.receiveOutgoing,
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Unbind this agent from a primary agent (stop shadowing).
|
|
503
|
+
* @param primaryAgent - The agent to stop shadowing
|
|
504
|
+
*/
|
|
505
|
+
unbindAsShadow(primaryAgent) {
|
|
506
|
+
if (this._state !== 'READY')
|
|
507
|
+
return false;
|
|
508
|
+
return this.send({
|
|
509
|
+
v: PROTOCOL_VERSION,
|
|
510
|
+
type: 'SHADOW_UNBIND',
|
|
511
|
+
id: generateId(),
|
|
512
|
+
ts: Date.now(),
|
|
513
|
+
payload: {
|
|
514
|
+
primaryAgent,
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Send log/output data to the daemon for dashboard streaming.
|
|
520
|
+
* Used by daemon-connected agents (not spawned workers) to stream their output.
|
|
521
|
+
* @param data - The log/output data to send
|
|
522
|
+
* @returns true if sent successfully, false otherwise
|
|
523
|
+
*/
|
|
524
|
+
sendLog(data) {
|
|
525
|
+
if (this._state !== 'READY') {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
const envelope = {
|
|
529
|
+
v: PROTOCOL_VERSION,
|
|
530
|
+
type: 'LOG',
|
|
531
|
+
id: generateId(),
|
|
532
|
+
ts: Date.now(),
|
|
533
|
+
payload: {
|
|
534
|
+
data,
|
|
535
|
+
timestamp: Date.now(),
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
return this.send(envelope);
|
|
539
|
+
}
|
|
540
|
+
setState(state) {
|
|
541
|
+
this._state = state;
|
|
542
|
+
if (this.onStateChange) {
|
|
543
|
+
this.onStateChange(state);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
sendHello() {
|
|
547
|
+
const hello = {
|
|
548
|
+
v: PROTOCOL_VERSION,
|
|
549
|
+
type: 'HELLO',
|
|
550
|
+
id: generateId(),
|
|
551
|
+
ts: Date.now(),
|
|
552
|
+
payload: {
|
|
553
|
+
agent: this.config.agentName,
|
|
554
|
+
entityType: this.config.entityType,
|
|
555
|
+
cli: this.config.cli,
|
|
556
|
+
program: this.config.program,
|
|
557
|
+
model: this.config.model,
|
|
558
|
+
task: this.config.task,
|
|
559
|
+
workingDirectory: this.config.workingDirectory,
|
|
560
|
+
displayName: this.config.displayName,
|
|
561
|
+
avatarUrl: this.config.avatarUrl,
|
|
562
|
+
capabilities: {
|
|
563
|
+
ack: true,
|
|
564
|
+
resume: true,
|
|
565
|
+
max_inflight: 256,
|
|
566
|
+
supports_topics: true,
|
|
567
|
+
},
|
|
568
|
+
session: this.resumeToken ? { resume_token: this.resumeToken } : undefined,
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
this.send(hello);
|
|
572
|
+
}
|
|
573
|
+
send(envelope) {
|
|
574
|
+
if (!this.socket)
|
|
575
|
+
return false;
|
|
576
|
+
try {
|
|
577
|
+
const frame = encodeFrameLegacy(envelope);
|
|
578
|
+
this.writeQueue.push(frame);
|
|
579
|
+
// Coalesce writes: schedule flush on next tick if not already scheduled
|
|
580
|
+
if (!this.writeScheduled) {
|
|
581
|
+
this.writeScheduled = true;
|
|
582
|
+
setImmediate(() => this.flushWrites());
|
|
583
|
+
}
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
this.handleError(err);
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Flush all queued writes in a single syscall.
|
|
593
|
+
*/
|
|
594
|
+
flushWrites() {
|
|
595
|
+
this.writeScheduled = false;
|
|
596
|
+
if (this.writeQueue.length === 0 || !this.socket)
|
|
597
|
+
return;
|
|
598
|
+
if (this.writeQueue.length === 1) {
|
|
599
|
+
// Single frame - write directly (no concat needed)
|
|
600
|
+
this.socket.write(this.writeQueue[0]);
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
// Multiple frames - batch into single write
|
|
604
|
+
this.socket.write(Buffer.concat(this.writeQueue));
|
|
605
|
+
}
|
|
606
|
+
this.writeQueue = [];
|
|
607
|
+
}
|
|
608
|
+
handleData(data) {
|
|
609
|
+
try {
|
|
610
|
+
const frames = this.parser.push(data);
|
|
611
|
+
for (const frame of frames) {
|
|
612
|
+
this.processFrame(frame);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch (err) {
|
|
616
|
+
this.handleError(err);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
processFrame(envelope) {
|
|
620
|
+
switch (envelope.type) {
|
|
621
|
+
case 'WELCOME':
|
|
622
|
+
this.handleWelcome(envelope);
|
|
623
|
+
break;
|
|
624
|
+
case 'DELIVER':
|
|
625
|
+
this.handleDeliver(envelope);
|
|
626
|
+
break;
|
|
627
|
+
case 'CHANNEL_MESSAGE':
|
|
628
|
+
this.handleChannelMessage(envelope);
|
|
629
|
+
break;
|
|
630
|
+
case 'PING':
|
|
631
|
+
this.handlePing(envelope);
|
|
632
|
+
break;
|
|
633
|
+
case 'ACK':
|
|
634
|
+
this.handleAck(envelope);
|
|
635
|
+
break;
|
|
636
|
+
case 'ERROR':
|
|
637
|
+
this.handleErrorFrame(envelope);
|
|
638
|
+
break;
|
|
639
|
+
case 'BUSY':
|
|
640
|
+
console.warn('[client] Server busy, backing off');
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
handleWelcome(envelope) {
|
|
645
|
+
this.sessionId = envelope.payload.session_id;
|
|
646
|
+
this.resumeToken = envelope.payload.resume_token;
|
|
647
|
+
this.reconnectAttempts = 0;
|
|
648
|
+
this.reconnectDelay = this.config.reconnectDelayMs;
|
|
649
|
+
this.setState('READY');
|
|
650
|
+
if (!this.config.quiet) {
|
|
651
|
+
console.log(`[client] Connected as ${this.config.agentName} (session: ${this.sessionId})`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
handleDeliver(envelope) {
|
|
655
|
+
// Send ACK
|
|
656
|
+
this.send({
|
|
657
|
+
v: PROTOCOL_VERSION,
|
|
658
|
+
type: 'ACK',
|
|
659
|
+
id: generateId(),
|
|
660
|
+
ts: Date.now(),
|
|
661
|
+
payload: {
|
|
662
|
+
ack_id: envelope.id,
|
|
663
|
+
seq: envelope.delivery.seq,
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
const duplicate = this.markDelivered(envelope.id);
|
|
667
|
+
if (duplicate) {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// Notify handler
|
|
671
|
+
// Pass originalTo from delivery info so handlers know if this was a broadcast
|
|
672
|
+
if (this.onMessage && envelope.from) {
|
|
673
|
+
this.onMessage(envelope.from, envelope.payload, envelope.id, envelope.payload_meta, envelope.delivery.originalTo);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
handleAck(envelope) {
|
|
677
|
+
const correlationId = envelope.payload.correlationId;
|
|
678
|
+
if (!correlationId)
|
|
679
|
+
return;
|
|
680
|
+
const pending = this.pendingSyncAcks.get(correlationId);
|
|
681
|
+
if (!pending)
|
|
682
|
+
return;
|
|
683
|
+
clearTimeout(pending.timeoutHandle);
|
|
684
|
+
this.pendingSyncAcks.delete(correlationId);
|
|
685
|
+
pending.resolve(envelope.payload);
|
|
686
|
+
}
|
|
687
|
+
handleChannelMessage(envelope) {
|
|
688
|
+
if (!this.config.quiet) {
|
|
689
|
+
console.log(`[client] handleChannelMessage: from=${envelope.from}, channel=${envelope.payload.channel}`);
|
|
690
|
+
}
|
|
691
|
+
const duplicate = this.markDelivered(envelope.id);
|
|
692
|
+
if (duplicate) {
|
|
693
|
+
if (!this.config.quiet) {
|
|
694
|
+
console.log(`[client] handleChannelMessage: duplicate message ${envelope.id}, skipping`);
|
|
695
|
+
}
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
// Notify channel message handler
|
|
699
|
+
if (this.onChannelMessage && envelope.from) {
|
|
700
|
+
if (!this.config.quiet) {
|
|
701
|
+
console.log(`[client] Calling onChannelMessage callback`);
|
|
702
|
+
}
|
|
703
|
+
this.onChannelMessage(envelope.from, envelope.payload.channel, envelope.payload.body, envelope);
|
|
704
|
+
}
|
|
705
|
+
else if (!this.config.quiet) {
|
|
706
|
+
console.log(`[client] No onChannelMessage handler set (handler=${!!this.onChannelMessage}, from=${envelope.from})`);
|
|
707
|
+
}
|
|
708
|
+
// Also call onMessage for backwards compatibility
|
|
709
|
+
// Convert to SendPayload format (channel is passed as 5th argument, not in payload)
|
|
710
|
+
if (this.onMessage && envelope.from) {
|
|
711
|
+
const sendPayload = {
|
|
712
|
+
kind: 'message',
|
|
713
|
+
body: envelope.payload.body,
|
|
714
|
+
data: {
|
|
715
|
+
_isChannelMessage: true,
|
|
716
|
+
_channel: envelope.payload.channel,
|
|
717
|
+
_mentions: envelope.payload.mentions,
|
|
718
|
+
},
|
|
719
|
+
thread: envelope.payload.thread,
|
|
720
|
+
};
|
|
721
|
+
this.onMessage(envelope.from, sendPayload, envelope.id, undefined, envelope.payload.channel);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
handlePing(envelope) {
|
|
725
|
+
this.send({
|
|
726
|
+
v: PROTOCOL_VERSION,
|
|
727
|
+
type: 'PONG',
|
|
728
|
+
id: generateId(),
|
|
729
|
+
ts: Date.now(),
|
|
730
|
+
payload: envelope.payload ?? {},
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
handleErrorFrame(envelope) {
|
|
734
|
+
console.error('[client] Server error:', envelope.payload);
|
|
735
|
+
if (envelope.payload.code === 'RESUME_TOO_OLD') {
|
|
736
|
+
if (this.resumeToken) {
|
|
737
|
+
console.warn('[client] Resume token rejected, clearing and requesting new session');
|
|
738
|
+
}
|
|
739
|
+
// Clear resume token so next HELLO starts a fresh session instead of looping on an invalid token
|
|
740
|
+
this.resumeToken = undefined;
|
|
741
|
+
this.sessionId = undefined;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
handleDisconnect() {
|
|
745
|
+
this.parser.reset();
|
|
746
|
+
this.socket = undefined;
|
|
747
|
+
this.rejectPendingSyncAcks(new Error('Disconnected while awaiting ACK'));
|
|
748
|
+
// Don't reconnect if permanently destroyed
|
|
749
|
+
if (this._destroyed) {
|
|
750
|
+
this.setState('DISCONNECTED');
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (this.config.reconnect && this.reconnectAttempts < this.config.maxReconnectAttempts) {
|
|
754
|
+
this.scheduleReconnect();
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
this.setState('DISCONNECTED');
|
|
758
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
759
|
+
console.error(`[client] Max reconnect attempts reached (${this.config.maxReconnectAttempts}), giving up`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
handleError(error) {
|
|
764
|
+
console.error('[client] Error:', error.message);
|
|
765
|
+
if (this.onError) {
|
|
766
|
+
this.onError(error);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
rejectPendingSyncAcks(error) {
|
|
770
|
+
for (const [correlationId, pending] of this.pendingSyncAcks.entries()) {
|
|
771
|
+
clearTimeout(pending.timeoutHandle);
|
|
772
|
+
pending.reject(error);
|
|
773
|
+
this.pendingSyncAcks.delete(correlationId);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
scheduleReconnect() {
|
|
777
|
+
this.setState('BACKOFF');
|
|
778
|
+
this.reconnectAttempts++;
|
|
779
|
+
// Exponential backoff with jitter
|
|
780
|
+
const jitter = Math.random() * 0.3 + 0.85; // 0.85 - 1.15
|
|
781
|
+
const delay = Math.min(this.reconnectDelay * jitter, this.config.reconnectMaxDelayMs);
|
|
782
|
+
this.reconnectDelay *= 2;
|
|
783
|
+
if (!this.config.quiet) {
|
|
784
|
+
console.log(`[client] Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`);
|
|
785
|
+
}
|
|
786
|
+
this.reconnectTimer = setTimeout(() => {
|
|
787
|
+
this.connect().catch(() => {
|
|
788
|
+
// Will trigger another reconnect
|
|
789
|
+
});
|
|
790
|
+
}, delay);
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Check if message was already delivered (deduplication).
|
|
794
|
+
* Uses circular buffer for O(1) eviction.
|
|
795
|
+
* @returns true if the message has already been seen.
|
|
796
|
+
*/
|
|
797
|
+
markDelivered(id) {
|
|
798
|
+
return this.dedupeCache.check(id);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
//# sourceMappingURL=client.js.map
|