agent-gear 1.0.4 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acp/bridge.d.ts +3 -0
- package/dist/acp/bridge.d.ts.map +1 -1
- package/dist/acp/bridge.js +41 -9
- package/dist/acp/bridge.js.map +1 -1
- package/dist/acp/bridge.test.d.ts +2 -0
- package/dist/acp/bridge.test.d.ts.map +1 -0
- package/dist/acp/bridge.test.js +149 -0
- package/dist/acp/bridge.test.js.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -1
- package/dist/port-forward.d.ts +10 -0
- package/dist/port-forward.d.ts.map +1 -0
- package/dist/port-forward.js +68 -0
- package/dist/port-forward.js.map +1 -0
- package/dist/port-forward.test.d.ts +2 -0
- package/dist/port-forward.test.d.ts.map +1 -0
- package/dist/port-forward.test.js +163 -0
- package/dist/port-forward.test.js.map +1 -0
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +42 -3
- package/dist/socketio-client.js.map +1 -1
- package/package.json +1 -1
- package/src/acp/bridge.test.ts +196 -0
- package/src/acp/bridge.ts +51 -9
- package/src/config.ts +18 -0
- package/src/port-forward.test.ts +202 -0
- package/src/port-forward.ts +88 -0
- package/src/socketio-client.ts +53 -3
- package/tests/acp-bridge.test.ts +97 -9
- package/tests/socketio-client.test.ts +7 -2
package/src/acp/bridge.ts
CHANGED
|
@@ -38,6 +38,11 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
|
|
|
38
38
|
private textChunkBuffer = '';
|
|
39
39
|
private textChunkTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
40
|
private initializing = false;
|
|
41
|
+
private stopped = false;
|
|
42
|
+
private pendingPermissions = new Map<string, {
|
|
43
|
+
resolve: (response: acp.RequestPermissionResponse) => void;
|
|
44
|
+
timeout: NodeJS.Timeout;
|
|
45
|
+
}>();
|
|
41
46
|
|
|
42
47
|
constructor(socket: Socket, options: AcpBridgeOptions, spawner?: AcpSpawner) {
|
|
43
48
|
super();
|
|
@@ -173,19 +178,29 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
|
|
|
173
178
|
}
|
|
174
179
|
|
|
175
180
|
private async processPrompt(message: QueuedInput): Promise<void> {
|
|
176
|
-
if (!this.connection || !this.acpSessionId) return;
|
|
181
|
+
if (!this.connection || !this.acpSessionId || this.stopped) return;
|
|
177
182
|
|
|
178
183
|
this.activePrompt = this.connection.prompt({
|
|
179
184
|
sessionId: this.acpSessionId,
|
|
180
185
|
prompt: [{ type: 'text' as const, text: message.content }],
|
|
181
186
|
});
|
|
182
187
|
|
|
188
|
+
this.socket.emit('acp:prompt:start', { sessionId: this.sessionId });
|
|
189
|
+
|
|
190
|
+
let stopReason = 'end_turn';
|
|
183
191
|
try {
|
|
184
|
-
await this.activePrompt;
|
|
192
|
+
const result = await this.activePrompt;
|
|
193
|
+
stopReason = result?.stopReason ?? 'end_turn';
|
|
185
194
|
} catch {
|
|
186
|
-
|
|
195
|
+
stopReason = 'error';
|
|
187
196
|
} finally {
|
|
188
197
|
this.activePrompt = null;
|
|
198
|
+
if (!this.stopped) {
|
|
199
|
+
this.socket.emit('acp:prompt:complete', {
|
|
200
|
+
sessionId: this.sessionId,
|
|
201
|
+
stopReason,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
189
204
|
if (this.inputQueue.length > 0) {
|
|
190
205
|
this.processPrompt(this.inputQueue.shift()!);
|
|
191
206
|
}
|
|
@@ -267,12 +282,37 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
|
|
|
267
282
|
async requestPermission(
|
|
268
283
|
params: acp.RequestPermissionRequest,
|
|
269
284
|
): Promise<acp.RequestPermissionResponse> {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
285
|
+
const requestId = `perm_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
286
|
+
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
const timeout = setTimeout(() => {
|
|
289
|
+
this.pendingPermissions.delete(requestId);
|
|
290
|
+
resolve({ outcome: { outcome: 'cancelled' } });
|
|
291
|
+
}, 65_000);
|
|
292
|
+
|
|
293
|
+
this.pendingPermissions.set(requestId, { resolve, timeout });
|
|
294
|
+
|
|
295
|
+
this.socket.emit('acp:permission', {
|
|
296
|
+
requestId,
|
|
297
|
+
sessionId: this.sessionId,
|
|
298
|
+
toolCall: params.toolCall,
|
|
299
|
+
options: params.options,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
this.emit('permission:sent', requestId);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
handlePermissionResponse(requestId: string, outcome: acp.RequestPermissionOutcome): void {
|
|
307
|
+
const pending = this.pendingPermissions.get(requestId);
|
|
308
|
+
if (!pending) {
|
|
309
|
+
console.warn(`Permission request ${requestId} not found`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
clearTimeout(pending.timeout);
|
|
314
|
+
this.pendingPermissions.delete(requestId);
|
|
315
|
+
pending.resolve({ outcome });
|
|
276
316
|
}
|
|
277
317
|
|
|
278
318
|
// ── text buffering ──
|
|
@@ -306,11 +346,13 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
|
|
|
306
346
|
}
|
|
307
347
|
|
|
308
348
|
private emitStopped(reason: string, exitCode?: number): void {
|
|
349
|
+
this.stopped = true;
|
|
309
350
|
this.socket.emit('acp:stopped', { sessionId: this.sessionId, reason, exitCode });
|
|
310
351
|
this.emit('acp:stopped', { sessionId: this.sessionId, reason, exitCode });
|
|
311
352
|
}
|
|
312
353
|
|
|
313
354
|
private handleError(error: unknown): void {
|
|
355
|
+
this.stopped = true;
|
|
314
356
|
const msg = error instanceof Error ? error.message : String(error);
|
|
315
357
|
this.socket.emit('acp:stopped', {
|
|
316
358
|
sessionId: this.sessionId,
|
package/src/config.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface ConfigData {
|
|
|
10
10
|
key?: { value: string };
|
|
11
11
|
server?: { endpoint: string };
|
|
12
12
|
controller?: { name: string };
|
|
13
|
+
resumeToken?: { value: string };
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export function readConfig(): ConfigData {
|
|
@@ -73,3 +74,20 @@ export function resolveEndpoint(cliEndpoint?: string): string | undefined {
|
|
|
73
74
|
if (envEndpoint) return envEndpoint;
|
|
74
75
|
return getEndpointFromConfig();
|
|
75
76
|
}
|
|
77
|
+
|
|
78
|
+
export function getResumeTokenFromConfig(): string | undefined {
|
|
79
|
+
const config = readConfig();
|
|
80
|
+
return config.resumeToken?.value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function saveResumeTokenToConfig(token: string): void {
|
|
84
|
+
const config = readConfig();
|
|
85
|
+
config.resumeToken = { value: token };
|
|
86
|
+
writeConfig(config);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function clearResumeTokenFromConfig(): void {
|
|
90
|
+
const config = readConfig();
|
|
91
|
+
delete config.resumeToken;
|
|
92
|
+
writeConfig(config);
|
|
93
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('node:net', () => ({
|
|
4
|
+
default: {
|
|
5
|
+
connect: vi.fn(),
|
|
6
|
+
},
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import net from 'node:net';
|
|
10
|
+
import { PortForwardManager } from './port-forward.js';
|
|
11
|
+
|
|
12
|
+
function createMockLocalSocket() {
|
|
13
|
+
const handlers = new Map<string, Function>();
|
|
14
|
+
return {
|
|
15
|
+
once: vi.fn((event: string, handler: Function) => {
|
|
16
|
+
handlers.set(event, handler);
|
|
17
|
+
}),
|
|
18
|
+
on: vi.fn((event: string, handler: Function) => {
|
|
19
|
+
handlers.set(event, handler);
|
|
20
|
+
}),
|
|
21
|
+
removeListener: vi.fn(),
|
|
22
|
+
destroy: vi.fn(),
|
|
23
|
+
write: vi.fn(),
|
|
24
|
+
emit: vi.fn(),
|
|
25
|
+
_handlers: handlers,
|
|
26
|
+
_trigger(event: string, ...args: unknown[]) {
|
|
27
|
+
const handler = handlers.get(event);
|
|
28
|
+
if (handler) handler(...args);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createMockServerSocket() {
|
|
34
|
+
return {
|
|
35
|
+
emit: vi.fn(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('PortForwardManager', () => {
|
|
40
|
+
let portForwardManager: PortForwardManager;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
portForwardManager = new PortForwardManager();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('createTunnel', () => {
|
|
48
|
+
it('should send port-forward:created(success: true) and store tunnel on connection success', async () => {
|
|
49
|
+
const mockLocalSocket = createMockLocalSocket();
|
|
50
|
+
const mockServerSocket = createMockServerSocket();
|
|
51
|
+
vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
|
|
52
|
+
|
|
53
|
+
const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
|
|
54
|
+
mockLocalSocket._trigger('connect');
|
|
55
|
+
await promise;
|
|
56
|
+
|
|
57
|
+
expect(net.connect).toHaveBeenCalledWith({ host: '127.0.0.1', port: 3000 });
|
|
58
|
+
expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:created', {
|
|
59
|
+
tunnelId: 'tunnel-1',
|
|
60
|
+
success: true,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should send port-forward:created(success: false, error) and destroy socket on connection failure', async () => {
|
|
65
|
+
const mockLocalSocket = createMockLocalSocket();
|
|
66
|
+
const mockServerSocket = createMockServerSocket();
|
|
67
|
+
vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
|
|
68
|
+
|
|
69
|
+
const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
|
|
70
|
+
mockLocalSocket._trigger('error', new Error('ECONNREFUSED'));
|
|
71
|
+
await promise;
|
|
72
|
+
|
|
73
|
+
expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:created', {
|
|
74
|
+
tunnelId: 'tunnel-1',
|
|
75
|
+
success: false,
|
|
76
|
+
error: 'ECONNREFUSED',
|
|
77
|
+
});
|
|
78
|
+
expect(mockLocalSocket.destroy).toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should send port-forward:data with base64 encoded data when local socket receives data', async () => {
|
|
82
|
+
const mockLocalSocket = createMockLocalSocket();
|
|
83
|
+
const mockServerSocket = createMockServerSocket();
|
|
84
|
+
vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
|
|
85
|
+
|
|
86
|
+
const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
|
|
87
|
+
mockLocalSocket._trigger('connect');
|
|
88
|
+
await promise;
|
|
89
|
+
|
|
90
|
+
const testData = Buffer.from('hello world');
|
|
91
|
+
mockLocalSocket._trigger('data', testData);
|
|
92
|
+
|
|
93
|
+
expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:data', {
|
|
94
|
+
tunnelId: 'tunnel-1',
|
|
95
|
+
data: testData.toString('base64'),
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should send port-forward:close and remove tunnel when local socket closes', async () => {
|
|
100
|
+
const mockLocalSocket = createMockLocalSocket();
|
|
101
|
+
const mockServerSocket = createMockServerSocket();
|
|
102
|
+
vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
|
|
103
|
+
|
|
104
|
+
const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
|
|
105
|
+
mockLocalSocket._trigger('connect');
|
|
106
|
+
await promise;
|
|
107
|
+
|
|
108
|
+
mockLocalSocket._trigger('close');
|
|
109
|
+
|
|
110
|
+
expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:close', {
|
|
111
|
+
tunnelId: 'tunnel-1',
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should send port-forward:error and remove tunnel when local socket errors after connect', async () => {
|
|
116
|
+
const mockLocalSocket = createMockLocalSocket();
|
|
117
|
+
const mockServerSocket = createMockServerSocket();
|
|
118
|
+
vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
|
|
119
|
+
|
|
120
|
+
const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
|
|
121
|
+
mockLocalSocket._trigger('connect');
|
|
122
|
+
await promise;
|
|
123
|
+
|
|
124
|
+
mockLocalSocket._trigger('error', new Error('ECONNRESET'));
|
|
125
|
+
|
|
126
|
+
expect(mockServerSocket.emit).toHaveBeenCalledWith('port-forward:error', {
|
|
127
|
+
tunnelId: 'tunnel-1',
|
|
128
|
+
error: 'ECONNRESET',
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('handleTunnelData', () => {
|
|
134
|
+
it('should write base64 decoded data to the corresponding local socket', async () => {
|
|
135
|
+
const mockLocalSocket = createMockLocalSocket();
|
|
136
|
+
const mockServerSocket = createMockServerSocket();
|
|
137
|
+
vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
|
|
138
|
+
|
|
139
|
+
const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
|
|
140
|
+
mockLocalSocket._trigger('connect');
|
|
141
|
+
await promise;
|
|
142
|
+
|
|
143
|
+
const base64Data = Buffer.from('test payload').toString('base64');
|
|
144
|
+
portForwardManager.handleTunnelData('tunnel-1', base64Data);
|
|
145
|
+
|
|
146
|
+
expect(mockLocalSocket.write).toHaveBeenCalledWith(Buffer.from('test payload'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should log warning and ignore for unknown tunnelId', () => {
|
|
150
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
151
|
+
portForwardManager.handleTunnelData('unknown-tunnel', 'data');
|
|
152
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
153
|
+
expect.stringContaining('unknown tunnel'),
|
|
154
|
+
);
|
|
155
|
+
warnSpy.mockRestore();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('closeTunnel', () => {
|
|
160
|
+
it('should destroy local socket and remove tunnel from map', async () => {
|
|
161
|
+
const mockLocalSocket = createMockLocalSocket();
|
|
162
|
+
const mockServerSocket = createMockServerSocket();
|
|
163
|
+
vi.mocked(net.connect).mockReturnValue(mockLocalSocket as never);
|
|
164
|
+
|
|
165
|
+
const promise = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
|
|
166
|
+
mockLocalSocket._trigger('connect');
|
|
167
|
+
await promise;
|
|
168
|
+
|
|
169
|
+
portForwardManager.closeTunnel('tunnel-1');
|
|
170
|
+
|
|
171
|
+
expect(mockLocalSocket.destroy).toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should silently ignore unknown tunnelId', () => {
|
|
175
|
+
expect(() => portForwardManager.closeTunnel('unknown-tunnel')).not.toThrow();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('closeAll', () => {
|
|
180
|
+
it('should close all tunnels and clear the map', async () => {
|
|
181
|
+
const socket1 = createMockLocalSocket();
|
|
182
|
+
const socket2 = createMockLocalSocket();
|
|
183
|
+
const mockServerSocket = createMockServerSocket();
|
|
184
|
+
vi.mocked(net.connect)
|
|
185
|
+
.mockReturnValueOnce(socket1 as never)
|
|
186
|
+
.mockReturnValueOnce(socket2 as never);
|
|
187
|
+
|
|
188
|
+
const promise1 = portForwardManager.createTunnel('tunnel-1', 3000, mockServerSocket as never);
|
|
189
|
+
socket1._trigger('connect');
|
|
190
|
+
await promise1;
|
|
191
|
+
|
|
192
|
+
const promise2 = portForwardManager.createTunnel('tunnel-2', 4000, mockServerSocket as never);
|
|
193
|
+
socket2._trigger('connect');
|
|
194
|
+
await promise2;
|
|
195
|
+
|
|
196
|
+
portForwardManager.closeAll();
|
|
197
|
+
|
|
198
|
+
expect(socket1.destroy).toHaveBeenCalled();
|
|
199
|
+
expect(socket2.destroy).toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import type { Socket } from 'socket.io-client';
|
|
3
|
+
|
|
4
|
+
interface Tunnel {
|
|
5
|
+
id: string;
|
|
6
|
+
port: number;
|
|
7
|
+
localSocket: net.Socket;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class PortForwardManager {
|
|
11
|
+
private tunnels: Map<string, Tunnel> = new Map();
|
|
12
|
+
|
|
13
|
+
async createTunnel(tunnelId: string, port: number, serverSocket: Socket): Promise<void> {
|
|
14
|
+
const localSocket = net.connect({ host: '127.0.0.1', port });
|
|
15
|
+
|
|
16
|
+
return new Promise<void>((resolve) => {
|
|
17
|
+
const onError = (err: Error) => {
|
|
18
|
+
console.error(`Port forward tunnel error: tunnelId=${tunnelId} error=${err.message}`);
|
|
19
|
+
serverSocket.emit('port-forward:created', {
|
|
20
|
+
tunnelId,
|
|
21
|
+
success: false,
|
|
22
|
+
error: err.message,
|
|
23
|
+
});
|
|
24
|
+
localSocket.destroy();
|
|
25
|
+
resolve();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
localSocket.once('error', onError);
|
|
29
|
+
|
|
30
|
+
localSocket.once('connect', () => {
|
|
31
|
+
localSocket.removeListener('error', onError);
|
|
32
|
+
|
|
33
|
+
this.tunnels.set(tunnelId, { id: tunnelId, port, localSocket });
|
|
34
|
+
console.log(`Port forward tunnel created: tunnelId=${tunnelId} port=${port}`);
|
|
35
|
+
|
|
36
|
+
serverSocket.emit('port-forward:created', { tunnelId, success: true });
|
|
37
|
+
|
|
38
|
+
localSocket.on('data', (data: Buffer) => {
|
|
39
|
+
serverSocket.emit('port-forward:data', {
|
|
40
|
+
tunnelId,
|
|
41
|
+
data: data.toString('base64'),
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
localSocket.on('close', () => {
|
|
46
|
+
console.log(`Port forward local socket closed: tunnelId=${tunnelId}`);
|
|
47
|
+
this.tunnels.delete(tunnelId);
|
|
48
|
+
serverSocket.emit('port-forward:close', { tunnelId });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
localSocket.on('error', (err: Error) => {
|
|
52
|
+
console.error(`Port forward local socket error: tunnelId=${tunnelId} error=${err.message}`);
|
|
53
|
+
this.tunnels.delete(tunnelId);
|
|
54
|
+
serverSocket.emit('port-forward:error', { tunnelId, error: err.message });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
resolve();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
handleTunnelData(tunnelId: string, data: string): void {
|
|
63
|
+
const tunnel = this.tunnels.get(tunnelId);
|
|
64
|
+
if (!tunnel) {
|
|
65
|
+
console.warn(`Port forward data for unknown tunnel: tunnelId=${tunnelId}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
tunnel.localSocket.write(Buffer.from(data, 'base64'));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
closeTunnel(tunnelId: string): void {
|
|
72
|
+
const tunnel = this.tunnels.get(tunnelId);
|
|
73
|
+
if (!tunnel) return;
|
|
74
|
+
console.log(`Port forward closing tunnel: tunnelId=${tunnelId}`);
|
|
75
|
+
tunnel.localSocket.destroy();
|
|
76
|
+
this.tunnels.delete(tunnelId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
closeAll(): void {
|
|
80
|
+
console.log(`Port forward closing all tunnels: count=${this.tunnels.size}`);
|
|
81
|
+
for (const tunnel of this.tunnels.values()) {
|
|
82
|
+
tunnel.localSocket.destroy();
|
|
83
|
+
}
|
|
84
|
+
this.tunnels.clear();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const portForwardManager = new PortForwardManager();
|
package/src/socketio-client.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { io as createSocketIOClient, type Socket } from 'socket.io-client';
|
|
2
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
2
3
|
import { hostname, platform } from 'os';
|
|
3
4
|
import { readdir, access } from 'fs/promises';
|
|
4
5
|
import { join } from 'path';
|
|
5
6
|
import { homedir } from 'os';
|
|
6
7
|
import { Heartbeat } from './heartbeat.js';
|
|
7
|
-
import { clearConfigKey, saveKeyToConfig, saveEndpointToConfig } from './config.js';
|
|
8
|
+
import { clearConfigKey, saveKeyToConfig, saveEndpointToConfig, getResumeTokenFromConfig, saveResumeTokenToConfig, clearResumeTokenFromConfig } from './config.js';
|
|
8
9
|
import { acpDetector } from './acp/detector.js';
|
|
9
10
|
import { AcpSpawner } from './acp/spawner.js';
|
|
10
11
|
import { AcpSessionBridge } from './acp/bridge.js';
|
|
@@ -12,9 +13,11 @@ import { AcpSyncBridge } from './acp/sync-bridge.js';
|
|
|
12
13
|
import { gitHandler } from './git-handler.js';
|
|
13
14
|
import { worktreeHandler } from './worktree-handler.js';
|
|
14
15
|
import { fsHandler } from './fs-handler.js';
|
|
16
|
+
import { portForwardManager } from './port-forward.js';
|
|
15
17
|
|
|
16
|
-
const CONTROLLER_VERSION = '1.
|
|
18
|
+
const CONTROLLER_VERSION = '1.1.1';
|
|
17
19
|
const acpBridges = new Map<string, AcpSessionBridge>();
|
|
20
|
+
const permissionRequestBridge = new Map<string, AcpSessionBridge>();
|
|
18
21
|
|
|
19
22
|
function createSpawner(serverType: string, sessionId: string, cwd: string): AcpSpawner {
|
|
20
23
|
const config = acpDetector.getSpawnerConfig(serverType);
|
|
@@ -23,6 +26,7 @@ function createSpawner(serverType: string, sessionId: string, cwd: string): AcpS
|
|
|
23
26
|
|
|
24
27
|
interface RegisteredPayload {
|
|
25
28
|
controllerId: string;
|
|
29
|
+
resumeToken?: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
interface ErrorPayload {
|
|
@@ -154,22 +158,32 @@ export function connectToServer(
|
|
|
154
158
|
|
|
155
159
|
socket.on('connect', () => {
|
|
156
160
|
console.log(`Connected to ${endpoint}`);
|
|
161
|
+
const resumeToken = getResumeTokenFromConfig();
|
|
157
162
|
socket.emit('register', {
|
|
158
163
|
key,
|
|
159
164
|
hostname: controllerName,
|
|
160
165
|
version: CONTROLLER_VERSION,
|
|
166
|
+
resumeToken,
|
|
161
167
|
});
|
|
162
168
|
});
|
|
163
169
|
|
|
164
170
|
socket.on('registered', (data: RegisteredPayload) => {
|
|
165
171
|
console.log(`Controller registered: ${data.controllerId}`);
|
|
172
|
+
if (data.resumeToken) {
|
|
173
|
+
saveResumeTokenToConfig(data.resumeToken);
|
|
174
|
+
console.log('Resume token saved to config');
|
|
175
|
+
}
|
|
166
176
|
heartbeat.start(() => socket.emit('heartbeat'));
|
|
167
177
|
});
|
|
168
178
|
|
|
169
179
|
socket.on('error', (data: ErrorPayload) => {
|
|
170
180
|
console.error(`Server error: ${data.code} - ${data.message}`);
|
|
171
181
|
if (data.code === 'KEY_ALREADY_ONLINE') {
|
|
172
|
-
console.
|
|
182
|
+
console.log('Clearing resume token and retrying connection...');
|
|
183
|
+
clearResumeTokenFromConfig();
|
|
184
|
+
// Don't exit, let the reconnection logic handle it
|
|
185
|
+
socket.disconnect();
|
|
186
|
+
return;
|
|
173
187
|
}
|
|
174
188
|
socket.disconnect();
|
|
175
189
|
process.exit(1);
|
|
@@ -214,6 +228,10 @@ export function connectToServer(
|
|
|
214
228
|
|
|
215
229
|
acpBridges.set(data.sessionId, bridge);
|
|
216
230
|
|
|
231
|
+
bridge.on('permission:sent', (requestId: string) => {
|
|
232
|
+
permissionRequestBridge.set(requestId, bridge);
|
|
233
|
+
});
|
|
234
|
+
|
|
217
235
|
const onInitialized = (payload: Record<string, unknown>) => {
|
|
218
236
|
if (payload.sessionId !== data.sessionId) return;
|
|
219
237
|
bridge.off('acp:initialized', onInitialized);
|
|
@@ -275,6 +293,10 @@ export function connectToServer(
|
|
|
275
293
|
|
|
276
294
|
acpBridges.set(data.sessionId, bridge);
|
|
277
295
|
|
|
296
|
+
bridge.on('permission:sent', (requestId: string) => {
|
|
297
|
+
permissionRequestBridge.set(requestId, bridge);
|
|
298
|
+
});
|
|
299
|
+
|
|
278
300
|
const onInitialized = (payload: Record<string, unknown>) => {
|
|
279
301
|
if (payload.sessionId !== data.sessionId) return;
|
|
280
302
|
bridge.off('acp:initialized', onInitialized);
|
|
@@ -563,9 +585,37 @@ export function connectToServer(
|
|
|
563
585
|
socket.emit('git:commit:result', { requestId: data.requestId, ...result });
|
|
564
586
|
});
|
|
565
587
|
|
|
588
|
+
// Port Forward events
|
|
589
|
+
socket.on('port-forward:create', async (data: { tunnelId: string; port: number }) => {
|
|
590
|
+
console.log(`Port forward create requested: tunnelId=${data.tunnelId} port=${data.port}`);
|
|
591
|
+
await portForwardManager.createTunnel(data.tunnelId, data.port, socket);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
socket.on('port-forward:data', (data: { tunnelId: string; data: string }) => {
|
|
595
|
+
portForwardManager.handleTunnelData(data.tunnelId, data.data);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
socket.on('port-forward:close', (data: { tunnelId: string }) => {
|
|
599
|
+
console.log(`Port forward close: tunnelId=${data.tunnelId}`);
|
|
600
|
+
portForwardManager.closeTunnel(data.tunnelId);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
socket.on('acp:permission:response', (data: {
|
|
604
|
+
requestId: string;
|
|
605
|
+
outcome: { outcome: string; optionId?: string };
|
|
606
|
+
}) => {
|
|
607
|
+
const bridge = permissionRequestBridge.get(data.requestId);
|
|
608
|
+
if (bridge) {
|
|
609
|
+
permissionRequestBridge.delete(data.requestId);
|
|
610
|
+
bridge.handlePermissionResponse(data.requestId, data.outcome as acp.RequestPermissionOutcome);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
566
614
|
socket.on('disconnect', (reason) => {
|
|
567
615
|
console.log(`Disconnected: ${reason}`);
|
|
568
616
|
heartbeat.stop();
|
|
617
|
+
portForwardManager.closeAll();
|
|
618
|
+
permissionRequestBridge.clear();
|
|
569
619
|
});
|
|
570
620
|
|
|
571
621
|
socket.on('connect_error', (err) => {
|
package/tests/acp-bridge.test.ts
CHANGED
|
@@ -331,6 +331,67 @@ describe('AcpSessionBridge', () => {
|
|
|
331
331
|
|
|
332
332
|
expect(mc().prompt).toHaveBeenCalledTimes(2);
|
|
333
333
|
});
|
|
334
|
+
|
|
335
|
+
it('should emit acp:prompt:start when prompt begins', async () => {
|
|
336
|
+
const bridge = createBridge();
|
|
337
|
+
bridge.start();
|
|
338
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
339
|
+
|
|
340
|
+
mc().prompt.mockReturnValue(new Promise(() => {}));
|
|
341
|
+
bridge.sendInput({ type: 'text', content: 'Hello' });
|
|
342
|
+
|
|
343
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:prompt:start', {
|
|
344
|
+
sessionId: 'sess-1',
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should emit acp:prompt:complete when prompt ends successfully', async () => {
|
|
349
|
+
const bridge = createBridge();
|
|
350
|
+
bridge.start();
|
|
351
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
352
|
+
|
|
353
|
+
bridge.sendInput({ type: 'text', content: 'Hello' });
|
|
354
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
355
|
+
|
|
356
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:prompt:complete', {
|
|
357
|
+
sessionId: 'sess-1',
|
|
358
|
+
stopReason: 'end_turn',
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('should emit acp:prompt:complete with error stopReason on prompt failure', async () => {
|
|
363
|
+
const bridge = createBridge();
|
|
364
|
+
bridge.start();
|
|
365
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
366
|
+
|
|
367
|
+
mc().prompt.mockRejectedValueOnce(new Error('prompt failed'));
|
|
368
|
+
bridge.sendInput({ type: 'text', content: 'Hello' });
|
|
369
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
370
|
+
|
|
371
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:prompt:complete', {
|
|
372
|
+
sessionId: 'sess-1',
|
|
373
|
+
stopReason: 'error',
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should not emit acp:prompt:complete after stop', async () => {
|
|
378
|
+
const bridge = createBridge();
|
|
379
|
+
bridge.start();
|
|
380
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
381
|
+
|
|
382
|
+
mc().prompt.mockReturnValue(new Promise(() => {}));
|
|
383
|
+
bridge.sendInput({ type: 'text', content: 'Hello' });
|
|
384
|
+
|
|
385
|
+
mockSocket.emit.mockClear();
|
|
386
|
+
bridge.stop();
|
|
387
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
388
|
+
|
|
389
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:stopped', expect.objectContaining({
|
|
390
|
+
sessionId: 'sess-1',
|
|
391
|
+
reason: 'user_close',
|
|
392
|
+
}));
|
|
393
|
+
expect(mockSocket.emit).not.toHaveBeenCalledWith('acp:prompt:complete', expect.anything());
|
|
394
|
+
});
|
|
334
395
|
});
|
|
335
396
|
|
|
336
397
|
describe('sessionUpdate handling', () => {
|
|
@@ -388,18 +449,45 @@ describe('AcpSessionBridge', () => {
|
|
|
388
449
|
});
|
|
389
450
|
|
|
390
451
|
describe('requestPermission', () => {
|
|
391
|
-
it('should
|
|
452
|
+
it('should emit acp:permission and wait for response', async () => {
|
|
392
453
|
const bridge = createBridge();
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
454
|
+
const options = [
|
|
455
|
+
{ optionId: 'opt-1', kind: 'allow_once', label: 'Allow' },
|
|
456
|
+
{ optionId: 'opt-2', kind: 'reject', label: 'Reject' },
|
|
457
|
+
];
|
|
458
|
+
|
|
459
|
+
const promise = bridge.requestPermission({
|
|
460
|
+
options,
|
|
461
|
+
toolCall: { toolCallId: 'tc-1' },
|
|
398
462
|
} as never);
|
|
399
463
|
|
|
400
|
-
expect(
|
|
401
|
-
|
|
402
|
-
|
|
464
|
+
expect(mockSocket.emit).toHaveBeenCalledWith('acp:permission', expect.objectContaining({
|
|
465
|
+
sessionId: 'sess-1',
|
|
466
|
+
toolCall: { toolCallId: 'tc-1' },
|
|
467
|
+
options,
|
|
468
|
+
}));
|
|
469
|
+
|
|
470
|
+
const emitCall = mockSocket.emit.mock.calls.find((c: unknown[]) => c[0] === 'acp:permission');
|
|
471
|
+
const requestId = (emitCall![1] as { requestId: string }).requestId;
|
|
472
|
+
|
|
473
|
+
bridge.handlePermissionResponse(requestId, { outcome: 'selected', optionId: 'opt-1' });
|
|
474
|
+
|
|
475
|
+
const result = await promise;
|
|
476
|
+
expect(result).toEqual({ outcome: { outcome: 'selected', optionId: 'opt-1' } });
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('should resolve with cancelled on 65s timeout', async () => {
|
|
480
|
+
const bridge = createBridge();
|
|
481
|
+
|
|
482
|
+
const promise = bridge.requestPermission({
|
|
483
|
+
options: [{ optionId: 'opt-1', kind: 'allow_once', label: 'Allow' }],
|
|
484
|
+
toolCall: { toolCallId: 'tc-1' },
|
|
485
|
+
} as never);
|
|
486
|
+
|
|
487
|
+
await vi.advanceTimersByTimeAsync(65_000);
|
|
488
|
+
|
|
489
|
+
const result = await promise;
|
|
490
|
+
expect(result).toEqual({ outcome: { outcome: 'cancelled' } });
|
|
403
491
|
});
|
|
404
492
|
});
|
|
405
493
|
|