@agentuity/coder 1.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -0
- package/dist/chain-preview.d.ts +55 -0
- package/dist/chain-preview.d.ts.map +1 -0
- package/dist/chain-preview.js +472 -0
- package/dist/chain-preview.js.map +1 -0
- package/dist/client.d.ts +43 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +402 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +22 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +99 -0
- package/dist/commands.js.map +1 -0
- package/dist/footer.d.ts +34 -0
- package/dist/footer.d.ts.map +1 -0
- package/dist/footer.js +249 -0
- package/dist/footer.js.map +1 -0
- package/dist/handlers.d.ts +24 -0
- package/dist/handlers.d.ts.map +1 -0
- package/dist/handlers.js +83 -0
- package/dist/handlers.js.map +1 -0
- package/dist/hub-overlay.d.ts +107 -0
- package/dist/hub-overlay.d.ts.map +1 -0
- package/dist/hub-overlay.js +1794 -0
- package/dist/hub-overlay.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1585 -0
- package/dist/index.js.map +1 -0
- package/dist/output-viewer.d.ts +49 -0
- package/dist/output-viewer.d.ts.map +1 -0
- package/dist/output-viewer.js +389 -0
- package/dist/output-viewer.js.map +1 -0
- package/dist/overlay.d.ts +40 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +225 -0
- package/dist/overlay.js.map +1 -0
- package/dist/protocol.d.ts +118 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +3 -0
- package/dist/protocol.js.map +1 -0
- package/dist/remote-session.d.ts +113 -0
- package/dist/remote-session.d.ts.map +1 -0
- package/dist/remote-session.js +645 -0
- package/dist/remote-session.js.map +1 -0
- package/dist/remote-tui.d.ts +40 -0
- package/dist/remote-tui.d.ts.map +1 -0
- package/dist/remote-tui.js +606 -0
- package/dist/remote-tui.js.map +1 -0
- package/dist/renderers.d.ts +34 -0
- package/dist/renderers.d.ts.map +1 -0
- package/dist/renderers.js +669 -0
- package/dist/renderers.js.map +1 -0
- package/dist/review.d.ts +15 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +154 -0
- package/dist/review.js.map +1 -0
- package/dist/titlebar.d.ts +3 -0
- package/dist/titlebar.d.ts.map +1 -0
- package/dist/titlebar.js +59 -0
- package/dist/titlebar.js.map +1 -0
- package/dist/todo/index.d.ts +3 -0
- package/dist/todo/index.d.ts.map +1 -0
- package/dist/todo/index.js +3 -0
- package/dist/todo/index.js.map +1 -0
- package/dist/todo/store.d.ts +6 -0
- package/dist/todo/store.d.ts.map +1 -0
- package/dist/todo/store.js +43 -0
- package/dist/todo/store.js.map +1 -0
- package/dist/todo/types.d.ts +13 -0
- package/dist/todo/types.d.ts.map +1 -0
- package/dist/todo/types.js +2 -0
- package/dist/todo/types.js.map +1 -0
- package/package.json +44 -0
- package/src/chain-preview.ts +621 -0
- package/src/client.ts +515 -0
- package/src/commands.ts +132 -0
- package/src/footer.ts +305 -0
- package/src/handlers.ts +113 -0
- package/src/hub-overlay.ts +2324 -0
- package/src/index.ts +1907 -0
- package/src/output-viewer.ts +480 -0
- package/src/overlay.ts +294 -0
- package/src/protocol.ts +157 -0
- package/src/remote-session.ts +800 -0
- package/src/remote-tui.ts +707 -0
- package/src/renderers.ts +740 -0
- package/src/review.ts +201 -0
- package/src/titlebar.ts +63 -0
- package/src/todo/index.ts +2 -0
- package/src/todo/store.ts +49 -0
- package/src/todo/types.ts +14 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import type { InitMessage, HubRequest, HubResponse } from './protocol.ts';
|
|
2
|
+
|
|
3
|
+
/** How long to wait for a response before rejecting the pending promise (ms). */
|
|
4
|
+
const SEND_TIMEOUT_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
/** How long to wait for the init message after connecting (ms). */
|
|
7
|
+
const CONNECT_TIMEOUT_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
/** Reconnect backoff starts at 1s and doubles per attempt, capped at 30s. */
|
|
10
|
+
const RECONNECT_BASE_DELAY_MS = 1_000;
|
|
11
|
+
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
12
|
+
const RECONNECT_MAX_ATTEMPTS = 10;
|
|
13
|
+
const RECONNECT_JITTER_MAX_MS = 1_000;
|
|
14
|
+
|
|
15
|
+
/** Bound queue growth while disconnected. */
|
|
16
|
+
const MAX_QUEUED_MESSAGES = 1_000;
|
|
17
|
+
|
|
18
|
+
/** How long queued requests may wait for reconnection before failing. */
|
|
19
|
+
const QUEUED_REQUEST_TIMEOUT_MS = 120_000;
|
|
20
|
+
|
|
21
|
+
const DEBUG = !!process.env['AGENTUITY_DEBUG'];
|
|
22
|
+
|
|
23
|
+
function log(message: string): void {
|
|
24
|
+
if (DEBUG) console.error(`[agentuity-coder] ${message}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ConnectionState = 'connected' | 'disconnected' | 'reconnecting' | 'closed';
|
|
28
|
+
|
|
29
|
+
type FireAndForgetMessage = HubRequest | Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
interface QueuedFireAndForgetMessage {
|
|
32
|
+
kind: 'fire-and-forget';
|
|
33
|
+
message: FireAndForgetMessage;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface QueuedRequestMessage {
|
|
37
|
+
kind: 'request';
|
|
38
|
+
request: HubRequest;
|
|
39
|
+
resolve: (resp: HubResponse) => void;
|
|
40
|
+
reject: (err: Error) => void;
|
|
41
|
+
queueTimer: ReturnType<typeof setTimeout>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type QueuedMessage = QueuedFireAndForgetMessage | QueuedRequestMessage;
|
|
45
|
+
|
|
46
|
+
export class HubClient {
|
|
47
|
+
private ws: WebSocket | null = null;
|
|
48
|
+
private reconnectAttempts = 0;
|
|
49
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
50
|
+
private intentionallyClosed = false;
|
|
51
|
+
private lastConnectUrl: string | null = null;
|
|
52
|
+
private queue: QueuedMessage[] = [];
|
|
53
|
+
private connectionStateListeners = new Set<(state: ConnectionState) => void>();
|
|
54
|
+
private pending = new Map<
|
|
55
|
+
string,
|
|
56
|
+
{
|
|
57
|
+
resolve: (resp: HubResponse) => void;
|
|
58
|
+
reject: (err: Error) => void;
|
|
59
|
+
timer: ReturnType<typeof setTimeout>;
|
|
60
|
+
}
|
|
61
|
+
>();
|
|
62
|
+
|
|
63
|
+
public connectionState: ConnectionState = 'closed';
|
|
64
|
+
public onConnectionStateChange?: (state: ConnectionState) => void;
|
|
65
|
+
public onBeforeReconnect?: () => Promise<void>;
|
|
66
|
+
public onInitMessage?: (initMessage: InitMessage) => void;
|
|
67
|
+
/** Called when an unsolicited server message arrives (broadcast, presence, hydration). */
|
|
68
|
+
public onServerMessage?: (message: Record<string, unknown>) => void;
|
|
69
|
+
|
|
70
|
+
/** API key for Hub authentication (sent as x-agentuity-auth-api-key header) */
|
|
71
|
+
// TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
|
|
72
|
+
public apiKey: string | null = null;
|
|
73
|
+
|
|
74
|
+
private setConnectionState(state: ConnectionState): void {
|
|
75
|
+
if (this.connectionState === state) return;
|
|
76
|
+
this.connectionState = state;
|
|
77
|
+
this.onConnectionStateChange?.(state);
|
|
78
|
+
for (const listener of this.connectionStateListeners) listener(state);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private buildWebSocketUrl(url: string): string {
|
|
82
|
+
let wsUrl = url;
|
|
83
|
+
if (wsUrl.startsWith('http://')) {
|
|
84
|
+
wsUrl = 'ws://' + wsUrl.slice(7);
|
|
85
|
+
} else if (wsUrl.startsWith('https://')) {
|
|
86
|
+
wsUrl = 'wss://' + wsUrl.slice(8);
|
|
87
|
+
} else if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
|
|
88
|
+
wsUrl = 'ws://' + wsUrl;
|
|
89
|
+
}
|
|
90
|
+
return wsUrl;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private enqueue(message: QueuedMessage): void {
|
|
94
|
+
if (this.queue.length >= MAX_QUEUED_MESSAGES) {
|
|
95
|
+
const dropped = this.queue.shift();
|
|
96
|
+
if (dropped?.kind === 'request') {
|
|
97
|
+
clearTimeout(dropped.queueTimer);
|
|
98
|
+
dropped.reject(
|
|
99
|
+
new Error('Dropped queued request because queue reached maximum capacity')
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
log(`Queue full (${MAX_QUEUED_MESSAGES}); dropping oldest queued message`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.queue.push(message);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private sendRequestNow(request: HubRequest): Promise<HubResponse> {
|
|
109
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
110
|
+
return Promise.reject(new Error('WebSocket is not connected'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return new Promise<HubResponse>((resolve, reject) => {
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
const entry = this.pending.get(request.id);
|
|
116
|
+
if (entry) {
|
|
117
|
+
this.pending.delete(request.id);
|
|
118
|
+
entry.reject(
|
|
119
|
+
new Error(
|
|
120
|
+
`Hub response timeout after ${SEND_TIMEOUT_MS}ms for request ${request.id}`
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}, SEND_TIMEOUT_MS);
|
|
125
|
+
|
|
126
|
+
this.pending.set(request.id, { resolve, reject, timer });
|
|
127
|
+
this.ws!.send(JSON.stringify(request));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private sendFireAndForgetNow(message: FireAndForgetMessage): void {
|
|
132
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
133
|
+
this.enqueue({ kind: 'fire-and-forget', message });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.ws.send(JSON.stringify(message));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async flushQueue(): Promise<void> {
|
|
140
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
141
|
+
if (this.queue.length === 0) return;
|
|
142
|
+
|
|
143
|
+
const items = this.queue;
|
|
144
|
+
this.queue = [];
|
|
145
|
+
log(`Replaying ${items.length} queued message(s)`);
|
|
146
|
+
|
|
147
|
+
for (let i = 0; i < items.length; i++) {
|
|
148
|
+
const item = items[i]!;
|
|
149
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
150
|
+
this.queue = items.slice(i).concat(this.queue);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (item.kind === 'fire-and-forget') {
|
|
155
|
+
this.ws.send(JSON.stringify(item.message));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
clearTimeout(item.queueTimer);
|
|
160
|
+
this.sendRequestNow(item.request).then(item.resolve).catch(item.reject);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private handleUnexpectedClose(): void {
|
|
165
|
+
for (const [id, entry] of this.pending) {
|
|
166
|
+
clearTimeout(entry.timer);
|
|
167
|
+
entry.reject(new Error('WebSocket closed while request in-flight'));
|
|
168
|
+
this.pending.delete(id);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.ws = null;
|
|
172
|
+
|
|
173
|
+
if (this.intentionallyClosed) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.setConnectionState('disconnected');
|
|
178
|
+
this.startReconnectLoop();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private clearReconnectTimer(): void {
|
|
182
|
+
if (this.reconnectTimer) {
|
|
183
|
+
clearTimeout(this.reconnectTimer);
|
|
184
|
+
this.reconnectTimer = null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private rejectQueuedRequests(reason: string): void {
|
|
189
|
+
for (const item of this.queue) {
|
|
190
|
+
if (item.kind !== 'request') continue;
|
|
191
|
+
clearTimeout(item.queueTimer);
|
|
192
|
+
item.reject(new Error(reason));
|
|
193
|
+
}
|
|
194
|
+
this.queue = [];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private startReconnectLoop(): void {
|
|
198
|
+
if (this.reconnectTimer || this.intentionallyClosed) return;
|
|
199
|
+
if (!this.lastConnectUrl) {
|
|
200
|
+
log('Cannot reconnect: missing previous connection URL');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const attemptReconnect = (): void => {
|
|
205
|
+
if (this.intentionallyClosed) {
|
|
206
|
+
this.reconnectTimer = null;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
|
|
211
|
+
log(`Reconnect failed after ${RECONNECT_MAX_ATTEMPTS} attempts; giving up`);
|
|
212
|
+
this.reconnectTimer = null;
|
|
213
|
+
this.setConnectionState('disconnected');
|
|
214
|
+
this.rejectQueuedRequests(
|
|
215
|
+
'Reconnect attempts exhausted before queued request could be sent'
|
|
216
|
+
);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const attempt = this.reconnectAttempts + 1;
|
|
221
|
+
const delay = Math.min(
|
|
222
|
+
RECONNECT_MAX_DELAY_MS,
|
|
223
|
+
RECONNECT_BASE_DELAY_MS * 2 ** this.reconnectAttempts
|
|
224
|
+
);
|
|
225
|
+
const jitter = Math.floor(Math.random() * (RECONNECT_JITTER_MAX_MS + 1));
|
|
226
|
+
const waitMs = delay + jitter;
|
|
227
|
+
|
|
228
|
+
this.setConnectionState('reconnecting');
|
|
229
|
+
log(
|
|
230
|
+
`Reconnect attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS} in ${waitMs}ms ` +
|
|
231
|
+
`(base=${delay}ms, jitter=${jitter}ms)`
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
235
|
+
this.reconnectTimer = null;
|
|
236
|
+
if (this.intentionallyClosed) return;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
if (this.onBeforeReconnect) {
|
|
240
|
+
await this.onBeforeReconnect();
|
|
241
|
+
}
|
|
242
|
+
await this.connectInternal(this.lastConnectUrl!, true);
|
|
243
|
+
this.reconnectAttempts = 0;
|
|
244
|
+
log('Reconnected to Hub successfully');
|
|
245
|
+
} catch (err) {
|
|
246
|
+
this.reconnectAttempts += 1;
|
|
247
|
+
log(
|
|
248
|
+
`Reconnect attempt ${attempt} failed: ${
|
|
249
|
+
err instanceof Error ? err.message : String(err)
|
|
250
|
+
}`
|
|
251
|
+
);
|
|
252
|
+
attemptReconnect();
|
|
253
|
+
}
|
|
254
|
+
}, waitMs);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
attemptReconnect();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async connectInternal(url: string, isReconnect = false): Promise<InitMessage> {
|
|
261
|
+
const wsUrl = this.buildWebSocketUrl(url);
|
|
262
|
+
// TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
|
|
263
|
+
// Bun extension: custom headers on WebSocket upgrade request
|
|
264
|
+
const ws = this.apiKey
|
|
265
|
+
? new WebSocket(wsUrl, { headers: { 'x-agentuity-auth-api-key': this.apiKey } })
|
|
266
|
+
: new WebSocket(wsUrl);
|
|
267
|
+
this.ws = ws;
|
|
268
|
+
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
let initResolved = false;
|
|
271
|
+
|
|
272
|
+
const connectTimer = setTimeout(() => {
|
|
273
|
+
if (!initResolved) {
|
|
274
|
+
reject(new Error(`Hub did not send init message within ${CONNECT_TIMEOUT_MS}ms`));
|
|
275
|
+
this.ws?.close();
|
|
276
|
+
}
|
|
277
|
+
}, CONNECT_TIMEOUT_MS);
|
|
278
|
+
|
|
279
|
+
ws.onmessage = (event: MessageEvent) => {
|
|
280
|
+
let data: Record<string, unknown>;
|
|
281
|
+
try {
|
|
282
|
+
const raw =
|
|
283
|
+
typeof event.data === 'string'
|
|
284
|
+
? event.data
|
|
285
|
+
: new TextDecoder().decode(event.data as ArrayBuffer);
|
|
286
|
+
data = JSON.parse(raw) as Record<string, unknown>;
|
|
287
|
+
} catch {
|
|
288
|
+
// Malformed or non-JSON frame — ignore
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// First message should be init
|
|
293
|
+
if (data.type === 'init' && !initResolved) {
|
|
294
|
+
initResolved = true;
|
|
295
|
+
clearTimeout(connectTimer);
|
|
296
|
+
const initMessage = data as unknown as InitMessage;
|
|
297
|
+
this.onInitMessage?.(initMessage);
|
|
298
|
+
this.setConnectionState('connected');
|
|
299
|
+
void this.flushQueue();
|
|
300
|
+
resolve(initMessage);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Explicit server-side rejection before init (expired session, duplicate lead, etc.)
|
|
305
|
+
if (!initResolved && data.type === 'connection_rejected') {
|
|
306
|
+
clearTimeout(connectTimer);
|
|
307
|
+
this.intentionallyClosed = true;
|
|
308
|
+
const code = typeof data.code === 'string' ? data.code : 'unknown';
|
|
309
|
+
const message =
|
|
310
|
+
typeof data.message === 'string' ? data.message : 'Connection rejected';
|
|
311
|
+
reject(new Error(`Hub rejected connection (${code}): ${message}`));
|
|
312
|
+
try {
|
|
313
|
+
this.ws?.close();
|
|
314
|
+
} catch {
|
|
315
|
+
/* ignore */
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Unsolicited server messages (broadcast, presence, hydration)
|
|
321
|
+
// These have a `type` field but no `id` matching a pending request.
|
|
322
|
+
const msgType = data.type as string | undefined;
|
|
323
|
+
if (
|
|
324
|
+
msgType === 'broadcast' ||
|
|
325
|
+
msgType === 'presence' ||
|
|
326
|
+
msgType === 'session_hydration' ||
|
|
327
|
+
msgType === 'rpc_event' ||
|
|
328
|
+
msgType === 'rpc_response' ||
|
|
329
|
+
msgType === 'rpc_ui_request'
|
|
330
|
+
) {
|
|
331
|
+
this.onServerMessage?.(data);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Otherwise it's a response to a pending request
|
|
336
|
+
const response = data as unknown as HubResponse;
|
|
337
|
+
const entry = this.pending.get(response.id);
|
|
338
|
+
if (entry) {
|
|
339
|
+
clearTimeout(entry.timer);
|
|
340
|
+
this.pending.delete(response.id);
|
|
341
|
+
entry.resolve(response);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
ws.onerror = (err: Event) => {
|
|
346
|
+
if (initResolved) return;
|
|
347
|
+
|
|
348
|
+
const message =
|
|
349
|
+
'message' in err && typeof (err as ErrorEvent).message === 'string'
|
|
350
|
+
? (err as ErrorEvent).message
|
|
351
|
+
: `connection to ${wsUrl} failed`;
|
|
352
|
+
clearTimeout(connectTimer);
|
|
353
|
+
reject(new Error(`WebSocket error: ${message}`));
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
ws.onclose = (event: CloseEvent) => {
|
|
357
|
+
clearTimeout(connectTimer);
|
|
358
|
+
if (!initResolved) {
|
|
359
|
+
const reason = event.reason ? ` (${event.reason})` : '';
|
|
360
|
+
reject(
|
|
361
|
+
new Error(
|
|
362
|
+
`WebSocket closed before init message received (code ${event.code})${reason}`
|
|
363
|
+
)
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
if (isReconnect) {
|
|
367
|
+
this.setConnectionState('disconnected');
|
|
368
|
+
}
|
|
369
|
+
this.handleUnexpectedClose();
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async connect(url: string, opts?: { sessionId?: string; role?: string }): Promise<InitMessage> {
|
|
375
|
+
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
|
|
376
|
+
throw new Error('Already connected or connecting — call close() first');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
this.intentionallyClosed = false;
|
|
380
|
+
|
|
381
|
+
// Append query params for session/role targeting
|
|
382
|
+
let connectUrl = url;
|
|
383
|
+
if (opts?.sessionId) {
|
|
384
|
+
const separator = connectUrl.includes('?') ? '&' : '?';
|
|
385
|
+
connectUrl = `${connectUrl}${separator}sessionId=${encodeURIComponent(opts.sessionId)}`;
|
|
386
|
+
}
|
|
387
|
+
if (opts?.role) {
|
|
388
|
+
const separator = connectUrl.includes('?') ? '&' : '?';
|
|
389
|
+
connectUrl = `${connectUrl}${separator}role=${encodeURIComponent(opts.role)}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
this.lastConnectUrl = connectUrl;
|
|
393
|
+
this.reconnectAttempts = 0;
|
|
394
|
+
this.clearReconnectTimer();
|
|
395
|
+
|
|
396
|
+
return this.connectInternal(connectUrl, false);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private waitForConnection(timeoutMs: number): Promise<void> {
|
|
400
|
+
if (this.connected) return Promise.resolve();
|
|
401
|
+
|
|
402
|
+
if (this.connectionState === 'disconnected') {
|
|
403
|
+
this.startReconnectLoop();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
const timer = setTimeout(() => {
|
|
408
|
+
cleanup();
|
|
409
|
+
reject(new Error(`Timed out waiting for Hub reconnection after ${timeoutMs}ms`));
|
|
410
|
+
}, timeoutMs);
|
|
411
|
+
|
|
412
|
+
const listener = (state: ConnectionState): void => {
|
|
413
|
+
if (state !== 'connected') return;
|
|
414
|
+
cleanup();
|
|
415
|
+
resolve();
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const cleanup = (): void => {
|
|
419
|
+
clearTimeout(timer);
|
|
420
|
+
this.connectionStateListeners.delete(listener);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
this.connectionStateListeners.add(listener);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
nextId(): string {
|
|
428
|
+
return crypto.randomUUID();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async send(request: HubRequest): Promise<HubResponse> {
|
|
432
|
+
if (this.connectionState === 'closed') {
|
|
433
|
+
throw new Error('WebSocket client is closed');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
437
|
+
return new Promise<HubResponse>((resolve, reject) => {
|
|
438
|
+
const queuedEntry: QueuedRequestMessage = {
|
|
439
|
+
kind: 'request',
|
|
440
|
+
request,
|
|
441
|
+
resolve,
|
|
442
|
+
reject,
|
|
443
|
+
queueTimer: setTimeout(() => {
|
|
444
|
+
this.queue = this.queue.filter((item) => item !== queuedEntry);
|
|
445
|
+
reject(
|
|
446
|
+
new Error(
|
|
447
|
+
`Timed out waiting ${QUEUED_REQUEST_TIMEOUT_MS}ms for Hub reconnection`
|
|
448
|
+
)
|
|
449
|
+
);
|
|
450
|
+
}, QUEUED_REQUEST_TIMEOUT_MS),
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
this.enqueue(queuedEntry);
|
|
454
|
+
|
|
455
|
+
if (this.connectionState === 'disconnected') {
|
|
456
|
+
this.startReconnectLoop();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return this.sendRequestNow(request);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
sendNoWait(message: HubRequest | Record<string, unknown>): void {
|
|
465
|
+
if (this.connectionState === 'closed') {
|
|
466
|
+
log('Dropping fire-and-forget message because client is closed');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
this.sendFireAndForgetNow(message);
|
|
471
|
+
|
|
472
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
473
|
+
if (this.connectionState === 'disconnected') {
|
|
474
|
+
this.startReconnectLoop();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async waitUntilConnected(timeoutMs = CONNECT_TIMEOUT_MS): Promise<void> {
|
|
480
|
+
await this.waitForConnection(timeoutMs);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
close(): void {
|
|
484
|
+
this.intentionallyClosed = true;
|
|
485
|
+
this.setConnectionState('closed');
|
|
486
|
+
this.clearReconnectTimer();
|
|
487
|
+
|
|
488
|
+
for (const item of this.queue) {
|
|
489
|
+
if (item.kind !== 'request') continue;
|
|
490
|
+
clearTimeout(item.queueTimer);
|
|
491
|
+
item.reject(new Error('WebSocket client closed before queued request was sent'));
|
|
492
|
+
}
|
|
493
|
+
this.queue = [];
|
|
494
|
+
|
|
495
|
+
for (const [id, entry] of this.pending) {
|
|
496
|
+
clearTimeout(entry.timer);
|
|
497
|
+
entry.reject(new Error('WebSocket client closed'));
|
|
498
|
+
this.pending.delete(id);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (this.ws) {
|
|
502
|
+
// Detach handlers so the stale onclose doesn't trigger reconnect
|
|
503
|
+
// after a subsequent connect() resets intentionallyClosed.
|
|
504
|
+
this.ws.onclose = null;
|
|
505
|
+
this.ws.onerror = null;
|
|
506
|
+
this.ws.onmessage = null;
|
|
507
|
+
this.ws.close();
|
|
508
|
+
this.ws = null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
get connected(): boolean {
|
|
513
|
+
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
|
514
|
+
}
|
|
515
|
+
}
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash commands for routing to specific Coder Hub agents.
|
|
3
|
+
*
|
|
4
|
+
* Registers /lead, /memory, /product, etc. that prefix the user's
|
|
5
|
+
* message with routing instructions so the lead agent delegates
|
|
6
|
+
* to the specified agent.
|
|
7
|
+
*
|
|
8
|
+
* When the user types `/memory what happened last session`, the command
|
|
9
|
+
* handler sends a user message with a routing prefix that the lead agent
|
|
10
|
+
* recognizes and delegates accordingly.
|
|
11
|
+
*/
|
|
12
|
+
import type { ExtensionAPI, ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
|
|
13
|
+
import type { AgentDefinition } from './protocol.ts';
|
|
14
|
+
import { handleReview } from './review.ts';
|
|
15
|
+
|
|
16
|
+
type HubStatus = 'connected' | 'reconnecting' | 'offline';
|
|
17
|
+
|
|
18
|
+
const DEBUG = !!process.env['AGENTUITY_DEBUG'];
|
|
19
|
+
|
|
20
|
+
function log(msg: string): void {
|
|
21
|
+
if (DEBUG) console.error(`[agentuity-coder] ${msg}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Register slash commands for each Hub agent.
|
|
26
|
+
* When invoked, the command sends a user message prefixed with a routing directive
|
|
27
|
+
* so the lead agent knows to delegate to the specified agent.
|
|
28
|
+
*/
|
|
29
|
+
export function registerAgentCommands(
|
|
30
|
+
pi: ExtensionAPI,
|
|
31
|
+
agents: AgentDefinition[],
|
|
32
|
+
getHubStatus: () => HubStatus,
|
|
33
|
+
openAgentManager?: (ctx: ExtensionCommandContext) => Promise<void>,
|
|
34
|
+
openChainEditor?: (ctx: ExtensionCommandContext, initialAgents: string[]) => Promise<void>
|
|
35
|
+
): void {
|
|
36
|
+
for (const agent of agents) {
|
|
37
|
+
const name = agent.name;
|
|
38
|
+
log(`Registering command: /${name}`);
|
|
39
|
+
|
|
40
|
+
pi.registerCommand(name, {
|
|
41
|
+
description: `Route to ${name} agent: ${agent.description}`,
|
|
42
|
+
handler: async (args, ctx) => {
|
|
43
|
+
const trimmed = args.trim();
|
|
44
|
+
if (!trimmed) {
|
|
45
|
+
if (ctx.hasUI) {
|
|
46
|
+
ctx.ui.notify(`Usage: /${name} <message>`, 'info');
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Send a user message with routing prefix.
|
|
51
|
+
// The lead agent's system prompt recognizes [ROUTE TO: <agent>]
|
|
52
|
+
// and delegates to that agent.
|
|
53
|
+
pi.sendUserMessage(`@${name} ${trimmed}`, { deliverAs: 'followUp' });
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Register the /agents command that lists all available agents
|
|
59
|
+
pi.registerCommand('agents', {
|
|
60
|
+
description: 'List all available Coder Hub agents',
|
|
61
|
+
handler: async (_args, ctx) => {
|
|
62
|
+
if (ctx.hasUI && openAgentManager) {
|
|
63
|
+
await openAgentManager(ctx);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const lines = agents.map((a) => {
|
|
68
|
+
const model = a.model ? ` [${a.model}]` : '';
|
|
69
|
+
const caps = a.capabilities?.length ? ` (${a.capabilities.join(', ')})` : '';
|
|
70
|
+
const readOnly = a.readOnly ? ' [read-only]' : '';
|
|
71
|
+
return ` ${a.name}${model}${readOnly}\n ${a.description}${caps}`;
|
|
72
|
+
});
|
|
73
|
+
const message = `Available agents:\n${lines.join('\n')}`;
|
|
74
|
+
if (ctx.hasUI) {
|
|
75
|
+
ctx.ui.notify(message, 'info');
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
pi.registerCommand('chain', {
|
|
81
|
+
description: 'Open chain editor to compose multi-agent execution',
|
|
82
|
+
handler: async (args, ctx) => {
|
|
83
|
+
if (!ctx.hasUI || !openChainEditor) {
|
|
84
|
+
if (ctx.hasUI) {
|
|
85
|
+
ctx.ui.notify('Chain editor requires an interactive UI session.', 'warning');
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const initialAgents = args
|
|
91
|
+
.split(/\s+/)
|
|
92
|
+
.map((part) => part.trim())
|
|
93
|
+
.filter((part) => part.length > 0);
|
|
94
|
+
|
|
95
|
+
await openChainEditor(ctx, initialAgents);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Register the /status command that shows current session status
|
|
100
|
+
pi.registerCommand('status', {
|
|
101
|
+
description: 'Show current session status',
|
|
102
|
+
handler: async (_args, ctx) => {
|
|
103
|
+
const hubStatus = getHubStatus();
|
|
104
|
+
const lines: string[] = [];
|
|
105
|
+
lines.push('Coder Hub Status');
|
|
106
|
+
lines.push(` Hub: ${hubStatus}`);
|
|
107
|
+
lines.push(` Agents: ${agents.length} available`);
|
|
108
|
+
lines.push(
|
|
109
|
+
` ${agents
|
|
110
|
+
.map((a) => {
|
|
111
|
+
const model = a.model || 'default';
|
|
112
|
+
return `${a.name} [${model}]`;
|
|
113
|
+
})
|
|
114
|
+
.join(', ')}`
|
|
115
|
+
);
|
|
116
|
+
const message = lines.join('\n');
|
|
117
|
+
if (ctx.hasUI) {
|
|
118
|
+
ctx.ui.notify(message, 'info');
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Register /review command for interactive code reviews
|
|
124
|
+
pi.registerCommand('review', {
|
|
125
|
+
description: 'Launch interactive code review',
|
|
126
|
+
handler: async (args, ctx) => {
|
|
127
|
+
await handleReview(args, ctx, pi);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
log(`Registered ${agents.length} agent commands + /agents + /chain + /status + /review`);
|
|
132
|
+
}
|