agent-gear 1.1.0 → 1.1.2

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/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
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ import { hostname } from 'os';
4
4
  import { resolveKey, resolveEndpoint, saveKeyToConfig, saveEndpointToConfig } from './config.js';
5
5
  import { connectToServer } from './socketio-client.js';
6
6
  import { writePid, removePid, isRunning } from './daemon.js';
7
+ import { startMcpRegistration } from './commands/mcp-proxy.js';
8
+ import { startMcpProxy } from './commands/mcp.js';
7
9
 
8
10
  const program = new Command();
9
11
 
@@ -56,4 +58,30 @@ program
56
58
  });
57
59
  });
58
60
 
61
+ program
62
+ .command('mcp-proxy')
63
+ .description('Register a local MCP command as a proxy')
64
+ .requiredOption('--mcp-server <endpoint>', 'Server endpoint URL')
65
+ .requiredOption('--mcp-key <key>', 'MCP Proxy Key')
66
+ .requiredOption('--command <command>', 'Local MCP command to proxy')
67
+ .action(async (options) => {
68
+ await startMcpRegistration({
69
+ server: options.mcpServer,
70
+ key: options.mcpKey,
71
+ command: options.command,
72
+ });
73
+ });
74
+
75
+ program
76
+ .command('mcp')
77
+ .description('Start stdio MCP proxy (HTTP to stdio)')
78
+ .requiredOption('--endpoint <url>', 'MCP forwarder endpoint URL')
79
+ .requiredOption('--token <token>', 'Bearer token for authentication')
80
+ .action(async (options) => {
81
+ await startMcpProxy({
82
+ endpoint: options.endpoint,
83
+ token: options.token,
84
+ });
85
+ });
86
+
59
87
  program.parse();
@@ -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';
@@ -14,8 +15,9 @@ import { worktreeHandler } from './worktree-handler.js';
14
15
  import { fsHandler } from './fs-handler.js';
15
16
  import { portForwardManager } from './port-forward.js';
16
17
 
17
- const CONTROLLER_VERSION = '1.0.4';
18
+ const CONTROLLER_VERSION = '1.1.1';
18
19
  const acpBridges = new Map<string, AcpSessionBridge>();
20
+ const permissionRequestBridge = new Map<string, AcpSessionBridge>();
19
21
 
20
22
  function createSpawner(serverType: string, sessionId: string, cwd: string): AcpSpawner {
21
23
  const config = acpDetector.getSpawnerConfig(serverType);
@@ -24,6 +26,7 @@ function createSpawner(serverType: string, sessionId: string, cwd: string): AcpS
24
26
 
25
27
  interface RegisteredPayload {
26
28
  controllerId: string;
29
+ resumeToken?: string;
27
30
  }
28
31
 
29
32
  interface ErrorPayload {
@@ -155,22 +158,32 @@ export function connectToServer(
155
158
 
156
159
  socket.on('connect', () => {
157
160
  console.log(`Connected to ${endpoint}`);
161
+ const resumeToken = getResumeTokenFromConfig();
158
162
  socket.emit('register', {
159
163
  key,
160
164
  hostname: controllerName,
161
165
  version: CONTROLLER_VERSION,
166
+ resumeToken,
162
167
  });
163
168
  });
164
169
 
165
170
  socket.on('registered', (data: RegisteredPayload) => {
166
171
  console.log(`Controller registered: ${data.controllerId}`);
172
+ if (data.resumeToken) {
173
+ saveResumeTokenToConfig(data.resumeToken);
174
+ console.log('Resume token saved to config');
175
+ }
167
176
  heartbeat.start(() => socket.emit('heartbeat'));
168
177
  });
169
178
 
170
179
  socket.on('error', (data: ErrorPayload) => {
171
180
  console.error(`Server error: ${data.code} - ${data.message}`);
172
181
  if (data.code === 'KEY_ALREADY_ONLINE') {
173
- console.error('This key is already in use on another machine.');
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;
174
187
  }
175
188
  socket.disconnect();
176
189
  process.exit(1);
@@ -215,6 +228,10 @@ export function connectToServer(
215
228
 
216
229
  acpBridges.set(data.sessionId, bridge);
217
230
 
231
+ bridge.on('permission:sent', (requestId: string) => {
232
+ permissionRequestBridge.set(requestId, bridge);
233
+ });
234
+
218
235
  const onInitialized = (payload: Record<string, unknown>) => {
219
236
  if (payload.sessionId !== data.sessionId) return;
220
237
  bridge.off('acp:initialized', onInitialized);
@@ -226,10 +243,10 @@ export function connectToServer(
226
243
  success: true,
227
244
  externalSessionId: payload.acpSessionId,
228
245
  };
229
- if (payload.modes) result.modes = payload.modes;
246
+ result.modes = payload.modes ?? [];
230
247
  if (payload.currentModeId) result.currentModeId = payload.currentModeId;
231
248
  if (payload.currentModel) result.currentModel = payload.currentModel;
232
- if (payload.availableModels) result.availableModels = payload.availableModels;
249
+ result.availableModels = payload.availableModels ?? [];
233
250
  if (payload.currentModelId) result.currentModelId = payload.currentModelId;
234
251
  socket.emit('acp:start:result', result);
235
252
  };
@@ -276,6 +293,10 @@ export function connectToServer(
276
293
 
277
294
  acpBridges.set(data.sessionId, bridge);
278
295
 
296
+ bridge.on('permission:sent', (requestId: string) => {
297
+ permissionRequestBridge.set(requestId, bridge);
298
+ });
299
+
279
300
  const onInitialized = (payload: Record<string, unknown>) => {
280
301
  if (payload.sessionId !== data.sessionId) return;
281
302
  bridge.off('acp:initialized', onInitialized);
@@ -285,10 +306,10 @@ export function connectToServer(
285
306
  sessionId: data.sessionId,
286
307
  success: true,
287
308
  };
288
- if (payload.modes) result.modes = payload.modes;
309
+ result.modes = payload.modes ?? [];
289
310
  if (payload.currentModeId) result.currentModeId = payload.currentModeId;
290
311
  if (payload.currentModel) result.currentModel = payload.currentModel;
291
- if (payload.availableModels) result.availableModels = payload.availableModels;
312
+ result.availableModels = payload.availableModels ?? [];
292
313
  if (payload.currentModelId) result.currentModelId = payload.currentModelId;
293
314
  socket.emit('acp:resume:result', result);
294
315
  };
@@ -579,10 +600,22 @@ export function connectToServer(
579
600
  portForwardManager.closeTunnel(data.tunnelId);
580
601
  });
581
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
+
582
614
  socket.on('disconnect', (reason) => {
583
615
  console.log(`Disconnected: ${reason}`);
584
616
  heartbeat.stop();
585
617
  portForwardManager.closeAll();
618
+ permissionRequestBridge.clear();
586
619
  });
587
620
 
588
621
  socket.on('connect_error', (err) => {
@@ -449,18 +449,45 @@ describe('AcpSessionBridge', () => {
449
449
  });
450
450
 
451
451
  describe('requestPermission', () => {
452
- it('should auto-accept with allow_once', async () => {
452
+ it('should emit acp:permission and wait for response', async () => {
453
453
  const bridge = createBridge();
454
- const result = await bridge.requestPermission({
455
- options: [
456
- { optionId: 'opt-1', kind: 'allow_once', label: 'Allow' },
457
- { optionId: 'opt-2', kind: 'reject', label: 'Reject' },
458
- ],
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' },
459
462
  } as never);
460
463
 
461
- expect(result).toEqual({
462
- outcome: { outcome: 'selected', optionId: 'opt-1' },
463
- });
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' } });
464
491
  });
465
492
  });
466
493
 
@@ -509,26 +536,26 @@ describe('AcpSessionBridge', () => {
509
536
  });
510
537
 
511
538
  describe('extractAvailableModels', () => {
512
- it('should return null when models is null', () => {
539
+ it('should return empty array when models is null', () => {
513
540
  const bridge = createBridge();
514
541
 
515
542
  // Access private method via bracket notation
516
543
  const result = (bridge as unknown as Record<string, unknown>).extractAvailableModels(null);
517
- expect(result).toBeNull();
544
+ expect(result).toEqual([]);
518
545
  });
519
546
 
520
- it('should return null when models is undefined', () => {
547
+ it('should return empty array when models is undefined', () => {
521
548
  const bridge = createBridge();
522
549
 
523
550
  const result = (bridge as unknown as Record<string, unknown>).extractAvailableModels(undefined);
524
- expect(result).toBeNull();
551
+ expect(result).toEqual([]);
525
552
  });
526
553
 
527
- it('should return null when availableModels is missing', () => {
554
+ it('should return empty array when availableModels is missing', () => {
528
555
  const bridge = createBridge();
529
556
 
530
557
  const result = (bridge as unknown as Record<string, unknown>).extractAvailableModels({});
531
- expect(result).toBeNull();
558
+ expect(result).toEqual([]);
532
559
  });
533
560
  });
534
561
  });
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock('child_process', () => ({
4
+ spawn: vi.fn().mockReturnValue({
5
+ stdin: { write: vi.fn() },
6
+ stdout: { on: vi.fn(), removeListener: vi.fn() },
7
+ on: vi.fn(),
8
+ kill: vi.fn(),
9
+ }),
10
+ }));
11
+
12
+ vi.mock('socket.io-client', () => ({
13
+ io: vi.fn().mockReturnValue({
14
+ on: vi.fn(),
15
+ emit: vi.fn(),
16
+ disconnect: vi.fn(),
17
+ connected: true,
18
+ }),
19
+ }));
20
+
21
+ vi.mock('readline', () => ({
22
+ createInterface: vi.fn().mockReturnValue({
23
+ on: vi.fn(),
24
+ }),
25
+ }));
26
+
27
+ import { startMcpRegistration } from '../../src/commands/mcp-proxy.js';
28
+
29
+ beforeEach(() => {
30
+ vi.clearAllMocks();
31
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
32
+ json: () => Promise.resolve({ success: true, proxyId: 'proxy_1', serverId: 'server_1' }),
33
+ }));
34
+ vi.stubGlobal('process', {
35
+ ...process,
36
+ env: { HOSTNAME: 'test-host' },
37
+ on: vi.fn(),
38
+ exit: vi.fn(),
39
+ });
40
+ });
41
+
42
+ describe('mcp-proxy command', () => {
43
+ it('should register with server and spawn child process', async () => {
44
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
45
+
46
+ await startMcpRegistration({
47
+ server: 'http://localhost:4001',
48
+ key: 'mcp_test-key',
49
+ command: 'npx @modelcontextprotocol/server-github',
50
+ });
51
+
52
+ expect(fetch).toHaveBeenCalledWith('http://localhost:4001/api/mcp-proxy/register', {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json' },
55
+ body: JSON.stringify({
56
+ key: 'mcp_test-key',
57
+ hostname: 'test-host',
58
+ command: 'npx @modelcontextprotocol/server-github',
59
+ serverName: '@modelcontextprotocol/server-github',
60
+ }),
61
+ });
62
+
63
+ consoleSpy.mockRestore();
64
+ });
65
+
66
+ it('should exit if registration fails', async () => {
67
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
68
+ json: () => Promise.resolve({ success: false, error: 'Invalid key' }),
69
+ }));
70
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
71
+ const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
72
+
73
+ await startMcpRegistration({
74
+ server: 'http://localhost:4001',
75
+ key: 'invalid-key',
76
+ command: 'npx @modelcontextprotocol/server-github',
77
+ });
78
+
79
+ expect(processExitSpy).toHaveBeenCalledWith(1);
80
+
81
+ consoleErrorSpy.mockRestore();
82
+ processExitSpy.mockRestore();
83
+ });
84
+
85
+ it('should use correct option names for CLI parsing', () => {
86
+ // This test verifies that the CLI options are correctly named
87
+ // to avoid conflicts with global options
88
+ const { Command } = require('commander');
89
+ const program = new Command();
90
+
91
+ program
92
+ .command('mcp-proxy')
93
+ .description('Register a local MCP command as a proxy')
94
+ .requiredOption('--mcp-server <endpoint>', 'Server endpoint URL')
95
+ .requiredOption('--mcp-key <key>', 'MCP Proxy Key')
96
+ .requiredOption('--command <command>', 'Local MCP command to proxy');
97
+
98
+ // Verify the command has the correct options
99
+ const cmd = program.commands.find((c: any) => c.name() === 'mcp-proxy');
100
+ expect(cmd).toBeDefined();
101
+
102
+ const options = cmd.options.map((o: any) => o.long);
103
+ expect(options).toContain('--mcp-server');
104
+ expect(options).toContain('--mcp-key');
105
+ expect(options).toContain('--command');
106
+
107
+ // Verify these don't conflict with global options
108
+ expect(options).not.toContain('--key');
109
+ expect(options).not.toContain('--server');
110
+ });
111
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock('readline', () => ({
4
+ createInterface: vi.fn().mockReturnValue({
5
+ on: vi.fn(),
6
+ }),
7
+ }));
8
+
9
+ import { startMcpProxy } from '../../src/commands/mcp.js';
10
+
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
14
+ json: () => Promise.resolve({ jsonrpc: '2.0', id: 1, result: { tools: [] } }),
15
+ }));
16
+ vi.stubGlobal('process', {
17
+ ...process,
18
+ stdout: { write: vi.fn() },
19
+ exit: vi.fn(),
20
+ });
21
+ });
22
+
23
+ describe('mcp command', () => {
24
+ it('should start stdio proxy with endpoint and token', async () => {
25
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
26
+ const readline = await import('readline');
27
+ const mockRl = {
28
+ on: vi.fn(),
29
+ };
30
+ vi.mocked(readline.createInterface).mockReturnValue(mockRl as never);
31
+
32
+ await startMcpProxy({
33
+ endpoint: 'http://localhost:3002/mcp/session-1',
34
+ token: 'test-token',
35
+ });
36
+
37
+ expect(readline.createInterface).toHaveBeenCalledWith({ input: process.stdin });
38
+ expect(mockRl.on).toHaveBeenCalledWith('line', expect.any(Function));
39
+ expect(mockRl.on).toHaveBeenCalledWith('close', expect.any(Function));
40
+
41
+ consoleSpy.mockRestore();
42
+ });
43
+ });
@@ -21,6 +21,9 @@ vi.mock('../src/config.js', () => ({
21
21
  clearConfigKey: vi.fn(),
22
22
  saveKeyToConfig: vi.fn(),
23
23
  saveEndpointToConfig: vi.fn(),
24
+ getResumeTokenFromConfig: vi.fn(),
25
+ saveResumeTokenToConfig: vi.fn(),
26
+ clearResumeTokenFromConfig: vi.fn(),
24
27
  }));
25
28
 
26
29
  vi.mock('os', () => ({
@@ -101,7 +104,8 @@ describe('SocketIO Client', () => {
101
104
  expect(mockSocket.emit).toHaveBeenCalledWith('register', {
102
105
  key: 'zag_k_1_abc123',
103
106
  hostname: 'my-machine',
104
- version: '1.0.4',
107
+ version: '1.1.1',
108
+ resumeToken: undefined,
105
109
  });
106
110
  });
107
111
 
@@ -116,7 +120,8 @@ describe('SocketIO Client', () => {
116
120
  expect(mockSocket.emit).toHaveBeenCalledWith('register', {
117
121
  key: 'zag_k_1_abc123',
118
122
  hostname: 'test-machine',
119
- version: '1.0.4',
123
+ version: '1.1.1',
124
+ resumeToken: undefined,
120
125
  });
121
126
  });
122
127