@townco/ui 0.1.15 → 0.1.17

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.
Files changed (42) hide show
  1. package/dist/core/hooks/index.d.ts +1 -0
  2. package/dist/core/hooks/index.js +1 -0
  3. package/dist/core/hooks/use-chat-messages.d.ts +50 -11
  4. package/dist/core/hooks/use-chat-session.d.ts +5 -5
  5. package/dist/core/hooks/use-tool-calls.d.ts +52 -0
  6. package/dist/core/hooks/use-tool-calls.js +61 -0
  7. package/dist/core/schemas/chat.d.ts +166 -83
  8. package/dist/core/schemas/chat.js +27 -27
  9. package/dist/core/schemas/index.d.ts +1 -0
  10. package/dist/core/schemas/index.js +1 -0
  11. package/dist/core/schemas/tool-call.d.ts +174 -0
  12. package/dist/core/schemas/tool-call.js +130 -0
  13. package/dist/core/store/chat-store.d.ts +28 -28
  14. package/dist/core/store/chat-store.js +123 -59
  15. package/dist/gui/components/ChatLayout.js +11 -10
  16. package/dist/gui/components/Dialog.js +8 -84
  17. package/dist/gui/components/Label.js +2 -12
  18. package/dist/gui/components/MessageContent.js +4 -1
  19. package/dist/gui/components/Select.js +12 -118
  20. package/dist/gui/components/Tabs.js +4 -32
  21. package/dist/gui/components/ToolCall.d.ts +8 -0
  22. package/dist/gui/components/ToolCall.js +100 -0
  23. package/dist/gui/components/ToolCallList.d.ts +9 -0
  24. package/dist/gui/components/ToolCallList.js +22 -0
  25. package/dist/gui/components/index.d.ts +2 -0
  26. package/dist/gui/components/index.js +2 -0
  27. package/dist/gui/components/resizable.d.ts +7 -0
  28. package/dist/gui/components/resizable.js +7 -0
  29. package/dist/sdk/schemas/session.d.ts +390 -220
  30. package/dist/sdk/schemas/session.js +74 -29
  31. package/dist/sdk/transports/http.js +705 -472
  32. package/dist/sdk/transports/stdio.js +187 -32
  33. package/dist/tui/components/ChatView.js +19 -51
  34. package/dist/tui/components/MessageList.d.ts +2 -4
  35. package/dist/tui/components/MessageList.js +13 -37
  36. package/dist/tui/components/ToolCall.d.ts +9 -0
  37. package/dist/tui/components/ToolCall.js +41 -0
  38. package/dist/tui/components/ToolCallList.d.ts +8 -0
  39. package/dist/tui/components/ToolCallList.js +17 -0
  40. package/dist/tui/components/index.d.ts +2 -0
  41. package/dist/tui/components/index.js +2 -0
  42. package/package.json +4 -2
@@ -4,476 +4,709 @@ import * as acp from "@agentclientprotocol/sdk";
4
4
  * Uses POST /rpc for client->agent messages and GET /events (SSE) for agent->client
5
5
  */
6
6
  export class HttpTransport {
7
- connected = false;
8
- sessionUpdateCallbacks = new Set();
9
- errorCallbacks = new Set();
10
- messageQueue = [];
11
- currentSessionId = null;
12
- chunkResolvers = [];
13
- streamComplete = false;
14
- sseAbortController = null;
15
- reconnectAttempts = 0;
16
- maxReconnectAttempts = 5;
17
- reconnectDelay = 1000; // Start with 1 second
18
- reconnecting = false;
19
- abortController = null;
20
- options;
21
- constructor(options) {
22
- // Ensure baseUrl doesn't end with a slash
23
- this.options = { ...options, baseUrl: options.baseUrl.replace(/\/$/, "") };
24
- }
25
- async connect() {
26
- if (this.connected) {
27
- return;
28
- }
29
- try {
30
- this.abortController = new AbortController();
31
- // Step 1: Initialize the ACP connection
32
- const initRequest = {
33
- protocolVersion: acp.PROTOCOL_VERSION,
34
- clientCapabilities: {
35
- fs: {
36
- readTextFile: true,
37
- writeTextFile: true,
38
- },
39
- },
40
- };
41
- const initResponse = await this.sendRpcRequest("initialize", initRequest);
42
- console.log("ACP connection initialized:", initResponse);
43
- // Step 2: Create a new session
44
- const sessionRequest = {
45
- cwd: "/",
46
- mcpServers: [],
47
- };
48
- const sessionResponse = await this.sendRpcRequest(
49
- "session/new",
50
- sessionRequest,
51
- );
52
- this.currentSessionId = sessionResponse.sessionId;
53
- console.log("Session created:", this.currentSessionId);
54
- // Step 3: Open SSE connection for receiving messages
55
- await this.connectSSE();
56
- this.connected = true;
57
- this.reconnectAttempts = 0; // Reset on successful connection
58
- } catch (error) {
59
- this.connected = false;
60
- const err = error instanceof Error ? error : new Error(String(error));
61
- this.notifyError(err);
62
- throw err;
63
- }
64
- }
65
- async disconnect() {
66
- if (!this.connected) {
67
- return;
68
- }
69
- try {
70
- // Abort any ongoing requests
71
- if (this.abortController) {
72
- this.abortController.abort();
73
- this.abortController = null;
74
- }
75
- // Abort SSE connection
76
- if (this.sseAbortController) {
77
- this.sseAbortController.abort();
78
- this.sseAbortController = null;
79
- }
80
- // Clear state
81
- this.connected = false;
82
- this.currentSessionId = null;
83
- this.messageQueue = [];
84
- this.chunkResolvers = [];
85
- this.streamComplete = false;
86
- this.reconnecting = false;
87
- this.reconnectAttempts = 0;
88
- } catch (error) {
89
- const err = error instanceof Error ? error : new Error(String(error));
90
- this.notifyError(err);
91
- throw err;
92
- }
93
- }
94
- async send(message) {
95
- if (!this.connected || !this.currentSessionId) {
96
- throw new Error("Transport not connected");
97
- }
98
- try {
99
- // Reset stream state for new message
100
- this.streamComplete = false;
101
- this.messageQueue = [];
102
- // Convert our message format to ACP prompt format
103
- const textContent = message.content
104
- .filter((c) => c.type === "text")
105
- .map((c) => c.text)
106
- .join("\n");
107
- // Create ACP prompt request
108
- const promptRequest = {
109
- sessionId: this.currentSessionId,
110
- prompt: [
111
- {
112
- type: "text",
113
- text: textContent,
114
- },
115
- ],
116
- };
117
- // Send the prompt - this will trigger streaming responses via SSE
118
- const promptResponse = await this.sendRpcRequest(
119
- "session/prompt",
120
- promptRequest,
121
- );
122
- console.log("Prompt sent:", promptResponse);
123
- // Mark stream as complete after prompt finishes
124
- this.streamComplete = true;
125
- // Send completion chunk
126
- const resolver = this.chunkResolvers.shift();
127
- if (resolver) {
128
- resolver({
129
- id: this.currentSessionId || "unknown",
130
- role: "assistant",
131
- contentDelta: { type: "text", text: "" },
132
- isComplete: true,
133
- });
134
- } else {
135
- this.messageQueue.push({
136
- id: this.currentSessionId || "unknown",
137
- role: "assistant",
138
- contentDelta: { type: "text", text: "" },
139
- isComplete: true,
140
- });
141
- }
142
- } catch (error) {
143
- this.streamComplete = true;
144
- const err = error instanceof Error ? error : new Error(String(error));
145
- this.notifyError(err);
146
- throw err;
147
- }
148
- }
149
- async *receive() {
150
- // Keep yielding chunks until stream is complete
151
- while (!this.streamComplete) {
152
- // Check if there are queued messages
153
- if (this.messageQueue.length > 0) {
154
- const chunk = this.messageQueue.shift();
155
- if (chunk) {
156
- yield chunk;
157
- if (chunk.isComplete) {
158
- return;
159
- }
160
- }
161
- } else {
162
- // Wait for next chunk to arrive
163
- const chunk = await new Promise((resolve) => {
164
- this.chunkResolvers.push(resolve);
165
- });
166
- if (chunk.isComplete) {
167
- yield chunk;
168
- return;
169
- } else {
170
- yield chunk;
171
- }
172
- }
173
- }
174
- // Yield any remaining queued messages
175
- while (this.messageQueue.length > 0) {
176
- const chunk = this.messageQueue.shift();
177
- if (chunk) {
178
- yield chunk;
179
- }
180
- }
181
- // Mark the stream as complete
182
- yield {
183
- id: this.currentSessionId || "unknown",
184
- role: "assistant",
185
- contentDelta: { type: "text", text: "" },
186
- isComplete: true,
187
- };
188
- }
189
- isConnected() {
190
- return this.connected;
191
- }
192
- onSessionUpdate(callback) {
193
- this.sessionUpdateCallbacks.add(callback);
194
- return () => {
195
- this.sessionUpdateCallbacks.delete(callback);
196
- };
197
- }
198
- onError(callback) {
199
- this.errorCallbacks.add(callback);
200
- return () => {
201
- this.errorCallbacks.delete(callback);
202
- };
203
- }
204
- /**
205
- * Send an ACP RPC request to the server
206
- */
207
- async sendRpcRequest(method, params) {
208
- const requestId = this.generateRequestId();
209
- // Construct ACP request message
210
- const request = {
211
- jsonrpc: "2.0",
212
- id: requestId,
213
- method,
214
- params,
215
- };
216
- const headers = {
217
- "Content-Type": "application/json",
218
- ...this.options.headers,
219
- };
220
- const timeout = this.options.timeout || 30000;
221
- const controller = new AbortController();
222
- const timeoutId = setTimeout(() => controller.abort(), timeout);
223
- try {
224
- const response = await fetch(`${this.options.baseUrl}/rpc`, {
225
- method: "POST",
226
- headers,
227
- body: JSON.stringify(request),
228
- signal: controller.signal,
229
- });
230
- clearTimeout(timeoutId);
231
- if (!response.ok) {
232
- const errorText = await response.text();
233
- throw new Error(`HTTP ${response.status}: ${errorText}`);
234
- }
235
- const result = await response.json();
236
- // Check for JSON-RPC error
237
- if (result.error) {
238
- throw new Error(
239
- `ACP error: ${result.error.message || JSON.stringify(result.error)}`,
240
- );
241
- }
242
- return result.result || result;
243
- } catch (error) {
244
- clearTimeout(timeoutId);
245
- if (error instanceof Error && error.name === "AbortError") {
246
- throw new Error(`Request timeout after ${timeout}ms`);
247
- }
248
- throw error;
249
- }
250
- }
251
- /**
252
- * Connect to the SSE event stream
253
- * Uses fetch-based SSE to support custom headers (X-Session-ID)
254
- */
255
- async connectSSE() {
256
- if (!this.currentSessionId) {
257
- throw new Error("Cannot connect SSE without a session ID");
258
- }
259
- const url = `${this.options.baseUrl}/events`;
260
- const headers = {
261
- "X-Session-ID": this.currentSessionId,
262
- ...this.options.headers,
263
- };
264
- // Create a new abort controller for this SSE connection
265
- this.sseAbortController = new AbortController();
266
- try {
267
- const response = await fetch(url, {
268
- method: "GET",
269
- headers,
270
- signal: this.sseAbortController.signal,
271
- });
272
- if (!response.ok) {
273
- throw new Error(`SSE connection failed: HTTP ${response.status}`);
274
- }
275
- if (!response.body) {
276
- throw new Error("Response body is null");
277
- }
278
- console.log("SSE connection opened");
279
- this.reconnectAttempts = 0;
280
- this.reconnectDelay = 1000;
281
- // Read the SSE stream
282
- const reader = response.body.getReader();
283
- const decoder = new TextDecoder();
284
- let buffer = "";
285
- // Process the stream in the background
286
- (async () => {
287
- try {
288
- while (true) {
289
- const { done, value } = await reader.read();
290
- if (done) {
291
- console.log("SSE stream closed by server");
292
- if (this.connected) {
293
- await this.handleSSEDisconnect();
294
- }
295
- break;
296
- }
297
- // Decode the chunk and add to buffer
298
- buffer += decoder.decode(value, { stream: true });
299
- // Process complete SSE messages
300
- const lines = buffer.split("\n");
301
- buffer = lines.pop() || ""; // Keep incomplete line in buffer
302
- let currentEvent = { event: "message", data: "" };
303
- for (const line of lines) {
304
- if (line.startsWith("event:")) {
305
- currentEvent.event = line.substring(6).trim();
306
- } else if (line.startsWith("data:")) {
307
- currentEvent.data = line.substring(5).trim();
308
- } else if (line === "") {
309
- // Empty line signals end of event
310
- if (currentEvent.event === "message" && currentEvent.data) {
311
- this.handleSSEMessage(currentEvent.data);
312
- }
313
- // Reset for next event
314
- currentEvent = { event: "message", data: "" };
315
- }
316
- }
317
- }
318
- } catch (error) {
319
- if (error instanceof Error && error.name === "AbortError") {
320
- console.log("SSE stream aborted");
321
- return;
322
- }
323
- console.error("Error reading SSE stream:", error);
324
- if (this.connected && !this.reconnecting) {
325
- await this.handleSSEDisconnect();
326
- }
327
- }
328
- })();
329
- } catch (error) {
330
- console.error("SSE connection error:", error);
331
- throw error;
332
- }
333
- }
334
- /**
335
- * Handle SSE disconnection with automatic reconnection
336
- */
337
- async handleSSEDisconnect() {
338
- if (this.reconnecting || !this.connected) {
339
- return;
340
- }
341
- this.reconnecting = true;
342
- // Abort the current SSE connection
343
- if (this.sseAbortController) {
344
- this.sseAbortController.abort();
345
- this.sseAbortController = null;
346
- }
347
- if (this.reconnectAttempts >= this.maxReconnectAttempts) {
348
- const error = new Error(
349
- `SSE reconnection failed after ${this.maxReconnectAttempts} attempts`,
350
- );
351
- this.notifyError(error);
352
- this.connected = false;
353
- this.reconnecting = false;
354
- return;
355
- }
356
- this.reconnectAttempts++;
357
- const delay = Math.min(
358
- this.reconnectDelay * 2 ** (this.reconnectAttempts - 1),
359
- 32000,
360
- );
361
- console.log(
362
- `SSE reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
363
- );
364
- await new Promise((resolve) => setTimeout(resolve, delay));
365
- try {
366
- await this.connectSSE();
367
- console.log("SSE reconnected successfully");
368
- this.reconnecting = false;
369
- } catch (error) {
370
- console.error("SSE reconnection failed:", error);
371
- this.reconnecting = false;
372
- // Will try again on next error event
373
- }
374
- }
375
- /**
376
- * Handle an incoming SSE message
377
- */
378
- handleSSEMessage(data) {
379
- try {
380
- const message = JSON.parse(data);
381
- // Validate the message is an ACP agent outgoing message
382
- const parseResult = acp.agentOutgoingMessageSchema.safeParse(message);
383
- if (!parseResult.success) {
384
- console.error(
385
- "Invalid ACP message from SSE:",
386
- parseResult.error.issues,
387
- );
388
- return;
389
- }
390
- const acpMessage = parseResult.data;
391
- // Check if this is a notification (has method but not a response)
392
- if ("method" in acpMessage && acpMessage.method === "session/update") {
393
- // Type narrowing: we know it has method and params
394
- if ("params" in acpMessage && acpMessage.params) {
395
- this.handleSessionNotification(acpMessage.params);
396
- }
397
- }
398
- } catch (error) {
399
- console.error("Error parsing SSE message:", error);
400
- this.notifyError(
401
- error instanceof Error ? error : new Error(String(error)),
402
- );
403
- }
404
- }
405
- /**
406
- * Handle a session notification from the agent
407
- */
408
- handleSessionNotification(params) {
409
- // Extract content from the update
410
- const paramsExtended = params;
411
- const update = paramsExtended.update;
412
- const sessionUpdate = {
413
- sessionId: this.currentSessionId || params.sessionId,
414
- status: "active",
415
- };
416
- // Queue message chunks if present
417
- if (update?.content) {
418
- const content = update.content;
419
- let chunk = null;
420
- if (content.type === "text" && content.text) {
421
- chunk = {
422
- id: params.sessionId,
423
- role: "assistant",
424
- contentDelta: { type: "text", text: content.text },
425
- isComplete: false,
426
- };
427
- } else if (content.type === "tool_call") {
428
- chunk = {
429
- id: params.sessionId,
430
- role: "assistant",
431
- // biome-ignore lint/suspicious/noExplicitAny: hopefully assume that the server is sending the right stuff
432
- contentDelta: content,
433
- isComplete: false,
434
- };
435
- }
436
- if (chunk) {
437
- // Resolve any waiting receive() calls immediately
438
- const resolver = this.chunkResolvers.shift();
439
- if (resolver) {
440
- resolver(chunk);
441
- } else {
442
- // Only queue if no resolver is waiting
443
- this.messageQueue.push(chunk);
444
- }
445
- }
446
- }
447
- this.notifySessionUpdate(sessionUpdate);
448
- }
449
- /**
450
- * Generate a unique request ID
451
- */
452
- generateRequestId() {
453
- return `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
454
- }
455
- /**
456
- * Notify all session update callbacks
457
- */
458
- notifySessionUpdate(update) {
459
- for (const callback of this.sessionUpdateCallbacks) {
460
- try {
461
- callback(update);
462
- } catch (error) {
463
- console.error("Error in session update callback:", error);
464
- }
465
- }
466
- }
467
- /**
468
- * Notify all error callbacks
469
- */
470
- notifyError(error) {
471
- for (const callback of this.errorCallbacks) {
472
- try {
473
- callback(error);
474
- } catch (err) {
475
- console.error("Error in error callback:", err);
476
- }
477
- }
478
- }
7
+ connected = false;
8
+ sessionUpdateCallbacks = new Set();
9
+ errorCallbacks = new Set();
10
+ messageQueue = [];
11
+ currentSessionId = null;
12
+ chunkResolvers = [];
13
+ streamComplete = false;
14
+ sseAbortController = null;
15
+ reconnectAttempts = 0;
16
+ maxReconnectAttempts = 5;
17
+ reconnectDelay = 1000; // Start with 1 second
18
+ reconnecting = false;
19
+ abortController = null;
20
+ options;
21
+ constructor(options) {
22
+ // Ensure baseUrl doesn't end with a slash
23
+ this.options = { ...options, baseUrl: options.baseUrl.replace(/\/$/, "") };
24
+ }
25
+ async connect() {
26
+ if (this.connected) {
27
+ return;
28
+ }
29
+ try {
30
+ this.abortController = new AbortController();
31
+ // Step 1: Initialize the ACP connection
32
+ const initRequest = {
33
+ protocolVersion: acp.PROTOCOL_VERSION,
34
+ clientCapabilities: {
35
+ fs: {
36
+ readTextFile: true,
37
+ writeTextFile: true,
38
+ },
39
+ },
40
+ };
41
+ const initResponse = await this.sendRpcRequest("initialize", initRequest);
42
+ console.log("ACP connection initialized:", initResponse);
43
+ // Step 2: Create a new session
44
+ const sessionRequest = {
45
+ cwd: "/",
46
+ mcpServers: [],
47
+ };
48
+ const sessionResponse = await this.sendRpcRequest("session/new", sessionRequest);
49
+ this.currentSessionId = sessionResponse.sessionId;
50
+ console.log("Session created:", this.currentSessionId);
51
+ // Step 3: Open SSE connection for receiving messages
52
+ await this.connectSSE();
53
+ this.connected = true;
54
+ this.reconnectAttempts = 0; // Reset on successful connection
55
+ }
56
+ catch (error) {
57
+ this.connected = false;
58
+ const err = error instanceof Error ? error : new Error(String(error));
59
+ this.notifyError(err);
60
+ throw err;
61
+ }
62
+ }
63
+ async disconnect() {
64
+ if (!this.connected) {
65
+ return;
66
+ }
67
+ try {
68
+ // Abort any ongoing requests
69
+ if (this.abortController) {
70
+ this.abortController.abort();
71
+ this.abortController = null;
72
+ }
73
+ // Abort SSE connection
74
+ if (this.sseAbortController) {
75
+ this.sseAbortController.abort();
76
+ this.sseAbortController = null;
77
+ }
78
+ // Clear state
79
+ this.connected = false;
80
+ this.currentSessionId = null;
81
+ this.messageQueue = [];
82
+ this.chunkResolvers = [];
83
+ this.streamComplete = false;
84
+ this.reconnecting = false;
85
+ this.reconnectAttempts = 0;
86
+ }
87
+ catch (error) {
88
+ const err = error instanceof Error ? error : new Error(String(error));
89
+ this.notifyError(err);
90
+ throw err;
91
+ }
92
+ }
93
+ async send(message) {
94
+ if (!this.connected || !this.currentSessionId) {
95
+ throw new Error("Transport not connected");
96
+ }
97
+ try {
98
+ // Reset stream state for new message
99
+ this.streamComplete = false;
100
+ this.messageQueue = [];
101
+ // Convert our message format to ACP prompt format
102
+ const textContent = message.content
103
+ .filter((c) => c.type === "text")
104
+ .map((c) => c.text)
105
+ .join("\n");
106
+ // Create ACP prompt request
107
+ const promptRequest = {
108
+ sessionId: this.currentSessionId,
109
+ prompt: [
110
+ {
111
+ type: "text",
112
+ text: textContent,
113
+ },
114
+ ],
115
+ };
116
+ // Send the prompt - this will trigger streaming responses via SSE
117
+ const promptResponse = await this.sendRpcRequest("session/prompt", promptRequest);
118
+ console.log("Prompt sent:", promptResponse);
119
+ // Mark stream as complete after prompt finishes
120
+ this.streamComplete = true;
121
+ // Send completion chunk
122
+ const resolver = this.chunkResolvers.shift();
123
+ if (resolver) {
124
+ resolver({
125
+ id: this.currentSessionId || "unknown",
126
+ role: "assistant",
127
+ contentDelta: { type: "text", text: "" },
128
+ isComplete: true,
129
+ });
130
+ }
131
+ else {
132
+ this.messageQueue.push({
133
+ id: this.currentSessionId || "unknown",
134
+ role: "assistant",
135
+ contentDelta: { type: "text", text: "" },
136
+ isComplete: true,
137
+ });
138
+ }
139
+ }
140
+ catch (error) {
141
+ this.streamComplete = true;
142
+ const err = error instanceof Error ? error : new Error(String(error));
143
+ this.notifyError(err);
144
+ throw err;
145
+ }
146
+ }
147
+ async *receive() {
148
+ // Keep yielding chunks until stream is complete
149
+ while (!this.streamComplete) {
150
+ // Check if there are queued messages
151
+ if (this.messageQueue.length > 0) {
152
+ const chunk = this.messageQueue.shift();
153
+ if (chunk) {
154
+ yield chunk;
155
+ if (chunk.isComplete) {
156
+ return;
157
+ }
158
+ }
159
+ }
160
+ else {
161
+ // Wait for next chunk to arrive
162
+ const chunk = await new Promise((resolve) => {
163
+ this.chunkResolvers.push(resolve);
164
+ });
165
+ if (chunk.isComplete) {
166
+ yield chunk;
167
+ return;
168
+ }
169
+ else {
170
+ yield chunk;
171
+ }
172
+ }
173
+ }
174
+ // Yield any remaining queued messages
175
+ while (this.messageQueue.length > 0) {
176
+ const chunk = this.messageQueue.shift();
177
+ if (chunk) {
178
+ yield chunk;
179
+ }
180
+ }
181
+ // Mark the stream as complete
182
+ yield {
183
+ id: this.currentSessionId || "unknown",
184
+ role: "assistant",
185
+ contentDelta: { type: "text", text: "" },
186
+ isComplete: true,
187
+ };
188
+ }
189
+ isConnected() {
190
+ return this.connected;
191
+ }
192
+ onSessionUpdate(callback) {
193
+ this.sessionUpdateCallbacks.add(callback);
194
+ return () => {
195
+ this.sessionUpdateCallbacks.delete(callback);
196
+ };
197
+ }
198
+ onError(callback) {
199
+ this.errorCallbacks.add(callback);
200
+ return () => {
201
+ this.errorCallbacks.delete(callback);
202
+ };
203
+ }
204
+ /**
205
+ * Send an ACP RPC request to the server
206
+ */
207
+ async sendRpcRequest(method, params) {
208
+ const requestId = this.generateRequestId();
209
+ // Construct ACP request message
210
+ const request = {
211
+ jsonrpc: "2.0",
212
+ id: requestId,
213
+ method,
214
+ params,
215
+ };
216
+ const headers = {
217
+ "Content-Type": "application/json",
218
+ ...this.options.headers,
219
+ };
220
+ const timeout = this.options.timeout || 30000;
221
+ const controller = new AbortController();
222
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
223
+ try {
224
+ const response = await fetch(`${this.options.baseUrl}/rpc`, {
225
+ method: "POST",
226
+ headers,
227
+ body: JSON.stringify(request),
228
+ signal: controller.signal,
229
+ });
230
+ clearTimeout(timeoutId);
231
+ if (!response.ok) {
232
+ const errorText = await response.text();
233
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
234
+ }
235
+ const result = await response.json();
236
+ // Check for JSON-RPC error
237
+ if (result.error) {
238
+ throw new Error(`ACP error: ${result.error.message || JSON.stringify(result.error)}`);
239
+ }
240
+ return result.result || result;
241
+ }
242
+ catch (error) {
243
+ clearTimeout(timeoutId);
244
+ if (error instanceof Error && error.name === "AbortError") {
245
+ throw new Error(`Request timeout after ${timeout}ms`);
246
+ }
247
+ throw error;
248
+ }
249
+ }
250
+ /**
251
+ * Connect to the SSE event stream
252
+ * Uses fetch-based SSE to support custom headers (X-Session-ID)
253
+ */
254
+ async connectSSE() {
255
+ if (!this.currentSessionId) {
256
+ throw new Error("Cannot connect SSE without a session ID");
257
+ }
258
+ const url = `${this.options.baseUrl}/events`;
259
+ const headers = {
260
+ "X-Session-ID": this.currentSessionId,
261
+ ...this.options.headers,
262
+ };
263
+ // Create a new abort controller for this SSE connection
264
+ this.sseAbortController = new AbortController();
265
+ try {
266
+ const response = await fetch(url, {
267
+ method: "GET",
268
+ headers,
269
+ signal: this.sseAbortController.signal,
270
+ });
271
+ if (!response.ok) {
272
+ throw new Error(`SSE connection failed: HTTP ${response.status}`);
273
+ }
274
+ if (!response.body) {
275
+ throw new Error("Response body is null");
276
+ }
277
+ console.log("SSE connection opened");
278
+ this.reconnectAttempts = 0;
279
+ this.reconnectDelay = 1000;
280
+ // Read the SSE stream
281
+ const reader = response.body.getReader();
282
+ const decoder = new TextDecoder();
283
+ let buffer = "";
284
+ // Process the stream in the background
285
+ (async () => {
286
+ try {
287
+ while (true) {
288
+ const { done, value } = await reader.read();
289
+ if (done) {
290
+ console.log("SSE stream closed by server");
291
+ if (this.connected) {
292
+ await this.handleSSEDisconnect();
293
+ }
294
+ break;
295
+ }
296
+ // Decode the chunk and add to buffer
297
+ buffer += decoder.decode(value, { stream: true });
298
+ // Process complete SSE messages
299
+ const lines = buffer.split("\n");
300
+ buffer = lines.pop() || ""; // Keep incomplete line in buffer
301
+ let currentEvent = { event: "message", data: "" };
302
+ for (const line of lines) {
303
+ if (line.startsWith("event:")) {
304
+ currentEvent.event = line.substring(6).trim();
305
+ }
306
+ else if (line.startsWith("data:")) {
307
+ currentEvent.data = line.substring(5).trim();
308
+ }
309
+ else if (line === "") {
310
+ // Empty line signals end of event
311
+ if (currentEvent.event === "message" && currentEvent.data) {
312
+ this.handleSSEMessage(currentEvent.data);
313
+ }
314
+ // Reset for next event
315
+ currentEvent = { event: "message", data: "" };
316
+ }
317
+ }
318
+ }
319
+ }
320
+ catch (error) {
321
+ if (error instanceof Error && error.name === "AbortError") {
322
+ console.log("SSE stream aborted");
323
+ return;
324
+ }
325
+ console.error("Error reading SSE stream:", error);
326
+ if (this.connected && !this.reconnecting) {
327
+ await this.handleSSEDisconnect();
328
+ }
329
+ }
330
+ })();
331
+ }
332
+ catch (error) {
333
+ console.error("SSE connection error:", error);
334
+ throw error;
335
+ }
336
+ }
337
+ /**
338
+ * Handle SSE disconnection with automatic reconnection
339
+ */
340
+ async handleSSEDisconnect() {
341
+ if (this.reconnecting || !this.connected) {
342
+ return;
343
+ }
344
+ this.reconnecting = true;
345
+ // Abort the current SSE connection
346
+ if (this.sseAbortController) {
347
+ this.sseAbortController.abort();
348
+ this.sseAbortController = null;
349
+ }
350
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
351
+ const error = new Error(`SSE reconnection failed after ${this.maxReconnectAttempts} attempts`);
352
+ this.notifyError(error);
353
+ this.connected = false;
354
+ this.reconnecting = false;
355
+ return;
356
+ }
357
+ this.reconnectAttempts++;
358
+ const delay = Math.min(this.reconnectDelay * 2 ** (this.reconnectAttempts - 1), 32000);
359
+ console.log(`SSE reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
360
+ await new Promise((resolve) => setTimeout(resolve, delay));
361
+ try {
362
+ await this.connectSSE();
363
+ console.log("SSE reconnected successfully");
364
+ this.reconnecting = false;
365
+ }
366
+ catch (error) {
367
+ console.error("SSE reconnection failed:", error);
368
+ this.reconnecting = false;
369
+ // Will try again on next error event
370
+ }
371
+ }
372
+ /**
373
+ * Handle an incoming SSE message
374
+ */
375
+ handleSSEMessage(data) {
376
+ try {
377
+ const message = JSON.parse(data);
378
+ console.log("[HTTP Transport] Received SSE message:", message);
379
+ // Validate the message is an ACP agent outgoing message
380
+ const parseResult = acp.agentOutgoingMessageSchema.safeParse(message);
381
+ if (!parseResult.success) {
382
+ console.error("Invalid ACP message from SSE:", parseResult.error.issues);
383
+ return;
384
+ }
385
+ const acpMessage = parseResult.data;
386
+ console.log("[HTTP Transport] Parsed ACP message, method:", "method" in acpMessage ? acpMessage.method : "(no method)");
387
+ // Check if this is a notification (has method but not a response)
388
+ if ("method" in acpMessage && acpMessage.method === "session/update") {
389
+ console.log("[HTTP Transport] This is a session/update notification");
390
+ // Type narrowing: we know it has method and params
391
+ if ("params" in acpMessage && acpMessage.params) {
392
+ console.log("[HTTP Transport] Calling handleSessionNotification with params:", acpMessage.params);
393
+ this.handleSessionNotification(acpMessage.params);
394
+ }
395
+ }
396
+ }
397
+ catch (error) {
398
+ console.error("Error parsing SSE message:", error);
399
+ this.notifyError(error instanceof Error ? error : new Error(String(error)));
400
+ }
401
+ }
402
+ /**
403
+ * Handle a session notification from the agent
404
+ */
405
+ handleSessionNotification(params) {
406
+ console.log("[HTTP Transport] handleSessionNotification called with:", params);
407
+ // Extract content from the update
408
+ const paramsExtended = params;
409
+ const update = paramsExtended.update;
410
+ const sessionId = this.currentSessionId || params.sessionId;
411
+ console.log("[HTTP Transport] update.sessionUpdate type:", update?.sessionUpdate);
412
+ // Handle ACP tool call notifications
413
+ if (update?.sessionUpdate === "tool_call") {
414
+ console.log("[HTTP Transport] tool_call - tokenUsage:", update.tokenUsage);
415
+ // Extract messageId from _meta
416
+ const messageId = update._meta &&
417
+ typeof update._meta === "object" &&
418
+ "messageId" in update._meta
419
+ ? String(update._meta.messageId)
420
+ : undefined;
421
+ // Initial tool call notification
422
+ const toolCall = {
423
+ id: update.toolCallId ?? "",
424
+ title: update.title ?? "",
425
+ kind: update.kind || "other",
426
+ status: update.status || "pending",
427
+ locations: update.locations,
428
+ rawInput: update.rawInput,
429
+ tokenUsage: update.tokenUsage,
430
+ content: update.content?.map((c) => {
431
+ // Type guard to safely check properties
432
+ if (typeof c !== "object" || c === null) {
433
+ return { type: "text", text: "" };
434
+ }
435
+ const content = c;
436
+ // Handle ACP nested content format
437
+ if (content.type === "content" &&
438
+ typeof content.content === "object" &&
439
+ content.content !== null) {
440
+ const innerContent = content.content;
441
+ if (innerContent.type === "text") {
442
+ return {
443
+ type: "content",
444
+ content: {
445
+ type: "text",
446
+ text: typeof innerContent.text === "string"
447
+ ? innerContent.text
448
+ : "",
449
+ },
450
+ };
451
+ }
452
+ }
453
+ // Handle legacy direct formats
454
+ if (content.type === "text")
455
+ return {
456
+ type: "text",
457
+ text: typeof content.text === "string" ? content.text : "",
458
+ };
459
+ if (content.type === "diff")
460
+ return {
461
+ type: "diff",
462
+ path: typeof content.path === "string" ? content.path : "",
463
+ oldText: typeof content.oldText === "string" ? content.oldText : "",
464
+ newText: typeof content.newText === "string" ? content.newText : "",
465
+ line: typeof content.line === "number" ? content.line : null,
466
+ };
467
+ if (content.type === "terminal")
468
+ return {
469
+ type: "terminal",
470
+ terminalId: typeof content.terminalId === "string"
471
+ ? content.terminalId
472
+ : "",
473
+ };
474
+ return { type: "text", text: "" };
475
+ }),
476
+ startedAt: Date.now(),
477
+ };
478
+ const sessionUpdate = {
479
+ type: "tool_call",
480
+ sessionId,
481
+ status: "active",
482
+ toolCall: toolCall,
483
+ messageId,
484
+ };
485
+ this.notifySessionUpdate(sessionUpdate);
486
+ }
487
+ else if (update?.sessionUpdate === "tool_call_update") {
488
+ // Extract messageId from _meta
489
+ const messageId = update._meta &&
490
+ typeof update._meta === "object" &&
491
+ "messageId" in update._meta
492
+ ? String(update._meta.messageId)
493
+ : undefined;
494
+ // Tool call update notification
495
+ const toolCallUpdate = {
496
+ id: update.toolCallId ?? "",
497
+ status: update.status,
498
+ locations: update.locations,
499
+ rawOutput: update.rawOutput,
500
+ tokenUsage: update.tokenUsage,
501
+ content: update.content?.map((c) => {
502
+ // Type guard to safely check properties
503
+ if (typeof c !== "object" || c === null) {
504
+ return { type: "text", text: "" };
505
+ }
506
+ const content = c;
507
+ // Handle ACP nested content format
508
+ if (content.type === "content" &&
509
+ typeof content.content === "object" &&
510
+ content.content !== null) {
511
+ const innerContent = content.content;
512
+ if (innerContent.type === "text") {
513
+ return {
514
+ type: "content",
515
+ content: {
516
+ type: "text",
517
+ text: typeof innerContent.text === "string"
518
+ ? innerContent.text
519
+ : "",
520
+ },
521
+ };
522
+ }
523
+ }
524
+ // Handle legacy direct formats
525
+ if (content.type === "text")
526
+ return {
527
+ type: "text",
528
+ text: typeof content.text === "string" ? content.text : "",
529
+ };
530
+ if (content.type === "diff")
531
+ return {
532
+ type: "diff",
533
+ path: typeof content.path === "string" ? content.path : "",
534
+ oldText: typeof content.oldText === "string" ? content.oldText : "",
535
+ newText: typeof content.newText === "string" ? content.newText : "",
536
+ line: typeof content.line === "number" ? content.line : null,
537
+ };
538
+ if (content.type === "terminal")
539
+ return {
540
+ type: "terminal",
541
+ terminalId: typeof content.terminalId === "string"
542
+ ? content.terminalId
543
+ : "",
544
+ };
545
+ return { type: "text", text: "" };
546
+ }),
547
+ error: update.error,
548
+ completedAt: update.status === "completed" || update.status === "failed"
549
+ ? Date.now()
550
+ : undefined,
551
+ };
552
+ const sessionUpdate = {
553
+ type: "tool_call_update",
554
+ sessionId,
555
+ status: "active",
556
+ toolCallUpdate: toolCallUpdate,
557
+ messageId,
558
+ };
559
+ console.log("[HTTP Transport] Notifying tool_call_update session update:", sessionUpdate);
560
+ this.notifySessionUpdate(sessionUpdate);
561
+ }
562
+ else if (update &&
563
+ "sessionUpdate" in update &&
564
+ update.sessionUpdate === "tool_output") {
565
+ // Tool output notification (sent separately from tool_call_update)
566
+ // Type guard for tool_output extension
567
+ const toolOutputUpdate = update;
568
+ // Extract messageId from _meta
569
+ const messageId = toolOutputUpdate._meta &&
570
+ typeof toolOutputUpdate._meta === "object" &&
571
+ "messageId" in toolOutputUpdate._meta
572
+ ? String(toolOutputUpdate._meta.messageId)
573
+ : undefined;
574
+ const toolOutput = {
575
+ id: toolOutputUpdate.toolCallId ?? "",
576
+ rawOutput: toolOutputUpdate.rawOutput,
577
+ content: toolOutputUpdate.content?.map((c) => {
578
+ // Type guard to safely check properties
579
+ if (typeof c !== "object" || c === null) {
580
+ return { type: "text", text: "" };
581
+ }
582
+ const content = c;
583
+ // Handle ACP nested content format
584
+ if (content.type === "content" &&
585
+ typeof content.content === "object" &&
586
+ content.content !== null) {
587
+ const innerContent = content.content;
588
+ if (innerContent.type === "text") {
589
+ return {
590
+ type: "content",
591
+ content: {
592
+ type: "text",
593
+ text: typeof innerContent.text === "string"
594
+ ? innerContent.text
595
+ : "",
596
+ },
597
+ };
598
+ }
599
+ }
600
+ // Handle legacy direct formats
601
+ if (content.type === "text")
602
+ return {
603
+ type: "text",
604
+ text: typeof content.text === "string" ? content.text : "",
605
+ };
606
+ if (content.type === "diff")
607
+ return {
608
+ type: "diff",
609
+ path: typeof content.path === "string" ? content.path : "",
610
+ oldText: typeof content.oldText === "string" ? content.oldText : "",
611
+ newText: typeof content.newText === "string" ? content.newText : "",
612
+ line: typeof content.line === "number" ? content.line : null,
613
+ };
614
+ if (content.type === "terminal")
615
+ return {
616
+ type: "terminal",
617
+ terminalId: typeof content.terminalId === "string"
618
+ ? content.terminalId
619
+ : "",
620
+ };
621
+ return { type: "text", text: "" };
622
+ }),
623
+ };
624
+ // Emit as a tool_call_update so it merges with existing tool call
625
+ const sessionUpdate = {
626
+ type: "tool_call_update",
627
+ sessionId,
628
+ status: "active",
629
+ toolCallUpdate: toolOutput,
630
+ messageId,
631
+ };
632
+ console.log("[HTTP Transport] Notifying tool_output as tool_call_update:", sessionUpdate);
633
+ this.notifySessionUpdate(sessionUpdate);
634
+ }
635
+ else if (update?.sessionUpdate === "agent_message_chunk") {
636
+ // Handle agent message chunks
637
+ const sessionUpdate = {
638
+ type: "generic",
639
+ sessionId,
640
+ status: "active",
641
+ };
642
+ // Queue message chunks if present
643
+ // For agent_message_chunk, content is an object, not an array
644
+ const content = update.content;
645
+ if (content && typeof content === "object") {
646
+ const contentObj = content;
647
+ let chunk = null;
648
+ if (contentObj.type === "text" && typeof contentObj.text === "string") {
649
+ chunk = {
650
+ id: params.sessionId,
651
+ role: "assistant",
652
+ contentDelta: { type: "text", text: contentObj.text },
653
+ isComplete: false,
654
+ };
655
+ }
656
+ if (chunk) {
657
+ // Resolve any waiting receive() calls immediately
658
+ const resolver = this.chunkResolvers.shift();
659
+ if (resolver) {
660
+ resolver(chunk);
661
+ }
662
+ else {
663
+ // Only queue if no resolver is waiting
664
+ this.messageQueue.push(chunk);
665
+ }
666
+ }
667
+ }
668
+ this.notifySessionUpdate(sessionUpdate);
669
+ }
670
+ else {
671
+ // Handle other session updates
672
+ const sessionUpdate = {
673
+ type: "generic",
674
+ sessionId,
675
+ status: "active",
676
+ };
677
+ this.notifySessionUpdate(sessionUpdate);
678
+ }
679
+ }
680
+ /**
681
+ * Generate a unique request ID
682
+ */
683
+ generateRequestId() {
684
+ return `req-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
685
+ }
686
+ /**
687
+ * Notify all session update callbacks
688
+ */
689
+ notifySessionUpdate(update) {
690
+ for (const callback of this.sessionUpdateCallbacks) {
691
+ try {
692
+ callback(update);
693
+ }
694
+ catch (error) {
695
+ console.error("Error in session update callback:", error);
696
+ }
697
+ }
698
+ }
699
+ /**
700
+ * Notify all error callbacks
701
+ */
702
+ notifyError(error) {
703
+ for (const callback of this.errorCallbacks) {
704
+ try {
705
+ callback(error);
706
+ }
707
+ catch (err) {
708
+ console.error("Error in error callback:", err);
709
+ }
710
+ }
711
+ }
479
712
  }