@almyty/acp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/auth.test.d.ts +1 -0
- package/dist/__tests__/auth.test.js +29 -0
- package/dist/__tests__/events.test.d.ts +1 -0
- package/dist/__tests__/events.test.js +81 -0
- package/dist/__tests__/integration.test.d.ts +1 -0
- package/dist/__tests__/integration.test.js +144 -0
- package/dist/__tests__/session.test.d.ts +1 -0
- package/dist/__tests__/session.test.js +69 -0
- package/dist/agent.d.ts +85 -0
- package/dist/agent.js +527 -0
- package/dist/auth.d.ts +22 -0
- package/dist/auth.js +36 -0
- package/dist/events.d.ts +76 -0
- package/dist/events.js +247 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +205 -0
- package/dist/proxy.d.ts +78 -0
- package/dist/proxy.js +209 -0
- package/dist/session.d.ts +56 -0
- package/dist/session.js +77 -0
- package/package.json +39 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AlmytyAcpAgent: handles all ACP JSON-RPC methods.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the ACP protocol to the almyty backend REST API. Manages
|
|
5
|
+
* session lifecycle, dispatches prompts to the correct execution engine,
|
|
6
|
+
* and streams SessionUpdate notifications back to the client.
|
|
7
|
+
*/
|
|
8
|
+
import { SessionManager } from './session.js';
|
|
9
|
+
import { mapStreamEvent, buildPlanFromPipeline, isTerminalEvent, extractFinalOutput, } from './events.js';
|
|
10
|
+
// ── Standard JSON-RPC error codes ────────────────────────────────
|
|
11
|
+
const PARSE_ERROR = -32700;
|
|
12
|
+
const INVALID_REQUEST = -32600;
|
|
13
|
+
const METHOD_NOT_FOUND = -32601;
|
|
14
|
+
const INVALID_PARAMS = -32602;
|
|
15
|
+
const INTERNAL_ERROR = -32603;
|
|
16
|
+
// ── Agent implementation ─────────────────────────────────────────
|
|
17
|
+
export class AlmytyAcpAgent {
|
|
18
|
+
proxy;
|
|
19
|
+
sessions;
|
|
20
|
+
agentId;
|
|
21
|
+
send;
|
|
22
|
+
agentInfo = null;
|
|
23
|
+
authenticated = false;
|
|
24
|
+
constructor(proxy, agentId, send) {
|
|
25
|
+
this.proxy = proxy;
|
|
26
|
+
this.sessions = new SessionManager();
|
|
27
|
+
this.agentId = agentId;
|
|
28
|
+
this.send = send;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Update the send function (used if transport is re-initialized).
|
|
32
|
+
*/
|
|
33
|
+
setSend(send) {
|
|
34
|
+
this.send = send;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Handle an incoming JSON-RPC message from the ACP client.
|
|
38
|
+
*/
|
|
39
|
+
async handleMessage(msg) {
|
|
40
|
+
const { id, method, params } = msg;
|
|
41
|
+
// Notifications have no id — they don't expect a response
|
|
42
|
+
const isNotification = id === undefined || id === null;
|
|
43
|
+
try {
|
|
44
|
+
let result;
|
|
45
|
+
switch (method) {
|
|
46
|
+
case 'initialize':
|
|
47
|
+
result = await this.handleInitialize(params ?? {});
|
|
48
|
+
break;
|
|
49
|
+
case 'authenticate':
|
|
50
|
+
result = await this.handleAuthenticate(params ?? {});
|
|
51
|
+
break;
|
|
52
|
+
case 'session/new':
|
|
53
|
+
result = await this.handleSessionNew(params ?? {});
|
|
54
|
+
break;
|
|
55
|
+
case 'session/list':
|
|
56
|
+
result = await this.handleSessionList();
|
|
57
|
+
break;
|
|
58
|
+
case 'session/load':
|
|
59
|
+
result = await this.handleSessionLoad(params ?? {});
|
|
60
|
+
break;
|
|
61
|
+
case 'session/prompt':
|
|
62
|
+
result = await this.handleSessionPrompt(params ?? {});
|
|
63
|
+
break;
|
|
64
|
+
case 'session/cancel':
|
|
65
|
+
this.handleSessionCancel(params ?? {});
|
|
66
|
+
if (isNotification)
|
|
67
|
+
return; // No response for notifications
|
|
68
|
+
result = {};
|
|
69
|
+
break;
|
|
70
|
+
case 'session/set_mode':
|
|
71
|
+
result = this.handleSessionSetMode(params ?? {});
|
|
72
|
+
break;
|
|
73
|
+
case 'session/set_config_option':
|
|
74
|
+
result = {};
|
|
75
|
+
break;
|
|
76
|
+
case 'session/close':
|
|
77
|
+
result = this.handleSessionClose(params ?? {});
|
|
78
|
+
break;
|
|
79
|
+
case 'logout':
|
|
80
|
+
this.authenticated = false;
|
|
81
|
+
result = {};
|
|
82
|
+
break;
|
|
83
|
+
// Document sync notifications — acknowledge silently
|
|
84
|
+
case 'document/didOpen':
|
|
85
|
+
case 'document/didChange':
|
|
86
|
+
case 'document/didClose':
|
|
87
|
+
case 'document/didSave':
|
|
88
|
+
case 'document/didFocus':
|
|
89
|
+
return; // Notifications, no response
|
|
90
|
+
default:
|
|
91
|
+
if (isNotification)
|
|
92
|
+
return;
|
|
93
|
+
this.sendError(id, METHOD_NOT_FOUND, `Unknown method: ${method}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!isNotification) {
|
|
97
|
+
this.sendResult(id, result);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (!isNotification) {
|
|
102
|
+
if (err instanceof JsonRpcError) {
|
|
103
|
+
this.sendError(id, err.code, err.message, err.data);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
107
|
+
this.sendError(id, INTERNAL_ERROR, message);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
process.stderr.write(`[acp] Error handling notification ${method}: ${err}\n`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ── Method handlers ────────────────────────────────────────────
|
|
116
|
+
async handleInitialize(_params) {
|
|
117
|
+
// Pre-fetch agent info for capabilities
|
|
118
|
+
try {
|
|
119
|
+
this.agentInfo = await this.proxy.getAgent(this.agentId);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
process.stderr.write(`[acp] Warning: could not fetch agent info: ${err}\n`);
|
|
123
|
+
}
|
|
124
|
+
const hasPipeline = this.agentInfo?.mode === 'workflow' && this.agentInfo.pipeline?.nodes?.length;
|
|
125
|
+
return {
|
|
126
|
+
protocolVersion: 1,
|
|
127
|
+
agentCapabilities: {
|
|
128
|
+
tools: true,
|
|
129
|
+
streaming: true,
|
|
130
|
+
sessions: true,
|
|
131
|
+
sessionList: true,
|
|
132
|
+
plan: !!hasPipeline,
|
|
133
|
+
modes: false,
|
|
134
|
+
},
|
|
135
|
+
authMethods: [
|
|
136
|
+
{
|
|
137
|
+
id: 'almyty_token',
|
|
138
|
+
type: 'env_var',
|
|
139
|
+
label: 'almyty API token',
|
|
140
|
+
envVars: ['ALMYTY_TOKEN'],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
agentInfo: this.agentInfo
|
|
144
|
+
? { name: this.agentInfo.name, description: this.agentInfo.description }
|
|
145
|
+
: { name: 'almyty agent' },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async handleAuthenticate(params) {
|
|
149
|
+
const methodId = params.methodId;
|
|
150
|
+
if (methodId === 'almyty_token') {
|
|
151
|
+
// The token is already configured via env var or credentials file.
|
|
152
|
+
// Validate by making a lightweight API call.
|
|
153
|
+
try {
|
|
154
|
+
await this.proxy.getAgent(this.agentId);
|
|
155
|
+
this.authenticated = true;
|
|
156
|
+
return { authenticated: true };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return { authenticated: false };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Unknown auth method — the token was pre-configured at startup,
|
|
163
|
+
// so we consider it authenticated if we can reach the backend.
|
|
164
|
+
try {
|
|
165
|
+
await this.proxy.getAgent(this.agentId);
|
|
166
|
+
this.authenticated = true;
|
|
167
|
+
return { authenticated: true };
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return { authenticated: false };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async handleSessionNew(params) {
|
|
174
|
+
const mode = this.agentInfo?.mode ?? 'workflow';
|
|
175
|
+
const session = this.sessions.create(this.agentId, mode);
|
|
176
|
+
process.stderr.write(`[acp] Session created: ${session.id} (agent: ${this.agentId}, mode: ${mode})\n`);
|
|
177
|
+
// For workflow agents with a pipeline, send the plan
|
|
178
|
+
if (mode === 'workflow' && this.agentInfo?.pipeline?.nodes?.length) {
|
|
179
|
+
const plan = buildPlanFromPipeline(this.agentInfo.pipeline.nodes);
|
|
180
|
+
this.sendNotification('session/update', {
|
|
181
|
+
sessionId: session.id,
|
|
182
|
+
update: plan,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return { sessionId: session.id };
|
|
186
|
+
}
|
|
187
|
+
async handleSessionList() {
|
|
188
|
+
const sessions = this.sessions.list().map((s) => ({
|
|
189
|
+
id: s.id,
|
|
190
|
+
agentId: s.agentId,
|
|
191
|
+
mode: s.mode,
|
|
192
|
+
}));
|
|
193
|
+
return { sessions };
|
|
194
|
+
}
|
|
195
|
+
async handleSessionLoad(params) {
|
|
196
|
+
const sessionId = params.sessionId;
|
|
197
|
+
if (!sessionId) {
|
|
198
|
+
throw new JsonRpcError(INVALID_PARAMS, 'sessionId is required');
|
|
199
|
+
}
|
|
200
|
+
const session = this.sessions.get(sessionId);
|
|
201
|
+
if (!session) {
|
|
202
|
+
throw new JsonRpcError(INVALID_PARAMS, `Session not found: ${sessionId}`);
|
|
203
|
+
}
|
|
204
|
+
return { sessionId: session.id };
|
|
205
|
+
}
|
|
206
|
+
async handleSessionPrompt(params) {
|
|
207
|
+
const sessionId = params.sessionId;
|
|
208
|
+
if (!sessionId) {
|
|
209
|
+
throw new JsonRpcError(INVALID_PARAMS, 'sessionId is required');
|
|
210
|
+
}
|
|
211
|
+
const session = this.sessions.get(sessionId);
|
|
212
|
+
if (!session) {
|
|
213
|
+
throw new JsonRpcError(INVALID_PARAMS, `Session not found: ${sessionId}`);
|
|
214
|
+
}
|
|
215
|
+
// Extract text from ContentBlock array
|
|
216
|
+
const content = params.content;
|
|
217
|
+
const inputText = extractTextFromContent(content);
|
|
218
|
+
if (!inputText) {
|
|
219
|
+
throw new JsonRpcError(INVALID_PARAMS, 'No text content in prompt');
|
|
220
|
+
}
|
|
221
|
+
// Set up abort controller for this prompt turn
|
|
222
|
+
const abortController = new AbortController();
|
|
223
|
+
this.sessions.update(sessionId, { abortController });
|
|
224
|
+
try {
|
|
225
|
+
if (session.mode === 'autonomous') {
|
|
226
|
+
return await this.handleAutonomousPrompt(session, inputText, abortController.signal);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
return await this.handleWorkflowPrompt(session, inputText, abortController.signal);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
this.sessions.update(sessionId, { abortController: undefined, activeRunId: undefined });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Handle a prompt for a workflow agent: POST /agents/:id/stream.
|
|
238
|
+
* Streams events via SSE and maps them to ACP session updates.
|
|
239
|
+
*/
|
|
240
|
+
async handleWorkflowPrompt(session, input, signal) {
|
|
241
|
+
const collectedText = [];
|
|
242
|
+
try {
|
|
243
|
+
for await (const event of this.proxy.streamAgent(session.agentId, { input }, signal)) {
|
|
244
|
+
if (signal.aborted) {
|
|
245
|
+
return { stopReason: 'cancelled' };
|
|
246
|
+
}
|
|
247
|
+
const updates = mapStreamEvent(event);
|
|
248
|
+
for (const update of updates) {
|
|
249
|
+
this.sendSessionUpdate(session.id, update);
|
|
250
|
+
if (update.type === 'agent_message_chunk') {
|
|
251
|
+
collectedText.push(update.text);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (isTerminalEvent(event)) {
|
|
255
|
+
const finalOutput = extractFinalOutput(event);
|
|
256
|
+
if (finalOutput && !collectedText.includes(finalOutput)) {
|
|
257
|
+
collectedText.push(finalOutput);
|
|
258
|
+
}
|
|
259
|
+
if (event.event === 'execution.error' || event.event === 'run.error') {
|
|
260
|
+
return {
|
|
261
|
+
stopReason: 'end_turn',
|
|
262
|
+
content: [{ type: 'text', text: collectedText.join('') || 'Execution failed.' }],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
if (signal.aborted) {
|
|
270
|
+
return { stopReason: 'cancelled' };
|
|
271
|
+
}
|
|
272
|
+
// If streaming fails, fall back to synchronous invoke
|
|
273
|
+
process.stderr.write(`[acp] Streaming failed, falling back to invoke: ${err}\n`);
|
|
274
|
+
try {
|
|
275
|
+
const result = await this.proxy.invokeAgent(session.agentId, { input });
|
|
276
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
277
|
+
this.sendSessionUpdate(session.id, {
|
|
278
|
+
type: 'agent_message_chunk',
|
|
279
|
+
text,
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
stopReason: 'end_turn',
|
|
283
|
+
content: [{ type: 'text', text }],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
catch (invokeErr) {
|
|
287
|
+
const errMsg = invokeErr instanceof Error ? invokeErr.message : String(invokeErr);
|
|
288
|
+
return {
|
|
289
|
+
stopReason: 'end_turn',
|
|
290
|
+
content: [{ type: 'text', text: `Error: ${errMsg}` }],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
stopReason: 'end_turn',
|
|
296
|
+
content: collectedText.length > 0
|
|
297
|
+
? [{ type: 'text', text: collectedText.join('') }]
|
|
298
|
+
: undefined,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Handle a prompt for an autonomous agent: start or continue a run.
|
|
303
|
+
* If the session has no active conversation, starts a new run.
|
|
304
|
+
* If the agent is waiting for input, sends input to the existing run.
|
|
305
|
+
*/
|
|
306
|
+
async handleAutonomousPrompt(session, input, signal) {
|
|
307
|
+
const collectedText = [];
|
|
308
|
+
try {
|
|
309
|
+
let run;
|
|
310
|
+
if (session.activeRunId) {
|
|
311
|
+
// Continue an existing run that is waiting for input
|
|
312
|
+
run = await this.proxy.sendRunInput(session.agentId, session.activeRunId, { message: input });
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
// Start a new run
|
|
316
|
+
run = await this.proxy.startRun(session.agentId, { message: input });
|
|
317
|
+
this.sessions.update(session.id, {
|
|
318
|
+
activeRunId: run.id,
|
|
319
|
+
conversationId: run.conversationId,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
process.stderr.write(`[acp] Run started: ${run.id} (status: ${run.status})\n`);
|
|
323
|
+
// Stream events from the run
|
|
324
|
+
try {
|
|
325
|
+
for await (const event of this.proxy.streamRun(session.agentId, run.id, signal)) {
|
|
326
|
+
if (signal.aborted) {
|
|
327
|
+
// Try to cancel the run on the backend
|
|
328
|
+
try {
|
|
329
|
+
await this.proxy.cancelRun(session.agentId, run.id);
|
|
330
|
+
}
|
|
331
|
+
catch { /* best effort */ }
|
|
332
|
+
return { stopReason: 'cancelled' };
|
|
333
|
+
}
|
|
334
|
+
const updates = mapStreamEvent(event);
|
|
335
|
+
for (const update of updates) {
|
|
336
|
+
this.sendSessionUpdate(session.id, update);
|
|
337
|
+
if (update.type === 'agent_message_chunk') {
|
|
338
|
+
collectedText.push(update.text);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (isTerminalEvent(event)) {
|
|
342
|
+
const finalOutput = extractFinalOutput(event);
|
|
343
|
+
if (finalOutput && !collectedText.includes(finalOutput)) {
|
|
344
|
+
collectedText.push(finalOutput);
|
|
345
|
+
}
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (streamErr) {
|
|
351
|
+
// If streaming fails, fall back to polling the run status
|
|
352
|
+
process.stderr.write(`[acp] Run streaming failed, polling: ${streamErr}\n`);
|
|
353
|
+
const finalRun = await this.pollRunToCompletion(session.agentId, run.id, signal);
|
|
354
|
+
if (finalRun.output) {
|
|
355
|
+
const text = typeof finalRun.output === 'string'
|
|
356
|
+
? finalRun.output
|
|
357
|
+
: JSON.stringify(finalRun.output, null, 2);
|
|
358
|
+
collectedText.push(text);
|
|
359
|
+
this.sendSessionUpdate(session.id, {
|
|
360
|
+
type: 'agent_message_chunk',
|
|
361
|
+
text,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (finalRun.error) {
|
|
365
|
+
collectedText.push(`Error: ${finalRun.error}`);
|
|
366
|
+
}
|
|
367
|
+
if (finalRun.status === 'waiting_input') {
|
|
368
|
+
// Agent needs more input — keep the run active
|
|
369
|
+
this.sessions.update(session.id, { activeRunId: finalRun.id });
|
|
370
|
+
return {
|
|
371
|
+
stopReason: 'end_turn',
|
|
372
|
+
content: collectedText.length > 0
|
|
373
|
+
? [{ type: 'text', text: collectedText.join('') }]
|
|
374
|
+
: [{ type: 'text', text: 'The agent is waiting for your input.' }],
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Check final run status — if still running, poll until complete
|
|
379
|
+
let finalRun = await this.proxy.getRun(session.agentId, run.id).catch(() => run);
|
|
380
|
+
if (finalRun.status === 'running') {
|
|
381
|
+
process.stderr.write(`[acp] Run still running after stream ended, polling...\n`);
|
|
382
|
+
finalRun = await this.pollRunToCompletion(session.agentId, run.id, signal);
|
|
383
|
+
if (finalRun.output) {
|
|
384
|
+
const text = typeof finalRun.output === 'string'
|
|
385
|
+
? finalRun.output
|
|
386
|
+
: JSON.stringify(finalRun.output, null, 2);
|
|
387
|
+
if (!collectedText.includes(text)) {
|
|
388
|
+
collectedText.push(text);
|
|
389
|
+
this.sendSessionUpdate(session.id, { type: 'agent_message_chunk', text });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (finalRun.error) {
|
|
393
|
+
const errText = typeof finalRun.error === 'string'
|
|
394
|
+
? finalRun.error
|
|
395
|
+
: JSON.stringify(finalRun.error);
|
|
396
|
+
collectedText.push(`Error: ${errText}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (finalRun.status === 'waiting_input') {
|
|
400
|
+
this.sessions.update(session.id, { activeRunId: finalRun.id });
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
this.sessions.update(session.id, { activeRunId: undefined });
|
|
404
|
+
}
|
|
405
|
+
if (finalRun.status === 'cancelled') {
|
|
406
|
+
return { stopReason: 'cancelled' };
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
stopReason: finalRun.status === 'failed' ? 'refusal' : 'end_turn',
|
|
410
|
+
content: collectedText.length > 0
|
|
411
|
+
? [{ type: 'text', text: collectedText.join('') }]
|
|
412
|
+
: undefined,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
if (signal.aborted) {
|
|
417
|
+
return { stopReason: 'cancelled' };
|
|
418
|
+
}
|
|
419
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
420
|
+
return {
|
|
421
|
+
stopReason: 'end_turn',
|
|
422
|
+
content: [{ type: 'text', text: `Error: ${errMsg}` }],
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Poll a run until it reaches a terminal state or the signal is aborted.
|
|
428
|
+
*/
|
|
429
|
+
async pollRunToCompletion(agentId, runId, signal) {
|
|
430
|
+
const POLL_INTERVAL_MS = 1000;
|
|
431
|
+
const MAX_POLLS = 300; // 5 minutes max
|
|
432
|
+
for (let i = 0; i < MAX_POLLS; i++) {
|
|
433
|
+
if (signal.aborted) {
|
|
434
|
+
throw new Error('Cancelled');
|
|
435
|
+
}
|
|
436
|
+
const run = await this.proxy.getRun(agentId, runId);
|
|
437
|
+
if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled' || run.status === 'waiting_input') {
|
|
438
|
+
return run;
|
|
439
|
+
}
|
|
440
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
441
|
+
}
|
|
442
|
+
throw new Error('Run polling timed out');
|
|
443
|
+
}
|
|
444
|
+
handleSessionCancel(params) {
|
|
445
|
+
const sessionId = params.sessionId;
|
|
446
|
+
if (sessionId) {
|
|
447
|
+
process.stderr.write(`[acp] Cancelling session: ${sessionId}\n`);
|
|
448
|
+
this.sessions.cancel(sessionId);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
handleSessionSetMode(params) {
|
|
452
|
+
const sessionId = params.sessionId;
|
|
453
|
+
const mode = params.mode;
|
|
454
|
+
if (sessionId && mode) {
|
|
455
|
+
this.sessions.update(sessionId, { userMode: mode });
|
|
456
|
+
}
|
|
457
|
+
// Acknowledge but no-op — almyty agents don't have client-side modes
|
|
458
|
+
return {};
|
|
459
|
+
}
|
|
460
|
+
handleSessionClose(params) {
|
|
461
|
+
const sessionId = params.sessionId;
|
|
462
|
+
if (!sessionId) {
|
|
463
|
+
throw new JsonRpcError(INVALID_PARAMS, 'sessionId is required');
|
|
464
|
+
}
|
|
465
|
+
const closed = this.sessions.close(sessionId);
|
|
466
|
+
process.stderr.write(`[acp] Session closed: ${sessionId} (found: ${closed})\n`);
|
|
467
|
+
return { closed };
|
|
468
|
+
}
|
|
469
|
+
// ── Message sending helpers ────────────────────────────────────
|
|
470
|
+
sendResult(id, result) {
|
|
471
|
+
this.send({
|
|
472
|
+
jsonrpc: '2.0',
|
|
473
|
+
id,
|
|
474
|
+
result,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
sendError(id, code, message, data) {
|
|
478
|
+
this.send({
|
|
479
|
+
jsonrpc: '2.0',
|
|
480
|
+
id,
|
|
481
|
+
error: { code, message, data },
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
sendNotification(method, params) {
|
|
485
|
+
this.send({
|
|
486
|
+
jsonrpc: '2.0',
|
|
487
|
+
method,
|
|
488
|
+
params,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
sendSessionUpdate(sessionId, update) {
|
|
492
|
+
this.sendNotification('session/update', {
|
|
493
|
+
sessionId,
|
|
494
|
+
update,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Clean up all sessions. Called during shutdown.
|
|
499
|
+
*/
|
|
500
|
+
shutdown() {
|
|
501
|
+
this.sessions.closeAll();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
505
|
+
/**
|
|
506
|
+
* Extract plain text from an array of ACP ContentBlocks.
|
|
507
|
+
*/
|
|
508
|
+
function extractTextFromContent(content) {
|
|
509
|
+
if (!content || !Array.isArray(content))
|
|
510
|
+
return '';
|
|
511
|
+
return content
|
|
512
|
+
.filter((block) => block.type === 'text' && block.text)
|
|
513
|
+
.map((block) => block.text)
|
|
514
|
+
.join('\n');
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Typed JSON-RPC error for structured error responses.
|
|
518
|
+
*/
|
|
519
|
+
class JsonRpcError extends Error {
|
|
520
|
+
code;
|
|
521
|
+
data;
|
|
522
|
+
constructor(code, message, data) {
|
|
523
|
+
super(message);
|
|
524
|
+
this.code = code;
|
|
525
|
+
this.data = data;
|
|
526
|
+
}
|
|
527
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential resolver for @almyty/acp-server.
|
|
3
|
+
*
|
|
4
|
+
* Reads the shared credential store written by `npx @almyty/auth login`.
|
|
5
|
+
* Lookup order: ALMYTY_TOKEN env var, then ~/.almyty/credentials.json.
|
|
6
|
+
*/
|
|
7
|
+
interface StoredCredentials {
|
|
8
|
+
url: string;
|
|
9
|
+
token: string;
|
|
10
|
+
email?: string;
|
|
11
|
+
frontendUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function loadCredentials(): StoredCredentials | null;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve token: env var > stored credentials. Returns null when no
|
|
16
|
+
* credentials are available.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveCredentials(): {
|
|
19
|
+
url: string;
|
|
20
|
+
token: string;
|
|
21
|
+
} | null;
|
|
22
|
+
export {};
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential resolver for @almyty/acp-server.
|
|
3
|
+
*
|
|
4
|
+
* Reads the shared credential store written by `npx @almyty/auth login`.
|
|
5
|
+
* Lookup order: ALMYTY_TOKEN env var, then ~/.almyty/credentials.json.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
const CREDENTIALS_FILE = join(homedir(), '.almyty', 'credentials.json');
|
|
11
|
+
export function loadCredentials() {
|
|
12
|
+
try {
|
|
13
|
+
if (!existsSync(CREDENTIALS_FILE))
|
|
14
|
+
return null;
|
|
15
|
+
return JSON.parse(readFileSync(CREDENTIALS_FILE, 'utf-8'));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve token: env var > stored credentials. Returns null when no
|
|
23
|
+
* credentials are available.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveCredentials() {
|
|
26
|
+
const envToken = process.env.ALMYTY_TOKEN;
|
|
27
|
+
const envUrl = process.env.ALMYTY_URL || 'https://api.almyty.com';
|
|
28
|
+
if (envToken) {
|
|
29
|
+
return { url: envUrl, token: envToken };
|
|
30
|
+
}
|
|
31
|
+
const creds = loadCredentials();
|
|
32
|
+
if (creds?.token) {
|
|
33
|
+
return { url: creds.url, token: creds.token };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event mapper: converts almyty StreamEvent / run events into ACP
|
|
3
|
+
* SessionUpdate notifications.
|
|
4
|
+
*
|
|
5
|
+
* ACP SessionUpdate variants: agent_message_chunk, tool_call,
|
|
6
|
+
* tool_call_update, plan, agent_thought_chunk, user_message_chunk.
|
|
7
|
+
*/
|
|
8
|
+
import type { StreamEvent, PipelineNode } from './proxy.js';
|
|
9
|
+
export interface SessionUpdate {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
update: SessionUpdatePayload;
|
|
12
|
+
}
|
|
13
|
+
export type SessionUpdatePayload = AgentMessageChunk | AgentThoughtChunk | ToolCall | ToolCallUpdate | Plan;
|
|
14
|
+
export interface AgentMessageChunk {
|
|
15
|
+
type: 'agent_message_chunk';
|
|
16
|
+
/** Incremental text content. */
|
|
17
|
+
text: string;
|
|
18
|
+
/** MIME type (default text/plain). */
|
|
19
|
+
mimeType?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface AgentThoughtChunk {
|
|
22
|
+
type: 'agent_thought_chunk';
|
|
23
|
+
text: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ToolCall {
|
|
26
|
+
type: 'tool_call';
|
|
27
|
+
/** Unique ID for this tool invocation (for pairing with updates). */
|
|
28
|
+
toolCallId: string;
|
|
29
|
+
/** Human-readable title (tool name or node label). */
|
|
30
|
+
title: string;
|
|
31
|
+
/** Tool call kind: read, edit, execute, fetch, think, other. */
|
|
32
|
+
kind: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other';
|
|
33
|
+
/** Current status. */
|
|
34
|
+
status: 'running' | 'completed' | 'error' | 'cancelled';
|
|
35
|
+
/** Raw input parameters (stringified JSON). */
|
|
36
|
+
rawInput?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ToolCallUpdate {
|
|
39
|
+
type: 'tool_call_update';
|
|
40
|
+
/** Matches the toolCallId from the original ToolCall. */
|
|
41
|
+
toolCallId: string;
|
|
42
|
+
/** Updated status. */
|
|
43
|
+
status: 'running' | 'completed' | 'error' | 'cancelled';
|
|
44
|
+
/** Output content (on completion). */
|
|
45
|
+
content?: string;
|
|
46
|
+
/** Error message (on failure). */
|
|
47
|
+
error?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface PlanEntry {
|
|
50
|
+
title: string;
|
|
51
|
+
priority: 'high' | 'medium' | 'low';
|
|
52
|
+
status: 'todo' | 'in_progress' | 'done' | 'error';
|
|
53
|
+
}
|
|
54
|
+
export interface Plan {
|
|
55
|
+
type: 'plan';
|
|
56
|
+
entries: PlanEntry[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Convert a single almyty StreamEvent into zero or more ACP SessionUpdate
|
|
60
|
+
* payloads. Most events map 1:1, but some (like node.completed with output)
|
|
61
|
+
* may produce both a tool_call_update and an agent_message_chunk.
|
|
62
|
+
*/
|
|
63
|
+
export declare function mapStreamEvent(event: StreamEvent): SessionUpdatePayload[];
|
|
64
|
+
/**
|
|
65
|
+
* Build an ACP Plan from almyty pipeline nodes. Maps node types to
|
|
66
|
+
* priorities and initializes all entries as "todo".
|
|
67
|
+
*/
|
|
68
|
+
export declare function buildPlanFromPipeline(nodes: PipelineNode[]): Plan;
|
|
69
|
+
/**
|
|
70
|
+
* Check if a StreamEvent indicates the execution has finished.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isTerminalEvent(event: StreamEvent): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Extract final output text from a terminal StreamEvent.
|
|
75
|
+
*/
|
|
76
|
+
export declare function extractFinalOutput(event: StreamEvent): string | undefined;
|