@acp-router/core 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/lib/agents.d.ts +5 -0
- package/lib/agents.d.ts.map +1 -0
- package/lib/agents.js +51 -0
- package/lib/config.d.ts +21 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +18 -0
- package/lib/constants.d.ts +5 -0
- package/lib/constants.d.ts.map +1 -0
- package/lib/constants.js +1 -0
- package/lib/content.d.ts +18 -0
- package/lib/content.d.ts.map +1 -0
- package/lib/content.js +25 -0
- package/lib/im-adapter.d.ts +48 -0
- package/lib/im-adapter.d.ts.map +1 -0
- package/lib/im-adapter.js +3 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +9 -0
- package/lib/logger.d.ts +3 -0
- package/lib/logger.d.ts.map +1 -0
- package/lib/logger.js +5 -0
- package/lib/registry.d.ts +4 -0
- package/lib/registry.d.ts.map +1 -0
- package/lib/registry.js +36 -0
- package/lib/router.d.ts +95 -0
- package/lib/router.d.ts.map +1 -0
- package/lib/router.js +807 -0
- package/lib/types.d.ts +46 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +1 -0
- package/package.json +21 -0
package/lib/router.js
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
2
|
+
import { normalizeContent } from './content.js';
|
|
3
|
+
import { CLIENT_INFO } from './constants.js';
|
|
4
|
+
import { logger } from './logger.js';
|
|
5
|
+
const TEXT_FLUSH_DELAY = 3000;
|
|
6
|
+
const TEXT_FLUSH_MAX_LEN = 3500;
|
|
7
|
+
export const BUILT_IN_COMMANDS = [
|
|
8
|
+
{ name: 'start', description: 'Start a session' },
|
|
9
|
+
{ name: 'cancel', description: 'Cancel current prompt turn' },
|
|
10
|
+
{ name: 'clear', description: 'Create a new session' },
|
|
11
|
+
{ name: 'help', description: 'Show available commands' },
|
|
12
|
+
{ name: 'agent', description: 'Get/set agent' },
|
|
13
|
+
{ name: 'mode', description: 'Get/set mode' },
|
|
14
|
+
{ name: 'model', description: 'Get/set model' },
|
|
15
|
+
{ name: 'config', description: 'Get/set config' },
|
|
16
|
+
];
|
|
17
|
+
export class RouterClient {
|
|
18
|
+
adapter;
|
|
19
|
+
chatId;
|
|
20
|
+
permissions;
|
|
21
|
+
conn;
|
|
22
|
+
sessionId = '';
|
|
23
|
+
agentInfo = null;
|
|
24
|
+
title = '';
|
|
25
|
+
configOptions = [];
|
|
26
|
+
availableCommands = [];
|
|
27
|
+
modes = null;
|
|
28
|
+
models = null;
|
|
29
|
+
textBuffer = '';
|
|
30
|
+
textBufferKind = 'message';
|
|
31
|
+
flushTimer = null;
|
|
32
|
+
toolCalls = new Map();
|
|
33
|
+
pendingPermissions = new Map();
|
|
34
|
+
updateQueue = Promise.resolve();
|
|
35
|
+
muteUpdates = false;
|
|
36
|
+
statusMessageId = null;
|
|
37
|
+
constructor(adapter, chatId, permissions) {
|
|
38
|
+
this.adapter = adapter;
|
|
39
|
+
this.chatId = chatId;
|
|
40
|
+
this.permissions = permissions;
|
|
41
|
+
}
|
|
42
|
+
get state() {
|
|
43
|
+
return {
|
|
44
|
+
agentInfo: this.agentInfo,
|
|
45
|
+
session: this.sessionId
|
|
46
|
+
? {
|
|
47
|
+
sessionId: this.sessionId,
|
|
48
|
+
title: this.title,
|
|
49
|
+
modes: this.modes ?? undefined,
|
|
50
|
+
models: this.models ?? undefined,
|
|
51
|
+
configOptions: this.configOptions,
|
|
52
|
+
availableCommands: this.availableCommands
|
|
53
|
+
}
|
|
54
|
+
: null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async requestPermission(params) {
|
|
58
|
+
const toolCallId = params.toolCall.toolCallId;
|
|
59
|
+
logger.debug({ chatId: this.chatId, toolCallId }, 'Permission requested');
|
|
60
|
+
await this.adapter.setActive(this.chatId, false);
|
|
61
|
+
const result = await this.presentToolApproval(params);
|
|
62
|
+
await this.adapter.setActive(this.chatId, true);
|
|
63
|
+
this.pendingPermissions.delete(toolCallId);
|
|
64
|
+
const tc = this.toolCalls.get(toolCallId);
|
|
65
|
+
logger.debug({ chatId: this.chatId, toolCallId, messageId: tc?.messageId, outcome: result.outcome.outcome }, 'Permission resolved');
|
|
66
|
+
if (tc) {
|
|
67
|
+
const label = result.outcome.outcome === 'cancelled' ? 'cancelled' : result.outcome.outcome === 'selected' ? 'approved' : 'resolved';
|
|
68
|
+
await this.adapter.editInteractiveMessage(this.chatId, tc.messageId, {
|
|
69
|
+
markdown: `**Permission ${label}:** ${tc.title}`,
|
|
70
|
+
actions: null
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
logger.warn({ chatId: this.chatId, toolCallId }, 'No message found for permission resolution');
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
async cancelPendingOperations() {
|
|
79
|
+
for (const [id, resolve] of this.pendingPermissions) {
|
|
80
|
+
logger.debug({ chatId: this.chatId, toolCallId: id }, 'Cancelling pending permission');
|
|
81
|
+
resolve({ outcome: { outcome: 'cancelled' } });
|
|
82
|
+
}
|
|
83
|
+
this.pendingPermissions.clear();
|
|
84
|
+
for (const [id, tc] of this.toolCalls) {
|
|
85
|
+
await this.adapter.editInteractiveMessage(this.chatId, tc.messageId, {
|
|
86
|
+
markdown: `**Tool cancelled:** ${tc.title}`,
|
|
87
|
+
actions: null
|
|
88
|
+
}).catch(() => { });
|
|
89
|
+
}
|
|
90
|
+
this.toolCalls.clear();
|
|
91
|
+
await this.flushTextBuffer();
|
|
92
|
+
}
|
|
93
|
+
reset() {
|
|
94
|
+
this.toolCalls.clear();
|
|
95
|
+
this.pendingPermissions.clear();
|
|
96
|
+
this.textBuffer = '';
|
|
97
|
+
this.textBufferKind = 'message';
|
|
98
|
+
this.title = '';
|
|
99
|
+
if (this.flushTimer) {
|
|
100
|
+
clearTimeout(this.flushTimer);
|
|
101
|
+
this.flushTimer = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
enqueue(fn) {
|
|
105
|
+
const task = this.updateQueue.then(fn, fn);
|
|
106
|
+
this.updateQueue = task.then(() => { }, () => { });
|
|
107
|
+
return task;
|
|
108
|
+
}
|
|
109
|
+
async drainQueue() {
|
|
110
|
+
await this.updateQueue;
|
|
111
|
+
}
|
|
112
|
+
async sessionUpdate(params) {
|
|
113
|
+
const muted = this.muteUpdates;
|
|
114
|
+
return this.enqueue(() => this.processUpdate(params, muted));
|
|
115
|
+
}
|
|
116
|
+
async processUpdate(params, muted) {
|
|
117
|
+
const update = params.update;
|
|
118
|
+
if (muted) {
|
|
119
|
+
switch (update.sessionUpdate) {
|
|
120
|
+
case 'available_commands_update':
|
|
121
|
+
this.availableCommands = update.availableCommands;
|
|
122
|
+
await this.syncCommands();
|
|
123
|
+
break;
|
|
124
|
+
case 'config_option_update':
|
|
125
|
+
this.configOptions = update.configOptions;
|
|
126
|
+
await this.syncCommands();
|
|
127
|
+
break;
|
|
128
|
+
case 'current_mode_update':
|
|
129
|
+
if (this.modes)
|
|
130
|
+
this.modes.currentModeId = update.currentModeId;
|
|
131
|
+
break;
|
|
132
|
+
case 'session_info_update':
|
|
133
|
+
if (update.title != null)
|
|
134
|
+
this.title = update.title;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
switch (update.sessionUpdate) {
|
|
140
|
+
case 'agent_message_chunk': {
|
|
141
|
+
logger.debug({ chatId: this.chatId, type: update.sessionUpdate, contentType: update.content.type, text: update.content.type === 'text' ? update.content.text : undefined }, 'Content chunk');
|
|
142
|
+
await this.handleContent(update.content, 'message');
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'agent_thought_chunk': {
|
|
146
|
+
logger.debug({ chatId: this.chatId, type: update.sessionUpdate, contentType: update.content.type, text: update.content.type === 'text' ? update.content.text : undefined }, 'Content chunk');
|
|
147
|
+
await this.handleContent(update.content, 'thought');
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case 'tool_call': {
|
|
151
|
+
await this.flushTextBuffer();
|
|
152
|
+
const toolTitle = update.title ?? 'Tool';
|
|
153
|
+
logger.debug({ chatId: this.chatId, toolCallId: update.toolCallId, title: toolTitle }, 'Tool call started');
|
|
154
|
+
const interaction = await this.adapter.sendInteractiveMessage(this.chatId, {
|
|
155
|
+
markdown: `**Tool call:** ${toolTitle}`
|
|
156
|
+
}, 'tool');
|
|
157
|
+
logger.debug({ chatId: this.chatId, toolCallId: update.toolCallId, messageId: interaction.id }, 'Tool call message sent');
|
|
158
|
+
this.toolCalls.set(update.toolCallId, { messageId: interaction.id, title: toolTitle });
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'tool_call_update': {
|
|
162
|
+
await this.flushTextBuffer();
|
|
163
|
+
const tc = this.toolCalls.get(update.toolCallId);
|
|
164
|
+
if (update.title && tc)
|
|
165
|
+
tc.title = update.title;
|
|
166
|
+
const toolTitle = tc?.title ?? 'Tool';
|
|
167
|
+
const statusLabel = update.status === 'completed' ? 'completed' : update.status === 'failed' ? 'failed' : 'running';
|
|
168
|
+
const text = `**Tool ${statusLabel}:** ${toolTitle}`;
|
|
169
|
+
logger.debug({ chatId: this.chatId, toolCallId: update.toolCallId, status: update.status, messageId: tc?.messageId }, 'Tool call update');
|
|
170
|
+
if (tc) {
|
|
171
|
+
const terminal = update.status === 'completed' || update.status === 'failed';
|
|
172
|
+
await this.adapter.editInteractiveMessage(this.chatId, tc.messageId, { markdown: text, actions: null });
|
|
173
|
+
if (terminal) {
|
|
174
|
+
this.toolCalls.delete(update.toolCallId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
logger.warn({ chatId: this.chatId, toolCallId: update.toolCallId }, 'No message found for tool call update, sending as new message');
|
|
179
|
+
await this.adapter.sendTextMessage(this.chatId, text);
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
case 'available_commands_update':
|
|
184
|
+
this.availableCommands = update.availableCommands;
|
|
185
|
+
await this.syncCommands();
|
|
186
|
+
break;
|
|
187
|
+
case 'config_option_update':
|
|
188
|
+
this.configOptions = update.configOptions;
|
|
189
|
+
await this.syncCommands();
|
|
190
|
+
break;
|
|
191
|
+
case 'current_mode_update':
|
|
192
|
+
if (this.modes)
|
|
193
|
+
this.modes.currentModeId = update.currentModeId;
|
|
194
|
+
break;
|
|
195
|
+
case 'session_info_update':
|
|
196
|
+
if (update.title != null)
|
|
197
|
+
this.title = update.title;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async syncCommands() {
|
|
202
|
+
const agentCmds = this.availableCommands.map((c) => ({ name: c.name, description: c.description }));
|
|
203
|
+
await this.adapter.setCommands([...BUILT_IN_COMMANDS, ...agentCmds]);
|
|
204
|
+
}
|
|
205
|
+
scheduleFlush() {
|
|
206
|
+
if (this.flushTimer)
|
|
207
|
+
return;
|
|
208
|
+
this.flushTimer = setTimeout(() => {
|
|
209
|
+
this.flushTimer = null;
|
|
210
|
+
this.flushTextBuffer().catch(() => { });
|
|
211
|
+
}, TEXT_FLUSH_DELAY);
|
|
212
|
+
}
|
|
213
|
+
async flushTextBuffer() {
|
|
214
|
+
if (this.flushTimer) {
|
|
215
|
+
clearTimeout(this.flushTimer);
|
|
216
|
+
this.flushTimer = null;
|
|
217
|
+
}
|
|
218
|
+
if (!this.textBuffer)
|
|
219
|
+
return;
|
|
220
|
+
const text = this.textBuffer;
|
|
221
|
+
const kind = this.textBufferKind;
|
|
222
|
+
this.textBuffer = '';
|
|
223
|
+
this.textBufferKind = 'message';
|
|
224
|
+
await this.adapter.sendTextMessage(this.chatId, text, kind);
|
|
225
|
+
}
|
|
226
|
+
async handleContent(block, kind) {
|
|
227
|
+
const normalized = normalizeContent(block);
|
|
228
|
+
if (normalized.kind === 'text') {
|
|
229
|
+
if (this.textBuffer && this.textBufferKind !== kind) {
|
|
230
|
+
await this.flushTextBuffer();
|
|
231
|
+
}
|
|
232
|
+
this.textBufferKind = kind;
|
|
233
|
+
this.textBuffer += normalized.text;
|
|
234
|
+
if (this.textBuffer.length >= TEXT_FLUSH_MAX_LEN) {
|
|
235
|
+
await this.flushTextBuffer();
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
this.scheduleFlush();
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
await this.flushTextBuffer();
|
|
243
|
+
if (normalized.kind === 'unknown')
|
|
244
|
+
return this.adapter.sendTextMessage(this.chatId, `[Content: ${normalized.type}]`);
|
|
245
|
+
return this.adapter.sendMedia(this.chatId, normalized);
|
|
246
|
+
}
|
|
247
|
+
async presentToolApproval(params) {
|
|
248
|
+
const title = params.toolCall.title ?? 'Tool';
|
|
249
|
+
const toolCallId = params.toolCall.toolCallId;
|
|
250
|
+
const nonce = Date.now().toString(36);
|
|
251
|
+
const actions = params.options.map((opt) => ({
|
|
252
|
+
id: `acp:tool:${nonce}:select:${opt.optionId}`,
|
|
253
|
+
label: opt.name
|
|
254
|
+
}));
|
|
255
|
+
let resolveApproval;
|
|
256
|
+
const approvalPromise = new Promise((resolve) => {
|
|
257
|
+
resolveApproval = resolve;
|
|
258
|
+
});
|
|
259
|
+
this.pendingPermissions.set(toolCallId, resolveApproval);
|
|
260
|
+
await this.enqueue(async () => {
|
|
261
|
+
const callback = async (actionId) => {
|
|
262
|
+
const optionId = actionId.split(':')[4] ?? '';
|
|
263
|
+
resolveApproval({ outcome: { outcome: 'selected', optionId } });
|
|
264
|
+
};
|
|
265
|
+
const inlineActions = { items: actions, callback };
|
|
266
|
+
const tc = this.toolCalls.get(toolCallId);
|
|
267
|
+
if (tc) {
|
|
268
|
+
await this.adapter
|
|
269
|
+
.editInteractiveMessage(this.chatId, tc.messageId, {
|
|
270
|
+
markdown: `**Permission requested:** ${title}`,
|
|
271
|
+
actions: inlineActions
|
|
272
|
+
})
|
|
273
|
+
.catch(() => resolveApproval(selectRejectOutcome(params)));
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
await this.adapter
|
|
277
|
+
.sendInteractiveMessage(this.chatId, {
|
|
278
|
+
markdown: `**Permission requested:** ${title}`,
|
|
279
|
+
actions: inlineActions
|
|
280
|
+
}, 'permission')
|
|
281
|
+
.then((interaction) => this.toolCalls.set(toolCallId, { messageId: interaction.id, title }))
|
|
282
|
+
.catch(() => resolveApproval(selectRejectOutcome(params)));
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
if (!this.permissions.timeoutMs)
|
|
286
|
+
return approvalPromise;
|
|
287
|
+
return Promise.race([
|
|
288
|
+
approvalPromise,
|
|
289
|
+
new Promise((resolve) => {
|
|
290
|
+
setTimeout(() => resolve(selectRejectOutcome(params)), this.permissions.timeoutMs ?? 0);
|
|
291
|
+
})
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
export class RouterCore {
|
|
296
|
+
adapter;
|
|
297
|
+
launcher;
|
|
298
|
+
registry;
|
|
299
|
+
cache;
|
|
300
|
+
defaults;
|
|
301
|
+
permissions;
|
|
302
|
+
clients = new Map();
|
|
303
|
+
clientProcs = new Map();
|
|
304
|
+
chatAgents = new Map();
|
|
305
|
+
constructor(adapter, launcher, registry, cache, defaults, permissions) {
|
|
306
|
+
this.adapter = adapter;
|
|
307
|
+
this.launcher = launcher;
|
|
308
|
+
this.registry = registry;
|
|
309
|
+
this.cache = cache;
|
|
310
|
+
this.defaults = defaults;
|
|
311
|
+
this.permissions = permissions;
|
|
312
|
+
}
|
|
313
|
+
messageQueues = new Map();
|
|
314
|
+
pendingMessages = new Map();
|
|
315
|
+
enqueueMessage(chatId, messageId, fn) {
|
|
316
|
+
const entry = { messageId, aborted: false };
|
|
317
|
+
const pending = this.pendingMessages.get(chatId) ?? [];
|
|
318
|
+
pending.push(entry);
|
|
319
|
+
this.pendingMessages.set(chatId, pending);
|
|
320
|
+
this.adapter.setReaction(chatId, messageId, 'queued').catch((err) => {
|
|
321
|
+
logger.warn({ chatId, messageId, err }, 'Failed to set queued reaction');
|
|
322
|
+
});
|
|
323
|
+
const prev = this.messageQueues.get(chatId) ?? Promise.resolve();
|
|
324
|
+
const wrapped = async () => {
|
|
325
|
+
const entries = this.pendingMessages.get(chatId);
|
|
326
|
+
if (entries) {
|
|
327
|
+
const idx = entries.indexOf(entry);
|
|
328
|
+
if (idx !== -1)
|
|
329
|
+
entries.splice(idx, 1);
|
|
330
|
+
}
|
|
331
|
+
if (entry.aborted) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
await this.adapter.setReaction(chatId, messageId, undefined).catch((err) => {
|
|
335
|
+
logger.warn({ chatId, messageId, err }, 'Failed to clear reaction');
|
|
336
|
+
});
|
|
337
|
+
await fn();
|
|
338
|
+
};
|
|
339
|
+
const next = prev.then(wrapped, wrapped);
|
|
340
|
+
this.messageQueues.set(chatId, next.then(() => { }, () => { }));
|
|
341
|
+
return next;
|
|
342
|
+
}
|
|
343
|
+
drainMessageQueue(chatId) {
|
|
344
|
+
const pending = this.pendingMessages.get(chatId);
|
|
345
|
+
if (!pending)
|
|
346
|
+
return;
|
|
347
|
+
for (const entry of pending) {
|
|
348
|
+
entry.aborted = true;
|
|
349
|
+
this.adapter.setReaction(chatId, entry.messageId, 'aborted').catch((err) => {
|
|
350
|
+
logger.warn({ chatId, messageId: entry.messageId, err }, 'Failed to set aborted reaction on drain');
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
logger.debug({ chatId, count: pending.length }, 'Drained message queue');
|
|
354
|
+
}
|
|
355
|
+
async init() {
|
|
356
|
+
await this.adapter.init();
|
|
357
|
+
logger.info({ platform: this.adapter.platform }, 'IM adapter initialized');
|
|
358
|
+
await this.adapter.setCommands(BUILT_IN_COMMANDS);
|
|
359
|
+
this.adapter.on('text', (chatId, messageId, text) => {
|
|
360
|
+
this.enqueueMessage(chatId, messageId, () => this.onMessage(chatId, text));
|
|
361
|
+
});
|
|
362
|
+
this.adapter.on('command', (chatId, messageId, command, args) => {
|
|
363
|
+
if (command === 'cancel' || command === 'clear') {
|
|
364
|
+
this.drainMessageQueue(chatId);
|
|
365
|
+
this.onCommand(chatId, command, args);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (['help', 'agent', 'sessions', 'mode', 'model', 'config'].includes(command)) {
|
|
369
|
+
this.onCommand(chatId, command, args);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (command === 'start') {
|
|
373
|
+
this.adapter.setReaction(chatId, messageId, 'ignored').catch((err) => {
|
|
374
|
+
logger.warn({ chatId, messageId, err }, 'Failed to set ignored reaction');
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this.enqueueMessage(chatId, messageId, () => this.onCommand(chatId, command, args));
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
getAgentEntry(agentId) {
|
|
382
|
+
const entry = this.registry.agents.find((agent) => agent.id === agentId);
|
|
383
|
+
if (!entry)
|
|
384
|
+
throw new Error(`Unknown agent: ${agentId}`);
|
|
385
|
+
return entry;
|
|
386
|
+
}
|
|
387
|
+
async ensureClient(chatId, agentId, launch) {
|
|
388
|
+
const existing = this.clients.get(chatId);
|
|
389
|
+
if (existing)
|
|
390
|
+
return existing;
|
|
391
|
+
const entry = this.getAgentEntry(agentId);
|
|
392
|
+
logger.info({ chatId, agentId }, 'Launching agent');
|
|
393
|
+
const proc = await this.launcher.launch(launch, entry);
|
|
394
|
+
const rawStream = acp.ndJsonStream(proc.stdin, proc.stdout);
|
|
395
|
+
let msgSeq = 0;
|
|
396
|
+
const debugReadable = new ReadableStream({
|
|
397
|
+
async start(controller) {
|
|
398
|
+
const reader = rawStream.readable.getReader();
|
|
399
|
+
try {
|
|
400
|
+
while (true) {
|
|
401
|
+
const { value, done } = await reader.read();
|
|
402
|
+
if (done)
|
|
403
|
+
break;
|
|
404
|
+
const seq = msgSeq++;
|
|
405
|
+
const msg = value;
|
|
406
|
+
logger.trace({ chatId, seq, method: msg.method, id: msg.id, hasResult: 'result' in msg }, 'Raw JSONRPC message');
|
|
407
|
+
controller.enqueue(value);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
reader.releaseLock();
|
|
412
|
+
controller.close();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
const stream = { readable: debugReadable, writable: rawStream.writable };
|
|
417
|
+
const client = new RouterClient(this.adapter, chatId, this.permissions);
|
|
418
|
+
client.conn = new acp.ClientSideConnection(() => client, stream);
|
|
419
|
+
const init = await client.conn.initialize({
|
|
420
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
421
|
+
clientInfo: CLIENT_INFO,
|
|
422
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }
|
|
423
|
+
});
|
|
424
|
+
client.agentInfo = init.agentInfo ?? null;
|
|
425
|
+
logger.info({ chatId, agentId, sessionId: client.sessionId }, 'Client initialized');
|
|
426
|
+
this.clients.set(chatId, client);
|
|
427
|
+
this.clientProcs.set(chatId, proc);
|
|
428
|
+
proc.stderr
|
|
429
|
+
?.getReader()
|
|
430
|
+
.read()
|
|
431
|
+
.catch(() => { });
|
|
432
|
+
return client;
|
|
433
|
+
}
|
|
434
|
+
async startSession(chatId, agentId, launch, cwd) {
|
|
435
|
+
this.chatAgents.set(chatId, agentId);
|
|
436
|
+
await this.cache.set(`agent:${chatId}`, agentId);
|
|
437
|
+
const client = await this.ensureClient(chatId, agentId, launch);
|
|
438
|
+
if (client.sessionId)
|
|
439
|
+
return client;
|
|
440
|
+
const statusMsg = await this.adapter.sendInteractiveMessage(chatId, {
|
|
441
|
+
markdown: '**Starting session...**'
|
|
442
|
+
}, 'status');
|
|
443
|
+
client.statusMessageId = statusMsg.id;
|
|
444
|
+
const cached = await this.cache.get(cacheKey(chatId, agentId, cwd));
|
|
445
|
+
if (cached) {
|
|
446
|
+
// Try resume (lightweight, no history replay)
|
|
447
|
+
try {
|
|
448
|
+
await this.adapter.editInteractiveMessage(chatId, statusMsg.id, {
|
|
449
|
+
markdown: '**Resuming session...**'
|
|
450
|
+
});
|
|
451
|
+
const res = await client.conn.unstable_resumeSession({ sessionId: cached, cwd });
|
|
452
|
+
client.sessionId = cached;
|
|
453
|
+
applySessionState(client, res);
|
|
454
|
+
logger.info({ chatId, sessionId: cached }, 'Resumed session');
|
|
455
|
+
await this.adapter.editInteractiveMessage(chatId, statusMsg.id, {
|
|
456
|
+
markdown: `**Session resumed** (${cached})${formatSessionInfo(client)}`
|
|
457
|
+
});
|
|
458
|
+
return client;
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
logger.warn({ chatId, sessionId: cached, err }, 'Failed to resume session; trying load');
|
|
462
|
+
}
|
|
463
|
+
// Try load (replays history, mute updates to avoid flooding)
|
|
464
|
+
try {
|
|
465
|
+
await this.adapter.editInteractiveMessage(chatId, statusMsg.id, {
|
|
466
|
+
markdown: '**Loading session...**'
|
|
467
|
+
});
|
|
468
|
+
client.muteUpdates = true;
|
|
469
|
+
const res = await client.conn.loadSession({ sessionId: cached, cwd, mcpServers: [] });
|
|
470
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
471
|
+
await client.drainQueue();
|
|
472
|
+
client.muteUpdates = false;
|
|
473
|
+
client.sessionId = cached;
|
|
474
|
+
applySessionState(client, res);
|
|
475
|
+
logger.info({ chatId, sessionId: cached }, 'Loaded session');
|
|
476
|
+
await this.adapter.editInteractiveMessage(chatId, statusMsg.id, {
|
|
477
|
+
markdown: `**Session loaded** (${cached})${formatSessionInfo(client)}`
|
|
478
|
+
});
|
|
479
|
+
return client;
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
483
|
+
await client.drainQueue();
|
|
484
|
+
client.muteUpdates = false;
|
|
485
|
+
logger.warn({ chatId, sessionId: cached, err }, 'Failed to load session; creating new');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
await this.adapter.editInteractiveMessage(chatId, statusMsg.id, {
|
|
489
|
+
markdown: '**Creating new session...**'
|
|
490
|
+
});
|
|
491
|
+
const res = await client.conn.newSession({ cwd, mcpServers: [] });
|
|
492
|
+
client.sessionId = res.sessionId;
|
|
493
|
+
applySessionState(client, res);
|
|
494
|
+
await this.cache.set(cacheKey(chatId, agentId, cwd), res.sessionId);
|
|
495
|
+
logger.info({ chatId, sessionId: res.sessionId }, 'Created new session');
|
|
496
|
+
await this.adapter.editInteractiveMessage(chatId, statusMsg.id, {
|
|
497
|
+
markdown: `**New session created** (${res.sessionId})${formatSessionInfo(client)}`
|
|
498
|
+
});
|
|
499
|
+
return client;
|
|
500
|
+
}
|
|
501
|
+
async resolveAgentId(chatId) {
|
|
502
|
+
const mem = this.chatAgents.get(chatId);
|
|
503
|
+
if (mem)
|
|
504
|
+
return mem;
|
|
505
|
+
const cached = await this.cache.get(`agent:${chatId}`);
|
|
506
|
+
if (cached && this.registry.agents.some((a) => a.id === cached)) {
|
|
507
|
+
return cached;
|
|
508
|
+
}
|
|
509
|
+
return this.defaults.agentId;
|
|
510
|
+
}
|
|
511
|
+
async ensureSession(chatId) {
|
|
512
|
+
const existing = this.clients.get(chatId);
|
|
513
|
+
if (existing?.sessionId)
|
|
514
|
+
return existing;
|
|
515
|
+
const agentId = await this.resolveAgentId(chatId);
|
|
516
|
+
return this.startSession(chatId, agentId, { agentId }, this.defaults.cwd);
|
|
517
|
+
}
|
|
518
|
+
async listAgents() {
|
|
519
|
+
return this.registry.agents;
|
|
520
|
+
}
|
|
521
|
+
async onMessage(chatId, text) {
|
|
522
|
+
logger.debug({ chatId }, 'Received user message');
|
|
523
|
+
await this.adapter.setActive(chatId, true);
|
|
524
|
+
try {
|
|
525
|
+
const client = await this.ensureSession(chatId);
|
|
526
|
+
await client.conn.prompt({ sessionId: client.sessionId, prompt: [{ type: 'text', text }] });
|
|
527
|
+
await client.flushTextBuffer();
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
logger.error({ chatId, err }, 'Error handling message');
|
|
531
|
+
await this.adapter.sendTextMessage(chatId, `Error: ${err instanceof Error ? err.message : 'Unknown error'}`).catch(() => { });
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
await this.adapter.setActive(chatId, false);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async onCommand(chatId, command, args) {
|
|
538
|
+
await this.adapter.setActive(chatId, true);
|
|
539
|
+
try {
|
|
540
|
+
if (command === 'clear') {
|
|
541
|
+
const agentId = await this.resolveAgentId(chatId);
|
|
542
|
+
const client = await this.ensureClient(chatId, agentId, { agentId });
|
|
543
|
+
if (client.sessionId) {
|
|
544
|
+
await client.cancelPendingOperations();
|
|
545
|
+
await client.conn.cancel({ sessionId: client.sessionId }).catch(() => { });
|
|
546
|
+
}
|
|
547
|
+
const res = await client.conn.newSession({ cwd: this.defaults.cwd, mcpServers: [] });
|
|
548
|
+
client.sessionId = res.sessionId;
|
|
549
|
+
client.reset();
|
|
550
|
+
applySessionState(client, res);
|
|
551
|
+
await this.cache.set(cacheKey(chatId, agentId, this.defaults.cwd), res.sessionId);
|
|
552
|
+
await this.adapter.sendTextMessage(chatId, `New session created (${res.sessionId})${formatSessionInfo(client)}`);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (command === 'agent') {
|
|
556
|
+
await this.handleAgent(chatId, args[0]);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (command === 'sessions') {
|
|
560
|
+
await this.handleSessions(chatId);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const client = await this.ensureSession(chatId);
|
|
564
|
+
if (command === 'start') {
|
|
565
|
+
await this.adapter.sendTextMessage(chatId, `Session ready (${client.sessionId})`);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (command === 'cancel') {
|
|
569
|
+
if (client.sessionId) {
|
|
570
|
+
await client.cancelPendingOperations();
|
|
571
|
+
await client.conn.cancel({ sessionId: client.sessionId });
|
|
572
|
+
}
|
|
573
|
+
await this.adapter.sendTextMessage(chatId, 'Cancellation requested.');
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (command === 'help') {
|
|
577
|
+
const lines = BUILT_IN_COMMANDS.map((c) => `/${c.name} - ${c.description}`);
|
|
578
|
+
if (client.availableCommands.length) {
|
|
579
|
+
lines.push('', 'Agent commands:');
|
|
580
|
+
for (const cmd of client.availableCommands) {
|
|
581
|
+
lines.push(`/${cmd.name} - ${cmd.description}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
await this.adapter.sendTextMessage(chatId, lines.join('\n'));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const handlers = {
|
|
588
|
+
mode: () => this.handleMode(chatId, client, args[0]),
|
|
589
|
+
model: () => this.handleModel(chatId, client, args[0]),
|
|
590
|
+
config: () => this.handleConfig(chatId, client, args)
|
|
591
|
+
};
|
|
592
|
+
const handler = handlers[command];
|
|
593
|
+
if (handler) {
|
|
594
|
+
await handler();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const agentCommand = client.availableCommands.find((c) => c.name === command);
|
|
598
|
+
if (agentCommand && client.sessionId) {
|
|
599
|
+
await this.executeAgentCommand(client, command, args);
|
|
600
|
+
await client.flushTextBuffer();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
// Unknown command: forward as plain text message
|
|
604
|
+
const fullText = `/${command}${args.length ? ' ' + args.join(' ') : ''}`;
|
|
605
|
+
await client.conn.prompt({ sessionId: client.sessionId, prompt: [{ type: 'text', text: fullText }] });
|
|
606
|
+
await client.flushTextBuffer();
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
logger.error({ chatId, command, err }, 'Error handling command');
|
|
610
|
+
await this.adapter.sendTextMessage(chatId, `Error: ${err instanceof Error ? err.message : 'Unknown error'}`).catch(() => { });
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
await this.adapter.setActive(chatId, false);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async handleAgent(chatId, target) {
|
|
617
|
+
const currentAgentId = this.chatAgents.get(chatId) ?? this.defaults.agentId;
|
|
618
|
+
if (!target) {
|
|
619
|
+
await this.presentOptionPicker(chatId, {
|
|
620
|
+
title: 'Agent',
|
|
621
|
+
current: currentAgentId,
|
|
622
|
+
options: this.registry.agents.map((a) => ({ id: a.id, label: a.name })),
|
|
623
|
+
apply: (selected) => this.switchAgent(chatId, selected)
|
|
624
|
+
});
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const result = await this.switchAgent(chatId, target);
|
|
628
|
+
await this.adapter.sendTextMessage(chatId, result);
|
|
629
|
+
}
|
|
630
|
+
async switchAgent(chatId, agentId) {
|
|
631
|
+
this.getAgentEntry(agentId);
|
|
632
|
+
const currentAgentId = this.chatAgents.get(chatId) ?? this.defaults.agentId;
|
|
633
|
+
if (agentId === currentAgentId)
|
|
634
|
+
return `Agent: ${agentId} (no change)`;
|
|
635
|
+
const existing = this.clients.get(chatId);
|
|
636
|
+
if (existing) {
|
|
637
|
+
if (existing.sessionId) {
|
|
638
|
+
await existing.cancelPendingOperations();
|
|
639
|
+
await existing.conn.cancel({ sessionId: existing.sessionId }).catch(() => { });
|
|
640
|
+
}
|
|
641
|
+
existing.reset();
|
|
642
|
+
this.clients.delete(chatId);
|
|
643
|
+
}
|
|
644
|
+
const proc = this.clientProcs.get(chatId);
|
|
645
|
+
if (proc) {
|
|
646
|
+
proc.kill();
|
|
647
|
+
this.clientProcs.delete(chatId);
|
|
648
|
+
}
|
|
649
|
+
this.drainMessageQueue(chatId);
|
|
650
|
+
this.chatAgents.set(chatId, agentId);
|
|
651
|
+
await this.cache.set(`agent:${chatId}`, agentId);
|
|
652
|
+
await this.startSession(chatId, agentId, { agentId }, this.defaults.cwd);
|
|
653
|
+
return `Agent → ${agentId}`;
|
|
654
|
+
}
|
|
655
|
+
async handleSessions(chatId) {
|
|
656
|
+
await this.adapter.sendTextMessage(chatId, 'Sessions list is not available in this version.');
|
|
657
|
+
}
|
|
658
|
+
async handleMode(chatId, client, target) {
|
|
659
|
+
if (!client.sessionId || !client.modes) {
|
|
660
|
+
await this.adapter.sendTextMessage(chatId, 'Modes are not available for this session.');
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (!target) {
|
|
664
|
+
await this.presentOptionPicker(chatId, {
|
|
665
|
+
title: 'Session mode',
|
|
666
|
+
current: client.modes.currentModeId,
|
|
667
|
+
options: client.modes.availableModes.map((mode) => ({ id: mode.id, label: mode.name ?? mode.id })),
|
|
668
|
+
apply: async (selected) => {
|
|
669
|
+
await client.conn.setSessionMode({ sessionId: client.sessionId, modeId: selected });
|
|
670
|
+
client.modes.currentModeId = selected;
|
|
671
|
+
return `Mode → ${selected}`;
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
await client.conn.setSessionMode({ sessionId: client.sessionId, modeId: target });
|
|
677
|
+
client.modes.currentModeId = target;
|
|
678
|
+
await this.adapter.sendTextMessage(chatId, `Mode → ${target}`);
|
|
679
|
+
}
|
|
680
|
+
async handleModel(chatId, client, target) {
|
|
681
|
+
if (!client.sessionId || !client.models) {
|
|
682
|
+
await this.adapter.sendTextMessage(chatId, 'Models are not available for this session.');
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (!target) {
|
|
686
|
+
await this.presentOptionPicker(chatId, {
|
|
687
|
+
title: 'Session model',
|
|
688
|
+
current: client.models.currentModelId,
|
|
689
|
+
options: client.models.availableModels.map((model) => ({ id: model.modelId, label: model.name ?? model.modelId })),
|
|
690
|
+
apply: async (selected) => {
|
|
691
|
+
await client.conn.unstable_setSessionModel({ sessionId: client.sessionId, modelId: selected });
|
|
692
|
+
client.models.currentModelId = selected;
|
|
693
|
+
return `Model → ${selected}`;
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
await client.conn.unstable_setSessionModel({ sessionId: client.sessionId, modelId: target });
|
|
699
|
+
client.models.currentModelId = target;
|
|
700
|
+
await this.adapter.sendTextMessage(chatId, `Model → ${target}`);
|
|
701
|
+
}
|
|
702
|
+
async handleConfig(chatId, client, args) {
|
|
703
|
+
if (!client.sessionId || !client.configOptions.length) {
|
|
704
|
+
await this.adapter.sendTextMessage(chatId, 'No config options available for this session.');
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const [optId, value] = args;
|
|
708
|
+
if (!optId) {
|
|
709
|
+
const summary = client.configOptions.map((opt) => `${opt.id}=${opt.currentValue}`).join('\n');
|
|
710
|
+
await this.adapter.sendTextMessage(chatId, `Config options:\n${summary}`);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (!value) {
|
|
714
|
+
const opt = client.configOptions.find((o) => o.id === optId);
|
|
715
|
+
if (!opt)
|
|
716
|
+
return;
|
|
717
|
+
await this.adapter.sendTextMessage(chatId, `${opt.name}: ${opt.currentValue}`);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const res = await client.conn.setSessionConfigOption({ sessionId: client.sessionId, configId: optId, value });
|
|
721
|
+
client.configOptions = res.configOptions;
|
|
722
|
+
await this.adapter.sendTextMessage(chatId, `Config ${optId} → ${value}`);
|
|
723
|
+
}
|
|
724
|
+
async executeAgentCommand(client, command, args) {
|
|
725
|
+
await client.conn.extMethod('command', { sessionId: client.sessionId, command: { name: command, args } });
|
|
726
|
+
}
|
|
727
|
+
async presentOptionPicker(chatId, params) {
|
|
728
|
+
const nonce = Date.now().toString(36);
|
|
729
|
+
const optionMap = new Map();
|
|
730
|
+
const actions = params.options.map((option, i) => {
|
|
731
|
+
const key = `p:${nonce}:${i}`;
|
|
732
|
+
optionMap.set(key, option.id);
|
|
733
|
+
return { id: key, label: option.label };
|
|
734
|
+
});
|
|
735
|
+
const cancelKey = `p:${nonce}:x`;
|
|
736
|
+
actions.push({ id: cancelKey, label: 'Cancel' });
|
|
737
|
+
let interactionId = '';
|
|
738
|
+
const message = {
|
|
739
|
+
markdown: `**${params.title}**\nCurrent: ${params.current}`,
|
|
740
|
+
actions: {
|
|
741
|
+
items: actions,
|
|
742
|
+
callback: async (actionId) => {
|
|
743
|
+
if (!interactionId)
|
|
744
|
+
return;
|
|
745
|
+
if (actionId === cancelKey) {
|
|
746
|
+
await this.adapter.editInteractiveMessage(chatId, interactionId, { markdown: `**${params.title}**\nCancelled`, actions: null });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const selected = optionMap.get(actionId);
|
|
750
|
+
if (!selected)
|
|
751
|
+
return;
|
|
752
|
+
await this.adapter.setActive(chatId, true);
|
|
753
|
+
try {
|
|
754
|
+
const result = await params.apply(selected);
|
|
755
|
+
await this.adapter.editInteractiveMessage(chatId, interactionId, { markdown: result, actions: null });
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
logger.error({ chatId, err }, 'Error applying option');
|
|
759
|
+
await this.adapter.editInteractiveMessage(chatId, interactionId, {
|
|
760
|
+
markdown: `**${params.title}**\nError: ${err instanceof Error ? err.message : 'Unknown error'}`,
|
|
761
|
+
actions: null
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
await this.adapter.setActive(chatId, false);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
const interaction = await this.adapter.sendInteractiveMessage(chatId, message, 'picker');
|
|
769
|
+
interactionId = interaction.id;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
function applySessionState(client, res) {
|
|
773
|
+
if (res.configOptions)
|
|
774
|
+
client.configOptions = res.configOptions;
|
|
775
|
+
if (res.modes)
|
|
776
|
+
client.modes = res.modes;
|
|
777
|
+
if (res.models)
|
|
778
|
+
client.models = res.models;
|
|
779
|
+
}
|
|
780
|
+
function formatSessionInfo(client) {
|
|
781
|
+
const lines = [];
|
|
782
|
+
if (client.modes) {
|
|
783
|
+
const current = client.modes.availableModes.find((m) => m.id === client.modes.currentModeId);
|
|
784
|
+
lines.push(`Mode: ${current?.name ?? client.modes.currentModeId}`);
|
|
785
|
+
}
|
|
786
|
+
if (client.models) {
|
|
787
|
+
const current = client.models.availableModels.find((m) => m.modelId === client.models.currentModelId);
|
|
788
|
+
lines.push(`Model: ${current?.name ?? client.models.currentModelId}`);
|
|
789
|
+
}
|
|
790
|
+
if (client.configOptions.length) {
|
|
791
|
+
for (const opt of client.configOptions) {
|
|
792
|
+
lines.push(`${opt.name}: ${opt.currentValue}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return lines.length ? '\n' + lines.join('\n') : '';
|
|
796
|
+
}
|
|
797
|
+
function cacheKey(chatId, agentId, cwd) {
|
|
798
|
+
return `${chatId}:${agentId}:${cwd}`;
|
|
799
|
+
}
|
|
800
|
+
function selectRejectOutcome(params) {
|
|
801
|
+
// NOTE: ACP does not yet support timeout denial messaging.
|
|
802
|
+
const option = params.options.find((opt) => opt.kind === 'reject_once') ?? params.options[0];
|
|
803
|
+
if (!option) {
|
|
804
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
805
|
+
}
|
|
806
|
+
return { outcome: { outcome: 'selected', optionId: option.optionId } };
|
|
807
|
+
}
|