agent-gear 1.0.0
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/AGENTS.md +67 -0
- package/dist/acp/bridge.d.ts +56 -0
- package/dist/acp/bridge.d.ts.map +1 -0
- package/dist/acp/bridge.js +295 -0
- package/dist/acp/bridge.js.map +1 -0
- package/dist/acp/detector.d.ts +13 -0
- package/dist/acp/detector.d.ts.map +1 -0
- package/dist/acp/detector.js +29 -0
- package/dist/acp/detector.js.map +1 -0
- package/dist/acp/spawner.d.ts +24 -0
- package/dist/acp/spawner.d.ts.map +1 -0
- package/dist/acp/spawner.js +82 -0
- package/dist/acp/spawner.js.map +1 -0
- package/dist/acp/sync-bridge.d.ts +21 -0
- package/dist/acp/sync-bridge.d.ts.map +1 -0
- package/dist/acp/sync-bridge.js +75 -0
- package/dist/acp/sync-bridge.js.map +1 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +63 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon.d.ts +5 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +32 -0
- package/dist/daemon.js.map +1 -0
- package/dist/git-handler.d.ts +17 -0
- package/dist/git-handler.d.ts.map +1 -0
- package/dist/git-handler.js +60 -0
- package/dist/git-handler.js.map +1 -0
- package/dist/git-handler.test.d.ts +2 -0
- package/dist/git-handler.test.d.ts.map +1 -0
- package/dist/git-handler.test.js +127 -0
- package/dist/git-handler.test.js.map +1 -0
- package/dist/heartbeat.d.ts +6 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +16 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/socketio-client.d.ts +6 -0
- package/dist/socketio-client.d.ts.map +1 -0
- package/dist/socketio-client.js +385 -0
- package/dist/socketio-client.js.map +1 -0
- package/package.json +34 -0
- package/src/acp/bridge.ts +327 -0
- package/src/acp/detector.ts +40 -0
- package/src/acp/spawner.ts +94 -0
- package/src/acp/sync-bridge.ts +111 -0
- package/src/config.ts +75 -0
- package/src/daemon.ts +33 -0
- package/src/git-handler.test.ts +153 -0
- package/src/git-handler.ts +75 -0
- package/src/heartbeat.ts +18 -0
- package/src/index.ts +60 -0
- package/src/socketio-client.ts +497 -0
- package/tests/acp-bridge.test.ts +473 -0
- package/tests/acp-detector.test.ts +70 -0
- package/tests/acp-spawner.test.ts +149 -0
- package/tests/acp-sync-bridge.test.ts +263 -0
- package/tests/config.test.ts +166 -0
- package/tests/socketio-client.test.ts +196 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import type { Socket } from 'socket.io-client';
|
|
4
|
+
import { AcpSpawner } from './spawner.js';
|
|
5
|
+
|
|
6
|
+
const ACP_PROTOCOL_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export interface AcpBridgeOptions {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
cwd: string;
|
|
11
|
+
mcpServers?: Array<{ name: string; command: string; args?: string[]; env?: Array<{ name: string; value: string }> }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface QueuedInput {
|
|
15
|
+
type: string;
|
|
16
|
+
content: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toMcpServers(servers: AcpBridgeOptions['mcpServers']): acp.McpServer[] {
|
|
20
|
+
return (servers ?? []).map((s) => ({
|
|
21
|
+
name: s.name,
|
|
22
|
+
command: s.command,
|
|
23
|
+
args: s.args ?? [],
|
|
24
|
+
env: s.env ?? [],
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class AcpSessionBridge extends EventEmitter implements acp.Client {
|
|
29
|
+
private spawner: AcpSpawner;
|
|
30
|
+
private socket: Socket;
|
|
31
|
+
private sessionId: string;
|
|
32
|
+
private cwd: string;
|
|
33
|
+
private mcpServers: AcpBridgeOptions['mcpServers'];
|
|
34
|
+
private connection: acp.ClientSideConnection | null = null;
|
|
35
|
+
private acpSessionId: string | null = null;
|
|
36
|
+
private activePrompt: Promise<acp.PromptResponse> | null = null;
|
|
37
|
+
private inputQueue: QueuedInput[] = [];
|
|
38
|
+
private textChunkBuffer = '';
|
|
39
|
+
private textChunkTimer: ReturnType<typeof setTimeout> | null = null;
|
|
40
|
+
private initializing = false;
|
|
41
|
+
|
|
42
|
+
constructor(socket: Socket, options: AcpBridgeOptions, spawner?: AcpSpawner) {
|
|
43
|
+
super();
|
|
44
|
+
this.socket = socket;
|
|
45
|
+
this.sessionId = options.sessionId;
|
|
46
|
+
this.cwd = options.cwd;
|
|
47
|
+
this.mcpServers = options.mcpServers;
|
|
48
|
+
this.spawner = spawner ?? new AcpSpawner({ sessionId: options.sessionId, cwd: options.cwd });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async start(): Promise<void> {
|
|
52
|
+
if (this.initializing) return;
|
|
53
|
+
this.initializing = true;
|
|
54
|
+
|
|
55
|
+
this.spawner.start();
|
|
56
|
+
try {
|
|
57
|
+
await this.initConnection();
|
|
58
|
+
const result = await this.connection!.newSession({
|
|
59
|
+
cwd: this.cwd,
|
|
60
|
+
mcpServers: toMcpServers(this.mcpServers),
|
|
61
|
+
});
|
|
62
|
+
this.acpSessionId = result.sessionId;
|
|
63
|
+
this.initializing = false;
|
|
64
|
+
|
|
65
|
+
this.emit('acp:initialized', {
|
|
66
|
+
sessionId: this.sessionId,
|
|
67
|
+
acpSessionId: this.acpSessionId,
|
|
68
|
+
modes: this.extractModes(result.modes),
|
|
69
|
+
currentModeId: result.modes?.currentModeId ?? null,
|
|
70
|
+
currentModel: this.extractCurrentModel(result.models),
|
|
71
|
+
availableModels: this.extractAvailableModels(result.models),
|
|
72
|
+
currentModelId: result.models?.currentModelId ?? null,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.flushInputQueue();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
this.initializing = false;
|
|
78
|
+
this.handleError(error);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async resume(externalSessionId: string): Promise<void> {
|
|
83
|
+
if (this.initializing) return;
|
|
84
|
+
this.initializing = true;
|
|
85
|
+
|
|
86
|
+
this.spawner.start();
|
|
87
|
+
try {
|
|
88
|
+
await this.initConnection();
|
|
89
|
+
const result = await this.connection!.resumeSession({
|
|
90
|
+
sessionId: externalSessionId,
|
|
91
|
+
cwd: this.cwd,
|
|
92
|
+
});
|
|
93
|
+
this.acpSessionId = externalSessionId;
|
|
94
|
+
this.initializing = false;
|
|
95
|
+
|
|
96
|
+
this.emit('acp:initialized', {
|
|
97
|
+
sessionId: this.sessionId,
|
|
98
|
+
acpSessionId: this.acpSessionId,
|
|
99
|
+
modes: this.extractModes(result.modes),
|
|
100
|
+
currentModeId: result.modes?.currentModeId ?? null,
|
|
101
|
+
currentModel: this.extractCurrentModel(result.models),
|
|
102
|
+
availableModels: this.extractAvailableModels(result.models),
|
|
103
|
+
currentModelId: result.models?.currentModelId ?? null,
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// resumeSession may fail if agent doesn't support session.resume
|
|
107
|
+
// fallback to newSession
|
|
108
|
+
try {
|
|
109
|
+
const result = await this.connection!.newSession({
|
|
110
|
+
cwd: this.cwd,
|
|
111
|
+
mcpServers: toMcpServers(this.mcpServers),
|
|
112
|
+
});
|
|
113
|
+
this.acpSessionId = result.sessionId;
|
|
114
|
+
this.initializing = false;
|
|
115
|
+
|
|
116
|
+
this.emit('acp:initialized', {
|
|
117
|
+
sessionId: this.sessionId,
|
|
118
|
+
acpSessionId: this.acpSessionId,
|
|
119
|
+
modes: this.extractModes(result.modes),
|
|
120
|
+
currentModeId: result.modes?.currentModeId ?? null,
|
|
121
|
+
currentModel: this.extractCurrentModel(result.models),
|
|
122
|
+
availableModels: this.extractAvailableModels(result.models),
|
|
123
|
+
currentModelId: result.models?.currentModelId ?? null,
|
|
124
|
+
});
|
|
125
|
+
} catch (fallbackError) {
|
|
126
|
+
this.initializing = false;
|
|
127
|
+
this.handleError(fallbackError);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async stop(): Promise<void> {
|
|
133
|
+
try {
|
|
134
|
+
if (this.acpSessionId && this.connection) {
|
|
135
|
+
await this.connection.cancel({ sessionId: this.acpSessionId });
|
|
136
|
+
await this.connection.closeSession({ sessionId: this.acpSessionId });
|
|
137
|
+
}
|
|
138
|
+
} catch { /* ignore */ }
|
|
139
|
+
this.activePrompt = null;
|
|
140
|
+
this.inputQueue = [];
|
|
141
|
+
this.spawner.stop();
|
|
142
|
+
this.emitStopped('user_close');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
cancel(): void {
|
|
146
|
+
if (this.acpSessionId && this.connection) {
|
|
147
|
+
this.connection.cancel({ sessionId: this.acpSessionId });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async setMode(modeId: string): Promise<void> {
|
|
152
|
+
if (!this.connection || !this.acpSessionId) return;
|
|
153
|
+
await this.connection.setSessionMode({
|
|
154
|
+
sessionId: this.acpSessionId,
|
|
155
|
+
modeId,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async setModel(modelId: string): Promise<void> {
|
|
160
|
+
if (!this.connection || !this.acpSessionId) return;
|
|
161
|
+
await this.connection.unstable_setSessionModel({
|
|
162
|
+
sessionId: this.acpSessionId,
|
|
163
|
+
modelId,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
sendInput(message: { type: string; content: string }): void {
|
|
168
|
+
if (this.activePrompt) {
|
|
169
|
+
this.inputQueue.push(message);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.processPrompt(message);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async processPrompt(message: QueuedInput): Promise<void> {
|
|
176
|
+
if (!this.connection || !this.acpSessionId) return;
|
|
177
|
+
|
|
178
|
+
this.activePrompt = this.connection.prompt({
|
|
179
|
+
sessionId: this.acpSessionId,
|
|
180
|
+
prompt: [{ type: 'text' as const, text: message.content }],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await this.activePrompt;
|
|
185
|
+
} catch {
|
|
186
|
+
// cancelled or error
|
|
187
|
+
} finally {
|
|
188
|
+
this.activePrompt = null;
|
|
189
|
+
if (this.inputQueue.length > 0) {
|
|
190
|
+
this.processPrompt(this.inputQueue.shift()!);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async initConnection(): Promise<void> {
|
|
196
|
+
const input = this.spawner.stdin;
|
|
197
|
+
const output = this.spawner.stdout;
|
|
198
|
+
const stream = acp.ndJsonStream(input, output);
|
|
199
|
+
this.connection = new acp.ClientSideConnection(() => this, stream);
|
|
200
|
+
await this.connection.initialize({
|
|
201
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
202
|
+
clientCapabilities: {},
|
|
203
|
+
clientInfo: {
|
|
204
|
+
name: 'agent-gear',
|
|
205
|
+
version: '1.0.0',
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private flushInputQueue(): void {
|
|
211
|
+
while (this.inputQueue.length > 0) {
|
|
212
|
+
this.processPrompt(this.inputQueue.shift()!);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private extractModes(modes: acp.SessionModeState | null | undefined): Array<{ id: string; name: string; description?: string }> | null {
|
|
217
|
+
if (!modes?.availableModes) return null;
|
|
218
|
+
return modes.availableModes.map((m) => ({
|
|
219
|
+
id: m.id,
|
|
220
|
+
name: m.name,
|
|
221
|
+
description: m.description ?? undefined,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private extractCurrentModel(models: acp.SessionModelState | null | undefined): { id: string; name: string; provider?: string } | null {
|
|
226
|
+
if (!models?.availableModels || !models.currentModelId) return null;
|
|
227
|
+
const found = models.availableModels.find((m) => m.modelId === models.currentModelId);
|
|
228
|
+
if (!found) return null;
|
|
229
|
+
return { id: found.modelId, name: found.name };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private extractAvailableModels(models: acp.SessionModelState | null | undefined): Array<{ id: string; name: string; provider?: string }> | null {
|
|
233
|
+
if (!models?.availableModels) return null;
|
|
234
|
+
return models.availableModels.map((m) => ({
|
|
235
|
+
id: m.modelId,
|
|
236
|
+
name: m.name,
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── acp.Client implementation ──
|
|
241
|
+
|
|
242
|
+
async sessionUpdate(params: acp.SessionNotification): Promise<void> {
|
|
243
|
+
const update = params.update;
|
|
244
|
+
switch (update.sessionUpdate) {
|
|
245
|
+
case 'agent_message_chunk':
|
|
246
|
+
if (update.content.type === 'text') {
|
|
247
|
+
this.textChunkBuffer += update.content.text;
|
|
248
|
+
this.scheduleTextFlush();
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
case 'tool_call':
|
|
252
|
+
case 'tool_call_update':
|
|
253
|
+
case 'plan':
|
|
254
|
+
case 'agent_thought_chunk':
|
|
255
|
+
this.flushTextBuffer();
|
|
256
|
+
this.sendUpdate(update);
|
|
257
|
+
break;
|
|
258
|
+
case 'user_message_chunk':
|
|
259
|
+
break;
|
|
260
|
+
default:
|
|
261
|
+
this.flushTextBuffer();
|
|
262
|
+
this.sendUpdate(update);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async requestPermission(
|
|
268
|
+
params: acp.RequestPermissionRequest,
|
|
269
|
+
): Promise<acp.RequestPermissionResponse> {
|
|
270
|
+
return {
|
|
271
|
+
outcome: {
|
|
272
|
+
outcome: 'selected',
|
|
273
|
+
optionId: params.options.find((o) => o.kind === 'allow_once')?.optionId ?? params.options[0]?.optionId ?? 'reject',
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── text buffering ──
|
|
279
|
+
|
|
280
|
+
private scheduleTextFlush(): void {
|
|
281
|
+
if (this.textChunkTimer) clearTimeout(this.textChunkTimer);
|
|
282
|
+
this.textChunkTimer = setTimeout(() => this.flushTextBuffer(), 50);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private flushTextBuffer(): void {
|
|
286
|
+
if (this.textChunkTimer) {
|
|
287
|
+
clearTimeout(this.textChunkTimer);
|
|
288
|
+
this.textChunkTimer = null;
|
|
289
|
+
}
|
|
290
|
+
if (this.textChunkBuffer) {
|
|
291
|
+
this.sendUpdate({
|
|
292
|
+
sessionUpdate: 'agent_message_chunk' as const,
|
|
293
|
+
content: { type: 'text' as const, text: this.textChunkBuffer },
|
|
294
|
+
} as acp.SessionUpdate);
|
|
295
|
+
this.textChunkBuffer = '';
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── socket forwarding ──
|
|
300
|
+
|
|
301
|
+
private sendUpdate(update: acp.SessionUpdate): void {
|
|
302
|
+
this.socket.emit('acp:message', {
|
|
303
|
+
sessionId: this.sessionId,
|
|
304
|
+
message: update,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private emitStopped(reason: string, exitCode?: number): void {
|
|
309
|
+
this.socket.emit('acp:stopped', { sessionId: this.sessionId, reason, exitCode });
|
|
310
|
+
this.emit('acp:stopped', { sessionId: this.sessionId, reason, exitCode });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private handleError(error: unknown): void {
|
|
314
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
315
|
+
this.socket.emit('acp:stopped', {
|
|
316
|
+
sessionId: this.sessionId,
|
|
317
|
+
reason: 'error',
|
|
318
|
+
error: msg,
|
|
319
|
+
});
|
|
320
|
+
this.emit('acp:stopped', {
|
|
321
|
+
sessionId: this.sessionId,
|
|
322
|
+
reason: 'error',
|
|
323
|
+
error: msg,
|
|
324
|
+
});
|
|
325
|
+
this.spawner.stop();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
|
|
6
|
+
interface AcpServerInfo {
|
|
7
|
+
type: string;
|
|
8
|
+
available: boolean;
|
|
9
|
+
version?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class AcpDetector {
|
|
13
|
+
async detect(): Promise<AcpServerInfo[]> {
|
|
14
|
+
const servers: AcpServerInfo[] = [];
|
|
15
|
+
|
|
16
|
+
// Detect OpenCode
|
|
17
|
+
const opencode = await this.detectOpenCode();
|
|
18
|
+
servers.push(opencode);
|
|
19
|
+
|
|
20
|
+
return servers;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async detectOpenCode(): Promise<AcpServerInfo> {
|
|
24
|
+
try {
|
|
25
|
+
const { stdout } = await execAsync('opencode -v');
|
|
26
|
+
const version = this.parseVersion(stdout);
|
|
27
|
+
return { type: 'opencode', available: true, version };
|
|
28
|
+
} catch {
|
|
29
|
+
return { type: 'opencode', available: false };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private parseVersion(output: string): string {
|
|
34
|
+
// Try to extract version from output
|
|
35
|
+
const match = output.match(/(\d+\.\d+\.\d+)/);
|
|
36
|
+
return match ? match[1] : 'unknown';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const acpDetector = new AcpDetector();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { spawn, execFileSync, type ChildProcess } from 'child_process';
|
|
2
|
+
import { Writable, Readable } from 'node:stream';
|
|
3
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
4
|
+
|
|
5
|
+
export interface AcpProcessOptions {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
command?: string;
|
|
9
|
+
args?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class AcpSpawner {
|
|
13
|
+
private process: ChildProcess | null = null;
|
|
14
|
+
private sessionId: string;
|
|
15
|
+
private cwd: string;
|
|
16
|
+
private command: string;
|
|
17
|
+
private args: string[];
|
|
18
|
+
private resolveClosed: (() => void) | null = null;
|
|
19
|
+
private _closed: Promise<void>;
|
|
20
|
+
|
|
21
|
+
constructor(options: AcpProcessOptions) {
|
|
22
|
+
this.sessionId = options.sessionId;
|
|
23
|
+
this.cwd = options.cwd;
|
|
24
|
+
this.command = options.command ?? 'opencode';
|
|
25
|
+
this.args = options.args ?? ['acp'];
|
|
26
|
+
this._closed = new Promise((resolve) => { this.resolveClosed = resolve; });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
start(): void {
|
|
30
|
+
const cmd = this.resolveCommand();
|
|
31
|
+
console.log(`Starting ACP server ${cmd}`)
|
|
32
|
+
mkdirSync(this.cwd, { recursive: true });
|
|
33
|
+
this.process = spawn(cmd, this.args, {
|
|
34
|
+
cwd: this.cwd,
|
|
35
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
this.process.stderr?.on('data', (data: Buffer) => {
|
|
39
|
+
console.error(`ACP stderr: ${data.toString()}`);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
this.process.on('exit', (code, signal) => {
|
|
43
|
+
console.log(`ACP process exited with code ${code} and signal ${signal}`);
|
|
44
|
+
this.process = null;
|
|
45
|
+
this.resolveClosed?.();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
this.process.on('error', (error) => {
|
|
49
|
+
console.error('ACP process error:', error);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get stdin(): WritableStream {
|
|
54
|
+
if (!this.process?.stdin) throw new Error('ACP process not running');
|
|
55
|
+
return Writable.toWeb(this.process.stdin) as WritableStream;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get stdout(): ReadableStream<Uint8Array> {
|
|
59
|
+
if (!this.process?.stdout) throw new Error('ACP process not running');
|
|
60
|
+
return Readable.toWeb(this.process.stdout) as ReadableStream<Uint8Array>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get closed(): Promise<void> {
|
|
64
|
+
return this._closed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stop(): void {
|
|
68
|
+
if (this.process) {
|
|
69
|
+
this.process.kill('SIGTERM');
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
if (this.process) {
|
|
72
|
+
this.process.kill('SIGKILL');
|
|
73
|
+
}
|
|
74
|
+
}, 5000);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
isRunning(): boolean {
|
|
79
|
+
return this.process !== null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private resolveCommand(): string {
|
|
83
|
+
if (this.command.includes('/')) return this.command;
|
|
84
|
+
const candidates = [this.command, `/usr/bin/${this.command}`, `/usr/local/bin/${this.command}`];
|
|
85
|
+
for (const cmd of candidates) {
|
|
86
|
+
try {
|
|
87
|
+
const result = execFileSync('which', [cmd], { encoding: 'utf8', timeout: 1000 });
|
|
88
|
+
if (result.trim()) return result.trim();
|
|
89
|
+
} catch { /* try next */ }
|
|
90
|
+
if (existsSync(cmd)) return cmd;
|
|
91
|
+
}
|
|
92
|
+
return this.command;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
2
|
+
import { AcpSpawner } from './spawner.js';
|
|
3
|
+
|
|
4
|
+
const ACP_PROTOCOL_VERSION = 1;
|
|
5
|
+
const CONNECT_TIMEOUT_MS = 30_000;
|
|
6
|
+
const LOAD_SESSION_TIMEOUT_MS = 120_000;
|
|
7
|
+
|
|
8
|
+
async function withTimeout<T>(
|
|
9
|
+
promise: Promise<T>,
|
|
10
|
+
ms: number,
|
|
11
|
+
message: string,
|
|
12
|
+
): Promise<T> {
|
|
13
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
14
|
+
const timerPromise = new Promise<never>((_, reject) => {
|
|
15
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
return await Promise.race([promise, timerPromise]);
|
|
19
|
+
} finally {
|
|
20
|
+
clearTimeout(timer!);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AcpSyncBridgeOptions {
|
|
25
|
+
cwd: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class AcpSyncBridge implements acp.Client {
|
|
29
|
+
private spawner: AcpSpawner;
|
|
30
|
+
private connection: acp.ClientSideConnection | null = null;
|
|
31
|
+
private updates: acp.SessionNotification[] = [];
|
|
32
|
+
|
|
33
|
+
constructor(options: AcpSyncBridgeOptions, spawner?: AcpSpawner) {
|
|
34
|
+
this.spawner = spawner ?? new AcpSpawner({
|
|
35
|
+
sessionId: `sync_${Date.now()}`,
|
|
36
|
+
cwd: options.cwd,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async connect(timeoutMs: number = CONNECT_TIMEOUT_MS): Promise<void> {
|
|
41
|
+
this.spawner.start();
|
|
42
|
+
const stream = acp.ndJsonStream(this.spawner.stdin, this.spawner.stdout);
|
|
43
|
+
this.connection = new acp.ClientSideConnection(() => this, stream);
|
|
44
|
+
|
|
45
|
+
const exitPromise = this.spawner.closed.then(() => {
|
|
46
|
+
throw new Error('ACP process exited unexpectedly');
|
|
47
|
+
});
|
|
48
|
+
exitPromise.catch(() => {});
|
|
49
|
+
|
|
50
|
+
const initPromise = this.connection.initialize({
|
|
51
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
52
|
+
clientCapabilities: {},
|
|
53
|
+
clientInfo: { name: 'agent-gear', version: '1.0.0' },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await withTimeout(
|
|
57
|
+
Promise.race([initPromise, exitPromise]),
|
|
58
|
+
timeoutMs,
|
|
59
|
+
`ACP connect timed out after ${timeoutMs}ms`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async listSessions(cwd: string): Promise<acp.ListSessionsResponse> {
|
|
64
|
+
return this.connection!.listSessions({ cwd });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async loadSession(
|
|
68
|
+
sessionId: string,
|
|
69
|
+
cwd: string,
|
|
70
|
+
timeoutMs: number = LOAD_SESSION_TIMEOUT_MS,
|
|
71
|
+
): Promise<{
|
|
72
|
+
config: acp.LoadSessionResponse;
|
|
73
|
+
updates: acp.SessionNotification[];
|
|
74
|
+
}> {
|
|
75
|
+
this.updates = [];
|
|
76
|
+
|
|
77
|
+
const exitPromise = this.spawner.closed.then(() => {
|
|
78
|
+
throw new Error('ACP process exited unexpectedly');
|
|
79
|
+
});
|
|
80
|
+
exitPromise.catch(() => {});
|
|
81
|
+
|
|
82
|
+
const config = await withTimeout(
|
|
83
|
+
Promise.race([
|
|
84
|
+
this.connection!.loadSession({
|
|
85
|
+
cwd,
|
|
86
|
+
sessionId,
|
|
87
|
+
mcpServers: [],
|
|
88
|
+
}),
|
|
89
|
+
exitPromise,
|
|
90
|
+
]),
|
|
91
|
+
timeoutMs,
|
|
92
|
+
`ACP load-session timed out after ${timeoutMs}ms`,
|
|
93
|
+
);
|
|
94
|
+
return { config, updates: [...this.updates] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async disconnect(sessionId?: string): Promise<void> {
|
|
98
|
+
if (sessionId) {
|
|
99
|
+
await this.connection?.closeSession({ sessionId }).catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
this.spawner.stop();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async sessionUpdate(params: acp.SessionNotification): Promise<void> {
|
|
105
|
+
this.updates.push(params);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async requestPermission(): Promise<acp.RequestPermissionResponse> {
|
|
109
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { parse, stringify } from 'smol-toml';
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = join(homedir(), '.zag');
|
|
7
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.toml');
|
|
8
|
+
|
|
9
|
+
export interface ConfigData {
|
|
10
|
+
key?: { value: string };
|
|
11
|
+
server?: { endpoint: string };
|
|
12
|
+
controller?: { name: string };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readConfig(): ConfigData {
|
|
16
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
|
20
|
+
return parse(content) as ConfigData;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeConfig(data: ConfigData): void {
|
|
24
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
25
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
writeFileSync(CONFIG_FILE, stringify(data), 'utf-8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function clearConfigKey(): void {
|
|
31
|
+
const config = readConfig();
|
|
32
|
+
delete config.key;
|
|
33
|
+
writeConfig(config);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getKeyFromConfig(): string | undefined {
|
|
37
|
+
const config = readConfig();
|
|
38
|
+
return config.key?.value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getEndpointFromConfig(): string | undefined {
|
|
42
|
+
const config = readConfig();
|
|
43
|
+
return config.server?.endpoint;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getControllerNameFromConfig(): string | undefined {
|
|
47
|
+
const config = readConfig();
|
|
48
|
+
return config.controller?.name;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function saveKeyToConfig(key: string): void {
|
|
52
|
+
const config = readConfig();
|
|
53
|
+
config.key = { value: key };
|
|
54
|
+
writeConfig(config);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function saveEndpointToConfig(endpoint: string): void {
|
|
58
|
+
const config = readConfig();
|
|
59
|
+
config.server = { endpoint };
|
|
60
|
+
writeConfig(config);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function resolveKey(cliKey?: string): string | undefined {
|
|
64
|
+
if (cliKey) return cliKey;
|
|
65
|
+
const envKey = process.env.ZAG_CONTROLLER_KEY;
|
|
66
|
+
if (envKey) return envKey;
|
|
67
|
+
return getKeyFromConfig();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resolveEndpoint(cliEndpoint?: string): string | undefined {
|
|
71
|
+
if (cliEndpoint) return cliEndpoint;
|
|
72
|
+
const envEndpoint = process.env.ZAG_SERVER_ENDPOINT;
|
|
73
|
+
if (envEndpoint) return envEndpoint;
|
|
74
|
+
return getEndpointFromConfig();
|
|
75
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { writeFileSync, existsSync, readFileSync, unlinkSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
|
|
5
|
+
const PID_FILE = join(tmpdir(), 'agent-gear.pid');
|
|
6
|
+
|
|
7
|
+
export function writePid(): void {
|
|
8
|
+
writeFileSync(PID_FILE, process.pid.toString(), 'utf-8');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readPid(): number | null {
|
|
12
|
+
if (!existsSync(PID_FILE)) return null;
|
|
13
|
+
const content = readFileSync(PID_FILE, 'utf-8').trim();
|
|
14
|
+
const pid = parseInt(content, 10);
|
|
15
|
+
return isNaN(pid) ? null : pid;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function removePid(): void {
|
|
19
|
+
if (existsSync(PID_FILE)) {
|
|
20
|
+
unlinkSync(PID_FILE);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isRunning(): boolean {
|
|
25
|
+
const pid = readPid();
|
|
26
|
+
if (!pid) return false;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|