@geminilight/mindos 0.6.28 → 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 +3 -5
- package/app/components/agents/AgentsContentPage.tsx +21 -6
- 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/help/HelpContent.tsx +9 -9
- package/app/components/panels/AgentsPanel.tsx +1 -0
- package/app/components/panels/AgentsPanelAgentDetail.tsx +5 -8
- 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
|
@@ -200,18 +200,18 @@ export function handleGetTask(params: GetTaskParams): A2ATask | null {
|
|
|
200
200
|
return tasks.get(params.id) ?? null;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
export function handleCancelTask(params: CancelTaskParams): A2ATask | null {
|
|
203
|
+
export function handleCancelTask(params: CancelTaskParams): { task: A2ATask | null; reason: 'not_found' | 'not_cancelable' | 'ok' } {
|
|
204
204
|
const task = tasks.get(params.id);
|
|
205
|
-
if (!task) return null;
|
|
205
|
+
if (!task) return { task: null, reason: 'not_found' };
|
|
206
206
|
|
|
207
207
|
const terminalStates: TaskState[] = ['TASK_STATE_COMPLETED', 'TASK_STATE_FAILED', 'TASK_STATE_CANCELED', 'TASK_STATE_REJECTED'];
|
|
208
|
-
if (terminalStates.includes(task.status.state)) return null
|
|
208
|
+
if (terminalStates.includes(task.status.state)) return { task: null, reason: 'not_cancelable' };
|
|
209
209
|
|
|
210
210
|
task.status = {
|
|
211
211
|
state: 'TASK_STATE_CANCELED',
|
|
212
212
|
timestamp: new Date().toISOString(),
|
|
213
213
|
};
|
|
214
|
-
return task;
|
|
214
|
+
return { task, reason: 'ok' };
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
/* ── Helpers ───────────────────────────────────────────────────────────── */
|
package/app/lib/a2a/types.ts
CHANGED
|
@@ -210,3 +210,18 @@ export interface SkillMatch {
|
|
|
210
210
|
skillName: string;
|
|
211
211
|
confidence: number;
|
|
212
212
|
}
|
|
213
|
+
|
|
214
|
+
/* ── Delegation History (Phase 3 UI) ─────────────────────────────────── */
|
|
215
|
+
|
|
216
|
+
/** A recorded delegation attempt for the history log */
|
|
217
|
+
export interface DelegationRecord {
|
|
218
|
+
id: string;
|
|
219
|
+
agentId: string;
|
|
220
|
+
agentName: string;
|
|
221
|
+
message: string;
|
|
222
|
+
status: 'pending' | 'completed' | 'failed';
|
|
223
|
+
startedAt: string;
|
|
224
|
+
completedAt: string | null;
|
|
225
|
+
result: string | null;
|
|
226
|
+
error: string | null;
|
|
227
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP Agent Tools — Expose ACP capabilities as tools
|
|
3
|
+
* for the MindOS built-in agent to discover and invoke ACP agents.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Type, type Static } from '@sinclair/typebox';
|
|
7
|
+
import type { AgentTool } from '@mariozechner/pi-agent-core';
|
|
8
|
+
import { getAcpAgents, findAcpAgent } from './registry';
|
|
9
|
+
import { createSessionFromEntry, prompt, closeSession } from './session';
|
|
10
|
+
|
|
11
|
+
function textResult(text: string) {
|
|
12
|
+
return { content: [{ type: 'text' as const, text }], details: {} };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* ── Parameter Schemas ─────────────────────────────────────────────────── */
|
|
16
|
+
|
|
17
|
+
const ListAcpAgentsParams = Type.Object({
|
|
18
|
+
tag: Type.Optional(Type.String({ description: 'Optional tag to filter agents by (e.g. "coding", "search")' })),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const CallAcpAgentParams = Type.Object({
|
|
22
|
+
agent_id: Type.String({ description: 'ID of the ACP agent from the registry (from list_acp_agents)' }),
|
|
23
|
+
message: Type.String({ description: 'Natural language message to send to the ACP agent' }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/* ── Tool Implementations ──────────────────────────────────────────────── */
|
|
27
|
+
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
export const acpTools: AgentTool<any>[] = [
|
|
30
|
+
{
|
|
31
|
+
name: 'list_acp_agents',
|
|
32
|
+
label: 'List ACP Agents',
|
|
33
|
+
description: 'List available ACP (Agent Client Protocol) agents from the public registry. These are local subprocess-based agents like Gemini CLI, Claude, Copilot, etc. Optionally filter by tag.',
|
|
34
|
+
parameters: ListAcpAgentsParams,
|
|
35
|
+
execute: async (_id: string, params: Static<typeof ListAcpAgentsParams>) => {
|
|
36
|
+
try {
|
|
37
|
+
let agents = await getAcpAgents();
|
|
38
|
+
|
|
39
|
+
if (params.tag) {
|
|
40
|
+
const tag = params.tag.toLowerCase();
|
|
41
|
+
agents = agents.filter(a =>
|
|
42
|
+
a.tags?.some(t => t.toLowerCase().includes(tag))
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (agents.length === 0) {
|
|
47
|
+
return textResult(
|
|
48
|
+
params.tag
|
|
49
|
+
? `No ACP agents found with tag "${params.tag}". Try list_acp_agents without a tag filter.`
|
|
50
|
+
: 'No ACP agents found in the registry. The registry may be unavailable.'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lines = agents.map(a => {
|
|
55
|
+
const tags = a.tags?.join(', ') || 'none';
|
|
56
|
+
return `- **${a.name}** (id: \`${a.id}\`, transport: ${a.transport})\n ${a.description}\n Tags: ${tags}`;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return textResult(`Available ACP agents (${agents.length}):\n\n${lines.join('\n\n')}`);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return textResult(`Failed to list ACP agents: ${(err as Error).message}`);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
name: 'call_acp_agent',
|
|
68
|
+
label: 'Call ACP Agent',
|
|
69
|
+
description: 'Spawn an ACP agent, send it a message, and return the result. The agent runs as a local subprocess. Use list_acp_agents first to see available agents.',
|
|
70
|
+
parameters: CallAcpAgentParams,
|
|
71
|
+
execute: async (_id: string, params: Static<typeof CallAcpAgentParams>) => {
|
|
72
|
+
try {
|
|
73
|
+
const entry = await findAcpAgent(params.agent_id);
|
|
74
|
+
if (!entry) {
|
|
75
|
+
return textResult(`ACP agent not found: ${params.agent_id}. Use list_acp_agents to see available agents.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const session = await createSessionFromEntry(entry);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const response = await prompt(session.id, params.message);
|
|
82
|
+
return textResult(
|
|
83
|
+
`**${entry.name}** responded:\n\n${response.text || '(empty response)'}`
|
|
84
|
+
);
|
|
85
|
+
} finally {
|
|
86
|
+
await closeSession(session.id).catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return textResult(`ACP call failed: ${(err as Error).message}`);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
];
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A-ACP Bridge — Translate between A2A protocol and ACP protocol.
|
|
3
|
+
* Allows A2A agents to delegate to ACP agents transparently,
|
|
4
|
+
* and ACP session results to be returned as A2A tasks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { A2AMessage, A2ATask, TaskState } from '@/lib/a2a/types';
|
|
8
|
+
import type { AcpPromptResponse, AcpSessionUpdate } from './types';
|
|
9
|
+
import { createSession, prompt, closeSession } from './session';
|
|
10
|
+
|
|
11
|
+
/* ── A2A → ACP ─────────────────────────────────────────────────────────── */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bridge an A2A SendMessage request to an ACP session/prompt.
|
|
15
|
+
* Creates a session, sends the message, returns the result, then closes.
|
|
16
|
+
*/
|
|
17
|
+
export async function bridgeA2aToAcp(
|
|
18
|
+
a2aMessage: A2AMessage,
|
|
19
|
+
acpAgentId: string,
|
|
20
|
+
): Promise<A2ATask> {
|
|
21
|
+
const taskId = `acp-bridge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
22
|
+
|
|
23
|
+
// Extract text from A2A message parts
|
|
24
|
+
const text = a2aMessage.parts
|
|
25
|
+
.map(p => p.text ?? '')
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join('\n');
|
|
28
|
+
|
|
29
|
+
if (!text) {
|
|
30
|
+
return makeFailedTask(taskId, 'No text content in A2A message');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let session;
|
|
34
|
+
try {
|
|
35
|
+
session = await createSession(acpAgentId);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return makeFailedTask(taskId, `Failed to create ACP session: ${(err as Error).message}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const response = await prompt(session.id, text);
|
|
42
|
+
return bridgeAcpResponseToA2a(taskId, response);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return makeFailedTask(taskId, `ACP prompt failed: ${(err as Error).message}`);
|
|
45
|
+
} finally {
|
|
46
|
+
await closeSession(session.id).catch(() => {});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ── ACP → A2A ─────────────────────────────────────────────────────────── */
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Convert a completed ACP prompt response to an A2A task result.
|
|
54
|
+
*/
|
|
55
|
+
export function bridgeAcpResponseToA2a(
|
|
56
|
+
taskId: string,
|
|
57
|
+
response: AcpPromptResponse,
|
|
58
|
+
): A2ATask {
|
|
59
|
+
return {
|
|
60
|
+
id: taskId,
|
|
61
|
+
status: {
|
|
62
|
+
state: 'TASK_STATE_COMPLETED',
|
|
63
|
+
message: {
|
|
64
|
+
role: 'ROLE_AGENT',
|
|
65
|
+
parts: [{ text: response.text }],
|
|
66
|
+
},
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
},
|
|
69
|
+
artifacts: response.text ? [{
|
|
70
|
+
artifactId: `${taskId}-artifact`,
|
|
71
|
+
parts: [{ text: response.text }],
|
|
72
|
+
}] : undefined,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Convert a stream of ACP session updates to A2A task updates.
|
|
78
|
+
* Aggregates text updates and maps final state.
|
|
79
|
+
*/
|
|
80
|
+
export function bridgeAcpUpdatesToA2a(
|
|
81
|
+
taskId: string,
|
|
82
|
+
updates: AcpSessionUpdate[],
|
|
83
|
+
): A2ATask {
|
|
84
|
+
let aggregatedText = '';
|
|
85
|
+
let finalState: TaskState = 'TASK_STATE_WORKING';
|
|
86
|
+
let errorMessage = '';
|
|
87
|
+
|
|
88
|
+
for (const update of updates) {
|
|
89
|
+
switch (update.type) {
|
|
90
|
+
case 'text':
|
|
91
|
+
aggregatedText += update.text ?? '';
|
|
92
|
+
break;
|
|
93
|
+
case 'done':
|
|
94
|
+
finalState = 'TASK_STATE_COMPLETED';
|
|
95
|
+
break;
|
|
96
|
+
case 'error':
|
|
97
|
+
finalState = 'TASK_STATE_FAILED';
|
|
98
|
+
errorMessage = update.error ?? 'Unknown error';
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (finalState === 'TASK_STATE_FAILED') {
|
|
104
|
+
return makeFailedTask(taskId, errorMessage);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
id: taskId,
|
|
109
|
+
status: {
|
|
110
|
+
state: finalState,
|
|
111
|
+
message: aggregatedText ? {
|
|
112
|
+
role: 'ROLE_AGENT',
|
|
113
|
+
parts: [{ text: aggregatedText }],
|
|
114
|
+
} : undefined,
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
},
|
|
117
|
+
artifacts: aggregatedText ? [{
|
|
118
|
+
artifactId: `${taskId}-artifact`,
|
|
119
|
+
parts: [{ text: aggregatedText }],
|
|
120
|
+
}] : undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ── Internal ──────────────────────────────────────────────────────────── */
|
|
125
|
+
|
|
126
|
+
function makeFailedTask(taskId: string, error: string): A2ATask {
|
|
127
|
+
return {
|
|
128
|
+
id: taskId,
|
|
129
|
+
status: {
|
|
130
|
+
state: 'TASK_STATE_FAILED',
|
|
131
|
+
message: {
|
|
132
|
+
role: 'ROLE_AGENT',
|
|
133
|
+
parts: [{ text: error }],
|
|
134
|
+
},
|
|
135
|
+
timestamp: new Date().toISOString(),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { fetchAcpRegistry, getAcpAgents, findAcpAgent, clearRegistryCache } from './registry';
|
|
2
|
+
export { spawnAcpAgent, sendMessage, sendAndWait, onMessage, killAgent, killAllAgents, getProcess, getActiveProcesses } from './subprocess';
|
|
3
|
+
export { createSession, createSessionFromEntry, prompt, promptStream, cancelPrompt, closeSession, getSession, getActiveSessions, closeAllSessions } from './session';
|
|
4
|
+
export { bridgeA2aToAcp, bridgeAcpResponseToA2a, bridgeAcpUpdatesToA2a } from './bridge';
|
|
5
|
+
export { acpTools } from './acp-tools';
|
|
6
|
+
export { ACP_ERRORS } from './types';
|
|
7
|
+
export type {
|
|
8
|
+
AcpCapabilities,
|
|
9
|
+
AcpSessionState,
|
|
10
|
+
AcpSession,
|
|
11
|
+
AcpJsonRpcRequest,
|
|
12
|
+
AcpJsonRpcResponse,
|
|
13
|
+
AcpJsonRpcError,
|
|
14
|
+
AcpPromptRequest,
|
|
15
|
+
AcpPromptResponse,
|
|
16
|
+
AcpUpdateType,
|
|
17
|
+
AcpSessionUpdate,
|
|
18
|
+
AcpToolCall,
|
|
19
|
+
AcpToolResult,
|
|
20
|
+
AcpRegistryEntry,
|
|
21
|
+
AcpRegistry,
|
|
22
|
+
AcpTransportType,
|
|
23
|
+
} from './types';
|
|
24
|
+
export type { AcpProcess } from './subprocess';
|
|
@@ -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
|
+
}
|