@anhlp/alp-runtime 0.0.0-beta.1
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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +7 -0
- package/dist/acp-transport.d.ts +114 -0
- package/dist/acp-transport.js +478 -0
- package/dist/antigravity-transport.d.ts +80 -0
- package/dist/antigravity-transport.js +609 -0
- package/dist/board-domain.d.ts +56 -0
- package/dist/board-domain.js +157 -0
- package/dist/board.d.ts +21 -0
- package/dist/board.js +69 -0
- package/dist/catalog.d.ts +31 -0
- package/dist/catalog.js +38 -0
- package/dist/claude-transport.d.ts +157 -0
- package/dist/claude-transport.js +671 -0
- package/dist/contracts.d.ts +163 -0
- package/dist/contracts.js +5 -0
- package/dist/events.d.ts +1 -0
- package/dist/events.js +0 -0
- package/dist/github.d.ts +10 -0
- package/dist/github.js +39 -0
- package/dist/handoff.d.ts +36 -0
- package/dist/handoff.js +73 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +64 -0
- package/dist/language.d.ts +41 -0
- package/dist/language.js +68 -0
- package/dist/live.d.ts +48 -0
- package/dist/live.js +45 -0
- package/dist/mailbox.d.ts +24 -0
- package/dist/mailbox.js +36 -0
- package/dist/mcp-tools.d.ts +115 -0
- package/dist/mcp-tools.js +71 -0
- package/dist/permission-path.d.ts +2 -0
- package/dist/permission-path.js +44 -0
- package/dist/process-info.d.ts +14 -0
- package/dist/process-info.js +32 -0
- package/dist/providers.d.ts +28 -0
- package/dist/providers.js +71 -0
- package/dist/recall.d.ts +42 -0
- package/dist/recall.js +60 -0
- package/dist/resolve.d.ts +121 -0
- package/dist/resolve.js +238 -0
- package/dist/runtime-context.d.ts +43 -0
- package/dist/runtime-context.js +61 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +2979 -0
- package/dist/session-config.d.ts +1973 -0
- package/dist/session-config.js +205 -0
- package/dist/session-state.d.ts +140 -0
- package/dist/session-state.js +0 -0
- package/dist/tool-results.d.ts +12 -0
- package/dist/tool-results.js +25 -0
- package/dist/tools.d.ts +674 -0
- package/dist/tools.js +375 -0
- package/dist/transport.d.ts +47 -0
- package/dist/transport.js +169 -0
- package/dist/usage.d.ts +15 -0
- package/dist/usage.js +35 -0
- package/dist/workspace.d.ts +101 -0
- package/dist/workspace.js +259 -0
- package/dist/worktree-domain.d.ts +88 -0
- package/dist/worktree-domain.js +196 -0
- package/package.json +256 -0
- package/src/acp-transport.ts +473 -0
- package/src/antigravity-transport.ts +658 -0
- package/src/board-domain.ts +185 -0
- package/src/board.ts +80 -0
- package/src/catalog.ts +32 -0
- package/src/claude-transport.ts +771 -0
- package/src/contracts.ts +166 -0
- package/src/events.ts +1 -0
- package/src/github.ts +37 -0
- package/src/handoff.ts +86 -0
- package/src/index.ts +26 -0
- package/src/language.ts +102 -0
- package/src/live.ts +86 -0
- package/src/mailbox.ts +46 -0
- package/src/mcp-tools.ts +73 -0
- package/src/permission-path.ts +37 -0
- package/src/process-info.ts +40 -0
- package/src/providers.ts +84 -0
- package/src/recall.ts +85 -0
- package/src/resolve.ts +230 -0
- package/src/runtime-context.ts +49 -0
- package/src/runtime.ts +3579 -0
- package/src/session-config.ts +301 -0
- package/src/session-state.ts +141 -0
- package/src/tool-results.ts +26 -0
- package/src/tools.ts +379 -0
- package/src/transport.ts +116 -0
- package/src/usage.ts +42 -0
- package/src/workspace.ts +318 -0
- package/src/worktree-domain.ts +216 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { writablePath } from './permission-path.js';
|
|
2
|
+
import { spawn, execFile, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
3
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
import { rmSync } from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
import { commandDecision, unwrapShell, type PermissionProfile } from '@anhlp/alp-core/permissions';
|
|
10
|
+
import { launchCommand } from '@anhlp/alp-core/providers';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* ACP agents (ALPD §46): a generic transport for any agent that speaks the Agent Client
|
|
14
|
+
* Protocol v1 over stdio, such as Gemini CLI or opencode. It maps the runtime's
|
|
15
|
+
* normalized protocol (thread/start, turn/start, item and turn notifications) to ACP's
|
|
16
|
+
* initialize, session/new, session/prompt and session/update.
|
|
17
|
+
*
|
|
18
|
+
* What ACP does not have, ALP does without: instructions go in the first prompt, mail
|
|
19
|
+
* waits for the turn to end (no steer), and a session resumes only when the agent can
|
|
20
|
+
* load one. ALP's tools reach the agent as an MCP server ("alp") that the agent starts:
|
|
21
|
+
* a small bridge process connected back to this transport by a local socket.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type AcpProvider = {
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
command: string;
|
|
28
|
+
args: string[];
|
|
29
|
+
env: Record<string, string>;
|
|
30
|
+
models?: Array<{ id: string; label?: string; description?: string }>;
|
|
31
|
+
additionalModels?: Array<{ id: string; label?: string; description?: string }>;
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Listener = (method: string, params: any) => void;
|
|
36
|
+
type RequestHandler = (method: string, params: any) => Promise<unknown>;
|
|
37
|
+
|
|
38
|
+
/** What ALP decides about an ACP agent's permission request. */
|
|
39
|
+
export type AcpPermission = { kind: string; title: string; command?: string; paths: string[]; alpTool: boolean };
|
|
40
|
+
|
|
41
|
+
/** The MCP server ALP's tools reach an ACP agent through; it speaks MCP on stdio and forwards calls to the socket. */
|
|
42
|
+
const BRIDGE = `
|
|
43
|
+
const net = require('node:net');
|
|
44
|
+
const readline = require('node:readline');
|
|
45
|
+
const socket = net.connect(process.env.ALP_BRIDGE_SOCKET);
|
|
46
|
+
const waiting = new Map();
|
|
47
|
+
let sequence = 0;
|
|
48
|
+
let buffer = '';
|
|
49
|
+
socket.setEncoding('utf8');
|
|
50
|
+
socket.write(JSON.stringify({ token: process.env.ALP_BRIDGE_TOKEN }) + '\\n');
|
|
51
|
+
socket.on('data', data => {
|
|
52
|
+
buffer += data;
|
|
53
|
+
for (let index; (index = buffer.indexOf('\\n')) >= 0;) {
|
|
54
|
+
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
|
55
|
+
try { const message = JSON.parse(line); const waiter = waiting.get(message.id); if (waiter) { waiting.delete(message.id); waiter(message); } } catch {}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
socket.on('close', () => process.exit(0));
|
|
59
|
+
socket.on('error', () => process.exit(1));
|
|
60
|
+
const ask = message => new Promise(resolve => { const id = ++sequence; waiting.set(id, resolve); socket.write(JSON.stringify({ ...message, id }) + '\\n'); });
|
|
61
|
+
const send = value => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\\n');
|
|
62
|
+
readline.createInterface({ input: process.stdin }).on('line', async line => {
|
|
63
|
+
let message; try { message = JSON.parse(line); } catch { return; }
|
|
64
|
+
if (message.id === undefined || message.id === null) return;
|
|
65
|
+
try {
|
|
66
|
+
if (message.method === 'initialize') return send({ id: message.id, result: { protocolVersion: (message.params && message.params.protocolVersion) || '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'alp', version: '1' } } });
|
|
67
|
+
if (message.method === 'ping') return send({ id: message.id, result: {} });
|
|
68
|
+
if (message.method === 'tools/list') { const reply = await ask({ op: 'list' }); return send({ id: message.id, result: { tools: reply.tools } }); }
|
|
69
|
+
if (message.method === 'tools/call') { const reply = await ask({ op: 'call', name: message.params.name, arguments: message.params.arguments || {} }); return send({ id: message.id, result: reply.result }); }
|
|
70
|
+
send({ id: message.id, error: { code: -32601, message: 'Method not found' } });
|
|
71
|
+
} catch (error) { send({ id: message.id, error: { code: -32603, message: String((error && error.message) || error) } }); }
|
|
72
|
+
});
|
|
73
|
+
process.stdin.on('end', () => process.exit(0));
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
const READERS = ['read', 'search', 'think', 'fetch'];
|
|
77
|
+
|
|
78
|
+
/** What an ACP permission request asks for: its tool kind, the command it runs, and the paths it touches. */
|
|
79
|
+
export function acpPermission(toolCall: any, tools: string[]): AcpPermission {
|
|
80
|
+
const raw = toolCall?.rawInput;
|
|
81
|
+
const kind = typeof toolCall?.kind === 'string' ? toolCall.kind : 'other';
|
|
82
|
+
const title = typeof toolCall?.title === 'string' ? toolCall.title : '';
|
|
83
|
+
const command = kind === 'execute'
|
|
84
|
+
? (typeof raw?.command === 'string' ? raw.command : Array.isArray(raw?.command) ? raw.command.join(' ') : title || undefined)
|
|
85
|
+
: undefined;
|
|
86
|
+
const paths = (Array.isArray(toolCall?.locations) ? toolCall.locations : []).map((location: any) => location?.path).filter((value: unknown): value is string => typeof value === 'string');
|
|
87
|
+
// An agent asks to call an MCP tool as kind other; ALP's own tools are always allowed.
|
|
88
|
+
const named = `${title} ${typeof toolCall?.name === 'string' ? toolCall.name : ''}`;
|
|
89
|
+
const alpTool = !['execute', 'edit', 'delete', 'move'].includes(kind) && tools.some(tool => new RegExp(`\\b${tool}\\b`).test(named));
|
|
90
|
+
return { kind, title, ...(command ? { command } : {}), paths, alpTool };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether ALP lets an ACP agent use a tool: ALP's tools always; then the profile's
|
|
95
|
+
* Bash rules for a command; then the session's mode. Read-only allows reading;
|
|
96
|
+
* workspace-write also changing files inside the workspace and running commands, which
|
|
97
|
+
* only the agent's own sandbox, if it has one, contains; full access allows everything.
|
|
98
|
+
* 'mode' is beyond the mode: the profile decides whether ALP asks the user.
|
|
99
|
+
*/
|
|
100
|
+
export function acpDecision(request: AcpPermission, mode: string, profile: PermissionProfile | null | undefined, workdir: string): 'allow' | 'deny' | 'rule' | 'mode' {
|
|
101
|
+
if (request.alpTool) return 'allow';
|
|
102
|
+
const rule = request.command !== undefined && profile ? commandDecision(profile, request.command) : undefined;
|
|
103
|
+
if (rule === 'deny') return 'deny';
|
|
104
|
+
if (rule === 'allow') return 'allow';
|
|
105
|
+
if (rule === 'ask') return 'rule';
|
|
106
|
+
if (mode === 'full-access' || READERS.includes(request.kind)) return 'allow';
|
|
107
|
+
if (mode === 'workspace-write') {
|
|
108
|
+
if (['edit', 'delete', 'move'].includes(request.kind) && !request.paths.length) return 'mode';
|
|
109
|
+
const outside = request.paths.some(file => !writablePath(file, workdir));
|
|
110
|
+
if (!outside) return 'allow';
|
|
111
|
+
}
|
|
112
|
+
return 'mode';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The text ALP shows the user when an ACP agent asks for a permission. */
|
|
116
|
+
export function acpWhat(request: AcpPermission) {
|
|
117
|
+
if (request.command !== undefined) return `run \`${unwrapShell(request.command).slice(0, 400)}\``;
|
|
118
|
+
return `${request.kind === 'other' ? 'use' : request.kind} ${request.title ? `\`${request.title.slice(0, 300)}\`` : 'a tool'}${request.paths.length ? ` (${request.paths.slice(0, 5).join(', ')})` : ''}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The environment an ACP agent starts with. ALP holds the session's mode only by answering
|
|
123
|
+
* permission requests, and OpenCode edits, runs commands and fetches without asking unless its
|
|
124
|
+
* config says `ask`; so for OpenCode those three are set to `ask` over whatever config the
|
|
125
|
+
* user passed in OPENCODE_CONFIG_CONTENT, and ALP answers each by the mode.
|
|
126
|
+
*/
|
|
127
|
+
export function acpEnv(provider: Pick<AcpProvider, 'command' | 'env'>, base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
128
|
+
const env = { ...base, ...provider.env };
|
|
129
|
+
if (provider.command.split(/[\\/]/).pop()!.replace(/\.exe$/i, '').toLowerCase() !== 'opencode') return env;
|
|
130
|
+
let config: any = {};
|
|
131
|
+
try { config = JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}') ?? {}; } catch {}
|
|
132
|
+
if (typeof config !== 'object' || Array.isArray(config)) config = {};
|
|
133
|
+
const permission = typeof config.permission === 'object' && config.permission && !Array.isArray(config.permission) ? config.permission : {};
|
|
134
|
+
return { ...env, OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, permission: { ...permission, edit: 'ask', bash: 'ask', webfetch: 'ask' } }) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** ACP's MCP server list: stdio servers with env as name and value pairs, HTTP when the agent takes it. */
|
|
138
|
+
function acpServers(servers: Record<string, any>, http: boolean) {
|
|
139
|
+
return Object.entries(servers).map(([name, server]) => {
|
|
140
|
+
if (server.url) {
|
|
141
|
+
if (!http) throw new Error(`MCP server '${name}' is HTTP, which this ACP agent does not take; use a stdio server`);
|
|
142
|
+
return { type: 'http', name, url: server.url, headers: Object.entries(server.http_headers ?? {}).map(([key, value]) => ({ name: key, value: String(value) })) };
|
|
143
|
+
}
|
|
144
|
+
return { name, command: server.command, args: server.args ?? [], env: Object.entries(server.env ?? {}).map(([key, value]) => ({ name: key, value: String(value) })) };
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Maps an ACP agent over stdio to the normalized runtime protocol. */
|
|
149
|
+
export class AcpTransport {
|
|
150
|
+
private child?: ChildProcessWithoutNullStreams;
|
|
151
|
+
private exit: Promise<void> = Promise.resolve();
|
|
152
|
+
private sequence = 0;
|
|
153
|
+
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer?: NodeJS.Timeout }>();
|
|
154
|
+
private listeners = new Set<Listener>();
|
|
155
|
+
private failures = new Set<(error: Error) => void>();
|
|
156
|
+
private requestHandler?: RequestHandler;
|
|
157
|
+
private closed = false;
|
|
158
|
+
private capabilities: any = {};
|
|
159
|
+
private sessionId = '';
|
|
160
|
+
private config: any;
|
|
161
|
+
/** Instructions go with the first prompt of a new session; a loaded one has them already. */
|
|
162
|
+
private instructionsSent = false;
|
|
163
|
+
private loading = false;
|
|
164
|
+
private activeTurn?: string;
|
|
165
|
+
/** The running prompt; a cancelled one still answers before the next starts. */
|
|
166
|
+
private prompting?: Promise<void>;
|
|
167
|
+
/** Text of the running turn's current message; a tool call starts the next one. */
|
|
168
|
+
private message?: { id: string; text: string };
|
|
169
|
+
private messages = 0;
|
|
170
|
+
private toolCalls = new Map<string, { command: string; alp: boolean }>();
|
|
171
|
+
/** Permission questions of the running turn, answered cancelled when it is cancelled. */
|
|
172
|
+
private questions = new Set<(value: unknown) => void>();
|
|
173
|
+
private bridge?: { server: net.Server; socket: string; token: string; connections: Set<net.Socket> };
|
|
174
|
+
private tools: Array<{ name: string; description: string; inputSchema?: unknown }> = [];
|
|
175
|
+
|
|
176
|
+
constructor(private readonly provider: AcpProvider, private readonly cwd: string, private readonly env: NodeJS.ProcessEnv) {}
|
|
177
|
+
|
|
178
|
+
async initialize() {
|
|
179
|
+
if (/\.(cmd|bat|ps1)$/i.test(this.provider.command)) throw new Error(`${this.provider.label}: the command must be a native executable, not a shell launcher`);
|
|
180
|
+
const env = acpEnv(this.provider, this.env);
|
|
181
|
+
const launch = launchCommand(this.provider.command, this.provider.args, env);
|
|
182
|
+
const child = spawn(launch.command, launch.args, { cwd: this.cwd, env, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
183
|
+
this.child = child;
|
|
184
|
+
this.exit = new Promise(resolve => { child.once('close', () => resolve()); child.once('error', () => resolve()); });
|
|
185
|
+
child.stderr.on('data', () => {});
|
|
186
|
+
child.stdin.on('error', error => this.fail(error));
|
|
187
|
+
child.on('error', error => this.fail(new Error(`${this.provider.label} did not start: ${error.message}`)));
|
|
188
|
+
child.on('close', code => { if (!this.closed) this.fail(new Error(`${this.provider.label} exited (${code})`)); });
|
|
189
|
+
createInterface({ input: child.stdout }).on('line', line => this.receive(line));
|
|
190
|
+
const result = await this.call('initialize', {
|
|
191
|
+
protocolVersion: 1,
|
|
192
|
+
// The agent reads, writes and runs commands with its own tools; ALP answers its permission requests.
|
|
193
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
194
|
+
clientInfo: { name: 'alp', title: 'ALP', version: '0.0.0' },
|
|
195
|
+
}, 30_000);
|
|
196
|
+
if (result?.protocolVersion !== 1) throw new Error(`${this.provider.label} speaks ACP version ${result?.protocolVersion}; ALP speaks version 1`);
|
|
197
|
+
this.capabilities = result.agentCapabilities ?? {};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
onNotification(listener: Listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
201
|
+
onFailure(listener: (error: Error) => void) { this.failures.add(listener); return () => this.failures.delete(listener); }
|
|
202
|
+
onRequest(handler: RequestHandler) { this.requestHandler = handler; }
|
|
203
|
+
|
|
204
|
+
async request(method: string, params: any): Promise<any> {
|
|
205
|
+
if (this.closed) throw new Error('Runtime is closed');
|
|
206
|
+
if (method === 'thread/start' || method === 'thread/resume') {
|
|
207
|
+
this.config = params;
|
|
208
|
+
this.tools = params.dynamicTools ?? [];
|
|
209
|
+
const mcpServers = [...acpServers(params.mcpServers ?? {}, Boolean(this.capabilities.mcpCapabilities?.http)), ...(this.tools.length ? [await this.openBridge()] : [])];
|
|
210
|
+
if (method === 'thread/resume') {
|
|
211
|
+
if (!this.capabilities.loadSession) throw new Error(`${this.provider.label} cannot resume a session: it does not support session/load`);
|
|
212
|
+
// The agent replays the session's history as updates while loading; ALP has it already.
|
|
213
|
+
this.loading = true;
|
|
214
|
+
try { await this.call('session/load', { sessionId: params.threadId, cwd: params.cwd, mcpServers }, 120_000); }
|
|
215
|
+
finally { this.loading = false; }
|
|
216
|
+
this.sessionId = params.threadId;
|
|
217
|
+
this.instructionsSent = true;
|
|
218
|
+
} else {
|
|
219
|
+
const result = await this.call('session/new', { cwd: params.cwd, mcpServers }, 120_000);
|
|
220
|
+
this.sessionId = result.sessionId;
|
|
221
|
+
await this.chooseModel(result.models, result.configOptions);
|
|
222
|
+
}
|
|
223
|
+
return { thread: { id: this.sessionId, turns: [] }, cwd: params.cwd, model: params.model, reasoningEffort: params.thinking };
|
|
224
|
+
}
|
|
225
|
+
if (method === 'turn/start') {
|
|
226
|
+
if (!this.sessionId) throw new Error(`${this.provider.label} session is not open`);
|
|
227
|
+
// After a cancel, the agent answers the cancelled prompt first; ALP waits a little for it.
|
|
228
|
+
if (this.prompting) await Promise.race([this.prompting, new Promise(resolve => setTimeout(resolve, 10_000).unref())]);
|
|
229
|
+
if (this.closed) throw new Error('Runtime is closed');
|
|
230
|
+
const turnId = randomUUID();
|
|
231
|
+
this.activeTurn = turnId;
|
|
232
|
+
this.message = undefined;
|
|
233
|
+
const prompt = (params.input ?? []).filter((part: any) => typeof part?.text === 'string').map((part: any) => ({ type: 'text', text: part.text }));
|
|
234
|
+
if (!this.instructionsSent && this.config?.developerInstructions) {
|
|
235
|
+
prompt.unshift({ type: 'text', text: `Instructions from ALP, which runs you. Follow them for this whole session.\n\n${this.config.developerInstructions}` });
|
|
236
|
+
this.instructionsSent = true;
|
|
237
|
+
}
|
|
238
|
+
this.prompting = this.call('session/prompt', { sessionId: this.sessionId, prompt }).then(
|
|
239
|
+
result => this.endTurn(turnId, result?.stopReason),
|
|
240
|
+
error => this.endTurn(turnId, 'error', error instanceof Error ? error.message : String(error)),
|
|
241
|
+
).finally(() => { if (this.activeTurn === undefined || this.activeTurn === turnId) this.prompting = undefined; });
|
|
242
|
+
return { turn: { id: turnId } };
|
|
243
|
+
}
|
|
244
|
+
if (method === 'turn/steer') throw new Error(`${this.provider.label} takes no messages during a turn; ALP delivers them when it ends`);
|
|
245
|
+
if (method === 'turn/interrupt') {
|
|
246
|
+
if (this.sessionId) this.write({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId: this.sessionId } });
|
|
247
|
+
for (const answer of this.questions) answer({ outcome: { outcome: 'cancelled' } });
|
|
248
|
+
this.questions.clear();
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
// ALP decides permissions itself, by the session's current mode.
|
|
252
|
+
if (method === 'session/configure') return {};
|
|
253
|
+
if (method === 'thread/delete') return {};
|
|
254
|
+
throw new Error(`${this.provider.label} does not support '${method}'`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Picks the session's model when ALP was given one and the agent offers a choice. */
|
|
258
|
+
private async chooseModel(models: any, configOptions?: any[]) {
|
|
259
|
+
const wanted = this.config?.model?.includes('/') ? this.config.model.slice(this.config.model.indexOf('/') + 1) : undefined;
|
|
260
|
+
if (!wanted) return;
|
|
261
|
+
const config = configOptions?.find(option => option.category === 'model' && option.type === 'select');
|
|
262
|
+
if (config) {
|
|
263
|
+
if (config.currentValue === wanted) return;
|
|
264
|
+
const options = (config.options ?? []).flatMap((option: any) => option.options ?? [option]);
|
|
265
|
+
if (!options.some((option: any) => option.value === wanted)) throw new Error(`${this.provider.label} has no model '${wanted}'`);
|
|
266
|
+
await this.call('session/set_config_option', { sessionId: this.sessionId, configId: config.id, value: wanted }, 30_000);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (models?.currentModelId === wanted) return;
|
|
270
|
+
if (!Array.isArray(models?.availableModels)) throw new Error(`${this.provider.label} does not let ALP choose its model; use acp:${this.provider.id} alone`);
|
|
271
|
+
if (!models.availableModels.some((model: any) => model?.modelId === wanted)) throw new Error(`${this.provider.label} has no model '${wanted}'`);
|
|
272
|
+
await this.call('session/set_model', { sessionId: this.sessionId, modelId: wanted }, 30_000);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private endTurn(turnId: string, stopReason: string, error?: string) {
|
|
276
|
+
if (this.closed || this.activeTurn !== turnId) return;
|
|
277
|
+
this.closeMessage();
|
|
278
|
+
this.activeTurn = undefined;
|
|
279
|
+
this.toolCalls.clear();
|
|
280
|
+
const status = stopReason === 'end_turn' ? 'completed' : stopReason === 'cancelled' ? 'interrupted' : 'failed';
|
|
281
|
+
this.emit('turn/completed', {
|
|
282
|
+
threadId: this.sessionId,
|
|
283
|
+
turn: { id: turnId, status, ...(status === 'failed' ? { error: { message: error ?? `${this.provider.label} stopped: ${stopReason}` } } : {}) },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private closeMessage() {
|
|
288
|
+
if (this.message?.text) this.emit('item/completed', { threadId: this.sessionId, item: { type: 'agentMessage', id: this.message.id, text: this.message.text } });
|
|
289
|
+
this.message = undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private update(update: any) {
|
|
293
|
+
if (this.loading || !this.activeTurn || !update) return;
|
|
294
|
+
const kind = update.sessionUpdate;
|
|
295
|
+
if (kind === 'agent_message_chunk') {
|
|
296
|
+
const text = update.content?.type === 'text' && typeof update.content.text === 'string' ? update.content.text : '';
|
|
297
|
+
if (!text) return;
|
|
298
|
+
this.message ??= { id: `acp-${this.activeTurn}-${++this.messages}`, text: '' };
|
|
299
|
+
this.message.text += text;
|
|
300
|
+
this.emit('item/agentMessage/delta', { threadId: this.sessionId, itemId: this.message.id, delta: text });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (kind === 'tool_call' || kind === 'tool_call_update') {
|
|
304
|
+
const id = String(update.toolCallId ?? '');
|
|
305
|
+
if (!id) return;
|
|
306
|
+
let call = this.toolCalls.get(id);
|
|
307
|
+
if (!call) {
|
|
308
|
+
const request = acpPermission(update, this.tools.map(tool => tool.name));
|
|
309
|
+
call = { command: request.command ?? `${request.kind}: ${request.title}`, alp: request.alpTool };
|
|
310
|
+
this.toolCalls.set(id, call);
|
|
311
|
+
this.closeMessage();
|
|
312
|
+
// ALP's own tools show as ALP tool calls; the agent's call to the bridge would repeat them.
|
|
313
|
+
if (!call.alp) this.emit('item/started', { threadId: this.sessionId, item: { type: 'commandExecution', id, command: call.command, cwd: this.cwd, status: 'inProgress', aggregatedOutput: '' } });
|
|
314
|
+
}
|
|
315
|
+
if ((update.status === 'completed' || update.status === 'failed') && !call.alp) {
|
|
316
|
+
const output = (Array.isArray(update.content) ? update.content : [])
|
|
317
|
+
.map((part: any) => part?.type === 'content' && part.content?.type === 'text' ? part.content.text : part?.type === 'diff' ? `diff ${part.path}` : '')
|
|
318
|
+
.filter(Boolean).join('\n');
|
|
319
|
+
this.emit('item/completed', { threadId: this.sessionId, item: { type: 'commandExecution', id, command: call.command, cwd: this.cwd, status: update.status, exitCode: update.status === 'failed' ? 1 : 0, aggregatedOutput: output } });
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (kind === 'usage_update' && typeof update.used === 'number') {
|
|
324
|
+
const last = { totalTokens: update.used };
|
|
325
|
+
this.emit('thread/tokenUsage/updated', { threadId: this.sessionId, turnId: this.activeTurn, tokenUsage: { last, total: last, ...(typeof update.size === 'number' ? { modelContextWindow: update.size } : {}) } });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The agent asks before using a tool; ALP's runtime decides, and the answer picks one of the agent's options. */
|
|
330
|
+
private async permission(params: any) {
|
|
331
|
+
const options: any[] = Array.isArray(params?.options) ? params.options : [];
|
|
332
|
+
const pick = (...kinds: string[]) => kinds.map(kind => options.find(option => option?.kind === kind)).find(Boolean);
|
|
333
|
+
if (!this.activeTurn || !this.requestHandler) return { outcome: { outcome: 'cancelled' } };
|
|
334
|
+
const request = acpPermission(params?.toolCall, this.tools.map(tool => tool.name));
|
|
335
|
+
const question = new Promise<unknown>(resolve => this.questions.add(resolve));
|
|
336
|
+
const answer: any = await Promise.race([this.requestHandler('item/acp/permission', request), question]);
|
|
337
|
+
if (answer?.outcome) return answer;
|
|
338
|
+
const option = answer?.allow ? pick('allow_once', 'allow_always') : pick('reject_once', 'reject_always');
|
|
339
|
+
return option ? { outcome: { outcome: 'selected', optionId: option.optionId } } : { outcome: { outcome: 'cancelled' } };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private receive(line: string) {
|
|
343
|
+
if (line.length > 16 * 1024 * 1024) { this.fail(new Error(`${this.provider.label} sent a frame over the size limit`)); return; }
|
|
344
|
+
let message: any;
|
|
345
|
+
try { message = JSON.parse(line); } catch { return; }
|
|
346
|
+
if (message.method && message.id !== undefined) {
|
|
347
|
+
const answer = message.method === 'session/request_permission' ? this.permission(message.params)
|
|
348
|
+
: Promise.reject(Object.assign(new Error(`ALP does not offer '${message.method}'`), { code: -32601 }));
|
|
349
|
+
answer.then(
|
|
350
|
+
result => { if (!this.closed) this.write({ jsonrpc: '2.0', id: message.id, result }); },
|
|
351
|
+
error => { if (!this.closed) this.write({ jsonrpc: '2.0', id: message.id, error: { code: error?.code ?? -32603, message: error instanceof Error ? error.message : String(error) } }); },
|
|
352
|
+
);
|
|
353
|
+
} else if (message.id !== undefined) {
|
|
354
|
+
const waiter = this.pending.get(message.id);
|
|
355
|
+
if (!waiter) return;
|
|
356
|
+
this.pending.delete(message.id); clearTimeout(waiter.timer);
|
|
357
|
+
if (message.error) {
|
|
358
|
+
const auth = message.error.code === -32000 || /auth/i.test(String(message.error.message ?? ''));
|
|
359
|
+
waiter.reject(new Error(auth ? `${this.provider.label} needs you to sign in first: run it once in a terminal and log in (${message.error.message ?? 'authentication required'})` : `${this.provider.label}: ${message.error.message ?? 'request failed'}`));
|
|
360
|
+
} else waiter.resolve(message.result);
|
|
361
|
+
} else if (message.method === 'session/update' && message.params?.sessionId === this.sessionId) {
|
|
362
|
+
this.update(message.params.update);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private call(method: string, params: unknown, timeout?: number): Promise<any> {
|
|
367
|
+
if (this.closed) return Promise.reject(new Error('Runtime is closed'));
|
|
368
|
+
const id = ++this.sequence;
|
|
369
|
+
return new Promise((resolve, reject) => {
|
|
370
|
+
const timer = timeout ? setTimeout(() => { this.pending.delete(id); reject(new Error(`${this.provider.label} did not answer ${method} within ${timeout / 1000} s`)); }, timeout) : undefined;
|
|
371
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
372
|
+
try { this.write({ jsonrpc: '2.0', id, method, params }); }
|
|
373
|
+
catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private write(value: unknown) {
|
|
378
|
+
if (this.closed || !this.child) throw new Error('Runtime is closed');
|
|
379
|
+
this.child.stdin.write(`${JSON.stringify(value)}\n`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Starts the socket ALP's MCP bridge connects to, and returns the server entry for the agent. */
|
|
383
|
+
private async openBridge() {
|
|
384
|
+
if (!this.bridge) {
|
|
385
|
+
const token = randomBytes(16).toString('hex');
|
|
386
|
+
const name = `alp-acp-${randomBytes(6).toString('hex')}`;
|
|
387
|
+
const socket = process.platform === 'win32' ? `\\\\.\\pipe\\${name}` : path.join(os.tmpdir(), `${name}.sock`);
|
|
388
|
+
const connections = new Set<net.Socket>();
|
|
389
|
+
const server = net.createServer(connection => {
|
|
390
|
+
connections.add(connection);
|
|
391
|
+
connection.once('close', () => connections.delete(connection));
|
|
392
|
+
this.serveBridge(connection, token);
|
|
393
|
+
});
|
|
394
|
+
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(socket, () => resolve()); });
|
|
395
|
+
this.bridge = { server, socket, token, connections };
|
|
396
|
+
}
|
|
397
|
+
// Under Paseo, alpd runs on Electron's binary, which is node only with ELECTRON_RUN_AS_NODE; the agent may not pass its own environment on.
|
|
398
|
+
const electron = process.versions.electron ? [{ name: 'ELECTRON_RUN_AS_NODE', value: '1' }] : [];
|
|
399
|
+
return { name: 'alp', command: process.execPath, args: ['-e', BRIDGE], env: [{ name: 'ALP_BRIDGE_SOCKET', value: this.bridge.socket }, { name: 'ALP_BRIDGE_TOKEN', value: this.bridge.token }, ...electron] };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private serveBridge(connection: net.Socket, token: string) {
|
|
403
|
+
let trusted = false;
|
|
404
|
+
connection.setEncoding('utf8');
|
|
405
|
+
connection.on('error', () => {});
|
|
406
|
+
const reply = (value: unknown) => { if (!connection.destroyed) connection.write(`${JSON.stringify(value)}\n`); };
|
|
407
|
+
createInterface({ input: connection }).on('line', async line => {
|
|
408
|
+
let message: any;
|
|
409
|
+
try { message = JSON.parse(line); } catch { connection.destroy(); return; }
|
|
410
|
+
if (!trusted) {
|
|
411
|
+
if (message.token !== token) { connection.destroy(); return; }
|
|
412
|
+
trusted = true;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (message.op === 'list') {
|
|
416
|
+
reply({ id: message.id, tools: this.tools.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema ?? { type: 'object' } })) });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (message.op === 'call') reply({ id: message.id, result: await this.toolCall(String(message.name), message.arguments ?? {}) });
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async toolCall(tool: string, args: Record<string, unknown>) {
|
|
424
|
+
if (!this.requestHandler || !this.activeTurn || !this.tools.some(candidate => candidate.name === tool)) {
|
|
425
|
+
return { content: [{ type: 'text', text: this.activeTurn ? `Unknown ALP tool '${tool}'` : 'ALP tools are unavailable between turns' }], isError: true };
|
|
426
|
+
}
|
|
427
|
+
const callId = `acp-tool-${randomUUID()}`;
|
|
428
|
+
const item = { type: 'dynamicToolCall', id: callId, callId, tool, arguments: args };
|
|
429
|
+
this.closeMessage();
|
|
430
|
+
this.emit('item/started', { threadId: this.sessionId, item: { ...item, status: 'inProgress' } });
|
|
431
|
+
const result: any = await this.requestHandler('item/tool/call', { threadId: this.sessionId, turnId: this.activeTurn, callId, namespace: null, tool, arguments: args })
|
|
432
|
+
.catch(error => ({ success: false, contentItems: [{ type: 'inputText', text: error instanceof Error ? error.message : String(error) }] }));
|
|
433
|
+
const ok = result?.success !== false;
|
|
434
|
+
this.emit('item/completed', { threadId: this.sessionId, item: { ...item, status: ok ? 'completed' : 'failed', success: ok, contentItems: result?.contentItems ?? [] } });
|
|
435
|
+
return { content: (result?.contentItems ?? []).map((part: any) => ({ type: 'text', text: part.text ?? JSON.stringify(part) })), isError: !ok };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private emit(method: string, params: any) {
|
|
439
|
+
for (const listener of this.listeners) listener(method, params);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private fail(error: Error) {
|
|
443
|
+
if (this.closed) return;
|
|
444
|
+
for (const waiter of this.pending.values()) { clearTimeout(waiter.timer); waiter.reject(error); }
|
|
445
|
+
this.pending.clear();
|
|
446
|
+
for (const listener of this.failures) listener(error);
|
|
447
|
+
void this.close();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async close() {
|
|
451
|
+
if (this.closed) return this.exit;
|
|
452
|
+
this.closed = true;
|
|
453
|
+
for (const waiter of this.pending.values()) { clearTimeout(waiter.timer); waiter.reject(new Error('Runtime closed')); }
|
|
454
|
+
this.pending.clear(); this.listeners.clear(); this.failures.clear();
|
|
455
|
+
for (const answer of this.questions) answer({ outcome: { outcome: 'cancelled' } });
|
|
456
|
+
this.questions.clear();
|
|
457
|
+
if (this.bridge) {
|
|
458
|
+
this.bridge.server.close();
|
|
459
|
+
for (const connection of this.bridge.connections) connection.destroy();
|
|
460
|
+
if (process.platform !== 'win32') try { rmSync(this.bridge.socket, { force: true }); } catch {}
|
|
461
|
+
}
|
|
462
|
+
const child = this.child;
|
|
463
|
+
if (!child) return;
|
|
464
|
+
child.stdin.end();
|
|
465
|
+
const timer = setTimeout(() => {
|
|
466
|
+
if (process.platform === 'win32' && child.pid) execFile('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => {});
|
|
467
|
+
else child.kill('SIGKILL');
|
|
468
|
+
}, 1500);
|
|
469
|
+
let deadline: NodeJS.Timeout | undefined;
|
|
470
|
+
try { await Promise.race([this.exit, new Promise<void>(resolve => { deadline = setTimeout(resolve, 4000); })]); }
|
|
471
|
+
finally { clearTimeout(timer); clearTimeout(deadline); }
|
|
472
|
+
}
|
|
473
|
+
}
|