@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/proxy.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * HTTP proxy client to the almyty backend REST API.
3
+ *
4
+ * Provides typed methods for agent discovery, invocation, streaming,
5
+ * and autonomous run management. All stdout is reserved for the ACP
6
+ * protocol; diagnostics go to stderr.
7
+ */
8
+ import { createParser } from 'eventsource-parser';
9
+ /** Default timeouts (ms). */
10
+ const DISCOVERY_TIMEOUT_MS = 15_000;
11
+ const INVOKE_TIMEOUT_MS = 120_000;
12
+ export class AlmytyProxy {
13
+ baseUrl;
14
+ token;
15
+ constructor(baseUrl, token) {
16
+ this.baseUrl = baseUrl.replace(/\/$/, '');
17
+ this.token = token;
18
+ }
19
+ // ── Agent discovery ────────────────────────────────────────────
20
+ async listAgents() {
21
+ const data = await this.get('/agents', DISCOVERY_TIMEOUT_MS);
22
+ // Backend returns { success, data: [...], pagination } or { agents: [...] }
23
+ const list = Array.isArray(data?.data) ? data.data : (data?.agents || (Array.isArray(data) ? data : []));
24
+ return list;
25
+ }
26
+ async getAgent(id) {
27
+ const data = await this.get(`/agents/${encodeURIComponent(id)}`, DISCOVERY_TIMEOUT_MS);
28
+ return data?.data || data;
29
+ }
30
+ // ── Workflow invocation ────────────────────────────────────────
31
+ async invokeAgent(id, input) {
32
+ return this.post(`/agents/${encodeURIComponent(id)}/invoke`, input, INVOKE_TIMEOUT_MS);
33
+ }
34
+ /**
35
+ * Stream a workflow agent execution via SSE. Yields StreamEvent objects
36
+ * as they arrive. The caller is responsible for consuming the async
37
+ * iterator; aborting the signal will terminate the stream.
38
+ */
39
+ async *streamAgent(id, input, signal) {
40
+ yield* this.ssePost(`/agents/${encodeURIComponent(id)}/stream`, input, signal);
41
+ }
42
+ // ── Autonomous run management ──────────────────────────────────
43
+ async startRun(id, input) {
44
+ const data = await this.post(`/agents/${encodeURIComponent(id)}/runs`, { input }, INVOKE_TIMEOUT_MS);
45
+ return data?.data || data;
46
+ }
47
+ async getRun(agentId, runId) {
48
+ const data = await this.get(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}`, DISCOVERY_TIMEOUT_MS);
49
+ return data?.data || data;
50
+ }
51
+ async sendRunInput(agentId, runId, input) {
52
+ return this.post(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/input`, input, INVOKE_TIMEOUT_MS);
53
+ }
54
+ async cancelRun(agentId, runId) {
55
+ await this.post(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/cancel`, {}, DISCOVERY_TIMEOUT_MS);
56
+ }
57
+ /**
58
+ * Stream an autonomous run via SSE. Similar to streamAgent but uses the
59
+ * run-specific SSE endpoint.
60
+ */
61
+ async *streamRun(agentId, runId, signal) {
62
+ yield* this.sseGet(`/agents/${encodeURIComponent(agentId)}/runs/${encodeURIComponent(runId)}/stream`, signal);
63
+ }
64
+ // ── Internal HTTP helpers ──────────────────────────────────────
65
+ headers() {
66
+ return {
67
+ 'Content-Type': 'application/json',
68
+ Authorization: `Bearer ${this.token}`,
69
+ };
70
+ }
71
+ async get(path, timeoutMs) {
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
74
+ try {
75
+ const resp = await fetch(`${this.baseUrl}${path}`, {
76
+ method: 'GET',
77
+ headers: this.headers(),
78
+ signal: controller.signal,
79
+ });
80
+ if (!resp.ok) {
81
+ const text = await resp.text().catch(() => '');
82
+ throw new Error(`GET ${path} failed (${resp.status}): ${text}`);
83
+ }
84
+ return (await resp.json());
85
+ }
86
+ catch (err) {
87
+ if (err instanceof Error && err.name === 'AbortError') {
88
+ throw new Error(`GET ${path} timed out after ${Math.round(timeoutMs / 1000)}s`);
89
+ }
90
+ throw err;
91
+ }
92
+ finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
96
+ async post(path, body, timeoutMs) {
97
+ const controller = new AbortController();
98
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
99
+ try {
100
+ const resp = await fetch(`${this.baseUrl}${path}`, {
101
+ method: 'POST',
102
+ headers: this.headers(),
103
+ body: JSON.stringify(body),
104
+ signal: controller.signal,
105
+ });
106
+ if (!resp.ok) {
107
+ const text = await resp.text().catch(() => '');
108
+ throw new Error(`POST ${path} failed (${resp.status}): ${text}`);
109
+ }
110
+ return (await resp.json());
111
+ }
112
+ catch (err) {
113
+ if (err instanceof Error && err.name === 'AbortError') {
114
+ throw new Error(`POST ${path} timed out after ${Math.round(timeoutMs / 1000)}s`);
115
+ }
116
+ throw err;
117
+ }
118
+ finally {
119
+ clearTimeout(timer);
120
+ }
121
+ }
122
+ /**
123
+ * Consume an SSE stream from a POST endpoint. Uses eventsource-parser
124
+ * to handle the text/event-stream format.
125
+ */
126
+ async *ssePost(path, body, signal) {
127
+ const controller = new AbortController();
128
+ const combinedSignal = signal
129
+ ? AbortSignal.any([signal, controller.signal])
130
+ : controller.signal;
131
+ const resp = await fetch(`${this.baseUrl}${path}`, {
132
+ method: 'POST',
133
+ headers: {
134
+ ...this.headers(),
135
+ Accept: 'text/event-stream',
136
+ },
137
+ body: JSON.stringify(body),
138
+ signal: combinedSignal,
139
+ });
140
+ if (!resp.ok) {
141
+ const text = await resp.text().catch(() => '');
142
+ throw new Error(`SSE POST ${path} failed (${resp.status}): ${text}`);
143
+ }
144
+ yield* this.consumeSse(resp);
145
+ }
146
+ /**
147
+ * Consume an SSE stream from a GET endpoint.
148
+ */
149
+ async *sseGet(path, signal) {
150
+ const controller = new AbortController();
151
+ const combinedSignal = signal
152
+ ? AbortSignal.any([signal, controller.signal])
153
+ : controller.signal;
154
+ const resp = await fetch(`${this.baseUrl}${path}`, {
155
+ method: 'GET',
156
+ headers: {
157
+ ...this.headers(),
158
+ Accept: 'text/event-stream',
159
+ },
160
+ signal: combinedSignal,
161
+ });
162
+ if (!resp.ok) {
163
+ const text = await resp.text().catch(() => '');
164
+ throw new Error(`SSE GET ${path} failed (${resp.status}): ${text}`);
165
+ }
166
+ yield* this.consumeSse(resp);
167
+ }
168
+ /**
169
+ * Parse an SSE response body into StreamEvent objects.
170
+ */
171
+ async *consumeSse(resp) {
172
+ const body = resp.body;
173
+ if (!body)
174
+ return;
175
+ // Buffer for events parsed by eventsource-parser
176
+ const events = [];
177
+ let done = false;
178
+ const parser = createParser({
179
+ onEvent(event) {
180
+ let data;
181
+ try {
182
+ data = JSON.parse(event.data);
183
+ }
184
+ catch {
185
+ data = { raw: event.data };
186
+ }
187
+ events.push({ event: event.event || 'message', data });
188
+ },
189
+ });
190
+ const reader = body.getReader();
191
+ const decoder = new TextDecoder();
192
+ try {
193
+ while (!done) {
194
+ const { value, done: readerDone } = await reader.read();
195
+ done = readerDone;
196
+ if (value) {
197
+ parser.feed(decoder.decode(value, { stream: !done }));
198
+ }
199
+ // Yield any buffered events
200
+ while (events.length > 0) {
201
+ yield events.shift();
202
+ }
203
+ }
204
+ }
205
+ finally {
206
+ reader.releaseLock();
207
+ }
208
+ }
209
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Session manager for the ACP server.
3
+ *
4
+ * Maps ACP session IDs to almyty agent state: agent ID, agent mode
5
+ * (workflow/autonomous), conversation context, and active run tracking.
6
+ */
7
+ /** Persisted state for one ACP session. */
8
+ export interface Session {
9
+ /** ACP session ID (UUID). */
10
+ id: string;
11
+ /** almyty agent ID this session is bound to. */
12
+ agentId: string;
13
+ /** Agent execution mode. */
14
+ mode: 'workflow' | 'autonomous';
15
+ /** Conversation ID for autonomous multi-turn sessions. */
16
+ conversationId?: string;
17
+ /** Currently in-flight run ID (for cancellation). */
18
+ activeRunId?: string;
19
+ /** AbortController for the active streaming request. */
20
+ abortController?: AbortController;
21
+ /** Timestamp of last activity (epoch ms). */
22
+ lastActivity: number;
23
+ /** User-requested session mode (e.g. "plan", "code"). Stored but not enforced. */
24
+ userMode?: string;
25
+ }
26
+ export declare class SessionManager {
27
+ private readonly sessions;
28
+ /**
29
+ * Create a new session bound to the given agent.
30
+ */
31
+ create(agentId: string, mode: 'workflow' | 'autonomous'): Session;
32
+ /**
33
+ * Look up a session by ID. Returns undefined if not found.
34
+ */
35
+ get(sessionId: string): Session | undefined;
36
+ /**
37
+ * List all active sessions.
38
+ */
39
+ list(): Session[];
40
+ /**
41
+ * Update fields on an existing session.
42
+ */
43
+ update(sessionId: string, patch: Partial<Pick<Session, 'conversationId' | 'activeRunId' | 'abortController' | 'userMode'>>): void;
44
+ /**
45
+ * Abort any in-flight work for a session and clear the active run.
46
+ */
47
+ cancel(sessionId: string): void;
48
+ /**
49
+ * Close and remove a session, aborting any in-flight work.
50
+ */
51
+ close(sessionId: string): boolean;
52
+ /**
53
+ * Close all sessions. Called during shutdown.
54
+ */
55
+ closeAll(): void;
56
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Session manager for the ACP server.
3
+ *
4
+ * Maps ACP session IDs to almyty agent state: agent ID, agent mode
5
+ * (workflow/autonomous), conversation context, and active run tracking.
6
+ */
7
+ import { randomUUID } from 'node:crypto';
8
+ export class SessionManager {
9
+ sessions = new Map();
10
+ /**
11
+ * Create a new session bound to the given agent.
12
+ */
13
+ create(agentId, mode) {
14
+ const session = {
15
+ id: randomUUID(),
16
+ agentId,
17
+ mode,
18
+ lastActivity: Date.now(),
19
+ };
20
+ this.sessions.set(session.id, session);
21
+ return session;
22
+ }
23
+ /**
24
+ * Look up a session by ID. Returns undefined if not found.
25
+ */
26
+ get(sessionId) {
27
+ return this.sessions.get(sessionId);
28
+ }
29
+ /**
30
+ * List all active sessions.
31
+ */
32
+ list() {
33
+ return Array.from(this.sessions.values());
34
+ }
35
+ /**
36
+ * Update fields on an existing session.
37
+ */
38
+ update(sessionId, patch) {
39
+ const session = this.sessions.get(sessionId);
40
+ if (!session)
41
+ return;
42
+ Object.assign(session, patch);
43
+ session.lastActivity = Date.now();
44
+ }
45
+ /**
46
+ * Abort any in-flight work for a session and clear the active run.
47
+ */
48
+ cancel(sessionId) {
49
+ const session = this.sessions.get(sessionId);
50
+ if (!session)
51
+ return;
52
+ session.abortController?.abort();
53
+ session.abortController = undefined;
54
+ session.activeRunId = undefined;
55
+ session.lastActivity = Date.now();
56
+ }
57
+ /**
58
+ * Close and remove a session, aborting any in-flight work.
59
+ */
60
+ close(sessionId) {
61
+ const session = this.sessions.get(sessionId);
62
+ if (!session)
63
+ return false;
64
+ session.abortController?.abort();
65
+ this.sessions.delete(sessionId);
66
+ return true;
67
+ }
68
+ /**
69
+ * Close all sessions. Called during shutdown.
70
+ */
71
+ closeAll() {
72
+ for (const session of this.sessions.values()) {
73
+ session.abortController?.abort();
74
+ }
75
+ this.sessions.clear();
76
+ }
77
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@almyty/acp-server",
3
+ "version": "1.0.0",
4
+ "description": "ACP agent server for almyty — expose any almyty agent via the Agent Client Protocol",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "almyty-acp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsx src/index.ts",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "acp",
22
+ "agent-client-protocol",
23
+ "ai-agents",
24
+ "almyty",
25
+ "zed",
26
+ "jetbrains"
27
+ ],
28
+ "author": "almyty",
29
+ "license": "BSL-1.1",
30
+ "dependencies": {
31
+ "eventsource-parser": "^3.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.0.0",
35
+ "tsx": "^4.0.0",
36
+ "typescript": "^5.7.0",
37
+ "vitest": "^4.1.0"
38
+ }
39
+ }