agent-gear 1.1.6 → 1.1.8
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 +13 -0
- package/dist/acp/bridge.d.ts.map +1 -1
- package/dist/acp/bridge.js +125 -3
- package/dist/acp/bridge.js.map +1 -1
- package/dist/acp/bridge.test.js +130 -7
- package/dist/acp/bridge.test.js.map +1 -1
- package/dist/acp/prober.d.ts +24 -0
- package/dist/acp/prober.d.ts.map +1 -0
- package/dist/acp/prober.js +53 -0
- package/dist/acp/prober.js.map +1 -0
- package/dist/acp/prober.test.d.ts +2 -0
- package/dist/acp/prober.test.d.ts.map +1 -0
- package/dist/acp/prober.test.js +94 -0
- package/dist/acp/prober.test.js.map +1 -0
- package/dist/acp/spawner.js +4 -4
- package/dist/acp/spawner.js.map +1 -1
- package/dist/acp/sync-bridge.d.ts +2 -0
- package/dist/acp/sync-bridge.d.ts.map +1 -1
- package/dist/acp/sync-bridge.js +6 -0
- package/dist/acp/sync-bridge.js.map +1 -1
- package/dist/commands/mcp-proxy.d.ts +10 -0
- package/dist/commands/mcp-proxy.d.ts.map +1 -1
- package/dist/commands/mcp-proxy.js +78 -24
- package/dist/commands/mcp-proxy.js.map +1 -1
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +57 -3
- package/dist/socketio-client.js.map +1 -1
- package/package.json +1 -1
- package/src/acp/bridge.test.ts +159 -7
- package/src/acp/bridge.ts +152 -3
- package/src/acp/prober.test.ts +107 -0
- package/src/acp/prober.ts +73 -0
- package/src/acp/spawner.ts +4 -4
- package/src/acp/sync-bridge.ts +8 -0
- package/src/commands/mcp-proxy.ts +91 -28
- package/src/socketio-client.ts +72 -3
- package/tests/acp-bridge.test.ts +119 -0
- package/tests/acp-spawner.test.ts +28 -1
- package/tests/acp-wire-elicitation.test.ts +158 -0
- package/tests/acp-wire-mcp.test.ts +114 -0
- package/tests/commands/mcp-proxy-dispatcher.test.ts +117 -0
- package/tests/fixtures/fake-acp-agent.mjs +122 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { mockNewSession, mockInitialize } = vi.hoisted(() => {
|
|
4
|
+
const mockNewSession = vi.fn().mockResolvedValue({ sessionId: 'acp-probe-1' });
|
|
5
|
+
const mockInitialize = vi.fn().mockResolvedValue({ protocolVersion: 1 });
|
|
6
|
+
return { mockNewSession, mockInitialize };
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
vi.mock('@agentclientprotocol/sdk', () => ({
|
|
10
|
+
ndJsonStream: vi.fn(() => ({})),
|
|
11
|
+
ClientSideConnection: vi.fn(function () {
|
|
12
|
+
return {
|
|
13
|
+
initialize: mockInitialize,
|
|
14
|
+
newSession: mockNewSession,
|
|
15
|
+
prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
|
|
16
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
closeSession: vi.fn().mockResolvedValue({}),
|
|
18
|
+
setSessionMode: vi.fn().mockResolvedValue({}),
|
|
19
|
+
setSessionConfigOption: vi.fn().mockResolvedValue({}),
|
|
20
|
+
};
|
|
21
|
+
}),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const mockSpawnerStop = vi.fn();
|
|
25
|
+
vi.mock('./spawner.js', () => ({
|
|
26
|
+
AcpSpawner: vi.fn(function () {
|
|
27
|
+
return {
|
|
28
|
+
start: vi.fn(),
|
|
29
|
+
stop: mockSpawnerStop,
|
|
30
|
+
isRunning: vi.fn().mockReturnValue(true),
|
|
31
|
+
stdin: new WritableStream(),
|
|
32
|
+
stdout: new ReadableStream(),
|
|
33
|
+
closed: Promise.resolve(),
|
|
34
|
+
};
|
|
35
|
+
}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import { probeAcpServerOptions } from './prober.js';
|
|
39
|
+
|
|
40
|
+
describe('probeAcpServerOptions', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('session/new 返回 modes + configOptions 时提取 Mode/模型选项并关闭进程', async () => {
|
|
50
|
+
mockInitialize.mockResolvedValue({ protocolVersion: 1 });
|
|
51
|
+
mockNewSession.mockResolvedValue({
|
|
52
|
+
sessionId: 'acp-probe-1',
|
|
53
|
+
modes: {
|
|
54
|
+
availableModes: [
|
|
55
|
+
{ id: 'code', name: 'Code', description: '编码模式' },
|
|
56
|
+
{ id: 'ask', name: 'Ask' },
|
|
57
|
+
],
|
|
58
|
+
currentModeId: 'code',
|
|
59
|
+
},
|
|
60
|
+
configOptions: [
|
|
61
|
+
{
|
|
62
|
+
id: 'model',
|
|
63
|
+
name: 'Model',
|
|
64
|
+
category: 'model',
|
|
65
|
+
type: 'select',
|
|
66
|
+
currentValue: 'gpt-5',
|
|
67
|
+
options: [
|
|
68
|
+
{ value: 'gpt-5', name: 'GPT-5' },
|
|
69
|
+
{ value: 'claude-4', name: 'Claude 4' },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const result = await probeAcpServerOptions('opencode');
|
|
76
|
+
|
|
77
|
+
expect(result.modes).toEqual([
|
|
78
|
+
{ id: 'code', name: 'Code', description: '编码模式' },
|
|
79
|
+
{ id: 'ask', name: 'Ask', description: undefined },
|
|
80
|
+
]);
|
|
81
|
+
expect(result.currentModeId).toBe('code');
|
|
82
|
+
expect(result.availableModels).toEqual([
|
|
83
|
+
{ id: 'gpt-5', name: 'GPT-5' },
|
|
84
|
+
{ id: 'claude-4', name: 'Claude 4' },
|
|
85
|
+
]);
|
|
86
|
+
expect(result.currentModelId).toBe('gpt-5');
|
|
87
|
+
expect(mockSpawnerStop).toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('Agent 无 modes/models 时返回空数组而非失败', async () => {
|
|
91
|
+
mockNewSession.mockResolvedValue({ sessionId: 'acp-probe-2' });
|
|
92
|
+
|
|
93
|
+
const result = await probeAcpServerOptions('opencode');
|
|
94
|
+
|
|
95
|
+
expect(result.modes).toEqual([]);
|
|
96
|
+
expect(result.availableModels).toEqual([]);
|
|
97
|
+
expect(result.currentModeId).toBeNull();
|
|
98
|
+
expect(result.currentModelId).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('initialize 失败时 reject 并关闭进程', async () => {
|
|
102
|
+
mockInitialize.mockRejectedValueOnce(new Error('handshake failed'));
|
|
103
|
+
|
|
104
|
+
await expect(probeAcpServerOptions('opencode')).rejects.toThrow('handshake failed');
|
|
105
|
+
expect(mockSpawnerStop).toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import type { Socket } from 'socket.io-client';
|
|
3
|
+
import { acpDetector } from './detector.js';
|
|
4
|
+
import { AcpSpawner } from './spawner.js';
|
|
5
|
+
import { AcpSessionBridge, type AcpInitializedPayload } from './bridge.js';
|
|
6
|
+
|
|
7
|
+
export interface AcpServerOptionsProbeResult {
|
|
8
|
+
modes: Array<{ id: string; name: string; description?: string }>;
|
|
9
|
+
availableModels: Array<{ id: string; name: string; provider?: string }>;
|
|
10
|
+
currentModeId: string | null;
|
|
11
|
+
currentModelId: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 空实例探测:临时拉起指定 ACP 工具进程(initialize + session/new),
|
|
16
|
+
* 收集 Mode/模型选项后立即关闭。用于配置阶段动态获取可选选项,
|
|
17
|
+
* 避免用户手填 modeId/modelId。
|
|
18
|
+
*
|
|
19
|
+
* 探测会话不与服务端交互:bridge 发出的 socket 事件全部丢弃。
|
|
20
|
+
* 任一环节失败(CLI 不存在 / 需要认证 / 超时)都会 reject,由调用方降级处理。
|
|
21
|
+
*/
|
|
22
|
+
export async function probeAcpServerOptions(
|
|
23
|
+
serverType: string,
|
|
24
|
+
timeoutMs = 20000,
|
|
25
|
+
): Promise<AcpServerOptionsProbeResult> {
|
|
26
|
+
const sessionId = `probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
27
|
+
const config = acpDetector.getSpawnerConfig(serverType);
|
|
28
|
+
const cwd = homedir();
|
|
29
|
+
const spawner = new AcpSpawner({ sessionId, cwd, command: config.command, args: config.args });
|
|
30
|
+
const noopSocket = { emit: () => false } as unknown as Socket;
|
|
31
|
+
const bridge = new AcpSessionBridge(noopSocket, { sessionId, cwd }, spawner);
|
|
32
|
+
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let settled = false;
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
finish(() => reject(new Error(`probe timeout after ${timeoutMs}ms: ${serverType}`)));
|
|
37
|
+
}, timeoutMs);
|
|
38
|
+
|
|
39
|
+
const cleanup = () => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
bridge.stop().catch(() => { /* ignore */ });
|
|
42
|
+
spawner.stop();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const finish = (fn: () => void) => {
|
|
46
|
+
if (settled) return;
|
|
47
|
+
settled = true;
|
|
48
|
+
cleanup();
|
|
49
|
+
fn();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
bridge.on('acp:initialized', (payload: AcpInitializedPayload) => {
|
|
53
|
+
finish(() =>
|
|
54
|
+
resolve({
|
|
55
|
+
modes: payload.modes ?? [],
|
|
56
|
+
availableModels: payload.availableModels ?? [],
|
|
57
|
+
currentModeId: payload.currentModeId ?? null,
|
|
58
|
+
currentModelId: payload.currentModelId ?? null,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
bridge.on('acp:stopped', (payload: { reason: string; error?: string }) => {
|
|
64
|
+
finish(() =>
|
|
65
|
+
reject(new Error(payload.error ?? `ACP process stopped during probe: ${payload.reason}`)),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
bridge.start().catch((error: unknown) => {
|
|
70
|
+
finish(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
package/src/acp/spawner.ts
CHANGED
|
@@ -29,7 +29,7 @@ export class AcpSpawner {
|
|
|
29
29
|
|
|
30
30
|
start(): void {
|
|
31
31
|
const cmd = this.resolveCommand();
|
|
32
|
-
console.log(`Starting ACP server
|
|
32
|
+
console.log(`Starting ACP server: session=${this.sessionId} cmd=${cmd} args=${JSON.stringify(this.args)} cwd=${this.cwd}`);
|
|
33
33
|
mkdirSync(this.cwd, { recursive: true });
|
|
34
34
|
|
|
35
35
|
const spawnOptions: SpawnOptions = {
|
|
@@ -43,17 +43,17 @@ export class AcpSpawner {
|
|
|
43
43
|
this.process = spawn(cmd, this.args, spawnOptions);
|
|
44
44
|
|
|
45
45
|
this.process.stderr?.on('data', (data: Buffer) => {
|
|
46
|
-
console.error(`ACP stderr: ${data.toString()}`);
|
|
46
|
+
console.error(`ACP stderr (session=${this.sessionId}): ${data.toString()}`);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
this.process.on('exit', (code, signal) => {
|
|
50
|
-
console.log(`ACP process exited
|
|
50
|
+
console.log(`ACP process exited: session=${this.sessionId} code=${code} signal=${signal} cmd=${cmd} args=${JSON.stringify(this.args)}`);
|
|
51
51
|
this.process = null;
|
|
52
52
|
this.resolveClosed?.();
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
this.process.on('error', (error) => {
|
|
56
|
-
console.error(
|
|
56
|
+
console.error(`ACP process error (session=${this.sessionId}, cmd=${cmd}):`, error);
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
59
|
|
package/src/acp/sync-bridge.ts
CHANGED
|
@@ -124,4 +124,12 @@ export class AcpSyncBridge implements acp.Client {
|
|
|
124
124
|
async requestPermission(): Promise<acp.RequestPermissionResponse> {
|
|
125
125
|
return { outcome: { outcome: 'cancelled' } };
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
async unstable_createElicitation(): Promise<acp.CreateElicitationResponse> {
|
|
129
|
+
return { action: 'cancel' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async unstable_completeElicitation(): Promise<void> {
|
|
133
|
+
// no-op
|
|
134
|
+
}
|
|
127
135
|
}
|
|
@@ -7,6 +7,92 @@ interface McpProxyOptions {
|
|
|
7
7
|
command: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
interface JsonRpcRequest {
|
|
11
|
+
jsonrpc?: string;
|
|
12
|
+
id?: unknown;
|
|
13
|
+
method?: string;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const FORWARD_TIMEOUT_MS = 30_000;
|
|
18
|
+
|
|
19
|
+
// JSON-RPC notifications have no id and MUST NOT be waited on: per the MCP
|
|
20
|
+
// spec the server never responds to them. Waiting would stall every hop of
|
|
21
|
+
// the forward chain for the full timeout.
|
|
22
|
+
function isNotification(request: JsonRpcRequest): boolean {
|
|
23
|
+
return request.id === undefined || request.id === null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Persistent line-buffered JSON-RPC dispatcher over a child process' stdio.
|
|
27
|
+
// Responses are matched to requests by id, so chunked responses (a single
|
|
28
|
+
// 'data' event is NOT guaranteed to contain exactly one complete JSON
|
|
29
|
+
// message) and concurrent in-flight requests are handled correctly.
|
|
30
|
+
export function createStdioDispatcher(child: ChildProcess) {
|
|
31
|
+
let buffer = '';
|
|
32
|
+
const pending = new Map<unknown, { resolve: (value: unknown) => void; reject: (reason: Error) => void; timeout: NodeJS.Timeout; method: string }>();
|
|
33
|
+
|
|
34
|
+
child.stdout?.on('data', (data: Buffer) => {
|
|
35
|
+
buffer += data.toString();
|
|
36
|
+
let idx;
|
|
37
|
+
while ((idx = buffer.indexOf('\n')) >= 0) {
|
|
38
|
+
const line = buffer.slice(0, idx).trim();
|
|
39
|
+
buffer = buffer.slice(idx + 1);
|
|
40
|
+
if (!line) continue;
|
|
41
|
+
let msg: { id?: unknown; error?: { message?: string } };
|
|
42
|
+
try {
|
|
43
|
+
msg = JSON.parse(line);
|
|
44
|
+
} catch {
|
|
45
|
+
console.error(`[MCP proxy] ignoring non-JSON line from child: ${line.slice(0, 200)}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const entry = pending.get(msg.id);
|
|
49
|
+
if (!entry) {
|
|
50
|
+
console.error(`[MCP proxy] ignoring response with unknown id: ${JSON.stringify(msg.id)}`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
clearTimeout(entry.timeout);
|
|
54
|
+
pending.delete(msg.id);
|
|
55
|
+
console.log(`[MCP proxy] response: method=${entry.method} id=${JSON.stringify(msg.id)}${msg.error ? ` error=${msg.error.message}` : ''}`);
|
|
56
|
+
entry.resolve(msg);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const rejectAll = (reason: Error) => {
|
|
61
|
+
for (const [, entry] of pending) {
|
|
62
|
+
clearTimeout(entry.timeout);
|
|
63
|
+
entry.reject(reason);
|
|
64
|
+
}
|
|
65
|
+
pending.clear();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
child.on('exit', (code) => {
|
|
69
|
+
console.log(`[MCP proxy] MCP command exited with code ${code}, rejecting ${pending.size} pending request(s)`);
|
|
70
|
+
rejectAll(new Error(`MCP command exited with code ${code}`));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
forward(request: JsonRpcRequest): Promise<unknown> {
|
|
75
|
+
const method = request.method ?? '(unknown)';
|
|
76
|
+
if (isNotification(request)) {
|
|
77
|
+
// Fire-and-forget: deliver the notification (e.g. notifications/initialized
|
|
78
|
+
// completes the MCP handshake) but do not wait for a response.
|
|
79
|
+
console.log(`[MCP proxy] forwarding notification: method=${method}`);
|
|
80
|
+
child.stdin?.write(JSON.stringify(request) + '\n');
|
|
81
|
+
return Promise.resolve(null);
|
|
82
|
+
}
|
|
83
|
+
console.log(`[MCP proxy] forwarding request: method=${method} id=${JSON.stringify(request.id)}`);
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const timeout = setTimeout(() => {
|
|
86
|
+
pending.delete(request.id);
|
|
87
|
+
reject(new Error(`Request timeout waiting for ${method}`));
|
|
88
|
+
}, FORWARD_TIMEOUT_MS);
|
|
89
|
+
pending.set(request.id, { resolve, reject, timeout, method });
|
|
90
|
+
child.stdin?.write(JSON.stringify(request) + '\n');
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
10
96
|
export async function startMcpRegistration(options: McpProxyOptions): Promise<void> {
|
|
11
97
|
const { server, key, command } = options;
|
|
12
98
|
|
|
@@ -45,10 +131,6 @@ export async function startMcpRegistration(options: McpProxyOptions): Promise<vo
|
|
|
45
131
|
console.error('Failed to start MCP command:', err.message);
|
|
46
132
|
});
|
|
47
133
|
|
|
48
|
-
child.on('exit', (code) => {
|
|
49
|
-
console.log(`MCP command exited with code ${code}`);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
134
|
// 3. Establish WebSocket connection
|
|
53
135
|
const socket = ioClient(`${server}/mcp-proxy`, {
|
|
54
136
|
transports: ['websocket'],
|
|
@@ -69,16 +151,18 @@ export async function startMcpRegistration(options: McpProxyOptions): Promise<vo
|
|
|
69
151
|
});
|
|
70
152
|
|
|
71
153
|
// 4. Bridge requests
|
|
72
|
-
|
|
154
|
+
const dispatcher = createStdioDispatcher(child);
|
|
155
|
+
socket.on('proxy:request', async (data: { requestId: string; request: JsonRpcRequest }) => {
|
|
73
156
|
try {
|
|
74
|
-
const response = await
|
|
157
|
+
const response = await dispatcher.forward(data.request);
|
|
75
158
|
socket.emit('proxy:response', { requestId: data.requestId, response });
|
|
76
159
|
} catch (err: any) {
|
|
160
|
+
console.error(`[MCP proxy] forward failed: method=${data.request?.method} id=${JSON.stringify(data.request?.id)} error=${err.message}`);
|
|
77
161
|
socket.emit('proxy:response', {
|
|
78
162
|
requestId: data.requestId,
|
|
79
163
|
response: {
|
|
80
164
|
jsonrpc: '2.0',
|
|
81
|
-
id: data.request.id,
|
|
165
|
+
id: data.request.id ?? null,
|
|
82
166
|
error: { code: -32603, message: err.message },
|
|
83
167
|
},
|
|
84
168
|
});
|
|
@@ -105,24 +189,3 @@ export async function startMcpRegistration(options: McpProxyOptions): Promise<vo
|
|
|
105
189
|
process.on('SIGINT', shutdown);
|
|
106
190
|
process.on('SIGTERM', shutdown);
|
|
107
191
|
}
|
|
108
|
-
|
|
109
|
-
function forwardToChild(child: ChildProcess, request: any): Promise<any> {
|
|
110
|
-
return new Promise((resolve, reject) => {
|
|
111
|
-
const timeout = setTimeout(() => {
|
|
112
|
-
reject(new Error('Request timeout'));
|
|
113
|
-
}, 30000);
|
|
114
|
-
|
|
115
|
-
const onData = (data: Buffer) => {
|
|
116
|
-
clearTimeout(timeout);
|
|
117
|
-
child.stdout?.removeListener('data', onData);
|
|
118
|
-
try {
|
|
119
|
-
resolve(JSON.parse(data.toString()));
|
|
120
|
-
} catch {
|
|
121
|
-
reject(new Error('Invalid JSON response from MCP command'));
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
child.stdout?.on('data', onData);
|
|
126
|
-
child.stdin?.write(JSON.stringify(request) + '\n');
|
|
127
|
-
});
|
|
128
|
-
}
|
package/src/socketio-client.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { homedir } from 'os';
|
|
|
7
7
|
import { Heartbeat } from './heartbeat.js';
|
|
8
8
|
import { clearConfigKey, saveKeyToConfig, saveEndpointToConfig, getResumeTokenFromConfig, saveResumeTokenToConfig, clearResumeTokenFromConfig } from './config.js';
|
|
9
9
|
import { acpDetector } from './acp/detector.js';
|
|
10
|
+
import { probeAcpServerOptions } from './acp/prober.js';
|
|
10
11
|
import { AcpSpawner } from './acp/spawner.js';
|
|
11
12
|
import { AcpSessionBridge } from './acp/bridge.js';
|
|
12
13
|
import { AcpSyncBridge } from './acp/sync-bridge.js';
|
|
@@ -18,6 +19,7 @@ import { gatewayManager } from './gateway.js';
|
|
|
18
19
|
const CONTROLLER_VERSION = '1.1.1';
|
|
19
20
|
const acpBridges = new Map<string, AcpSessionBridge>();
|
|
20
21
|
const permissionRequestBridge = new Map<string, AcpSessionBridge>();
|
|
22
|
+
const elicitationRequestBridge = new Map<string, AcpSessionBridge>();
|
|
21
23
|
|
|
22
24
|
function createSpawner(serverType: string, sessionId: string, cwd: string): AcpSpawner {
|
|
23
25
|
const config = acpDetector.getSpawnerConfig(serverType);
|
|
@@ -42,12 +44,18 @@ interface AcpCheckPayload {
|
|
|
42
44
|
requestId: string;
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
interface AcpProbeOptionsPayload {
|
|
48
|
+
requestId: string;
|
|
49
|
+
serverType: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
interface AcpStartPayload {
|
|
46
53
|
requestId: string;
|
|
47
54
|
sessionId: string;
|
|
48
55
|
serverType: string;
|
|
49
56
|
cwd: string;
|
|
50
57
|
mcpServers?: Array<{ name: string; command: string; args?: string[]; env?: Array<{ name: string; value: string }> }>;
|
|
58
|
+
mcpEndpoint?: { url: string; token: string; serverNames: string[] };
|
|
51
59
|
additionalDirectories?: string[];
|
|
52
60
|
}
|
|
53
61
|
|
|
@@ -56,6 +64,19 @@ interface AcpMessagePayload {
|
|
|
56
64
|
message: { type: string; content: string; attachments?: Array<Record<string, unknown>> };
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
// Summarize the MCP configuration of an acp:start/acp:resume payload for logs,
|
|
68
|
+
// without leaking the endpoint token.
|
|
69
|
+
function describeMcpConfig(data: { mcpServers?: unknown[]; mcpEndpoint?: { url: string; serverNames?: string[] } }): string {
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
if (data.mcpServers && data.mcpServers.length > 0) {
|
|
72
|
+
parts.push(`mcpServers=${data.mcpServers.length}`);
|
|
73
|
+
}
|
|
74
|
+
if (data.mcpEndpoint) {
|
|
75
|
+
parts.push(`mcpEndpoint(url=${data.mcpEndpoint.url}, serverNames=${JSON.stringify(data.mcpEndpoint.serverNames ?? null)})`);
|
|
76
|
+
}
|
|
77
|
+
return parts.length > 0 ? `mcp=[${parts.join(' ')}]` : 'mcp=[none]';
|
|
78
|
+
}
|
|
79
|
+
|
|
59
80
|
interface AcpCancelPayload {
|
|
60
81
|
sessionId: string;
|
|
61
82
|
}
|
|
@@ -103,6 +124,7 @@ interface AcpResumePayload {
|
|
|
103
124
|
externalSessionId: string;
|
|
104
125
|
serverType: string;
|
|
105
126
|
cwd: string;
|
|
127
|
+
mcpEndpoint?: { url: string; token: string; serverNames: string[] };
|
|
106
128
|
additionalDirectories?: string[];
|
|
107
129
|
}
|
|
108
130
|
|
|
@@ -243,8 +265,31 @@ export function connectToServer(
|
|
|
243
265
|
});
|
|
244
266
|
});
|
|
245
267
|
|
|
268
|
+
// 空实例探测:拉起指定工具的临时会话,收集 Mode/模型选项后关闭
|
|
269
|
+
socket.on('acp:probe-options', async (data: AcpProbeOptionsPayload) => {
|
|
270
|
+
console.log(`[ACP ctrl] acp:probe-options received: requestId=${data.requestId} serverType=${data.serverType}`);
|
|
271
|
+
try {
|
|
272
|
+
const options = await probeAcpServerOptions(data.serverType);
|
|
273
|
+
console.log(`[ACP ctrl] probe-options success: serverType=${data.serverType} modes=${options.modes.length} models=${options.availableModels.length}`);
|
|
274
|
+
socket.emit('acp:probe-options:result', {
|
|
275
|
+
requestId: data.requestId,
|
|
276
|
+
serverType: data.serverType,
|
|
277
|
+
success: true,
|
|
278
|
+
...options,
|
|
279
|
+
});
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.warn(`[ACP ctrl] probe-options failed: serverType=${data.serverType} error=${(error as Error).message}`);
|
|
282
|
+
socket.emit('acp:probe-options:result', {
|
|
283
|
+
requestId: data.requestId,
|
|
284
|
+
serverType: data.serverType,
|
|
285
|
+
success: false,
|
|
286
|
+
error: (error as Error).message,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
246
291
|
socket.on('acp:start', async (data: AcpStartPayload) => {
|
|
247
|
-
console.log(`[ACP ctrl] acp:start received: requestId=${data.requestId} sessionId=${data.sessionId} serverType=${data.serverType} cwd=${data.cwd}`);
|
|
292
|
+
console.log(`[ACP ctrl] acp:start received: requestId=${data.requestId} sessionId=${data.sessionId} serverType=${data.serverType} cwd=${data.cwd} ${describeMcpConfig(data)}`);
|
|
248
293
|
|
|
249
294
|
try {
|
|
250
295
|
const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
|
|
@@ -252,6 +297,7 @@ export function connectToServer(
|
|
|
252
297
|
sessionId: data.sessionId,
|
|
253
298
|
cwd: data.cwd,
|
|
254
299
|
mcpServers: data.mcpServers,
|
|
300
|
+
mcpEndpoint: data.mcpEndpoint,
|
|
255
301
|
additionalDirectories: data.additionalDirectories,
|
|
256
302
|
}, spawner);
|
|
257
303
|
|
|
@@ -261,6 +307,10 @@ export function connectToServer(
|
|
|
261
307
|
permissionRequestBridge.set(requestId, bridge);
|
|
262
308
|
});
|
|
263
309
|
|
|
310
|
+
bridge.on('elicitation:sent', (requestId: string) => {
|
|
311
|
+
elicitationRequestBridge.set(requestId, bridge);
|
|
312
|
+
});
|
|
313
|
+
|
|
264
314
|
const onInitialized = (payload: Record<string, unknown>) => {
|
|
265
315
|
if (payload.sessionId !== data.sessionId) return;
|
|
266
316
|
bridge.off('acp:initialized', onInitialized);
|
|
@@ -292,7 +342,7 @@ export function connectToServer(
|
|
|
292
342
|
bridge.off('acp:initialized', onInitialized);
|
|
293
343
|
bridge.off('acp:stopped', onStopped);
|
|
294
344
|
acpBridges.delete(data.sessionId);
|
|
295
|
-
console.log(`[ACP ctrl] emitting acp:start:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false`);
|
|
345
|
+
console.log(`[ACP ctrl] emitting acp:start:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
|
|
296
346
|
socket.emit('acp:start:result', {
|
|
297
347
|
requestId: data.requestId,
|
|
298
348
|
sessionId: data.sessionId,
|
|
@@ -318,13 +368,14 @@ export function connectToServer(
|
|
|
318
368
|
});
|
|
319
369
|
|
|
320
370
|
socket.on('acp:resume', async (data: AcpResumePayload) => {
|
|
321
|
-
console.log(`[ACP ctrl] acp:resume received: requestId=${data.requestId} sessionId=${data.sessionId} serverType=${data.serverType} externalSessionId=${data.externalSessionId}`);
|
|
371
|
+
console.log(`[ACP ctrl] acp:resume received: requestId=${data.requestId} sessionId=${data.sessionId} serverType=${data.serverType} externalSessionId=${data.externalSessionId} ${describeMcpConfig(data)}`);
|
|
322
372
|
|
|
323
373
|
try {
|
|
324
374
|
const spawner = createSpawner(data.serverType, data.sessionId, data.cwd);
|
|
325
375
|
const bridge = new AcpSessionBridge(socket, {
|
|
326
376
|
sessionId: data.sessionId,
|
|
327
377
|
cwd: data.cwd,
|
|
378
|
+
mcpEndpoint: data.mcpEndpoint,
|
|
328
379
|
additionalDirectories: data.additionalDirectories,
|
|
329
380
|
}, spawner);
|
|
330
381
|
|
|
@@ -334,6 +385,10 @@ export function connectToServer(
|
|
|
334
385
|
permissionRequestBridge.set(requestId, bridge);
|
|
335
386
|
});
|
|
336
387
|
|
|
388
|
+
bridge.on('elicitation:sent', (requestId: string) => {
|
|
389
|
+
elicitationRequestBridge.set(requestId, bridge);
|
|
390
|
+
});
|
|
391
|
+
|
|
337
392
|
const onInitialized = (payload: Record<string, unknown>) => {
|
|
338
393
|
if (payload.sessionId !== data.sessionId) return;
|
|
339
394
|
bridge.off('acp:initialized', onInitialized);
|
|
@@ -360,6 +415,7 @@ export function connectToServer(
|
|
|
360
415
|
bridge.off('acp:initialized', onInitialized);
|
|
361
416
|
bridge.off('acp:stopped', onStopped);
|
|
362
417
|
acpBridges.delete(data.sessionId);
|
|
418
|
+
console.log(`[ACP ctrl] emitting acp:resume:result: requestId=${data.requestId} sessionId=${data.sessionId} success=false reason=${payload.reason} error=${payload.error ?? 'n/a'}`);
|
|
363
419
|
socket.emit('acp:resume:result', {
|
|
364
420
|
requestId: data.requestId,
|
|
365
421
|
sessionId: data.sessionId,
|
|
@@ -769,11 +825,24 @@ export function connectToServer(
|
|
|
769
825
|
}
|
|
770
826
|
});
|
|
771
827
|
|
|
828
|
+
socket.on('acp:elicitation:response', (data: {
|
|
829
|
+
requestId: string;
|
|
830
|
+
action: 'accept' | 'decline' | 'cancel';
|
|
831
|
+
content?: Record<string, unknown>;
|
|
832
|
+
}) => {
|
|
833
|
+
const bridge = elicitationRequestBridge.get(data.requestId);
|
|
834
|
+
if (bridge) {
|
|
835
|
+
elicitationRequestBridge.delete(data.requestId);
|
|
836
|
+
bridge.handleElicitationResponse(data.requestId, data.action, data.content);
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
|
|
772
840
|
socket.on('disconnect', (reason) => {
|
|
773
841
|
console.log(`Disconnected: ${reason}`);
|
|
774
842
|
heartbeat.stop();
|
|
775
843
|
gatewayManager.closeAll();
|
|
776
844
|
permissionRequestBridge.clear();
|
|
845
|
+
elicitationRequestBridge.clear();
|
|
777
846
|
});
|
|
778
847
|
|
|
779
848
|
socket.on('connect_error', (err) => {
|