@almyty/acp-server 1.2.0 → 1.5.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/README.md CHANGED
@@ -54,8 +54,10 @@ almyty is the full-stack platform for AI agents, agnostic by design: any LLM, an
54
54
  API turned into tools, served over MCP, A2A, UTCP, and Agent Skills. Open source,
55
55
  no lock-in.
56
56
 
57
- - Website — https://almyty.com
58
- - Docs — https://docs.almyty.com
59
- - Source — https://github.com/almyty-inc/almyty
57
+ - Website: https://almyty.com
58
+ - Docs: https://docs.almyty.com
59
+ - Source: https://github.com/almyty-inc/almyty
60
+
61
+ This CLI is part of the `@almyty/*` suite (versioned together at 1.x) and works with the almyty platform 0.1 and later.
60
62
 
61
63
  Apache-2.0 © Almyty Inc.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Unit tests for the stdio line dispatcher.
3
+ *
4
+ * The behaviour under test is the one readline makes easy to get wrong:
5
+ * every line already sitting in the stdin pipe is delivered in a single
6
+ * synchronous burst, so the lazy agent resolution has to be guarded by the
7
+ * in-flight promise rather than by the settled value.
8
+ */
9
+ import { describe, it, expect, vi } from 'vitest';
10
+ import { createLineHandler } from '../dispatch.js';
11
+ /** A stand-in agent that records everything it is handed. */
12
+ class RecordingAgent {
13
+ seen = [];
14
+ async handleMessage(msg) {
15
+ this.seen.push(msg);
16
+ }
17
+ }
18
+ const req = (id, method) => JSON.stringify(id === null ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', id, method });
19
+ describe('createLineHandler', () => {
20
+ it('resolves the agent once for a burst of lines and shares it', async () => {
21
+ const agents = [];
22
+ const resolveAgent = vi.fn(async () => {
23
+ // A real resolution is at least one HTTP round-trip away.
24
+ await new Promise((r) => setTimeout(r, 10));
25
+ const agent = new RecordingAgent();
26
+ agents.push(agent);
27
+ return agent;
28
+ });
29
+ const handleLine = createLineHandler({ resolveAgent, send: vi.fn() });
30
+ // readline delivers a buffered burst synchronously, one call per line,
31
+ // without awaiting any of them.
32
+ await Promise.all([
33
+ handleLine(req(1, 'initialize')),
34
+ handleLine(req(2, 'session/new')),
35
+ handleLine(req(3, 'session/list')),
36
+ ]);
37
+ expect(resolveAgent).toHaveBeenCalledTimes(1);
38
+ expect(agents).toHaveLength(1);
39
+ expect(agents[0].seen.map((m) => m.id)).toEqual(expect.arrayContaining([1, 2, 3]));
40
+ expect(agents[0].seen).toHaveLength(3);
41
+ });
42
+ it('reuses the resolved agent for later lines', async () => {
43
+ const agent = new RecordingAgent();
44
+ const resolveAgent = vi.fn(async () => agent);
45
+ const handleLine = createLineHandler({ resolveAgent, send: vi.fn() });
46
+ await handleLine(req(1, 'initialize'));
47
+ await handleLine(req(2, 'session/new'));
48
+ expect(resolveAgent).toHaveBeenCalledTimes(1);
49
+ expect(agent.seen).toHaveLength(2);
50
+ });
51
+ it('answers every line of a burst when resolution fails, and retries later', async () => {
52
+ const send = vi.fn();
53
+ const resolveAgent = vi
54
+ .fn()
55
+ .mockRejectedValueOnce(new Error('No auth token. Run: npx @almyty/auth login'))
56
+ .mockResolvedValueOnce(new RecordingAgent());
57
+ const handleLine = createLineHandler({ resolveAgent, send });
58
+ await Promise.all([handleLine(req(1, 'initialize')), handleLine(req(2, 'session/new'))]);
59
+ expect(resolveAgent).toHaveBeenCalledTimes(1);
60
+ expect(send).toHaveBeenCalledTimes(2);
61
+ for (const [msg] of send.mock.calls) {
62
+ expect(msg.error.code).toBe(-32603);
63
+ expect(msg.error.message).toBe('No auth token. Run: npx @almyty/auth login');
64
+ }
65
+ expect(send.mock.calls.map(([m]) => m.id)).toEqual([1, 2]);
66
+ // The failed memo is cleared, so a later message tries again.
67
+ send.mockClear();
68
+ await handleLine(req(3, 'initialize'));
69
+ expect(resolveAgent).toHaveBeenCalledTimes(2);
70
+ expect(send).not.toHaveBeenCalled();
71
+ });
72
+ it('ignores blank lines without resolving an agent', async () => {
73
+ const resolveAgent = vi.fn(async () => new RecordingAgent());
74
+ const send = vi.fn();
75
+ const handleLine = createLineHandler({ resolveAgent, send });
76
+ await handleLine('');
77
+ await handleLine(' \t ');
78
+ expect(resolveAgent).not.toHaveBeenCalled();
79
+ expect(send).not.toHaveBeenCalled();
80
+ });
81
+ it('answers unparseable input with a parse error and no agent resolution', async () => {
82
+ const resolveAgent = vi.fn(async () => new RecordingAgent());
83
+ const send = vi.fn();
84
+ const handleLine = createLineHandler({ resolveAgent, send });
85
+ await handleLine('{not json');
86
+ expect(resolveAgent).not.toHaveBeenCalled();
87
+ expect(send).toHaveBeenCalledWith({
88
+ jsonrpc: '2.0',
89
+ id: null,
90
+ error: { code: -32700, message: 'Parse error' },
91
+ });
92
+ });
93
+ it('rejects a malformed envelope that still carries an id', async () => {
94
+ const send = vi.fn();
95
+ const handleLine = createLineHandler({ resolveAgent: vi.fn(), send });
96
+ await handleLine(JSON.stringify({ jsonrpc: '1.0', id: 7, method: 'initialize' }));
97
+ await handleLine(JSON.stringify({ jsonrpc: '2.0', id: 8 })); // no method
98
+ expect(send.mock.calls.map(([m]) => m)).toEqual([
99
+ { jsonrpc: '2.0', id: 7, error: { code: -32600, message: 'Invalid request' } },
100
+ { jsonrpc: '2.0', id: 8, error: { code: -32600, message: 'Invalid request' } },
101
+ ]);
102
+ });
103
+ it('stays silent for a malformed notification', async () => {
104
+ const send = vi.fn();
105
+ const handleLine = createLineHandler({ resolveAgent: vi.fn(), send });
106
+ await handleLine(JSON.stringify({ jsonrpc: '2.0' }));
107
+ expect(send).not.toHaveBeenCalled();
108
+ });
109
+ it('turns a thrown handler into an internal error for requests only', async () => {
110
+ const send = vi.fn();
111
+ const log = vi.fn();
112
+ const agent = {
113
+ handleMessage: vi.fn(async () => {
114
+ throw new Error('boom');
115
+ }),
116
+ };
117
+ const handleLine = createLineHandler({ resolveAgent: async () => agent, send, log });
118
+ await handleLine(req(9, 'session/new'));
119
+ expect(send).toHaveBeenCalledWith({
120
+ jsonrpc: '2.0',
121
+ id: 9,
122
+ error: { code: -32603, message: 'Internal error' },
123
+ });
124
+ send.mockClear();
125
+ await handleLine(req(null, 'session/cancel')); // notification: no id
126
+ expect(send).not.toHaveBeenCalled();
127
+ expect(log).toHaveBeenCalledTimes(2);
128
+ });
129
+ });
@@ -3,51 +3,66 @@
3
3
  * connect via stdin/stdout ndjson, run the full ACP lifecycle.
4
4
  *
5
5
  * Uses a mock HTTP server standing in for the almyty backend.
6
+ *
7
+ * No sleep before the first request: stdin is a pipe, so anything written
8
+ * before the child finishes booting is buffered by the OS and delivered to
9
+ * readline the moment the loop attaches. Readiness is the handshake reply
10
+ * itself, and every read goes through one persistent reader so a response
11
+ * can never land between two short-lived ones.
6
12
  */
7
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
13
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
8
14
  import { spawn } from 'node:child_process';
9
15
  import { createServer } from 'node:http';
10
- import { createInterface } from 'node:readline';
11
16
  import { resolve } from 'node:path';
12
17
  // ── Mock backend ────────────────────────────────────────────────
18
+ /**
19
+ * Agent ids are UUIDs everywhere in almyty; the server only takes its
20
+ * `GET /agents/:id` fast path for a UUID and otherwise resolves the
21
+ * argument as a name or slug against `GET /agents`.
22
+ */
23
+ const AGENT_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
13
24
  const MOCK_AGENT = {
14
- id: 'agent-123',
25
+ id: AGENT_ID,
15
26
  name: 'Test Agent',
27
+ slug: 'test-agent',
16
28
  description: 'A test agent',
17
29
  mode: 'autonomous',
18
30
  status: 'active',
19
31
  pipeline: { nodes: [], edges: [] },
20
32
  };
21
33
  function createMockBackend() {
22
- return new Promise((resolve) => {
34
+ return new Promise((resolveReady) => {
35
+ const requests = [];
23
36
  const server = createServer((req, res) => {
24
37
  let body = '';
25
38
  req.on('data', (chunk) => (body += chunk));
26
39
  req.on('end', () => {
27
40
  const url = req.url || '';
41
+ requests.push(`${req.method} ${url}`);
28
42
  res.setHeader('Content-Type', 'application/json');
29
43
  // Auth profile check
30
44
  if (url === '/auth/profile') {
31
45
  res.end(JSON.stringify({ id: 'user-1', email: 'test@test.com', organizations: [{ id: 'org-1', name: 'Test', role: 'owner' }] }));
32
46
  return;
33
47
  }
34
- // List agents
35
- if (url.startsWith('/agents') && req.method === 'GET' && !url.includes('/')) {
48
+ // List agents — "/agents", optionally with a query string, but not
49
+ // a sub-resource such as "/agents/:id".
50
+ if (req.method === 'GET' && /^\/agents(\?|$)/.test(url)) {
36
51
  res.end(JSON.stringify({ agents: [MOCK_AGENT] }));
37
52
  return;
38
53
  }
39
54
  // Get agent by ID
40
- if (url.match(/\/agents\/agent-123$/) && req.method === 'GET') {
55
+ if (req.method === 'GET' && new RegExp(`^/agents/${AGENT_ID}(\\?|$)`).test(url)) {
41
56
  res.end(JSON.stringify(MOCK_AGENT));
42
57
  return;
43
58
  }
44
59
  // Start run
45
- if (url.match(/\/agents\/agent-123\/runs$/) && req.method === 'POST') {
60
+ if (req.method === 'POST' && new RegExp(`^/agents/${AGENT_ID}/runs$`).test(url)) {
46
61
  res.end(JSON.stringify({ id: 'run-1', status: 'completed', output: { message: 'Hello from the agent' } }));
47
62
  return;
48
63
  }
49
64
  // Get run
50
- if (url.match(/\/agents\/agent-123\/runs\/run-1$/) && req.method === 'GET') {
65
+ if (req.method === 'GET' && new RegExp(`^/agents/${AGENT_ID}/runs/run-1$`).test(url)) {
51
66
  res.end(JSON.stringify({ id: 'run-1', status: 'completed', output: { message: 'Hello from the agent' } }));
52
67
  return;
53
68
  }
@@ -58,87 +73,173 @@ function createMockBackend() {
58
73
  });
59
74
  server.listen(0, '127.0.0.1', () => {
60
75
  const addr = server.address();
61
- resolve({ server, port: addr.port });
76
+ resolveReady({ server, port: addr.port, requests });
62
77
  });
63
78
  });
64
79
  }
65
- // ── ndjson helpers ───────────────────────────────────────────────
66
- function sendRequest(proc, method, params, id) {
67
- const msg = JSON.stringify({ jsonrpc: '2.0', id: id ?? 1, method, params: params ?? {} });
68
- proc.stdin.write(msg + '\n');
69
- }
70
- function readResponses(proc, count, timeoutMs = 10000) {
71
- return new Promise((resolve, reject) => {
72
- const results = [];
73
- const rl = createInterface({ input: proc.stdout });
74
- const timer = setTimeout(() => {
75
- rl.close();
76
- resolve(results); // return what we have
77
- }, timeoutMs);
78
- rl.on('line', (line) => {
80
+ // ── ndjson client ────────────────────────────────────────────────
81
+ const ENTRY_POINT = resolve(__dirname, '../../dist/index.js');
82
+ /**
83
+ * One reader over the child's stdout for the whole test: responses are
84
+ * matched by id, notifications are collected separately, and nothing is
85
+ * dropped between requests.
86
+ */
87
+ class AcpClient {
88
+ proc;
89
+ notifications = [];
90
+ stderr = [];
91
+ buffer = '';
92
+ responses = new Map();
93
+ waiters = new Map();
94
+ exited = null;
95
+ constructor(port, agentRef = 'test-agent') {
96
+ this.proc = spawn('node', [ENTRY_POINT, agentRef], {
97
+ env: {
98
+ ...process.env,
99
+ ALMYTY_URL: `http://127.0.0.1:${port}`,
100
+ ALMYTY_TOKEN: 'test-token',
101
+ },
102
+ stdio: ['pipe', 'pipe', 'pipe'],
103
+ });
104
+ this.proc.stdout.on('data', (chunk) => this.onStdout(String(chunk)));
105
+ this.proc.stderr.on('data', (chunk) => this.stderr.push(String(chunk)));
106
+ this.proc.on('exit', (code, signal) => {
107
+ this.exited = `child exited (code=${code}, signal=${signal})`;
108
+ });
109
+ }
110
+ onStdout(chunk) {
111
+ this.buffer += chunk;
112
+ let nl;
113
+ while ((nl = this.buffer.indexOf('\n')) >= 0) {
114
+ const line = this.buffer.slice(0, nl).trim();
115
+ this.buffer = this.buffer.slice(nl + 1);
116
+ if (!line)
117
+ continue;
118
+ let msg;
79
119
  try {
80
- results.push(JSON.parse(line));
81
- if (results.length >= count) {
82
- clearTimeout(timer);
83
- rl.close();
84
- resolve(results);
85
- }
120
+ msg = JSON.parse(line);
86
121
  }
87
122
  catch {
88
- // skip malformed lines
123
+ continue; // skip malformed lines
89
124
  }
125
+ if (msg.id === undefined || msg.id === null) {
126
+ this.notifications.push(msg);
127
+ continue;
128
+ }
129
+ const waiter = this.waiters.get(msg.id);
130
+ if (waiter) {
131
+ this.waiters.delete(msg.id);
132
+ waiter(msg);
133
+ }
134
+ else {
135
+ this.responses.set(msg.id, msg);
136
+ }
137
+ }
138
+ }
139
+ send(method, params, id) {
140
+ this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
141
+ }
142
+ /** Write several requests as a single chunk, so they arrive together. */
143
+ sendBurst(requests) {
144
+ const payload = requests
145
+ .map((r) => JSON.stringify({ jsonrpc: '2.0', id: r.id, method: r.method, params: r.params ?? {} }))
146
+ .join('\n');
147
+ this.proc.stdin.write(payload + '\n');
148
+ }
149
+ response(id, timeoutMs = 20_000) {
150
+ const already = this.responses.get(id);
151
+ if (already) {
152
+ this.responses.delete(id);
153
+ return Promise.resolve(already);
154
+ }
155
+ return new Promise((resolveMsg, reject) => {
156
+ const timer = setTimeout(() => {
157
+ this.waiters.delete(id);
158
+ reject(new Error(`Timed out after ${timeoutMs}ms waiting for response id=${id}. ` +
159
+ `${this.exited ?? 'child still running'}. stderr: ${this.stderr.join('')}`));
160
+ }, timeoutMs);
161
+ this.waiters.set(id, (msg) => {
162
+ clearTimeout(timer);
163
+ resolveMsg(msg);
164
+ });
90
165
  });
91
- });
166
+ }
167
+ /** Send a request and wait for its response. */
168
+ request(method, params, id, timeoutMs = 20_000) {
169
+ this.send(method, params, id);
170
+ return this.response(id, timeoutMs);
171
+ }
172
+ kill() {
173
+ this.proc.kill('SIGTERM');
174
+ }
92
175
  }
93
176
  // ── Tests ───────────────────────────────────────────────────────
94
177
  describe('ACP server integration', () => {
95
- let mockBackend;
96
- let backendPort;
97
- let proc;
178
+ let backend;
179
+ let client;
98
180
  beforeAll(async () => {
99
- const backend = await createMockBackend();
100
- mockBackend = backend.server;
101
- backendPort = backend.port;
181
+ backend = await createMockBackend();
102
182
  });
103
183
  afterAll(() => {
104
- proc?.kill('SIGTERM');
105
- mockBackend?.close();
184
+ backend?.server.close();
106
185
  });
107
- it('completes full lifecycle: initialize -> authenticate -> session/new -> session/prompt', async () => {
108
- const entryPoint = resolve(__dirname, '../../dist/index.js');
109
- proc = spawn('node', [entryPoint, 'agent-123'], {
110
- env: {
111
- ...process.env,
112
- ALMYTY_URL: `http://127.0.0.1:${backendPort}`,
113
- ALMYTY_TOKEN: 'test-token',
114
- },
115
- stdio: ['pipe', 'pipe', 'pipe'],
116
- });
117
- // Give the process a moment to start
118
- await new Promise((r) => setTimeout(r, 500));
119
- // 1. Initialize
120
- sendRequest(proc, 'initialize', { protocolVersion: 1, clientCapabilities: {} }, 1);
121
- const [initResp] = await readResponses(proc, 1, 5000);
186
+ beforeEach(() => {
187
+ backend.requests.length = 0;
188
+ });
189
+ afterEach(() => {
190
+ client?.kill();
191
+ client = undefined;
192
+ });
193
+ it('completes full lifecycle: initialize -> authenticate -> session/new -> session/close', async () => {
194
+ client = new AcpClient(backend.port);
195
+ // 1. Initialize — the reply is the readiness signal.
196
+ const initResp = await client.request('initialize', { protocolVersion: 1, clientCapabilities: {} }, 1);
197
+ expect(initResp.error).toBeUndefined();
122
198
  expect(initResp.id).toBe(1);
123
199
  expect(initResp.result).toBeDefined();
124
200
  expect(initResp.result.protocolVersion).toBe(1);
201
+ expect(initResp.result.agentInfo.name).toBe('Test Agent');
125
202
  // 2. Authenticate
126
- sendRequest(proc, 'authenticate', { methodId: 'env_var' }, 2);
127
- const [authResp] = await readResponses(proc, 1, 5000);
203
+ const authResp = await client.request('authenticate', { methodId: 'almyty_token' }, 2);
128
204
  expect(authResp.id).toBe(2);
129
205
  expect(authResp.error).toBeUndefined();
206
+ expect(authResp.result.authenticated).toBe(true);
130
207
  // 3. Session new
131
- sendRequest(proc, 'session/new', { cwd: '/tmp' }, 3);
132
- const [sessionResp] = await readResponses(proc, 1, 5000);
208
+ const sessionResp = await client.request('session/new', { cwd: '/tmp' }, 3);
133
209
  expect(sessionResp.id).toBe(3);
210
+ expect(sessionResp.error).toBeUndefined();
134
211
  expect(sessionResp.result?.sessionId).toBeDefined();
135
212
  const sessionId = sessionResp.result.sessionId;
136
- // 4. Close session
137
- sendRequest(proc, 'session/close', { sessionId }, 4);
138
- const [closeResp] = await readResponses(proc, 1, 5000);
213
+ // 4. Close session — the session must still exist on the same agent.
214
+ const closeResp = await client.request('session/close', { sessionId }, 4);
139
215
  expect(closeResp.id).toBe(4);
140
216
  expect(closeResp.error).toBeUndefined();
141
- // Clean up
142
- proc.kill('SIGTERM');
143
- }, 30000);
217
+ expect(closeResp.result.closed).toBe(true);
218
+ }, 60_000);
219
+ /**
220
+ * Regression guard for the lazy-init race: readline delivers every line
221
+ * already buffered in the pipe in one synchronous burst, so a batch of
222
+ * requests arriving before the first agent resolution settles used to
223
+ * build one agent — and one SessionManager — per line. Sessions created
224
+ * by one message were then invisible to the next.
225
+ */
226
+ it('shares one agent across requests that arrive in a single burst', async () => {
227
+ client = new AcpClient(backend.port);
228
+ client.sendBurst([
229
+ { id: 1, method: 'initialize', params: { protocolVersion: 1, clientCapabilities: {} } },
230
+ { id: 2, method: 'session/new', params: { cwd: '/tmp' } },
231
+ ]);
232
+ const initResp = await client.response(1);
233
+ const sessionResp = await client.response(2);
234
+ expect(initResp.result?.protocolVersion).toBe(1);
235
+ const sessionId = sessionResp.result?.sessionId;
236
+ expect(sessionId).toBeDefined();
237
+ // The agent was resolved exactly once, not once per buffered line.
238
+ const listCalls = backend.requests.filter((r) => r === 'GET /agents');
239
+ expect(listCalls).toHaveLength(1);
240
+ // And the session lives on the agent that later requests reach.
241
+ const closeResp = await client.request('session/close', { sessionId }, 3);
242
+ expect(closeResp.error).toBeUndefined();
243
+ expect(closeResp.result.closed).toBe(true);
244
+ }, 60_000);
144
245
  });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * stdio line dispatch for the ACP server.
3
+ *
4
+ * Owns the one piece of state the readline loop cannot own safely: the
5
+ * lazily-resolved agent. `readline` delivers every complete line already
6
+ * sitting in the stdin pipe in a single synchronous burst, so an async
7
+ * `if (!agent) agent = await resolveAgent()` guard inside the line handler
8
+ * is checked by every line in that burst before any of them assigns — each
9
+ * line then builds its own agent, with its own SessionManager and its own
10
+ * (empty) cached agent info. Sessions created by one message are invisible
11
+ * to the next, `initialize` caches the agent mode on an instance nobody
12
+ * keeps, and the backend is asked for the agent list once per line.
13
+ *
14
+ * Resolution is therefore memoised on the in-flight promise, not on the
15
+ * settled value. A failed resolution clears the memo so a later message
16
+ * can retry (the backend may simply have been down).
17
+ */
18
+ import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './agent.js';
19
+ /** The surface of AlmytyAcpAgent the dispatcher depends on. */
20
+ export interface MessageHandler {
21
+ handleMessage(msg: JsonRpcRequest): Promise<void>;
22
+ }
23
+ export interface LineHandlerOptions {
24
+ /** Build the agent. Called at most once per successful resolution. */
25
+ resolveAgent: () => Promise<MessageHandler>;
26
+ /** Write a JSON-RPC message back to the client. */
27
+ send: (msg: JsonRpcResponse | JsonRpcNotification) => void;
28
+ /** Diagnostic sink. Defaults to stderr — stdout is the protocol. */
29
+ log?: (text: string) => void;
30
+ }
31
+ /**
32
+ * Build the `line` handler for the ndjson stdio loop.
33
+ */
34
+ export declare function createLineHandler({ resolveAgent, send, log, }: LineHandlerOptions): (line: string) => Promise<void>;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * stdio line dispatch for the ACP server.
3
+ *
4
+ * Owns the one piece of state the readline loop cannot own safely: the
5
+ * lazily-resolved agent. `readline` delivers every complete line already
6
+ * sitting in the stdin pipe in a single synchronous burst, so an async
7
+ * `if (!agent) agent = await resolveAgent()` guard inside the line handler
8
+ * is checked by every line in that burst before any of them assigns — each
9
+ * line then builds its own agent, with its own SessionManager and its own
10
+ * (empty) cached agent info. Sessions created by one message are invisible
11
+ * to the next, `initialize` caches the agent mode on an instance nobody
12
+ * keeps, and the backend is asked for the agent list once per line.
13
+ *
14
+ * Resolution is therefore memoised on the in-flight promise, not on the
15
+ * settled value. A failed resolution clears the memo so a later message
16
+ * can retry (the backend may simply have been down).
17
+ */
18
+ const PARSE_ERROR = -32700;
19
+ const INVALID_REQUEST = -32600;
20
+ const INTERNAL_ERROR = -32603;
21
+ /**
22
+ * Build the `line` handler for the ndjson stdio loop.
23
+ */
24
+ export function createLineHandler({ resolveAgent, send, log = (text) => void process.stderr.write(text), }) {
25
+ let pending = null;
26
+ const agentOnce = () => {
27
+ if (!pending) {
28
+ pending = resolveAgent().catch((err) => {
29
+ pending = null;
30
+ throw err;
31
+ });
32
+ }
33
+ return pending;
34
+ };
35
+ return async function handleLine(line) {
36
+ const trimmed = line.trim();
37
+ if (!trimmed)
38
+ return;
39
+ let msg;
40
+ try {
41
+ msg = JSON.parse(trimmed);
42
+ }
43
+ catch {
44
+ send({ jsonrpc: '2.0', id: null, error: { code: PARSE_ERROR, message: 'Parse error' } });
45
+ return;
46
+ }
47
+ if (!msg || msg.jsonrpc !== '2.0' || !msg.method) {
48
+ if (msg && msg.id !== undefined) {
49
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: INVALID_REQUEST, message: 'Invalid request' } });
50
+ }
51
+ return;
52
+ }
53
+ let agent;
54
+ try {
55
+ agent = await agentOnce();
56
+ }
57
+ catch (err) {
58
+ const message = err instanceof Error ? err.message : String(err);
59
+ send({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: INTERNAL_ERROR, message } });
60
+ return;
61
+ }
62
+ try {
63
+ await agent.handleMessage(msg);
64
+ }
65
+ catch (err) {
66
+ log(`[acp] Error: ${err}\n`);
67
+ if (msg.id !== undefined && msg.id !== null) {
68
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: INTERNAL_ERROR, message: 'Internal error' } });
69
+ }
70
+ }
71
+ };
72
+ }
package/dist/index.js CHANGED
@@ -17,18 +17,32 @@ import * as readline from 'node:readline';
17
17
  import { resolveCredentials } from './auth.js';
18
18
  import { AlmytyProxy } from './proxy.js';
19
19
  import { AlmytyAcpAgent } from './agent.js';
20
+ import { createLineHandler } from './dispatch.js';
21
+ import { VERSION } from './version.js';
20
22
  // ── Argument parsing ─────────────────────────────────────────────
21
23
  const args = process.argv.slice(2);
22
24
  const HELP_FLAGS = ['--help', '-h'];
25
+ const VERSION_FLAGS = ['--version', '-v'];
26
+ // Checked before help so `almyty-acp --version --help` answers the
27
+ // narrower question, and before the no-args branch so asking the server
28
+ // what it is never exits non-zero.
29
+ if (args.some((a) => VERSION_FLAGS.includes(a))) {
30
+ process.stdout.write(`${VERSION}\n`);
31
+ process.exit(0);
32
+ }
23
33
  if (args.some((a) => HELP_FLAGS.includes(a)) || args.length === 0) {
24
34
  const text = `
25
- @almyty/acp-server — Expose any almyty agent via the Agent Client Protocol
35
+ @almyty/acp-server v${VERSION} — Expose any almyty agent via the Agent Client Protocol
26
36
 
27
37
  Usage:
28
38
  almyty-acp <agent>
29
39
  almyty-acp my-agent
30
40
  almyty-acp acme/my-agent
31
41
 
42
+ Options:
43
+ -h, --help Show this help.
44
+ -v, --version Print the version.
45
+
32
46
  Authentication:
33
47
  npx @almyty/auth login
34
48
 
@@ -89,46 +103,20 @@ async function main() {
89
103
  // Start listening on stdin IMMEDIATELY — Zed expects the process
90
104
  // to be ready for JSON-RPC as soon as it spawns. Agent resolution
91
105
  // and auth happen lazily on first `initialize` message.
106
+ //
107
+ // Resolution is memoised inside createLineHandler: readline hands us
108
+ // every line already buffered in the pipe in one burst, so the guard
109
+ // has to be the in-flight promise, not the settled agent.
92
110
  let agent = null;
93
- const rl = readline.createInterface({ input: process.stdin, terminal: false });
94
- rl.on('line', async (line) => {
95
- const trimmed = line.trim();
96
- if (!trimmed)
97
- return;
98
- let msg;
99
- try {
100
- msg = JSON.parse(trimmed);
101
- }
102
- catch {
103
- send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
104
- return;
105
- }
106
- if (!msg.jsonrpc || msg.jsonrpc !== '2.0' || !msg.method) {
107
- if (msg.id !== undefined) {
108
- send({ jsonrpc: '2.0', id: msg.id, error: { code: -32600, message: 'Invalid request' } });
109
- }
110
- return;
111
- }
112
- // Lazy init: resolve credentials + agent on first message
113
- if (!agent) {
114
- try {
115
- agent = await resolveAgent(agentArg, send);
116
- }
117
- catch (err) {
118
- send({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32603, message: err.message } });
119
- return;
120
- }
121
- }
122
- try {
123
- await agent.handleMessage(msg);
124
- }
125
- catch (err) {
126
- process.stderr.write(`[acp] Error: ${err}\n`);
127
- if (msg.id !== undefined && msg.id !== null) {
128
- send({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: 'Internal error' } });
129
- }
130
- }
111
+ const handleLine = createLineHandler({
112
+ resolveAgent: async () => {
113
+ agent = await resolveAgent(agentArg, send);
114
+ return agent;
115
+ },
116
+ send,
131
117
  });
118
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
119
+ rl.on('line', handleLine);
132
120
  // Handle stdin close (client disconnected)
133
121
  rl.on('close', () => {
134
122
  process.stderr.write('[acp] Client disconnected. Shutting down.\n');
@@ -0,0 +1,2 @@
1
+ export declare function readVersion(fallback?: string): string;
2
+ export declare const VERSION: string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The server's own version, read from its package.json at startup.
3
+ *
4
+ * Every other CLI in the suite answers `--version`; this one shipped
5
+ * without it, so the only way to tell which build a client had loaded
6
+ * was to diff the file. Reading package.json rather than restating the
7
+ * number keeps it honest across a publish: a literal does not move when
8
+ * the release workflow bumps the manifest. Both `dist/version.js` and
9
+ * `src/version.ts` sit one directory below the package root, so the same
10
+ * relative path resolves for the built bin and for `tsx src/index.ts`.
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ export function readVersion(fallback = '0.0.0') {
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
16
+ return typeof pkg.version === 'string' && pkg.version.length > 0
17
+ ? pkg.version
18
+ : fallback;
19
+ }
20
+ catch {
21
+ return fallback;
22
+ }
23
+ }
24
+ export const VERSION = readVersion();
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@almyty/acp-server",
3
- "version": "1.2.0",
3
+ "version": "1.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "description": "Expose any almyty agent over the Agent Client Protocol (ACP) \u2014 drop your agent into ACP-compatible editors and clients.",
7
+ "description": "Expose any almyty agent over the Agent Client Protocol (ACP) — drop your agent into ACP-compatible editors and clients.",
8
8
  "type": "module",
9
9
  "main": "dist/index.js",
10
10
  "bin": {
@@ -48,5 +48,8 @@
48
48
  },
49
49
  "bugs": {
50
50
  "url": "https://github.com/almyty-inc/almyty/issues"
51
+ },
52
+ "overrides": {
53
+ "postcss": "^8.5.23"
51
54
  }
52
55
  }
File without changes
@@ -1 +0,0 @@
1
- "use strict";