@elbow-dev/acp 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,4 @@
1
+ Copyright (c) 2026 Keyholdr, Inc. All rights reserved.
2
+
3
+ This software is provided to use with an Elbow account (https://elbow.dev), under your agreement with
4
+ Keyholdr, Inc. It may not be copied, modified or redistributed except as that agreement allows.
package/acp.mjs ADDED
@@ -0,0 +1,623 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ // Elbow's own harness (server/vm/agent.mjs) as an Agent Client Protocol agent on stdio, for apps that run one, such as
8
+ // T3 Code's "Local ACP command" (`elbow acp`). Protocol version 1: https://agentclientprotocol.com. Stdout carries only
9
+ // the protocol; anything else goes to stderr.
10
+
11
+ const KINDS = { read_file: 'read', write_file: 'edit', edit_file: 'edit', search: 'search', bash: 'execute' };
12
+ const EDITS = new Set(['write_file', 'edit_file']);
13
+ const OUTPUT_CHARS = 16_384;
14
+ // A running command's output reaches the app at most this often.
15
+ const LIVE_MS = 250;
16
+ // Elbow counts a request's cost as it logs it, a moment after the reply ends.
17
+ const COST_SETTLE_MS = 400;
18
+ const MODE_NAMES = { elbow: 'Balanced' };
19
+ // The coordinator's mode is tuned for the coordinator's own tools, not for coding.
20
+ const HIDDEN = new Set(['elbow-coordinator']);
21
+ // T3's Plan toggle picks the mode called plan.
22
+ const SESSION_MODES = [
23
+ { id: 'build', name: 'Build', description: 'Reads, edits and runs commands.' },
24
+ { id: 'plan', name: 'Plan', description: 'Reads and plans; changes no files.' },
25
+ ];
26
+ const PLAN_TASK =
27
+ '\n\n(Plan mode: read what you need, then reply with a plan for this. Change no files; the person will switch to Build to carry it out.)';
28
+ const PLAN_REFUSAL =
29
+ 'This conversation is in Plan mode, so files stay as they are. Put the change in your plan instead.';
30
+
31
+ const titleOf = (tool, input = {}) => {
32
+ if (tool === 'read_file') return `Read ${input.path}`;
33
+ if (tool === 'write_file') return `Write ${input.path}`;
34
+ if (tool === 'edit_file') return `Edit ${input.path ?? input.edits?.[0]?.path ?? ''}`.trim();
35
+ if (tool === 'search') return input.pattern ? `Search ${input.pattern}` : 'List files';
36
+ if (tool === 'bash') return String(input.command ?? '').split('\n')[0].slice(0, 120);
37
+ return tool;
38
+ };
39
+
40
+ const modeName = (id) =>
41
+ MODE_NAMES[id] ??
42
+ id
43
+ .replace(/^elbow-/, '')
44
+ .replace(/-/g, ' ')
45
+ .replace(/^./, (c) => c.toUpperCase());
46
+
47
+ const text = (value) => ({ type: 'content', content: { type: 'text', text: String(value ?? '').slice(-OUTPUT_CHARS) } });
48
+
49
+ // What the person sent, as the harness takes a task: text, with images apart and files the app attached inlined.
50
+ const taskOf = (prompt = []) => {
51
+ const parts = [];
52
+ const images = [];
53
+ for (const block of prompt) {
54
+ if (block.type === 'text') parts.push(block.text);
55
+ else if (block.type === 'image' && block.data)
56
+ images.push({ media_type: block.mimeType, data: block.data });
57
+ else if (block.type === 'resource_link') parts.push(`@${block.name ?? block.uri} (${block.uri})`);
58
+ else if (block.type === 'resource' && typeof block.resource?.text === 'string')
59
+ parts.push(`<file path="${block.resource.uri}">\n${block.resource.text}\n</file>`);
60
+ }
61
+ return { task: parts.join('\n\n'), images };
62
+ };
63
+
64
+ // A stopped turn can end on tool calls that never got results, which the next request may not carry.
65
+ const settled = (messages) => {
66
+ while (
67
+ messages.at(-1)?.role === 'assistant' &&
68
+ Array.isArray(messages.at(-1).content) &&
69
+ messages.at(-1).content.some((b) => b.type === 'tool_use')
70
+ )
71
+ messages.pop();
72
+ return messages;
73
+ };
74
+
75
+ const blocksOf = (content) => (typeof content === 'string' ? [{ type: 'text', text: content }] : (content ?? []));
76
+
77
+ // ACP's "authentication required": the app offers its sign-in methods.
78
+ const SIGN_IN = -32000;
79
+ const signIn = () => Object.assign(new Error('Sign in to Elbow first.'), { code: SIGN_IN });
80
+ const invalid = (message) => Object.assign(new Error(message), { code: -32602 });
81
+
82
+ // T3 runs the agent's own command again with these arguments in a terminal of its own, then reconnects.
83
+ const AUTH_METHODS = [
84
+ {
85
+ type: 'terminal',
86
+ id: 'elbow-login',
87
+ name: 'Sign in to Elbow',
88
+ description: 'Confirm this machine in your browser, as elbow login does.',
89
+ args: ['--login'],
90
+ },
91
+ ];
92
+
93
+ // The modes this key may use: Balanced for everyone, the hidden ones for Elbow's own people. A key Elbow refuses
94
+ // (signed out, or a revoked token) means signing in again.
95
+ const modesOf = async (url, key) => {
96
+ if (!key) throw signIn();
97
+ let res;
98
+ try {
99
+ res = await fetch(`${url}/v1/models?hidden=1`, { headers: { 'x-api-key': key } });
100
+ } catch {
101
+ return ['elbow'];
102
+ }
103
+ if (res.status === 401) throw signIn();
104
+ try {
105
+ const { data = [] } = await res.json();
106
+ const ids = data
107
+ .map((m) => m.id)
108
+ .filter((id) => (id === 'elbow' || id.startsWith('elbow-')) && !HIDDEN.has(id));
109
+ return ids.includes('elbow') ? ids : ['elbow', ...ids];
110
+ } catch {
111
+ return ['elbow'];
112
+ }
113
+ };
114
+
115
+ // What the dashboard calls the session's repository: the checkout's folder, as `elbow claude` names it.
116
+ const repoOf = async (box, cwd) => {
117
+ const { code, output } = await box
118
+ .run(['git', 'rev-parse', '--show-toplevel'], { cwd, seconds: 5 })
119
+ .catch(() => ({ code: 1 }));
120
+ const name = code ? '' : path.basename(output.trim()).replace(/[^\w.-]/g, '_').slice(0, 100);
121
+ return name || null;
122
+ };
123
+
124
+ // `saved` is the folder sessions are kept in, so a thread survives the app restarting; without it they last as long as
125
+ // the process.
126
+ export const serve = ({ input, output, url, key, agent, box, saved = null, version = 'dev' }) => {
127
+ const sessions = new Map();
128
+ const waiting = new Map();
129
+ // Costs still being counted after a turn, which the process waits for before it exits.
130
+ const counting = new Set();
131
+ let modes;
132
+ let asked = 0;
133
+ const write = (message) => output.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
134
+ const notify = (sessionId, update) =>
135
+ write({ method: 'session/update', params: { sessionId, update } });
136
+ const request = (method, params) =>
137
+ new Promise((resolve, reject) => {
138
+ asked += 1;
139
+ waiting.set(asked, { resolve, reject });
140
+ write({ id: asked, method, params });
141
+ });
142
+
143
+ const options = (session) => [
144
+ {
145
+ id: 'model',
146
+ name: 'Mode',
147
+ category: 'model',
148
+ type: 'select',
149
+ currentValue: session.model,
150
+ options: modes.map((value) => ({ value, name: modeName(value) })),
151
+ },
152
+ ];
153
+ const modeState = (session) => ({ currentModeId: session.mode, availableModes: SESSION_MODES });
154
+ const commands = () => [
155
+ { name: 'compact', description: 'Summarize the conversation so far, to free up context' },
156
+ {
157
+ name: 'mode',
158
+ description: "Switch Elbow's mode",
159
+ input: { hint: modes.map((m) => modeName(m).toLowerCase()).join(' | ') },
160
+ },
161
+ ];
162
+
163
+ const fileOf = (id) => saved && path.join(saved, `${id.replace(/[^\w-]/g, '')}.json`);
164
+ const save = async (id, session) => {
165
+ if (!saved) return;
166
+ await fs.mkdir(saved, { recursive: true, mode: 0o700 });
167
+ const { cwd, model, mode, title, messages, cost, updatedAt } = session;
168
+ const body = JSON.stringify({ sessionId: id, cwd, model, mode, title, updatedAt, cost, messages });
169
+ await fs.writeFile(`${fileOf(id)}.part`, body, { mode: 0o600 });
170
+ await fs.rename(`${fileOf(id)}.part`, fileOf(id));
171
+ };
172
+ const savedSession = async (id) => {
173
+ const body = saved && (await fs.readFile(fileOf(id), 'utf8').catch(() => null));
174
+ if (!body) throw invalid(`No session ${id}.`);
175
+ return JSON.parse(body);
176
+ };
177
+
178
+ const sessionOf = (id) => {
179
+ const session = sessions.get(id);
180
+ if (!session) throw invalid(`No session ${id}.`);
181
+ return session;
182
+ };
183
+
184
+ const choose = (session, value) => {
185
+ if (!modes.includes(value)) throw invalid(`${value} isn't a mode this key can use.`);
186
+ session.model = value;
187
+ };
188
+
189
+ const open = async (id, body) => {
190
+ modes ??= await modesOf(url, key);
191
+ const workspace = path.resolve(body.cwd);
192
+ const session = {
193
+ cwd: workspace,
194
+ repo: await repoOf(box, workspace),
195
+ model: modes.includes(body.model) ? body.model : modes[0],
196
+ mode: body.mode === 'plan' ? 'plan' : 'build',
197
+ title: body.title ?? null,
198
+ messages: body.messages ?? [],
199
+ cost: body.cost ?? { base: 0, last: 0 },
200
+ updatedAt: body.updatedAt ?? new Date().toISOString(),
201
+ allowed: new Set(),
202
+ controller: null,
203
+ turn: 0,
204
+ };
205
+ sessions.set(id, session);
206
+ // After the reply, so the app has the session before it hears about it.
207
+ setImmediate(() => {
208
+ notify(id, { sessionUpdate: 'available_commands_update', availableCommands: commands() });
209
+ report(id, session);
210
+ });
211
+ return session;
212
+ };
213
+ const opened = (session) => ({ modes: modeState(session), configOptions: options(session) });
214
+
215
+ // The context Elbow last reported against where the harness compacts, and what the session has cost. Elbow's count
216
+ // starts again when the API restarts, so a count that drops is added on top of the one before.
217
+ const report = (sessionId, session) =>
218
+ notify(sessionId, {
219
+ sessionUpdate: 'usage_update',
220
+ used: session.tokens ?? 0,
221
+ size: agent.COMPACT_TOKENS,
222
+ cost: { amount: +(session.cost.base + session.cost.last).toFixed(4), currency: 'USD' },
223
+ });
224
+ const priced = async (sessionId, session) => {
225
+ try {
226
+ const res = await fetch(`${url}/v1/usage?session=${encodeURIComponent(sessionId)}`, {
227
+ headers: { 'x-api-key': key },
228
+ });
229
+ const { cost_usd: now } = await res.json();
230
+ if (typeof now !== 'number') return;
231
+ if (now < session.cost.last) session.cost.base += session.cost.last;
232
+ session.cost.last = now;
233
+ } catch {}
234
+ };
235
+
236
+ // The history of a reopened thread, as the app shows a live one.
237
+ const replay = (sessionId, messages) => {
238
+ const results = new Map(
239
+ messages
240
+ .flatMap((m) => blocksOf(m.content))
241
+ .filter((b) => b.type === 'tool_result')
242
+ .map((b) => [b.tool_use_id, b]),
243
+ );
244
+ for (const m of messages) {
245
+ const blocks = blocksOf(m.content);
246
+ if (m.role === 'user') {
247
+ if (blocks.some((b) => b.type === 'tool_result')) continue;
248
+ for (const b of blocks)
249
+ if (b.type === 'text')
250
+ notify(sessionId, { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: b.text } });
251
+ continue;
252
+ }
253
+ for (const b of blocks) {
254
+ if (b.type === 'text')
255
+ notify(sessionId, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: b.text } });
256
+ else if (b.type === 'tool_use') {
257
+ const result = results.get(b.id);
258
+ notify(sessionId, {
259
+ sessionUpdate: 'tool_call',
260
+ toolCallId: b.id,
261
+ title: titleOf(b.name, b.input),
262
+ kind: KINDS[b.name] ?? 'other',
263
+ status: result?.is_error ? 'failed' : 'completed',
264
+ rawInput: b.input ?? {},
265
+ ...(result && { content: [text(result.content)] }),
266
+ });
267
+ }
268
+ }
269
+ }
270
+ };
271
+
272
+ const approve = (sessionId, session, signal) => async (use, call) => {
273
+ if (session.mode === 'plan' && EDITS.has(use.name)) return PLAN_REFUSAL;
274
+ if (session.allowed.has(use.name)) return true;
275
+ const asking = request('session/request_permission', {
276
+ sessionId,
277
+ toolCall: {
278
+ toolCallId: `call-${session.turn}-${call}`,
279
+ title: titleOf(use.name, use.input),
280
+ kind: KINDS[use.name] ?? 'other',
281
+ status: 'pending',
282
+ rawInput: use.input ?? {},
283
+ },
284
+ options: [
285
+ { optionId: 'allow', name: 'Allow', kind: 'allow_once' },
286
+ { optionId: 'always', name: `Always allow ${use.name}`, kind: 'allow_always' },
287
+ { optionId: 'reject', name: 'Reject', kind: 'reject_once' },
288
+ ],
289
+ });
290
+ const stopped = new Promise((resolve) =>
291
+ signal.addEventListener('abort', () => resolve({ outcome: { outcome: 'cancelled' } }), {
292
+ once: true,
293
+ }),
294
+ );
295
+ const { outcome } = await Promise.race([asking, stopped]);
296
+ if (outcome?.outcome !== 'selected' || outcome.optionId === 'reject') return false;
297
+ if (outcome.optionId === 'always') session.allowed.add(use.name);
298
+ return true;
299
+ };
300
+
301
+ const say = (sessionId, words) =>
302
+ notify(sessionId, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: words } });
303
+
304
+ // `/compact` and `/mode <name>`, the commands the app lists in its / menu.
305
+ const command = async (sessionId, session, task, signal) => {
306
+ const [, name, rest = ''] = /^\/(\w+)\s*([\s\S]*)$/.exec(task.trim()) ?? [];
307
+ if (name === 'mode') {
308
+ const wanted = rest.trim().toLowerCase();
309
+ const found = modes.find((m) => m === wanted || modeName(m).toLowerCase() === wanted);
310
+ if (!found) {
311
+ say(sessionId, `Elbow's modes here: ${modes.map(modeName).join(', ')}.`);
312
+ return true;
313
+ }
314
+ session.model = found;
315
+ notify(sessionId, { sessionUpdate: 'config_option_update', configOptions: options(session) });
316
+ say(sessionId, `Mode: ${modeName(found)}.`);
317
+ return true;
318
+ }
319
+ if (name !== 'compact') return false;
320
+ const summary = await agent
321
+ .compactConversation({
322
+ url,
323
+ key,
324
+ model: session.model,
325
+ run: sessionId,
326
+ workspace: session.cwd,
327
+ box,
328
+ messages: session.messages,
329
+ signal,
330
+ instructions: agent.chatSystem,
331
+ headers: { 'x-elbow-agent': 'elbow', ...(session.repo && { 'x-elbow-repo': session.repo }) },
332
+ })
333
+ .catch((e) => e);
334
+ if (signal.aborted) return true;
335
+ if (!session.messages.length) say(sessionId, 'Nothing to compact yet.');
336
+ else if (typeof summary === 'string') {
337
+ session.tokens = Math.ceil(summary.length / 4);
338
+ say(sessionId, 'Compacted the conversation; it goes on from a summary.');
339
+ } else say(sessionId, `Couldn't compact the conversation${summary?.message ? `: ${summary.message}` : '.'}`);
340
+ return true;
341
+ };
342
+
343
+ const prompt = async ({ sessionId, prompt: blocks }) => {
344
+ const session = sessionOf(sessionId);
345
+ session.controller?.abort();
346
+ const controller = new AbortController();
347
+ session.controller = controller;
348
+ session.turn += 1;
349
+ const { turn } = session;
350
+ const { task, images } = taskOf(blocks);
351
+ session.title ??= task.split('\n')[0].slice(0, 80) || null;
352
+ session.updatedAt = new Date().toISOString();
353
+ const keep = () =>
354
+ save(sessionId, session).catch((e) => console.error(`Couldn't save the session: ${e.message}`));
355
+ const finish = async (stopReason) => {
356
+ settled(session.messages);
357
+ if (session.controller === controller) session.controller = null;
358
+ await keep();
359
+ // After the reply, so the turn doesn't wait on the count.
360
+ const counted = new Promise((ok) => setTimeout(ok, COST_SETTLE_MS))
361
+ .then(() => priced(sessionId, session))
362
+ .then(() => {
363
+ report(sessionId, session);
364
+ return keep();
365
+ })
366
+ .finally(() => counting.delete(counted));
367
+ counting.add(counted);
368
+ return { stopReason };
369
+ };
370
+ if (task.trim().startsWith('/') && (await command(sessionId, session, task, controller.signal)))
371
+ return finish(controller.signal.aborted ? 'cancelled' : 'end_turn');
372
+
373
+ let spoke = false;
374
+ // Text after a tool call or another model turn starts a paragraph of its own.
375
+ let apart = false;
376
+ let limited = false;
377
+ let ended = null;
378
+ const speak = (words) => {
379
+ say(sessionId, spoke && apart ? `\n\n${words}` : words);
380
+ spoke = true;
381
+ apart = false;
382
+ };
383
+ const stream = ({ type, text: words }) => {
384
+ if (type === 'text') speak(words);
385
+ else
386
+ notify(sessionId, {
387
+ sessionUpdate: 'agent_thought_chunk',
388
+ content: { type: 'text', text: words },
389
+ });
390
+ };
391
+ // What each edit changed, for the app's diff view; and the command running now, whose output it shows as it comes.
392
+ const diffs = new Map();
393
+ const written = new Map();
394
+ let live = null;
395
+ const watched = {
396
+ ...box,
397
+ write: async (file, data) => {
398
+ const before = await box.read(file).then(String, () => null);
399
+ await box.write(file, data);
400
+ written.set(file, { oldText: before, newText: String(data) });
401
+ },
402
+ run: (args, o) =>
403
+ box.run(args, {
404
+ ...o,
405
+ onOutput: (chunk) => {
406
+ if (!live) return;
407
+ live.output += chunk;
408
+ if (live.timer) return;
409
+ live.timer = setTimeout(() => {
410
+ if (!live) return;
411
+ live.timer = null;
412
+ notify(sessionId, {
413
+ sessionUpdate: 'tool_call_update',
414
+ toolCallId: live.id,
415
+ content: [text(live.output)],
416
+ });
417
+ }, LIVE_MS);
418
+ },
419
+ }),
420
+ };
421
+ const record = (receipt) => {
422
+ apart = true;
423
+ const id = `call-${turn}-${receipt.call}`;
424
+ if (receipt.event === 'tool_started') {
425
+ const file = receipt.input?.path;
426
+ if (receipt.tool === 'bash') live = { id, output: '', timer: null };
427
+ notify(sessionId, {
428
+ sessionUpdate: 'tool_call',
429
+ toolCallId: id,
430
+ title: titleOf(receipt.tool, receipt.input),
431
+ kind: KINDS[receipt.tool] ?? 'other',
432
+ status: 'in_progress',
433
+ rawInput: receipt.input ?? {},
434
+ ...(typeof file === 'string' && { locations: [{ path: path.resolve(session.cwd, file) }] }),
435
+ });
436
+ } else if (receipt.event === 'write_completed') {
437
+ const full = path.resolve(session.cwd, receipt.path);
438
+ const change = written.get(full);
439
+ written.delete(full);
440
+ if (!change) return;
441
+ diffs.set(id, [...(diffs.get(id) ?? []), { type: 'diff', path: full, ...change }]);
442
+ notify(sessionId, { sessionUpdate: 'tool_call_update', toolCallId: id, content: diffs.get(id) });
443
+ } else if (receipt.event === 'tool_completed') {
444
+ if (live?.id === id) {
445
+ clearTimeout(live.timer);
446
+ live = null;
447
+ }
448
+ const ok = receipt.outcome === 'ok';
449
+ notify(sessionId, {
450
+ sessionUpdate: 'tool_call_update',
451
+ toolCallId: id,
452
+ status: ok ? 'completed' : 'failed',
453
+ content: ok && diffs.has(id) ? diffs.get(id) : [text(receipt.output)],
454
+ });
455
+ } else if (receipt.event === 'limit') limited = true;
456
+ else if (receipt.event === 'run_completed') ended = receipt;
457
+ };
458
+ await agent.runAgent({
459
+ url,
460
+ key,
461
+ model: session.model,
462
+ // One session per thread, so Elbow keeps its turns together on the dashboard.
463
+ run: sessionId,
464
+ task: session.mode === 'plan' ? `${task}${PLAN_TASK}` : task,
465
+ images,
466
+ workspace: session.cwd,
467
+ checks: [],
468
+ record,
469
+ signal: controller.signal,
470
+ box: watched,
471
+ instructions: agent.chatSystem,
472
+ conversation: session.messages,
473
+ approve: approve(sessionId, session, controller.signal),
474
+ // Only the warning that calls are running out: a question needs no push to start editing.
475
+ nudge: (counts) => {
476
+ const note = agent.nudgeOf(counts);
477
+ return note && /calls left/.test(note) ? note : null;
478
+ },
479
+ headers: { 'x-elbow-agent': 'elbow', ...(session.repo && { 'x-elbow-repo': session.repo }) },
480
+ stream,
481
+ measured: (tokens) => {
482
+ session.tokens = tokens;
483
+ report(sessionId, session);
484
+ priced(sessionId, session);
485
+ },
486
+ });
487
+ if (controller.signal.aborted) return finish('cancelled');
488
+ if (limited) return finish('max_turn_requests');
489
+ if (ended?.outcome === 'error') {
490
+ if (/out of output tokens/.test(ended.error)) return finish('max_tokens');
491
+ speak(`Elbow stopped: ${ended.error}`);
492
+ }
493
+ return finish('end_turn');
494
+ };
495
+
496
+ const methods = {
497
+ initialize: () => ({
498
+ protocolVersion: 1,
499
+ agentCapabilities: {
500
+ loadSession: !!saved,
501
+ promptCapabilities: { image: true, embeddedContext: true },
502
+ ...(saved && { sessionCapabilities: { list: {}, resume: {} } }),
503
+ },
504
+ agentInfo: { name: 'elbow', title: 'Elbow', version },
505
+ authMethods: AUTH_METHODS,
506
+ }),
507
+ authenticate: () => ({}),
508
+ 'session/new': async ({ cwd }) => {
509
+ const sessionId = `acp_${randomUUID()}`;
510
+ return { sessionId, ...opened(await open(sessionId, { cwd })) };
511
+ },
512
+ 'session/load': async ({ sessionId, cwd }) => {
513
+ const body = await savedSession(sessionId);
514
+ const session = await open(sessionId, { ...body, cwd: cwd ?? body.cwd });
515
+ replay(sessionId, session.messages);
516
+ return opened(session);
517
+ },
518
+ 'session/resume': async ({ sessionId, cwd }) => {
519
+ const body = await savedSession(sessionId);
520
+ return opened(await open(sessionId, { ...body, cwd: cwd ?? body.cwd }));
521
+ },
522
+ 'session/list': async ({ cwd }) => {
523
+ const names = saved ? await fs.readdir(saved).catch(() => []) : [];
524
+ const all = await Promise.all(
525
+ names
526
+ .filter((n) => n.endsWith('.json'))
527
+ .map((n) =>
528
+ fs
529
+ .readFile(path.join(saved, n), 'utf8')
530
+ .then((b) => JSON.parse(b))
531
+ .catch(() => null),
532
+ ),
533
+ );
534
+ const sessionsHere = all
535
+ .filter((s) => s && (!cwd || path.resolve(cwd) === s.cwd))
536
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
537
+ .map(({ sessionId, cwd: dir, title, updatedAt }) => ({ sessionId, cwd: dir, title, updatedAt }));
538
+ return { sessions: sessionsHere };
539
+ },
540
+ 'session/set_mode': ({ sessionId, modeId }) => {
541
+ const session = sessionOf(sessionId);
542
+ if (!SESSION_MODES.some((m) => m.id === modeId)) throw invalid(`No mode ${modeId}.`);
543
+ session.mode = modeId;
544
+ notify(sessionId, { sessionUpdate: 'current_mode_update', currentModeId: modeId });
545
+ return {};
546
+ },
547
+ 'session/set_config_option': ({ sessionId, configId, value }) => {
548
+ const session = sessionOf(sessionId);
549
+ if (configId !== 'model') throw invalid(`No option ${configId}.`);
550
+ choose(session, value);
551
+ return { configOptions: options(session) };
552
+ },
553
+ 'session/set_model': ({ sessionId, modelId }) => {
554
+ choose(sessionOf(sessionId), modelId);
555
+ return {};
556
+ },
557
+ 'session/prompt': prompt,
558
+ };
559
+
560
+ const answer = async ({ id, method, params }) => {
561
+ try {
562
+ if (!methods[method])
563
+ throw Object.assign(new Error(`Elbow doesn't support ${method}.`), { code: -32601 });
564
+ write({ id, result: await methods[method](params ?? {}) });
565
+ } catch (e) {
566
+ write({ id, error: { code: e.code ?? -32603, message: e.message } });
567
+ }
568
+ };
569
+
570
+ const lines = readline.createInterface({ input });
571
+ lines.on('line', (line) => {
572
+ if (!line.trim()) return;
573
+ let message;
574
+ try {
575
+ message = JSON.parse(line);
576
+ } catch {
577
+ return write({ id: null, error: { code: -32700, message: 'Not JSON.' } });
578
+ }
579
+ if (message.method && message.id != null) return answer(message);
580
+ if (message.method === 'session/cancel')
581
+ return sessions.get(message.params?.sessionId)?.controller?.abort();
582
+ if (message.method) return;
583
+ const pending = waiting.get(message.id);
584
+ if (!pending) return;
585
+ waiting.delete(message.id);
586
+ if (message.error) pending.reject(new Error(message.error.message));
587
+ else pending.resolve(message.result ?? {});
588
+ });
589
+ return new Promise((resolve) =>
590
+ lines.on('close', () => Promise.allSettled([...counting]).then(() => resolve())),
591
+ );
592
+ };
593
+
594
+ // Runs the agent with the person's own environment for their commands; `key`, when given, never reaches them.
595
+ export const start = async ({ agent, key, url, version, saved }) => {
596
+ const env = { ...process.env };
597
+ delete env.ELBOW_KEY;
598
+ const box = {
599
+ ...agent.local,
600
+ run: ([command, ...args], o) => agent.sh(command, args, { ...o, env }),
601
+ };
602
+ await serve({ input: process.stdin, output: process.stdout, url, key, agent, box, saved, version });
603
+ process.exit(0);
604
+ };
605
+
606
+ // `elbow acp` starts this with the harness's path, the key (empty when signed out) and the sessions folder in the
607
+ // environment; the key leaves the environment before anything the agent runs could see it.
608
+ const main = async () => {
609
+ const agent = await import(pathToFileURL(path.resolve(process.argv[2])));
610
+ const key = process.env.ELBOW_KEY;
611
+ const url = process.env.ELBOW_URL ?? 'https://api.elbow.dev';
612
+ const version = process.env.ELBOW_VERSION;
613
+ const saved = process.env.ELBOW_ACP_SESSIONS || null;
614
+ for (const name of ['ELBOW_KEY', 'ELBOW_VERSION', 'ELBOW_ACP_SESSIONS']) delete process.env[name];
615
+ await start({ agent, key, url, version, saved });
616
+ };
617
+
618
+ // Node resolves module symlinks, including macOS's /var -> /private/var, before setting import.meta.url.
619
+ const entry = await fs.realpath(process.argv[1] ?? '').catch(() => null);
620
+ if (
621
+ import.meta.url === pathToFileURL(process.argv[1] ?? '').href ||
622
+ (entry && import.meta.url === pathToFileURL(entry).href)
623
+ ) main();