@geminilight/mindos 0.6.27 → 0.6.29
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/app/app/api/a2a/agents/route.ts +9 -0
- package/app/app/api/a2a/delegations/route.ts +9 -0
- package/app/app/api/a2a/discover/route.ts +2 -0
- package/app/app/api/a2a/route.ts +6 -6
- package/app/app/api/acp/detect/route.ts +91 -0
- package/app/app/api/acp/registry/route.ts +31 -0
- package/app/app/api/acp/session/route.ts +55 -0
- package/app/app/layout.tsx +2 -0
- package/app/components/DirView.tsx +64 -2
- package/app/components/FileTree.tsx +19 -0
- package/app/components/GuideCard.tsx +7 -17
- package/app/components/MarkdownView.tsx +2 -0
- package/app/components/SearchModal.tsx +234 -80
- package/app/components/agents/AgentDetailContent.tsx +51 -6
- package/app/components/agents/AgentsContentPage.tsx +24 -6
- package/app/components/agents/AgentsOverviewSection.tsx +11 -0
- package/app/components/agents/AgentsPanelA2aTab.tsx +445 -0
- package/app/components/agents/SkillDetailPopover.tsx +4 -9
- package/app/components/agents/agents-content-model.ts +2 -2
- package/app/components/ask/AskContent.tsx +8 -0
- package/app/components/help/HelpContent.tsx +74 -18
- package/app/components/panels/AgentsPanel.tsx +1 -0
- package/app/components/panels/AgentsPanelAgentDetail.tsx +5 -8
- package/app/components/panels/AgentsPanelAgentListRow.tsx +10 -1
- package/app/components/panels/AgentsPanelHubNav.tsx +8 -1
- package/app/components/panels/EchoPanel.tsx +5 -1
- package/app/components/panels/EchoSidebarStats.tsx +136 -0
- package/app/components/settings/KnowledgeTab.tsx +3 -6
- package/app/components/settings/McpSkillsSection.tsx +4 -5
- package/app/components/settings/McpTab.tsx +6 -8
- package/app/components/setup/StepSecurity.tsx +4 -5
- package/app/components/setup/index.tsx +5 -11
- package/app/components/ui/Toaster.tsx +39 -0
- package/app/hooks/useA2aRegistry.ts +6 -1
- package/app/hooks/useAcpDetection.ts +65 -0
- package/app/hooks/useAcpRegistry.ts +51 -0
- package/app/hooks/useDelegationHistory.ts +49 -0
- package/app/lib/a2a/client.ts +49 -5
- package/app/lib/a2a/orchestrator.ts +0 -1
- package/app/lib/a2a/task-handler.ts +4 -4
- package/app/lib/a2a/types.ts +15 -0
- package/app/lib/acp/acp-tools.ts +93 -0
- package/app/lib/acp/bridge.ts +138 -0
- package/app/lib/acp/index.ts +24 -0
- package/app/lib/acp/registry.ts +135 -0
- package/app/lib/acp/session.ts +264 -0
- package/app/lib/acp/subprocess.ts +209 -0
- package/app/lib/acp/types.ts +136 -0
- package/app/lib/agent/tools.ts +2 -1
- package/app/lib/i18n/_core.ts +22 -0
- package/app/lib/i18n/index.ts +35 -0
- package/app/lib/i18n/modules/ai-chat.ts +215 -0
- package/app/lib/i18n/modules/common.ts +71 -0
- package/app/lib/i18n/modules/features.ts +153 -0
- package/app/lib/i18n/modules/knowledge.ts +425 -0
- package/app/lib/i18n/modules/navigation.ts +151 -0
- package/app/lib/i18n/modules/onboarding.ts +523 -0
- package/app/lib/i18n/modules/panels.ts +1052 -0
- package/app/lib/i18n/modules/settings.ts +585 -0
- package/app/lib/i18n-en.ts +2 -1518
- package/app/lib/i18n-zh.ts +2 -1542
- package/app/lib/i18n.ts +3 -6
- package/app/lib/toast.ts +79 -0
- package/bin/cli.js +25 -25
- package/bin/commands/file.js +29 -2
- package/bin/commands/space.js +249 -91
- package/package.json +1 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP Registry Client — Fetch and cache the ACP agent registry.
|
|
3
|
+
* The registry lists available ACP agents (Gemini CLI, Claude, Copilot, etc.)
|
|
4
|
+
* with their transport type, command, and metadata.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AcpRegistry, AcpRegistryEntry } from './types';
|
|
8
|
+
|
|
9
|
+
/* ── Constants ─────────────────────────────────────────────────────────── */
|
|
10
|
+
|
|
11
|
+
const REGISTRY_URL = 'https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json';
|
|
12
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
13
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
14
|
+
|
|
15
|
+
/* ── Cache ─────────────────────────────────────────────────────────────── */
|
|
16
|
+
|
|
17
|
+
let cachedRegistry: AcpRegistry | null = null;
|
|
18
|
+
|
|
19
|
+
/* ── Public API ────────────────────────────────────────────────────────── */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fetch the ACP registry from the CDN. Caches for 1 hour.
|
|
23
|
+
* Returns null if the fetch fails.
|
|
24
|
+
*/
|
|
25
|
+
export async function fetchAcpRegistry(): Promise<AcpRegistry | null> {
|
|
26
|
+
// Return cached if still valid
|
|
27
|
+
if (cachedRegistry && Date.now() - new Date(cachedRegistry.fetchedAt).getTime() < CACHE_TTL_MS) {
|
|
28
|
+
return cachedRegistry;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(REGISTRY_URL, {
|
|
33
|
+
headers: { 'Accept': 'application/json' },
|
|
34
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!res.ok) return cachedRegistry ?? null;
|
|
38
|
+
|
|
39
|
+
const data = await res.json();
|
|
40
|
+
|
|
41
|
+
// The registry JSON may have varying shapes; normalize it
|
|
42
|
+
const agents: AcpRegistryEntry[] = parseRegistryEntries(data);
|
|
43
|
+
|
|
44
|
+
cachedRegistry = {
|
|
45
|
+
version: data.version ?? '1',
|
|
46
|
+
agents,
|
|
47
|
+
fetchedAt: new Date().toISOString(),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return cachedRegistry;
|
|
51
|
+
} catch {
|
|
52
|
+
// Return stale cache if available, otherwise null
|
|
53
|
+
return cachedRegistry ?? null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get all available ACP agents from the registry.
|
|
59
|
+
*/
|
|
60
|
+
export async function getAcpAgents(): Promise<AcpRegistryEntry[]> {
|
|
61
|
+
const registry = await fetchAcpRegistry();
|
|
62
|
+
return registry?.agents ?? [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find a specific ACP agent by ID.
|
|
67
|
+
*/
|
|
68
|
+
export async function findAcpAgent(id: string): Promise<AcpRegistryEntry | null> {
|
|
69
|
+
const agents = await getAcpAgents();
|
|
70
|
+
return agents.find(a => a.id === id) ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Clear the registry cache (useful for testing).
|
|
75
|
+
*/
|
|
76
|
+
export function clearRegistryCache(): void {
|
|
77
|
+
cachedRegistry = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ── Internal ──────────────────────────────────────────────────────────── */
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse raw registry JSON into typed entries.
|
|
84
|
+
* Handles both array and object-keyed formats.
|
|
85
|
+
*/
|
|
86
|
+
function parseRegistryEntries(data: unknown): AcpRegistryEntry[] {
|
|
87
|
+
if (!data || typeof data !== 'object') return [];
|
|
88
|
+
|
|
89
|
+
// If data has an `agents` array, use that
|
|
90
|
+
const obj = data as Record<string, unknown>;
|
|
91
|
+
let rawAgents: unknown[];
|
|
92
|
+
|
|
93
|
+
if (Array.isArray(obj.agents)) {
|
|
94
|
+
rawAgents = obj.agents;
|
|
95
|
+
} else if (Array.isArray(data)) {
|
|
96
|
+
rawAgents = data;
|
|
97
|
+
} else {
|
|
98
|
+
// Object-keyed format: { "agent-id": { ... }, ... }
|
|
99
|
+
rawAgents = Object.entries(obj)
|
|
100
|
+
.filter(([key]) => key !== 'version' && key !== '$schema')
|
|
101
|
+
.map(([key, val]) => ({ ...(val as object), id: key }));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return rawAgents
|
|
105
|
+
.map(normalizeEntry)
|
|
106
|
+
.filter((e): e is AcpRegistryEntry => e !== null);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeEntry(raw: unknown): AcpRegistryEntry | null {
|
|
110
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
111
|
+
const entry = raw as Record<string, unknown>;
|
|
112
|
+
|
|
113
|
+
const id = String(entry.id ?? entry.name ?? '');
|
|
114
|
+
const name = String(entry.name ?? entry.id ?? '');
|
|
115
|
+
if (!id && !name) return null;
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
id: id || name,
|
|
119
|
+
name: name || id,
|
|
120
|
+
description: String(entry.description ?? ''),
|
|
121
|
+
version: entry.version ? String(entry.version) : undefined,
|
|
122
|
+
transport: normalizeTransport(entry.transport),
|
|
123
|
+
command: String(entry.command ?? entry.cmd ?? ''),
|
|
124
|
+
args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
|
|
125
|
+
env: entry.env && typeof entry.env === 'object' ? entry.env as Record<string, string> : undefined,
|
|
126
|
+
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : undefined,
|
|
127
|
+
homepage: entry.homepage ? String(entry.homepage) : undefined,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeTransport(raw: unknown): AcpRegistryEntry['transport'] {
|
|
132
|
+
const t = String(raw ?? 'stdio').toLowerCase();
|
|
133
|
+
if (t === 'npx' || t === 'uvx' || t === 'binary') return t;
|
|
134
|
+
return 'stdio';
|
|
135
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP Session Manager — High-level session lifecycle for ACP agents.
|
|
3
|
+
* Manages session creation, prompt turns, cancellation, and cleanup.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
AcpSession,
|
|
8
|
+
AcpSessionState,
|
|
9
|
+
AcpSessionUpdate,
|
|
10
|
+
AcpPromptResponse,
|
|
11
|
+
AcpRegistryEntry,
|
|
12
|
+
} from './types';
|
|
13
|
+
import {
|
|
14
|
+
spawnAcpAgent,
|
|
15
|
+
sendAndWait,
|
|
16
|
+
sendMessage,
|
|
17
|
+
onMessage,
|
|
18
|
+
killAgent,
|
|
19
|
+
type AcpProcess,
|
|
20
|
+
} from './subprocess';
|
|
21
|
+
import { findAcpAgent } from './registry';
|
|
22
|
+
|
|
23
|
+
/* ── State ─────────────────────────────────────────────────────────────── */
|
|
24
|
+
|
|
25
|
+
const sessions = new Map<string, AcpSession>();
|
|
26
|
+
const sessionProcesses = new Map<string, AcpProcess>();
|
|
27
|
+
|
|
28
|
+
/* ── Public API ────────────────────────────────────────────────────────── */
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a new ACP session by spawning an agent process.
|
|
32
|
+
*/
|
|
33
|
+
export async function createSession(
|
|
34
|
+
agentId: string,
|
|
35
|
+
options?: { env?: Record<string, string> },
|
|
36
|
+
): Promise<AcpSession> {
|
|
37
|
+
const entry = await findAcpAgent(agentId);
|
|
38
|
+
if (!entry) {
|
|
39
|
+
throw new Error(`ACP agent not found in registry: ${agentId}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return createSessionFromEntry(entry, options);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a session from a known registry entry (skips registry lookup).
|
|
47
|
+
*/
|
|
48
|
+
export async function createSessionFromEntry(
|
|
49
|
+
entry: AcpRegistryEntry,
|
|
50
|
+
options?: { env?: Record<string, string> },
|
|
51
|
+
): Promise<AcpSession> {
|
|
52
|
+
const proc = spawnAcpAgent(entry, options);
|
|
53
|
+
|
|
54
|
+
// Send session/new and wait for ack
|
|
55
|
+
try {
|
|
56
|
+
const response = await sendAndWait(proc, 'session/new', {}, 15_000);
|
|
57
|
+
|
|
58
|
+
if (response.error) {
|
|
59
|
+
killAgent(proc);
|
|
60
|
+
throw new Error(`session/new failed: ${response.error.message}`);
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
killAgent(proc);
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sessionId = `ses-${entry.id}-${Date.now()}`;
|
|
68
|
+
const session: AcpSession = {
|
|
69
|
+
id: sessionId,
|
|
70
|
+
agentId: entry.id,
|
|
71
|
+
state: 'idle',
|
|
72
|
+
createdAt: new Date().toISOString(),
|
|
73
|
+
lastActivityAt: new Date().toISOString(),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
sessions.set(sessionId, session);
|
|
77
|
+
sessionProcesses.set(sessionId, proc);
|
|
78
|
+
return session;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Send a prompt to an active session and collect the full response.
|
|
83
|
+
* For streaming, use promptStream() instead.
|
|
84
|
+
*/
|
|
85
|
+
export async function prompt(
|
|
86
|
+
sessionId: string,
|
|
87
|
+
text: string,
|
|
88
|
+
): Promise<AcpPromptResponse> {
|
|
89
|
+
const { session, proc } = getSessionAndProc(sessionId);
|
|
90
|
+
|
|
91
|
+
if (session.state === 'active') {
|
|
92
|
+
throw new Error(`Session ${sessionId} is busy processing another prompt`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
updateSessionState(session, 'active');
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const response = await sendAndWait(proc, 'session/prompt', { text }, 60_000);
|
|
99
|
+
|
|
100
|
+
if (response.error) {
|
|
101
|
+
updateSessionState(session, 'error');
|
|
102
|
+
throw new Error(`session/prompt error: ${response.error.message}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
updateSessionState(session, 'idle');
|
|
106
|
+
const result = response.result as Record<string, unknown>;
|
|
107
|
+
return {
|
|
108
|
+
sessionId,
|
|
109
|
+
text: String(result?.text ?? ''),
|
|
110
|
+
done: true,
|
|
111
|
+
toolCalls: result?.toolCalls as AcpPromptResponse['toolCalls'],
|
|
112
|
+
metadata: result?.metadata as AcpPromptResponse['metadata'],
|
|
113
|
+
};
|
|
114
|
+
} catch (err) {
|
|
115
|
+
updateSessionState(session, 'error');
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Send a prompt and receive streaming updates via callback.
|
|
122
|
+
* Returns the final aggregated response.
|
|
123
|
+
*/
|
|
124
|
+
export async function promptStream(
|
|
125
|
+
sessionId: string,
|
|
126
|
+
text: string,
|
|
127
|
+
onUpdate: (update: AcpSessionUpdate) => void,
|
|
128
|
+
): Promise<AcpPromptResponse> {
|
|
129
|
+
const { session, proc } = getSessionAndProc(sessionId);
|
|
130
|
+
|
|
131
|
+
if (session.state === 'active') {
|
|
132
|
+
throw new Error(`Session ${sessionId} is busy processing another prompt`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
updateSessionState(session, 'active');
|
|
136
|
+
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
let aggregatedText = '';
|
|
139
|
+
|
|
140
|
+
const unsub = onMessage(proc, (msg) => {
|
|
141
|
+
// Handle streaming notifications (no id field, or notification pattern)
|
|
142
|
+
if (msg.result && typeof msg.result === 'object') {
|
|
143
|
+
const update = msg.result as Record<string, unknown>;
|
|
144
|
+
const sessionUpdate: AcpSessionUpdate = {
|
|
145
|
+
sessionId,
|
|
146
|
+
type: (update.type as AcpSessionUpdate['type']) ?? 'text',
|
|
147
|
+
text: update.text as string | undefined,
|
|
148
|
+
toolCall: update.toolCall as AcpSessionUpdate['toolCall'],
|
|
149
|
+
toolResult: update.toolResult as AcpSessionUpdate['toolResult'],
|
|
150
|
+
error: update.error as string | undefined,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
onUpdate(sessionUpdate);
|
|
154
|
+
|
|
155
|
+
if (sessionUpdate.type === 'text' && sessionUpdate.text) {
|
|
156
|
+
aggregatedText += sessionUpdate.text;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (sessionUpdate.type === 'done') {
|
|
160
|
+
unsub();
|
|
161
|
+
updateSessionState(session, 'idle');
|
|
162
|
+
resolve({
|
|
163
|
+
sessionId,
|
|
164
|
+
text: aggregatedText,
|
|
165
|
+
done: true,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (sessionUpdate.type === 'error') {
|
|
170
|
+
unsub();
|
|
171
|
+
updateSessionState(session, 'error');
|
|
172
|
+
reject(new Error(sessionUpdate.error ?? 'Unknown ACP error'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Send the prompt
|
|
178
|
+
try {
|
|
179
|
+
sendMessage(proc, 'session/prompt', { text, stream: true });
|
|
180
|
+
} catch (err) {
|
|
181
|
+
unsub();
|
|
182
|
+
updateSessionState(session, 'error');
|
|
183
|
+
reject(err);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Cancel the current prompt turn on a session.
|
|
190
|
+
*/
|
|
191
|
+
export async function cancelPrompt(sessionId: string): Promise<void> {
|
|
192
|
+
const { session, proc } = getSessionAndProc(sessionId);
|
|
193
|
+
|
|
194
|
+
if (session.state !== 'active') return;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
await sendAndWait(proc, 'session/cancel', {}, 5_000);
|
|
198
|
+
} catch {
|
|
199
|
+
// Best-effort cancel — don't throw if the agent doesn't support it
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
updateSessionState(session, 'idle');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Close a session and terminate the subprocess.
|
|
207
|
+
*/
|
|
208
|
+
export async function closeSession(sessionId: string): Promise<void> {
|
|
209
|
+
const proc = sessionProcesses.get(sessionId);
|
|
210
|
+
|
|
211
|
+
if (proc?.alive) {
|
|
212
|
+
try {
|
|
213
|
+
await sendAndWait(proc, 'session/close', {}, 5_000);
|
|
214
|
+
} catch {
|
|
215
|
+
// Best-effort close
|
|
216
|
+
}
|
|
217
|
+
killAgent(proc);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
sessions.delete(sessionId);
|
|
221
|
+
sessionProcesses.delete(sessionId);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get a session by its ID.
|
|
226
|
+
*/
|
|
227
|
+
export function getSession(sessionId: string): AcpSession | undefined {
|
|
228
|
+
return sessions.get(sessionId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get all active sessions.
|
|
233
|
+
*/
|
|
234
|
+
export function getActiveSessions(): AcpSession[] {
|
|
235
|
+
return [...sessions.values()];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Close all active sessions. Used for cleanup.
|
|
240
|
+
*/
|
|
241
|
+
export async function closeAllSessions(): Promise<void> {
|
|
242
|
+
const ids = [...sessions.keys()];
|
|
243
|
+
await Promise.allSettled(ids.map(id => closeSession(id)));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* ── Internal ──────────────────────────────────────────────────────────── */
|
|
247
|
+
|
|
248
|
+
function getSessionAndProc(sessionId: string): { session: AcpSession; proc: AcpProcess } {
|
|
249
|
+
const session = sessions.get(sessionId);
|
|
250
|
+
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
|
251
|
+
|
|
252
|
+
const proc = sessionProcesses.get(sessionId);
|
|
253
|
+
if (!proc?.alive) {
|
|
254
|
+
updateSessionState(session, 'error');
|
|
255
|
+
throw new Error(`Session process is dead: ${sessionId}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { session, proc };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function updateSessionState(session: AcpSession, state: AcpSessionState): void {
|
|
262
|
+
session.state = state;
|
|
263
|
+
session.lastActivityAt = new Date().toISOString();
|
|
264
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP Subprocess Manager — Spawn and communicate with ACP agent processes.
|
|
3
|
+
* ACP agents communicate via JSON-RPC 2.0 over stdio (stdin/stdout).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, type ChildProcess } from 'child_process';
|
|
7
|
+
import type {
|
|
8
|
+
AcpJsonRpcRequest,
|
|
9
|
+
AcpJsonRpcResponse,
|
|
10
|
+
AcpRegistryEntry,
|
|
11
|
+
AcpTransportType,
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
/* ── Types ─────────────────────────────────────────────────────────────── */
|
|
15
|
+
|
|
16
|
+
export interface AcpProcess {
|
|
17
|
+
id: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
proc: ChildProcess;
|
|
20
|
+
alive: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type MessageCallback = (msg: AcpJsonRpcResponse) => void;
|
|
24
|
+
|
|
25
|
+
/* ── State ─────────────────────────────────────────────────────────────── */
|
|
26
|
+
|
|
27
|
+
const processes = new Map<string, AcpProcess>();
|
|
28
|
+
const messageListeners = new Map<string, Set<MessageCallback>>();
|
|
29
|
+
let rpcIdCounter = 1;
|
|
30
|
+
|
|
31
|
+
/* ── Public API ────────────────────────────────────────────────────────── */
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Spawn an ACP agent subprocess.
|
|
35
|
+
*/
|
|
36
|
+
export function spawnAcpAgent(
|
|
37
|
+
entry: AcpRegistryEntry,
|
|
38
|
+
options?: { env?: Record<string, string> },
|
|
39
|
+
): AcpProcess {
|
|
40
|
+
const { cmd, args } = buildCommand(entry);
|
|
41
|
+
|
|
42
|
+
const mergedEnv = {
|
|
43
|
+
...process.env,
|
|
44
|
+
...(entry.env ?? {}),
|
|
45
|
+
...(options?.env ?? {}),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const proc = spawn(cmd, args, {
|
|
49
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
|
+
env: mergedEnv,
|
|
51
|
+
shell: false,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const id = `acp-${entry.id}-${Date.now()}`;
|
|
55
|
+
const acpProc: AcpProcess = { id, agentId: entry.id, proc, alive: true };
|
|
56
|
+
|
|
57
|
+
processes.set(id, acpProc);
|
|
58
|
+
messageListeners.set(id, new Set());
|
|
59
|
+
|
|
60
|
+
// Parse newline-delimited JSON from stdout
|
|
61
|
+
let buffer = '';
|
|
62
|
+
proc.stdout?.on('data', (chunk: Buffer) => {
|
|
63
|
+
buffer += chunk.toString();
|
|
64
|
+
const lines = buffer.split('\n');
|
|
65
|
+
buffer = lines.pop() ?? '';
|
|
66
|
+
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (!trimmed) continue;
|
|
70
|
+
try {
|
|
71
|
+
const msg = JSON.parse(trimmed) as AcpJsonRpcResponse;
|
|
72
|
+
const listeners = messageListeners.get(id);
|
|
73
|
+
if (listeners) {
|
|
74
|
+
for (const cb of listeners) cb(msg);
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Not valid JSON — skip (could be agent debug output)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
proc.on('close', () => {
|
|
83
|
+
acpProc.alive = false;
|
|
84
|
+
messageListeners.delete(id);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
proc.on('error', () => {
|
|
88
|
+
acpProc.alive = false;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return acpProc;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Send a JSON-RPC message to an ACP agent's stdin.
|
|
96
|
+
*/
|
|
97
|
+
export function sendMessage(acpProc: AcpProcess, method: string, params?: Record<string, unknown>): string {
|
|
98
|
+
if (!acpProc.alive || !acpProc.proc.stdin?.writable) {
|
|
99
|
+
throw new Error(`ACP process ${acpProc.id} is not alive`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const id = `rpc-${rpcIdCounter++}`;
|
|
103
|
+
const request: AcpJsonRpcRequest = {
|
|
104
|
+
jsonrpc: '2.0',
|
|
105
|
+
id,
|
|
106
|
+
method,
|
|
107
|
+
...(params ? { params } : {}),
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
acpProc.proc.stdin.write(JSON.stringify(request) + '\n');
|
|
111
|
+
return id;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Register a callback for messages from an ACP agent.
|
|
116
|
+
* Returns an unsubscribe function.
|
|
117
|
+
*/
|
|
118
|
+
export function onMessage(acpProc: AcpProcess, callback: MessageCallback): () => void {
|
|
119
|
+
const listeners = messageListeners.get(acpProc.id);
|
|
120
|
+
if (!listeners) throw new Error(`ACP process ${acpProc.id} not found`);
|
|
121
|
+
|
|
122
|
+
listeners.add(callback);
|
|
123
|
+
return () => { listeners.delete(callback); };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Send a JSON-RPC request and wait for a response with the matching ID.
|
|
128
|
+
*/
|
|
129
|
+
export function sendAndWait(
|
|
130
|
+
acpProc: AcpProcess,
|
|
131
|
+
method: string,
|
|
132
|
+
params?: Record<string, unknown>,
|
|
133
|
+
timeoutMs = 30_000,
|
|
134
|
+
): Promise<AcpJsonRpcResponse> {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const rpcId = sendMessage(acpProc, method, params);
|
|
137
|
+
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
unsub();
|
|
140
|
+
reject(new Error(`ACP RPC timeout after ${timeoutMs}ms for method: ${method}`));
|
|
141
|
+
}, timeoutMs);
|
|
142
|
+
|
|
143
|
+
const unsub = onMessage(acpProc, (msg) => {
|
|
144
|
+
if (String(msg.id) === rpcId) {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
unsub();
|
|
147
|
+
resolve(msg);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Kill an ACP agent process.
|
|
155
|
+
*/
|
|
156
|
+
export function killAgent(acpProc: AcpProcess): void {
|
|
157
|
+
if (acpProc.alive && acpProc.proc.pid) {
|
|
158
|
+
acpProc.proc.kill('SIGTERM');
|
|
159
|
+
// Force kill after 5s if still alive
|
|
160
|
+
setTimeout(() => {
|
|
161
|
+
if (acpProc.alive) {
|
|
162
|
+
acpProc.proc.kill('SIGKILL');
|
|
163
|
+
}
|
|
164
|
+
}, 5000);
|
|
165
|
+
}
|
|
166
|
+
acpProc.alive = false;
|
|
167
|
+
processes.delete(acpProc.id);
|
|
168
|
+
messageListeners.delete(acpProc.id);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get a process by its ID.
|
|
173
|
+
*/
|
|
174
|
+
export function getProcess(id: string): AcpProcess | undefined {
|
|
175
|
+
return processes.get(id);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get all active processes.
|
|
180
|
+
*/
|
|
181
|
+
export function getActiveProcesses(): AcpProcess[] {
|
|
182
|
+
return [...processes.values()].filter(p => p.alive);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Kill all active ACP processes. Used for cleanup.
|
|
187
|
+
*/
|
|
188
|
+
export function killAllAgents(): void {
|
|
189
|
+
for (const proc of processes.values()) {
|
|
190
|
+
killAgent(proc);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* ── Internal ──────────────────────────────────────────────────────────── */
|
|
195
|
+
|
|
196
|
+
function buildCommand(entry: AcpRegistryEntry): { cmd: string; args: string[] } {
|
|
197
|
+
const transport: AcpTransportType = entry.transport;
|
|
198
|
+
|
|
199
|
+
switch (transport) {
|
|
200
|
+
case 'npx':
|
|
201
|
+
return { cmd: 'npx', args: [entry.command, ...(entry.args ?? [])] };
|
|
202
|
+
case 'uvx':
|
|
203
|
+
return { cmd: 'uvx', args: [entry.command, ...(entry.args ?? [])] };
|
|
204
|
+
case 'binary':
|
|
205
|
+
case 'stdio':
|
|
206
|
+
default:
|
|
207
|
+
return { cmd: entry.command, args: entry.args ?? [] };
|
|
208
|
+
}
|
|
209
|
+
}
|