agentp 0.9.0 → 0.11.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/CONTRIBUTING.md +139 -0
- package/README.md +54 -8
- package/bin/agentp +159 -20
- package/bin/ocmux +56 -41
- package/bin/tgagentp +971 -206
- package/docs/specification.md +391 -0
- package/lib/ocmux.js +113 -2
- package/lib/opencode.js +79 -12
- package/package.json +9 -2
- package/AGENTS.md +0 -177
- package/CHANGELOG.md +0 -24
- package/debugging_tg_integration.md +0 -5594
- package/tgagentp.log +0 -2156
package/bin/tgagentp
CHANGED
|
@@ -5,7 +5,7 @@ const https = require('https');
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { version } = require('../package.json');
|
|
8
|
-
const { sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, listenForSessionEvents, listAgents, listProviders } = require('../lib/opencode.js');
|
|
8
|
+
const { getAuthHeaders, sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, listenForSessionEvents, listAgents, listProviders, isServerAlive } = require('../lib/opencode.js');
|
|
9
9
|
|
|
10
10
|
// Logger: info→stdout, error→stderr (always), debug→stderr (--verbose only)
|
|
11
11
|
const log = {
|
|
@@ -15,11 +15,35 @@ const log = {
|
|
|
15
15
|
debug(msg) { if (this.verbose) process.stderr.write(`${msg}\n`); },
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
const STARTUP_CHAT_FILE = '/tmp/tgagentp-startup-chat';
|
|
19
18
|
const TGAGENTP_PORT = parseInt(process.env.TGAGENTP_PORT || '0', 10);
|
|
20
19
|
const PORT_FILE = '/tmp/tgagentp-port';
|
|
21
20
|
const TGAGENTP_DEBOUNCE_MS = parseInt(process.env.TGAGENTP_DEBOUNCE_MS || '5000', 10);
|
|
22
|
-
const
|
|
21
|
+
const CONNECTIONS_FILE = '/tmp/tgagentp-connections.json';
|
|
22
|
+
const { listServers, activateServer, resurrectServer, statefileFor, readState } = require('../lib/ocmux.js');
|
|
23
|
+
|
|
24
|
+
// Persist chat↔server directory mappings across restarts
|
|
25
|
+
function saveConnection(chatId, threadId, dir) {
|
|
26
|
+
let data = { connections: [] };
|
|
27
|
+
try { data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8')); } catch {}
|
|
28
|
+
const key = convKey(chatId, threadId);
|
|
29
|
+
const idx = data.connections.findIndex(c => convKey(c.chatId, c.threadId) === key);
|
|
30
|
+
const entry = { chatId, threadId: threadId || null, dir };
|
|
31
|
+
if (idx !== -1) data.connections[idx] = entry;
|
|
32
|
+
else data.connections.push(entry);
|
|
33
|
+
try { fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2)); } catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function removeConnection(chatId, threadId) {
|
|
37
|
+
let data = { connections: [] };
|
|
38
|
+
try { data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8')); } catch {}
|
|
39
|
+
const key = convKey(chatId, threadId);
|
|
40
|
+
data.connections = data.connections.filter(c => convKey(c.chatId, c.threadId) !== key);
|
|
41
|
+
try { fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2)); } catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function clearConnections() {
|
|
45
|
+
try { fs.unlinkSync(CONNECTIONS_FILE); } catch {}
|
|
46
|
+
}
|
|
23
47
|
|
|
24
48
|
// Agentp gateway message queue, keyed by server URL
|
|
25
49
|
const agentpQueues = {};
|
|
@@ -32,23 +56,89 @@ function validateAgentpAuth(req) {
|
|
|
32
56
|
return req.headers.authorization === expected;
|
|
33
57
|
}
|
|
34
58
|
|
|
35
|
-
function flushAgentpQueue(server, token, chatId) {
|
|
59
|
+
function flushAgentpQueue(server, token, chatId, threadId) {
|
|
36
60
|
if (!chatId) return;
|
|
37
61
|
const q = agentpQueues[server];
|
|
38
62
|
if (!q || q.messages.length === 0) return;
|
|
39
63
|
const msgs = q.messages.splice(0);
|
|
40
64
|
q.notified = false;
|
|
41
65
|
if (q.notifyTimer) { clearTimeout(q.notifyTimer); q.notifyTimer = null; }
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
const cs = { chatId, threadId };
|
|
67
|
+
sendLongMessage(token, cs,
|
|
68
|
+
`📨 Delivering ${msgs.length} queued agentp message(s):`, undefined, threadId)
|
|
44
69
|
.then(() => {
|
|
45
70
|
for (const msg of msgs) {
|
|
46
|
-
sendLongMessage(token,
|
|
71
|
+
sendLongMessage(token, cs, msg.trim(), undefined, threadId).catch(() => {});
|
|
47
72
|
}
|
|
48
73
|
})
|
|
49
74
|
.catch(() => {});
|
|
50
75
|
}
|
|
51
76
|
|
|
77
|
+
// Check if an opencode server is reachable by hitting its /session endpoint.
|
|
78
|
+
// Uses a short 5s timeout; returns true for any HTTP response (even 4xx/5xx)
|
|
79
|
+
// and false only for connection-level failures (dead server, DNS, timeout).
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Per-chat state
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
const MAX_RECORDED_MSGS = 100;
|
|
85
|
+
const MAX_RECORDED_BYTES = 100 * 1024;
|
|
86
|
+
|
|
87
|
+
const chatStates = {};
|
|
88
|
+
const serverOwners = {}; // serverUrl → { chatId, threadId } (for agentp gateway routing)
|
|
89
|
+
|
|
90
|
+
// Compound key for isolated chat+thread (forum topic) state
|
|
91
|
+
function convKey(chatId, threadId) {
|
|
92
|
+
return threadId ? `${chatId}:t${threadId}` : chatId;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getChatState(chatId, threadId, defaultServer) {
|
|
96
|
+
const key = convKey(chatId, threadId);
|
|
97
|
+
if (!chatStates[key]) {
|
|
98
|
+
chatStates[key] = {
|
|
99
|
+
chatId,
|
|
100
|
+
threadId: threadId || null,
|
|
101
|
+
serverBase: defaultServer || null,
|
|
102
|
+
recording: { active: false, paused: false, messages: [], bytes: 0 },
|
|
103
|
+
ring: { messages: [], bytes: 0 },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return chatStates[key];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const RING_MAX_MSGS = 50;
|
|
110
|
+
const RING_MAX_BYTES = 50 * 1024;
|
|
111
|
+
|
|
112
|
+
function bufferMessage(chatId, threadId, role, text) {
|
|
113
|
+
const cs = getChatState(chatId, threadId);
|
|
114
|
+
|
|
115
|
+
// Always maintain an in-memory ring buffer of recent messages.
|
|
116
|
+
// This enables retroactive /record N: copy last N messages into recording.
|
|
117
|
+
cs.ring.messages.push({ role, text });
|
|
118
|
+
cs.ring.bytes += Buffer.byteLength(text, 'utf8');
|
|
119
|
+
while (cs.ring.messages.length > RING_MAX_MSGS || cs.ring.bytes > RING_MAX_BYTES) {
|
|
120
|
+
const oldest = cs.ring.messages.shift();
|
|
121
|
+
cs.ring.bytes -= Buffer.byteLength(oldest.text, 'utf8');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Recording buffer (only when active AND not paused)
|
|
125
|
+
if (!cs.recording.active || cs.recording.paused) return;
|
|
126
|
+
cs.recording.messages.push({ role, text });
|
|
127
|
+
cs.recording.bytes += Buffer.byteLength(text, 'utf8');
|
|
128
|
+
while (cs.recording.messages.length > MAX_RECORDED_MSGS || cs.recording.bytes > MAX_RECORDED_BYTES) {
|
|
129
|
+
const oldest = cs.recording.messages.shift();
|
|
130
|
+
cs.recording.bytes -= Buffer.byteLength(oldest.text, 'utf8');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function flushRecorded(chatId, threadId) {
|
|
135
|
+
const cs = getChatState(chatId, threadId);
|
|
136
|
+
if (!cs.recording.active) return [];
|
|
137
|
+
const msgs = cs.recording.messages.splice(0);
|
|
138
|
+
cs.recording.bytes = 0;
|
|
139
|
+
return msgs;
|
|
140
|
+
}
|
|
141
|
+
|
|
52
142
|
// ---------------------------------------------------------------------------
|
|
53
143
|
// Telegram Bot API helpers
|
|
54
144
|
// ---------------------------------------------------------------------------
|
|
@@ -91,10 +181,11 @@ function getUpdates(token, offset, timeout) {
|
|
|
91
181
|
});
|
|
92
182
|
}
|
|
93
183
|
|
|
94
|
-
function sendTelegramMessage(token, chatId, text, parseMode, replyTo) {
|
|
184
|
+
function sendTelegramMessage(token, chatId, text, parseMode, replyTo, threadId) {
|
|
95
185
|
const params = { chat_id: chatId, text };
|
|
96
186
|
if (parseMode) params.parse_mode = parseMode;
|
|
97
187
|
if (replyTo) params.reply_to_message_id = replyTo;
|
|
188
|
+
if (threadId) params.message_thread_id = threadId;
|
|
98
189
|
return telegramRequest(token, 'sendMessage', params);
|
|
99
190
|
}
|
|
100
191
|
|
|
@@ -202,11 +293,15 @@ function splitMessage(text, maxLength = 4096) {
|
|
|
202
293
|
// Converts Markdown to Telegram HTML formatting.
|
|
203
294
|
// Splits the raw text first, then converts each chunk independently
|
|
204
295
|
// to avoid broken HTML tags across messages.
|
|
205
|
-
async function sendLongMessage(token,
|
|
296
|
+
async function sendLongMessage(token, chatIdOrState, text, replyTo, threadId) {
|
|
297
|
+
// Accept either (token, chatState, ...) or (token, rawChatId, ...)
|
|
298
|
+
const chatId = typeof chatIdOrState === 'object' && chatIdOrState.chatId != null
|
|
299
|
+
? chatIdOrState.chatId : chatIdOrState;
|
|
300
|
+
const effectiveThreadId = threadId || (typeof chatIdOrState === 'object' ? chatIdOrState.threadId : undefined);
|
|
206
301
|
const chunks = splitMessage(text);
|
|
207
302
|
for (let i = 0; i < chunks.length; i++) {
|
|
208
303
|
const html = convertMarkdownToHtml(chunks[i]);
|
|
209
|
-
await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined);
|
|
304
|
+
await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined, effectiveThreadId);
|
|
210
305
|
}
|
|
211
306
|
}
|
|
212
307
|
|
|
@@ -226,7 +321,7 @@ function quoteReply(msg) {
|
|
|
226
321
|
if (rm.text[i] === '`') { cur++; maxBackticks = Math.max(maxBackticks, cur); }
|
|
227
322
|
else cur = 0;
|
|
228
323
|
}
|
|
229
|
-
const fence = '`'.repeat(maxBackticks + 1);
|
|
324
|
+
const fence = '`'.repeat(Math.max(3, maxBackticks + 1));
|
|
230
325
|
return `In response to ${replyFrom} (${senderType}):\n\n${fence}\n${rm.text}\n${fence}\n\n`;
|
|
231
326
|
}
|
|
232
327
|
|
|
@@ -234,18 +329,20 @@ function quoteReply(msg) {
|
|
|
234
329
|
// Telegram command handlers
|
|
235
330
|
// ---------------------------------------------------------------------------
|
|
236
331
|
|
|
237
|
-
async function cmdHelp(token, chatId, args) {
|
|
332
|
+
async function cmdHelp(token, chatId, args, chatState) {
|
|
238
333
|
if (args.length > 0) {
|
|
239
334
|
const topic = args[0].toLowerCase();
|
|
240
335
|
if (topic === 'servers') {
|
|
241
|
-
await sendLongMessage(token,
|
|
336
|
+
await sendLongMessage(token, chatState,
|
|
242
337
|
'`/servers` ─ list all opencode servers (ocmux sessions)\n' +
|
|
243
|
-
'`/servers switch <name>` ─ switch target server\n
|
|
338
|
+
'`/servers switch <name>` ─ switch target server\n' +
|
|
339
|
+
'`/force-switch <name>` ─ take over a server from another chat\n\n' +
|
|
340
|
+
'Name matching: case-insensitive first, then case-sensitive. ' +
|
|
244
341
|
'`<name>` can be a full path or basename of the project directory.');
|
|
245
342
|
return;
|
|
246
343
|
}
|
|
247
344
|
if (topic === 'sessions') {
|
|
248
|
-
await sendLongMessage(token,
|
|
345
|
+
await sendLongMessage(token, chatState,
|
|
249
346
|
'`/sessions` ─ list active sessions (numbered, newest first, max 50)\n' +
|
|
250
347
|
'`/sessions switch <number-or-name>` ─ send messages to a specific session\n' +
|
|
251
348
|
'`/sessions new [name]` ─ create a new session\n' +
|
|
@@ -255,7 +352,7 @@ async function cmdHelp(token, chatId, args) {
|
|
|
255
352
|
return;
|
|
256
353
|
}
|
|
257
354
|
if (topic === 'shutdown') {
|
|
258
|
-
await sendLongMessage(token,
|
|
355
|
+
await sendLongMessage(token, chatState,
|
|
259
356
|
'`/shutdown` ─ stop tgagentp process\n' +
|
|
260
357
|
'`/shutdown force` ─ force shutdown even if busy (in-flight work will be lost)\n\n' +
|
|
261
358
|
'Only available when tgagentp is started with `--dev` flag.\n' +
|
|
@@ -264,14 +361,14 @@ async function cmdHelp(token, chatId, args) {
|
|
|
264
361
|
return;
|
|
265
362
|
}
|
|
266
363
|
if (topic === 'agents') {
|
|
267
|
-
await sendLongMessage(token,
|
|
364
|
+
await sendLongMessage(token, chatState,
|
|
268
365
|
'`/agents` ─ list primary agents (with ▶ marker for the active one).\n' +
|
|
269
366
|
'`/agents switch <name>` ─ switch the active agent. Subsequent messages will use this agent.\n\n' +
|
|
270
367
|
'Agent switching requires an active session (`/sessions switch` or `/sessions new`).');
|
|
271
368
|
return;
|
|
272
369
|
}
|
|
273
370
|
if (topic === 'session') {
|
|
274
|
-
await sendLongMessage(token,
|
|
371
|
+
await sendLongMessage(token, chatState,
|
|
275
372
|
'`/session` is an alias for `/sessions`.\n\n' +
|
|
276
373
|
'`/sessions` ─ list active sessions\n' +
|
|
277
374
|
'`/sessions switch <number-or-name>` ─ switch to a session\n' +
|
|
@@ -280,14 +377,14 @@ async function cmdHelp(token, chatId, args) {
|
|
|
280
377
|
return;
|
|
281
378
|
}
|
|
282
379
|
if (topic === 'models') {
|
|
283
|
-
await sendLongMessage(token,
|
|
380
|
+
await sendLongMessage(token, chatState,
|
|
284
381
|
'`/models` ─ list all providers and their models.\n' +
|
|
285
382
|
'Shows provider name, connection status, default model, and up to 5 models per provider\n' +
|
|
286
383
|
'with status, context limit, and input/output costs.');
|
|
287
384
|
return;
|
|
288
385
|
}
|
|
289
386
|
if (topic === 'allow' || topic === 'reject' || topic === 'always') {
|
|
290
|
-
await sendLongMessage(token,
|
|
387
|
+
await sendLongMessage(token, chatState,
|
|
291
388
|
'`/allow` ─ approve a permission request once\n' +
|
|
292
389
|
'`/reject` ─ deny a permission request\n' +
|
|
293
390
|
'`/always` ─ approve and remember for the session\n\n' +
|
|
@@ -297,7 +394,7 @@ async function cmdHelp(token, chatId, args) {
|
|
|
297
394
|
return;
|
|
298
395
|
}
|
|
299
396
|
if (topic === 'think') {
|
|
300
|
-
await sendLongMessage(token,
|
|
397
|
+
await sendLongMessage(token, chatState,
|
|
301
398
|
'`/think` ─ toggle thinking message forwarding\n' +
|
|
302
399
|
'`/think on` ─ forward model thinking to the chat in real-time\n' +
|
|
303
400
|
'`/think off` ─ stop forwarding thinking messages (default)\n' +
|
|
@@ -307,17 +404,88 @@ async function cmdHelp(token, chatId, args) {
|
|
|
307
404
|
'Can also be enabled at startup with `--think` flag.');
|
|
308
405
|
return;
|
|
309
406
|
}
|
|
310
|
-
|
|
311
|
-
|
|
407
|
+
if (topic === 'queue') {
|
|
408
|
+
await sendLongMessage(token, chatState,
|
|
409
|
+
'`/queue <message>` ─ queue a message when the server is busy\n\n' +
|
|
410
|
+
'When the server is processing a previous message, queuing allows you to\n' +
|
|
411
|
+
'continue working without waiting. Once the current task finishes, the next\n' +
|
|
412
|
+
'queued message is automatically sent and the answer will reply to the\n' +
|
|
413
|
+
'original queued message.\n\n' +
|
|
414
|
+
'Messages are also automatically queued when the server is unreachable.\n' +
|
|
415
|
+
'Use `/flush` to clear the queue at any time.');
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (topic === 'flush') {
|
|
419
|
+
await sendLongMessage(token, chatState,
|
|
420
|
+
'`/flush` ─ clear all queued messages\n\n' +
|
|
421
|
+
'Flushes both the regular message queue and any queued agentp messages.\n' +
|
|
422
|
+
'Messages are queued automatically when the server is unreachable or busy.');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (topic === 'comment') {
|
|
426
|
+
await sendLongMessage(token, chatState,
|
|
427
|
+
'`/comment <text>` ─ save a comment in the chat\n\n' +
|
|
428
|
+
'Comments are NOT forwarded to the agent. They are stored as a regular message\n' +
|
|
429
|
+
'in the chat. If you reply to a comment with a message that doesn\'t start with\n' +
|
|
430
|
+
'`/comment`, the comment text is included as context (via the reply-to quoting mechanism).');
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (topic === 'note') {
|
|
434
|
+
await sendLongMessage(token, chatState,
|
|
435
|
+
'`/note <message>` ─ send context for the agent to acknowledge\n\n' +
|
|
436
|
+
'The message is forwarded to the agent with an awareness prefix. ' +
|
|
437
|
+
'The agent will reply with "Ack" and take no action, ' +
|
|
438
|
+
'but the information will inform future responses.');
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (topic === 'disconnect') {
|
|
442
|
+
await sendLongMessage(token, chatState,
|
|
443
|
+
'`/disconnect` ─ disconnect this chat from its server\n\n' +
|
|
444
|
+
'Clears the server connection for this chat. Use `/servers switch <name>` to reconnect.');
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (topic === 'status') {
|
|
448
|
+
await sendLongMessage(token, chatState,
|
|
449
|
+
'`/status` ─ show current server, session, agent, and queue info\n\n' +
|
|
450
|
+
'Shows:\n' +
|
|
451
|
+
'• Server URL and directory path\n' +
|
|
452
|
+
'• Status: ✅ idle, ⏳ busy, or ❌ unreachable\n' +
|
|
453
|
+
'• Active session name/ID\n' +
|
|
454
|
+
'• Active agent name\n' +
|
|
455
|
+
'• Number of queued messages');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (topic === 'record') {
|
|
459
|
+
await sendLongMessage(token, chatState,
|
|
460
|
+
'`/record` ─ toggle between active/paused (starts fresh if inactive)\n' +
|
|
461
|
+
'`/record continue` ─ resume recording without clearing buffer\n' +
|
|
462
|
+
'`/record pause [N]` ─ pause recording, keep buffer (optionally discard last N)\n' +
|
|
463
|
+
'`/record flush` ─ clear buffer, keep current state (active/paused)\n' +
|
|
464
|
+
'`/record stop` ─ fully stop and clear buffer\n' +
|
|
465
|
+
'`/record N` ─ retroactively buffer the last N messages from the ring\n\n' +
|
|
466
|
+
'**States:**\n' +
|
|
467
|
+
'🔴 *active* — new messages are added to the buffer\n' +
|
|
468
|
+
'⏸ *paused* — buffer preserved, not recording new messages\n' +
|
|
469
|
+
'⏹ *inactive* — no buffer, nothing recorded\n\n' +
|
|
470
|
+
'When active or paused, the next `agentp` invocation retrieves the buffered\n' +
|
|
471
|
+
'conversation and prepends it to its `--qa` stdout output.\n' +
|
|
472
|
+
'`/record N` uses the always-on ring buffer (last 50 messages) to retroactively\n' +
|
|
473
|
+
'fill the recording buffer — as if `/record` had been enabled N messages ago.\n\n' +
|
|
474
|
+
'Max 100 messages / 100KB. Oldest messages are dropped first.');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
await sendLongMessage(token, chatState,
|
|
478
|
+
'Unknown topic. Try `/help servers`, `/help sessions`, `/help agents`, `/help models`, `/help allow`, `/help think`, `/help record`, `/help queue`, `/help flush`, `/help note`, `/help comment`, `/help disconnect`, or just `/help`.');
|
|
312
479
|
return;
|
|
313
480
|
}
|
|
314
|
-
await sendLongMessage(token,
|
|
481
|
+
await sendLongMessage(token, chatState,
|
|
315
482
|
'`/help` ─ show this message\n' +
|
|
316
483
|
'`/help <topic>` ─ detailed help\n' +
|
|
317
484
|
'`/status` ─ show current server and session\n' +
|
|
318
485
|
'`/cancel` ─ cancel the running prompt\n' +
|
|
319
486
|
'`/servers` ─ list running opencode servers\n' +
|
|
320
487
|
'`/servers switch <name>` ─ switch to a different server\n' +
|
|
488
|
+
'`/force-switch <name>` ─ take over a server from another chat\n' +
|
|
321
489
|
'`/sessions` ─ list/create/switch/rename sessions\n' +
|
|
322
490
|
'`/sessions switch <number-or-name>` ─ switch to a session\n' +
|
|
323
491
|
'`/sessions new [name]` ─ create a new session\n' +
|
|
@@ -327,51 +495,99 @@ async function cmdHelp(token, chatId, args) {
|
|
|
327
495
|
'`/agents switch <name>` ─ switch active agent\n' +
|
|
328
496
|
'`/models` ─ list providers and models\n' +
|
|
329
497
|
'`/think` ─ toggle thinking message forwarding\n' +
|
|
498
|
+
'`/record` ─ record Telegram conversation for agentp context\n' +
|
|
499
|
+
'`/queue <message>` ─ queue a message when server is busy\n' +
|
|
500
|
+
'`/flush` ─ clear all queued messages\n' +
|
|
501
|
+
'`/note <message>` ─ send context for the agent to acknowledge\n' +
|
|
502
|
+
'`/comment <text>` ─ save a comment in the chat (not forwarded to the agent)\n' +
|
|
503
|
+
'`/disconnect` ─ disconnect this chat from its server\n' +
|
|
330
504
|
'`/allow` / `/reject` / `/always` ─ respond to permission requests\n' +
|
|
331
505
|
'`/shutdown` ─ stop tgagentp (only with `--dev` flag)\n\n' +
|
|
332
506
|
'Everything else is forwarded to the active server as `[Telegram] <text>`.');
|
|
333
507
|
}
|
|
334
508
|
|
|
335
|
-
async function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
509
|
+
async function cmdForceSwitch(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
510
|
+
return cmdServers(token, chatId, ['switch', '--force', ...args], chatState, setServerForChat, setActiveSessionForChat);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function cmdServers(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
514
|
+
const isForce = args.length > 0 && args[0].toLowerCase() === 'force-switch';
|
|
515
|
+
if (args.length > 0 && (args[0].toLowerCase() === 'switch' || isForce)) {
|
|
516
|
+
const forceIdx = args.indexOf('--force');
|
|
517
|
+
const force = forceIdx !== -1 || isForce;
|
|
518
|
+
const switchArgs = args.slice(1).filter(a => a !== '--force');
|
|
519
|
+
if (switchArgs.length < 1) {
|
|
520
|
+
await sendLongMessage(token, chatState,
|
|
339
521
|
'Usage: `/servers switch <name>`\n' +
|
|
522
|
+
'To force-switch (take over a server from another chat), use `/force-switch <name>`.\n' +
|
|
340
523
|
'`<name>` can be a full directory path or a basename.');
|
|
341
524
|
return;
|
|
342
525
|
}
|
|
343
|
-
const name =
|
|
526
|
+
const name = switchArgs.join(' ');
|
|
344
527
|
const servers = listServers();
|
|
345
528
|
if (servers.length === 0) {
|
|
346
|
-
await sendLongMessage(token,
|
|
529
|
+
await sendLongMessage(token, chatState, 'No servers running.');
|
|
347
530
|
return;
|
|
348
531
|
}
|
|
349
|
-
|
|
532
|
+
|
|
533
|
+
// Phase 1: case-insensitive exact match on dir or basename
|
|
534
|
+
let matches = servers.filter(s => s.dir.toLowerCase() === name.toLowerCase());
|
|
350
535
|
if (matches.length === 0) {
|
|
351
|
-
matches = servers.filter(s => path.basename(s.dir) === name);
|
|
536
|
+
matches = servers.filter(s => path.basename(s.dir).toLowerCase() === name.toLowerCase());
|
|
352
537
|
}
|
|
538
|
+
// Fallback: case-insensitive substring match
|
|
353
539
|
if (matches.length === 0) {
|
|
354
|
-
|
|
540
|
+
const lowerName = name.toLowerCase();
|
|
541
|
+
matches = servers.filter(s => s.dir.toLowerCase().includes(lowerName) || path.basename(s.dir).toLowerCase().includes(lowerName));
|
|
355
542
|
}
|
|
356
|
-
|
|
357
|
-
|
|
543
|
+
|
|
544
|
+
if (matches.length === 1) {
|
|
545
|
+
// Phase 1 found a single match — use it directly
|
|
546
|
+
} else if (matches.length === 0) {
|
|
547
|
+
await sendLongMessage(token, chatState, `No server found matching "${name}".`);
|
|
358
548
|
return;
|
|
549
|
+
} else {
|
|
550
|
+
// Phase 2: case-sensitive exact match to disambiguate
|
|
551
|
+
let exact = servers.filter(s => s.dir === name);
|
|
552
|
+
if (exact.length === 0) {
|
|
553
|
+
exact = servers.filter(s => path.basename(s.dir) === name);
|
|
554
|
+
}
|
|
555
|
+
if (exact.length === 1) {
|
|
556
|
+
matches = exact;
|
|
557
|
+
} else {
|
|
558
|
+
const list = matches.map(s => ` ${s.url} → ${s.dir}`).join('\n');
|
|
559
|
+
await sendLongMessage(token, chatState,
|
|
560
|
+
`Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
359
563
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
564
|
+
const target = matches[0];
|
|
565
|
+
const existing = serverOwners[target.url];
|
|
566
|
+
if (existing && existing.chatId !== chatState.chatId && !force) {
|
|
567
|
+
await sendLongMessage(token, chatState,
|
|
568
|
+
`⚠️ Server \`${target.url}\` is already connected to another chat.\n\n` +
|
|
569
|
+
`Use \`/servers force-switch ${name}\` to take over (the other chat will be disconnected).`);
|
|
364
570
|
return;
|
|
365
571
|
}
|
|
366
|
-
|
|
367
|
-
|
|
572
|
+
if (existing && existing.chatId === chatState.chatId) {
|
|
573
|
+
// Already connected — re-run setup to refresh state
|
|
574
|
+
}
|
|
575
|
+
if (force && existing && existing.chatId !== chatState.chatId) {
|
|
576
|
+
// Reset the old owner's server and notify them
|
|
577
|
+
const oldState = getChatState(existing.chatId, existing.threadId);
|
|
578
|
+
oldState.serverBase = null;
|
|
579
|
+
sendLongMessage(token, oldState,
|
|
580
|
+
`⚠️ Server \`${target.url}\` has been taken over by another chat.\n\n` +
|
|
581
|
+
`Use \`/servers\` to see available servers and reconnect.`).catch(() => {});
|
|
582
|
+
}
|
|
583
|
+
const pending = await setServerForChat(chatState, target.url);
|
|
368
584
|
activateServer(target.dir, target.index, target.url);
|
|
369
585
|
let msg = `Switched to server: ${target.url} → ${target.dir}`;
|
|
370
586
|
if (pending && pending.trim()) {
|
|
371
|
-
await sendLongMessage(token,
|
|
372
|
-
await sendLongMessage(token,
|
|
587
|
+
await sendLongMessage(token, chatState, msg);
|
|
588
|
+
await sendLongMessage(token, chatState, pending.trim());
|
|
373
589
|
} else {
|
|
374
|
-
await sendLongMessage(token,
|
|
590
|
+
await sendLongMessage(token, chatState, msg);
|
|
375
591
|
}
|
|
376
592
|
return;
|
|
377
593
|
}
|
|
@@ -379,15 +595,16 @@ async function cmdServers(token, chatId, args, setServer, setActiveSession, serv
|
|
|
379
595
|
// Default: list servers
|
|
380
596
|
const servers = listServers();
|
|
381
597
|
if (servers.length === 0) {
|
|
382
|
-
await sendLongMessage(token,
|
|
598
|
+
await sendLongMessage(token, chatState, 'No servers running.');
|
|
383
599
|
return;
|
|
384
600
|
}
|
|
385
|
-
const activeUrl = normalizeUrl(serverBase);
|
|
601
|
+
const activeUrl = chatState.serverBase ? normalizeUrl(chatState.serverBase) : null;
|
|
386
602
|
const lines = servers.map(s => {
|
|
387
|
-
const bullet = normalizeUrl(s.url) === activeUrl ? '▶ ' : ' · ';
|
|
603
|
+
const bullet = activeUrl && normalizeUrl(s.url) === activeUrl ? '▶ ' : ' · ';
|
|
388
604
|
return bullet + `${s.url} → ${s.dir} (${s.status})`;
|
|
389
605
|
});
|
|
390
|
-
|
|
606
|
+
const prefix = chatState.serverBase ? 'Active servers:' : 'Available servers:';
|
|
607
|
+
await sendLongMessage(token, chatState, prefix + '\n' + lines.join('\n'));
|
|
391
608
|
}
|
|
392
609
|
|
|
393
610
|
function sessionName(s) {
|
|
@@ -428,7 +645,8 @@ async function discoverActiveSession(server) {
|
|
|
428
645
|
}
|
|
429
646
|
}
|
|
430
647
|
|
|
431
|
-
async function cmdSessions(token, chatId, args,
|
|
648
|
+
async function cmdSessions(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
649
|
+
const server = chatState.serverBase;
|
|
432
650
|
if (args.length > 0) {
|
|
433
651
|
const sub = args[0].toLowerCase();
|
|
434
652
|
|
|
@@ -437,41 +655,41 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
437
655
|
try {
|
|
438
656
|
const session = await createSession(server, title);
|
|
439
657
|
const name = session.title || session.slug || session.id;
|
|
440
|
-
|
|
658
|
+
setActiveSessionForChat(chatState, session.id);
|
|
441
659
|
selectSession(server, session.id);
|
|
442
|
-
await sendLongMessage(token,
|
|
660
|
+
await sendLongMessage(token, chatState,
|
|
443
661
|
`Created session: \`${name}\`\nIt is now the active session.`);
|
|
444
662
|
} catch (err) {
|
|
445
|
-
await sendLongMessage(token,
|
|
663
|
+
await sendLongMessage(token, chatState, `Error creating session: ${err.message}`);
|
|
446
664
|
}
|
|
447
665
|
return;
|
|
448
666
|
}
|
|
449
667
|
|
|
450
668
|
if (sub === 'rename') {
|
|
451
669
|
if (args.length < 2) {
|
|
452
|
-
await sendLongMessage(token,
|
|
670
|
+
await sendLongMessage(token, chatState, 'Usage: `/sessions rename <name>`');
|
|
453
671
|
return;
|
|
454
672
|
}
|
|
455
673
|
const st = getState(server);
|
|
456
674
|
if (!st.activeSessionId) {
|
|
457
|
-
await sendLongMessage(token,
|
|
675
|
+
await sendLongMessage(token, chatState, 'No active session. Use `/sessions switch` or `/sessions new` first.');
|
|
458
676
|
return;
|
|
459
677
|
}
|
|
460
678
|
const newTitle = args.slice(1).join(' ');
|
|
461
679
|
try {
|
|
462
680
|
const session = await updateSession(server, st.activeSessionId, newTitle);
|
|
463
681
|
const name = session.title || session.slug || session.id;
|
|
464
|
-
await sendLongMessage(token,
|
|
682
|
+
await sendLongMessage(token, chatState,
|
|
465
683
|
`Renamed session to: \`${name}\``);
|
|
466
684
|
} catch (err) {
|
|
467
|
-
await sendLongMessage(token,
|
|
685
|
+
await sendLongMessage(token, chatState, `Error renaming session: ${err.message}`);
|
|
468
686
|
}
|
|
469
687
|
return;
|
|
470
688
|
}
|
|
471
689
|
|
|
472
690
|
if (sub === 'switch') {
|
|
473
691
|
if (args.length < 2) {
|
|
474
|
-
await sendLongMessage(token,
|
|
692
|
+
await sendLongMessage(token, chatState,
|
|
475
693
|
'Usage: `/sessions switch <number-or-name>`\n' +
|
|
476
694
|
'Use `/sessions` to see numbered list. You can switch by number or by name (case-insensitive partial match).');
|
|
477
695
|
return;
|
|
@@ -489,9 +707,9 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
489
707
|
const num = parseInt(query, 10);
|
|
490
708
|
if (/^\d+$/.test(query) && num >= 1 && num <= sorted.length) {
|
|
491
709
|
const target = sorted[num - 1];
|
|
492
|
-
|
|
710
|
+
setActiveSessionForChat(chatId, target.id);
|
|
493
711
|
selectSession(server, target.id);
|
|
494
|
-
await sendLongMessage(token,
|
|
712
|
+
await sendLongMessage(token, chatState,
|
|
495
713
|
`Switched to session #${num}: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
|
|
496
714
|
return;
|
|
497
715
|
}
|
|
@@ -500,29 +718,29 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
500
718
|
const q = query.toLowerCase();
|
|
501
719
|
const matches = sorted.filter(s => sessionName(s).toLowerCase().includes(q));
|
|
502
720
|
if (matches.length === 0) {
|
|
503
|
-
await sendLongMessage(token,
|
|
721
|
+
await sendLongMessage(token, chatState,
|
|
504
722
|
`No session matching "${query}". Use /sessions to list active sessions.`);
|
|
505
723
|
return;
|
|
506
724
|
}
|
|
507
725
|
if (matches.length > 1) {
|
|
508
726
|
const list = matches.map(s => ` \`${sessionName(s)}\``).join('\n');
|
|
509
|
-
await sendLongMessage(token,
|
|
727
|
+
await sendLongMessage(token, chatState,
|
|
510
728
|
`Multiple matches for "${query}":\n${list}\n\nPlease be more specific or use a number.`);
|
|
511
729
|
return;
|
|
512
730
|
}
|
|
513
731
|
const target = matches[0];
|
|
514
|
-
|
|
732
|
+
setActiveSessionForChat(chatId, target.id);
|
|
515
733
|
selectSession(server, target.id);
|
|
516
|
-
await sendLongMessage(token,
|
|
734
|
+
await sendLongMessage(token, chatState,
|
|
517
735
|
`Switched to session: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
|
|
518
736
|
} catch (err) {
|
|
519
|
-
await sendLongMessage(token,
|
|
737
|
+
await sendLongMessage(token, chatState, `Error switching session: ${err.message}`);
|
|
520
738
|
}
|
|
521
739
|
return;
|
|
522
740
|
}
|
|
523
741
|
|
|
524
742
|
// Unknown subcommand
|
|
525
|
-
await sendLongMessage(token,
|
|
743
|
+
await sendLongMessage(token, chatState,
|
|
526
744
|
`Unknown subcommand: /sessions ${sub}\n\n` +
|
|
527
745
|
'Usage:\n' +
|
|
528
746
|
' `/sessions` ─ list sessions\n' +
|
|
@@ -537,7 +755,7 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
537
755
|
const projectDir = serverDir(server);
|
|
538
756
|
const sessions = await listSessions(server, projectDir);
|
|
539
757
|
if (!sessions || sessions.length === 0) {
|
|
540
|
-
await sendLongMessage(token,
|
|
758
|
+
await sendLongMessage(token, chatState, 'No active sessions.');
|
|
541
759
|
return;
|
|
542
760
|
}
|
|
543
761
|
|
|
@@ -547,7 +765,7 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
547
765
|
(s.agent || s.directory || s.id === activeId) && (!projectDir || s.directory === projectDir));
|
|
548
766
|
const sorted = sortedSessions(filtered, 50);
|
|
549
767
|
if (sorted.length === 0) {
|
|
550
|
-
await sendLongMessage(token,
|
|
768
|
+
await sendLongMessage(token, chatState, 'No active sessions.');
|
|
551
769
|
return;
|
|
552
770
|
}
|
|
553
771
|
|
|
@@ -591,16 +809,17 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
591
809
|
lines.push('`' + bullet + pos + ' ' + timeLabel + ' – ' + name.replace(/`/g, "'") + '`');
|
|
592
810
|
});
|
|
593
811
|
|
|
594
|
-
await sendLongMessage(token,
|
|
812
|
+
await sendLongMessage(token, chatState, 'Sessions:\n' + lines.join('\n'));
|
|
595
813
|
} catch (err) {
|
|
596
|
-
await sendLongMessage(token,
|
|
814
|
+
await sendLongMessage(token, chatState, `Error listing sessions: ${err.message}`);
|
|
597
815
|
}
|
|
598
816
|
}
|
|
599
817
|
|
|
600
|
-
async function cmdAgents(token, chatId, args,
|
|
818
|
+
async function cmdAgents(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
819
|
+
const server = chatState.serverBase;
|
|
601
820
|
if (args.length > 0 && args[0].toLowerCase() === 'switch') {
|
|
602
821
|
if (args.length < 2) {
|
|
603
|
-
await sendLongMessage(token,
|
|
822
|
+
await sendLongMessage(token, chatState,
|
|
604
823
|
'Usage: `/agents switch <name>`\n' +
|
|
605
824
|
'Use `/agents` to list available agents.');
|
|
606
825
|
return;
|
|
@@ -609,7 +828,7 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
|
|
|
609
828
|
try {
|
|
610
829
|
const agents = await listAgents(server);
|
|
611
830
|
if (!agents || agents.length === 0) {
|
|
612
|
-
await sendLongMessage(token,
|
|
831
|
+
await sendLongMessage(token, chatState, 'No agents available.');
|
|
613
832
|
return;
|
|
614
833
|
}
|
|
615
834
|
let matches = agents.filter(a => a.name === name);
|
|
@@ -617,12 +836,12 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
|
|
|
617
836
|
matches = agents.filter(a => a.name.toLowerCase().includes(name.toLowerCase()));
|
|
618
837
|
}
|
|
619
838
|
if (matches.length === 0) {
|
|
620
|
-
await sendLongMessage(token,
|
|
839
|
+
await sendLongMessage(token, chatState, `No agent matching "${name}".`);
|
|
621
840
|
return;
|
|
622
841
|
}
|
|
623
842
|
if (matches.length > 1) {
|
|
624
843
|
const list = matches.map(a => ` \`${a.name}\``).join('\n');
|
|
625
|
-
await sendLongMessage(token,
|
|
844
|
+
await sendLongMessage(token, chatState,
|
|
626
845
|
`Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
|
|
627
846
|
return;
|
|
628
847
|
}
|
|
@@ -634,10 +853,10 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
|
|
|
634
853
|
selectSession(server, st.activeSessionId);
|
|
635
854
|
}
|
|
636
855
|
const model = target.model ? ` ─ \`${target.model.providerID}/${target.model.modelID}\`` : '';
|
|
637
|
-
await sendLongMessage(token,
|
|
856
|
+
await sendLongMessage(token, chatState,
|
|
638
857
|
`Switched to agent: **${target.name}**${model}\nSubsequent messages will use this agent.`);
|
|
639
858
|
} catch (err) {
|
|
640
|
-
await sendLongMessage(token,
|
|
859
|
+
await sendLongMessage(token, chatState, `Error switching agent: ${err.message}`);
|
|
641
860
|
}
|
|
642
861
|
return;
|
|
643
862
|
}
|
|
@@ -645,12 +864,12 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
|
|
|
645
864
|
try {
|
|
646
865
|
const agents = await listAgents(server);
|
|
647
866
|
if (!agents || agents.length === 0) {
|
|
648
|
-
await sendLongMessage(token,
|
|
867
|
+
await sendLongMessage(token, chatState, 'No agents available.');
|
|
649
868
|
return;
|
|
650
869
|
}
|
|
651
870
|
const primary = agents.filter(a => a.mode === 'primary');
|
|
652
871
|
if (primary.length === 0) {
|
|
653
|
-
await sendLongMessage(token,
|
|
872
|
+
await sendLongMessage(token, chatState, 'No primary agents available.');
|
|
654
873
|
return;
|
|
655
874
|
}
|
|
656
875
|
const activeAgentName = getState(server).activeAgentName;
|
|
@@ -660,13 +879,14 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
|
|
|
660
879
|
const desc = a.description ? `. ${a.description}` : '';
|
|
661
880
|
return bullet + '**' + a.name + '**' + model + desc;
|
|
662
881
|
});
|
|
663
|
-
await sendLongMessage(token,
|
|
882
|
+
await sendLongMessage(token, chatState, 'Primary agents:\n' + lines.join('\n'));
|
|
664
883
|
} catch (err) {
|
|
665
|
-
await sendLongMessage(token,
|
|
884
|
+
await sendLongMessage(token, chatState, `Error listing agents: ${err.message}`);
|
|
666
885
|
}
|
|
667
886
|
}
|
|
668
887
|
|
|
669
|
-
async function cmdModels(token, chatId, args,
|
|
888
|
+
async function cmdModels(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
889
|
+
const server = chatState.serverBase;
|
|
670
890
|
try {
|
|
671
891
|
const result = await listProviders(server);
|
|
672
892
|
const all = result.all || [];
|
|
@@ -674,7 +894,7 @@ async function cmdModels(token, chatId, args, setServer, setActiveSession, serve
|
|
|
674
894
|
|
|
675
895
|
const filtered = all.filter(p => connectedIds.includes(p.id));
|
|
676
896
|
if (filtered.length === 0) {
|
|
677
|
-
await sendLongMessage(token,
|
|
897
|
+
await sendLongMessage(token, chatState, 'No connected providers.');
|
|
678
898
|
return;
|
|
679
899
|
}
|
|
680
900
|
|
|
@@ -698,9 +918,9 @@ async function cmdModels(token, chatId, args, setServer, setActiveSession, serve
|
|
|
698
918
|
}
|
|
699
919
|
}
|
|
700
920
|
|
|
701
|
-
await sendLongMessage(token,
|
|
921
|
+
await sendLongMessage(token, chatState, 'Connected providers:\n' + lines.join('\n'));
|
|
702
922
|
} catch (err) {
|
|
703
|
-
await sendLongMessage(token,
|
|
923
|
+
await sendLongMessage(token, chatState, `Error listing models: ${err.message}`);
|
|
704
924
|
}
|
|
705
925
|
}
|
|
706
926
|
|
|
@@ -710,7 +930,7 @@ const PERM_RESPONSE_HELP = ' No pending permission. Use `/status` to check the c
|
|
|
710
930
|
// Agentp gateway ─ tiny HTTP server for agentp --tg
|
|
711
931
|
// ---------------------------------------------------------------------------
|
|
712
932
|
|
|
713
|
-
function startAgentpGateway(token,
|
|
933
|
+
function startAgentpGateway(token, serverBaseResolver) {
|
|
714
934
|
return new Promise((resolve) => {
|
|
715
935
|
const server = http.createServer((req, res) => {
|
|
716
936
|
try {
|
|
@@ -740,16 +960,21 @@ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
|
|
|
740
960
|
res.end('Bad Request: text and server are required');
|
|
741
961
|
return;
|
|
742
962
|
}
|
|
743
|
-
res.writeHead(200);
|
|
744
|
-
res.end('OK');
|
|
745
963
|
|
|
746
|
-
|
|
747
|
-
if (!chatId) return;
|
|
748
|
-
const activeUrl = normalizeUrl(serverBaseResolver());
|
|
964
|
+
// Find the chat that owns this server
|
|
749
965
|
const targetUrl = normalizeUrl(server);
|
|
966
|
+
const owningChatId = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === targetUrl)?.[1];
|
|
967
|
+
|
|
968
|
+
// Return recorded conversation buffer for the owning chat
|
|
969
|
+
const buffered = owningChatId ? flushRecorded(owningChatId.chatId, owningChatId.threadId) : [];
|
|
970
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
971
|
+
res.end(JSON.stringify({ buffered }));
|
|
972
|
+
|
|
973
|
+
if (!owningChatId) return;
|
|
974
|
+
const activeUrl = normalizeUrl(serverBaseResolver());
|
|
750
975
|
|
|
751
976
|
if (targetUrl === activeUrl) {
|
|
752
|
-
sendLongMessage(token,
|
|
977
|
+
sendLongMessage(token, owningChatId, text.trim()).catch(() => {});
|
|
753
978
|
} else {
|
|
754
979
|
if (!agentpQueues[server]) {
|
|
755
980
|
agentpQueues[server] = { messages: [], notifyTimer: null, notified: false };
|
|
@@ -761,7 +986,7 @@ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
|
|
|
761
986
|
q.notifyTimer = null;
|
|
762
987
|
if (q.messages.length > 0 && !q.notified) {
|
|
763
988
|
q.notified = true;
|
|
764
|
-
sendLongMessage(token,
|
|
989
|
+
sendLongMessage(token, owningChatId,
|
|
765
990
|
`📥 ${q.messages.length} agentp message(s) queued for server \`${server}\`. Switch servers to view them.`)
|
|
766
991
|
.catch(() => {});
|
|
767
992
|
}
|
|
@@ -788,80 +1013,321 @@ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
|
|
|
788
1013
|
});
|
|
789
1014
|
}
|
|
790
1015
|
|
|
791
|
-
async function cmdAllow(token, chatId, args,
|
|
792
|
-
const st = getState(
|
|
1016
|
+
async function cmdAllow(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1017
|
+
const st = getState(chatState.serverBase);
|
|
793
1018
|
if (!st.pendingPermission) {
|
|
794
|
-
await sendLongMessage(token,
|
|
1019
|
+
await sendLongMessage(token, chatState, '/allow' + PERM_RESPONSE_HELP);
|
|
795
1020
|
return;
|
|
796
1021
|
}
|
|
797
1022
|
const p = st.pendingPermission;
|
|
798
1023
|
try {
|
|
799
1024
|
await respondToPermission(p.server, p.sessionId, p.permissionId, 'once');
|
|
800
|
-
await sendLongMessage(token,
|
|
1025
|
+
await sendLongMessage(token, chatState, '✅ Permission approved (once).');
|
|
801
1026
|
log.debug(` → /allow: ${p.type} ${p.pattern || ''}`);
|
|
802
1027
|
} catch (err) {
|
|
803
|
-
await sendLongMessage(token,
|
|
1028
|
+
await sendLongMessage(token, chatState, `Error: ${err.message}`);
|
|
804
1029
|
}
|
|
805
1030
|
}
|
|
806
1031
|
|
|
807
|
-
async function cmdReject(token, chatId, args,
|
|
808
|
-
const st = getState(
|
|
1032
|
+
async function cmdReject(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1033
|
+
const st = getState(chatState.serverBase);
|
|
809
1034
|
if (!st.pendingPermission) {
|
|
810
|
-
await sendLongMessage(token,
|
|
1035
|
+
await sendLongMessage(token, chatState, '/reject' + PERM_RESPONSE_HELP);
|
|
811
1036
|
return;
|
|
812
1037
|
}
|
|
813
1038
|
const p = st.pendingPermission;
|
|
814
1039
|
try {
|
|
815
1040
|
await respondToPermission(p.server, p.sessionId, p.permissionId, 'reject');
|
|
816
|
-
await sendLongMessage(token,
|
|
1041
|
+
await sendLongMessage(token, chatState, '⛔ Permission denied.');
|
|
817
1042
|
log.debug(` → /reject: ${p.type} ${p.pattern || ''}`);
|
|
818
1043
|
} catch (err) {
|
|
819
|
-
await sendLongMessage(token,
|
|
1044
|
+
await sendLongMessage(token, chatState, `Error: ${err.message}`);
|
|
820
1045
|
}
|
|
821
1046
|
}
|
|
822
1047
|
|
|
823
|
-
async function cmdAlways(token, chatId, args,
|
|
824
|
-
const st = getState(
|
|
1048
|
+
async function cmdAlways(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1049
|
+
const st = getState(chatState.serverBase);
|
|
825
1050
|
if (!st.pendingPermission) {
|
|
826
|
-
await sendLongMessage(token,
|
|
1051
|
+
await sendLongMessage(token, chatState, '/always' + PERM_RESPONSE_HELP);
|
|
827
1052
|
return;
|
|
828
1053
|
}
|
|
829
1054
|
const p = st.pendingPermission;
|
|
830
1055
|
try {
|
|
831
1056
|
await respondToPermission(p.server, p.sessionId, p.permissionId, 'always');
|
|
832
|
-
await sendLongMessage(token,
|
|
1057
|
+
await sendLongMessage(token, chatState, '✅ Permission approved (always).');
|
|
833
1058
|
log.debug(` → /always: ${p.type} ${p.pattern || ''}`);
|
|
834
1059
|
} catch (err) {
|
|
835
|
-
await sendLongMessage(token,
|
|
1060
|
+
await sendLongMessage(token, chatState, `Error: ${err.message}`);
|
|
836
1061
|
}
|
|
837
1062
|
}
|
|
838
1063
|
|
|
839
|
-
async function cmdThink(token, chatId, args,
|
|
840
|
-
const st = getState(
|
|
1064
|
+
async function cmdThink(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1065
|
+
const st = getState(chatState.serverBase);
|
|
841
1066
|
const sub = args && args.length > 0 ? args[0].toLowerCase() : 'switch';
|
|
842
1067
|
if (sub === 'on') {
|
|
843
1068
|
st.thinkingEnabled = true;
|
|
844
|
-
await sendLongMessage(token,
|
|
1069
|
+
await sendLongMessage(token, chatState, '💭 Thinking messages **on** (forwarded to chat).');
|
|
845
1070
|
} else if (sub === 'off') {
|
|
846
1071
|
st.thinkingEnabled = false;
|
|
847
|
-
await sendLongMessage(token,
|
|
1072
|
+
await sendLongMessage(token, chatState, '💭 Thinking messages **off**.');
|
|
848
1073
|
} else if (sub === 'switch') {
|
|
849
1074
|
st.thinkingEnabled = !st.thinkingEnabled;
|
|
850
|
-
await sendLongMessage(token,
|
|
1075
|
+
await sendLongMessage(token, chatState,
|
|
851
1076
|
`💭 Thinking messages ${st.thinkingEnabled ? '**on**' : '**off**'}.`);
|
|
852
1077
|
} else {
|
|
853
|
-
await sendLongMessage(token,
|
|
1078
|
+
await sendLongMessage(token, chatState, 'Usage: `/think [on|off|switch]` (default: switch).');
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function cmdQueue(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat, replyTo) {
|
|
1083
|
+
const st = getState(chatState.serverBase);
|
|
1084
|
+
if (!st.busy) {
|
|
1085
|
+
await sendLongMessage(token, chatState, 'Server is not busy. No need to queue — send your message directly.');
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const text = args.join(' ').trim();
|
|
1089
|
+
if (!text) {
|
|
1090
|
+
await sendLongMessage(token, chatState, 'Usage: `/queue <message>` — queue a message for when the server is idle.');
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
1094
|
+
st.messageQueue.push({ text, replyTo });
|
|
1095
|
+
await sendLongMessage(token, chatState, `📥 Queued. ${st.messageQueue.length} message(s) in queue. Will be sent when the current task finishes.`);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function recordingLabel(rec) {
|
|
1099
|
+
if (rec.paused) return '⏸ paused';
|
|
1100
|
+
return rec.active ? '🔴 active' : '⏹ inactive';
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function cmdRecord(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1104
|
+
const rec = chatState.recording;
|
|
1105
|
+
const ring = chatState.ring;
|
|
1106
|
+
|
|
1107
|
+
// /record N: retroactively buffer last N messages from the always-on ring
|
|
1108
|
+
if (args.length > 0 && /^\d+$/.test(args[0])) {
|
|
1109
|
+
const n = parseInt(args[0], 10);
|
|
1110
|
+
const count = Math.min(n, ring.messages.length);
|
|
1111
|
+
const msgs = ring.messages.slice(-count);
|
|
1112
|
+
for (const m of msgs) {
|
|
1113
|
+
rec.messages.push({ role: m.role, text: m.text });
|
|
1114
|
+
rec.bytes += Buffer.byteLength(m.text, 'utf8');
|
|
1115
|
+
}
|
|
1116
|
+
while (rec.messages.length > MAX_RECORDED_MSGS || rec.bytes > MAX_RECORDED_BYTES) {
|
|
1117
|
+
const oldest = rec.messages.shift();
|
|
1118
|
+
rec.bytes -= Buffer.byteLength(oldest.text, 'utf8');
|
|
1119
|
+
}
|
|
1120
|
+
rec.active = true;
|
|
1121
|
+
rec.paused = false;
|
|
1122
|
+
const label = rec.messages.length > 0 ? `🔴 *active*, ${rec.messages.length} buffered` : '⏹ *inactive*, buffer empty';
|
|
1123
|
+
await sendLongMessage(token, chatState,
|
|
1124
|
+
`${label} (${count} message(s) retroactively loaded from ring).`);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// /record pause [N]: pause recording, keep buffer, optionally discard last N
|
|
1129
|
+
if (args.length > 0 && args[0].toLowerCase() === 'pause') {
|
|
1130
|
+
if (rec.active && !rec.paused && args.length > 1 && /^\d+$/.test(args[1])) {
|
|
1131
|
+
const n = parseInt(args[1], 10);
|
|
1132
|
+
const count = Math.min(n, rec.messages.length);
|
|
1133
|
+
rec.messages.splice(-count);
|
|
1134
|
+
rec.bytes = rec.messages.reduce((b, m) => b + Buffer.byteLength(m.text, 'utf8'), 0);
|
|
1135
|
+
await sendLongMessage(token, chatState,
|
|
1136
|
+
`⏸ Recording paused. Discarded last ${count} message(s). ${rec.messages.length} remaining.`);
|
|
1137
|
+
} else {
|
|
1138
|
+
rec.paused = true;
|
|
1139
|
+
rec.active = true;
|
|
1140
|
+
await sendLongMessage(token, chatState,
|
|
1141
|
+
`⏸ Recording paused. Buffer preserved (${rec.messages.length} messages).`);
|
|
1142
|
+
}
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// /record flush: clear buffer, keep state
|
|
1147
|
+
if (args.length > 0 && args[0].toLowerCase() === 'flush') {
|
|
1148
|
+
rec.messages = [];
|
|
1149
|
+
rec.bytes = 0;
|
|
1150
|
+
await sendLongMessage(token, chatState,
|
|
1151
|
+
`🧹 Buffer cleared. State: ${recordingLabel(rec)}.`);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// /record stop: flush + pause = fully stop and clear
|
|
1156
|
+
if (args.length > 0 && args[0].toLowerCase() === 'stop') {
|
|
1157
|
+
rec.active = false;
|
|
1158
|
+
rec.paused = false;
|
|
1159
|
+
rec.messages = [];
|
|
1160
|
+
rec.bytes = 0;
|
|
1161
|
+
await sendLongMessage(token, chatState, '⏹ Recording stopped. Buffer cleared.');
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// /record (no args) or /record continue: toggle/continue
|
|
1166
|
+
if (args.length > 0 && args[0].toLowerCase() === 'continue') {
|
|
1167
|
+
rec.paused = false;
|
|
1168
|
+
rec.active = true;
|
|
1169
|
+
const empty = rec.messages.length === 0;
|
|
1170
|
+
await sendLongMessage(token, chatState,
|
|
1171
|
+
empty
|
|
1172
|
+
? '🔴 Recording started (buffer was empty).'
|
|
1173
|
+
: `🔴 Recording continued. ${rec.messages.length} buffered message(s) will grow.`);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Bare /record: toggle between active ↔ paused (if active), or activate (if inactive)
|
|
1178
|
+
if (!rec.active) {
|
|
1179
|
+
rec.active = true;
|
|
1180
|
+
rec.paused = false;
|
|
1181
|
+
rec.messages = [];
|
|
1182
|
+
rec.bytes = 0;
|
|
1183
|
+
await sendLongMessage(token, chatState, '🔴 Recording started.');
|
|
1184
|
+
} else if (!rec.paused) {
|
|
1185
|
+
rec.paused = true;
|
|
1186
|
+
await sendLongMessage(token, chatState,
|
|
1187
|
+
`⏸ Recording paused. ${rec.messages.length} message(s) preserved.`);
|
|
1188
|
+
} else {
|
|
1189
|
+
rec.paused = false;
|
|
1190
|
+
const empty = rec.messages.length === 0;
|
|
1191
|
+
await sendLongMessage(token, chatState,
|
|
1192
|
+
empty
|
|
1193
|
+
? '🔴 Recording continued (buffer was empty).'
|
|
1194
|
+
: `🔴 Recording continued. ${rec.messages.length} buffered message(s) will grow.`);
|
|
854
1195
|
}
|
|
855
1196
|
}
|
|
856
1197
|
|
|
1198
|
+
async function cmdFlush(token, chatId, args, chatState) {
|
|
1199
|
+
const st = getState(chatState.serverBase);
|
|
1200
|
+
const server = chatState.serverBase;
|
|
1201
|
+
const q = st.messageQueue || [];
|
|
1202
|
+
const qlen = q.length;
|
|
1203
|
+
|
|
1204
|
+
// Clear the message queue
|
|
1205
|
+
st.messageQueue = [];
|
|
1206
|
+
|
|
1207
|
+
// Clear the agentp queue
|
|
1208
|
+
const aq = agentpQueues[server];
|
|
1209
|
+
let alen = 0;
|
|
1210
|
+
if (aq) {
|
|
1211
|
+
alen = aq.messages.length;
|
|
1212
|
+
aq.messages = [];
|
|
1213
|
+
aq.notified = false;
|
|
1214
|
+
if (aq.notifyTimer) { clearTimeout(aq.notifyTimer); aq.notifyTimer = null; }
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
await sendLongMessage(token, chatState,
|
|
1218
|
+
qlen + alen > 0
|
|
1219
|
+
? `🧹 Flushed ${qlen} queued message(s) and ${alen} agentp message(s).`
|
|
1220
|
+
: '🧹 No queued messages to flush.');
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
async function cmdComment(token, chatId, args, chatState) {
|
|
1224
|
+
// No-op: the message is already in the chat. /comment prevents forwarding to the agent.
|
|
1225
|
+
// Replying to a comment with a non-/comment message includes it via the quote mechanism.
|
|
1226
|
+
log.info(`[COMMENT] chat=${chatId} text: ${(args || []).join(' ')}`);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async function cmdResurrect(token, chatId, args, chatState) {
|
|
1230
|
+
const url = chatState.serverBase;
|
|
1231
|
+
if (!url) {
|
|
1232
|
+
await sendLongMessage(token, chatState, '🔌 Not connected to any server. Nothing to resurrect.');
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
let dir = serverDir(url);
|
|
1237
|
+
if (!dir) {
|
|
1238
|
+
// Fallback: look up directory from saved connections
|
|
1239
|
+
try {
|
|
1240
|
+
const savedData = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8'));
|
|
1241
|
+
if (savedData && Array.isArray(savedData.connections)) {
|
|
1242
|
+
const match = savedData.connections.find(c =>
|
|
1243
|
+
String(c.chatId) === String(chatId) && (c.threadId || null) === (chatState.threadId || null)
|
|
1244
|
+
);
|
|
1245
|
+
if (match && match.dir) dir = match.dir;
|
|
1246
|
+
}
|
|
1247
|
+
} catch {}
|
|
1248
|
+
}
|
|
1249
|
+
if (!dir) {
|
|
1250
|
+
await sendLongMessage(token, chatState,
|
|
1251
|
+
'Could not find the directory for this server.\n' +
|
|
1252
|
+
'The tmux window may have been removed.\n' +
|
|
1253
|
+
'Use `ocmux resurrect` from the terminal to recover it.');
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
await sendLongMessage(token, chatState,
|
|
1258
|
+
`♻️ Resurrecting server \`${dir}\`...\n` +
|
|
1259
|
+
`The old server at \`${url}\` will be replaced with a new one.`);
|
|
1260
|
+
|
|
1261
|
+
try {
|
|
1262
|
+
const { url: newUrl } = resurrectServer(dir, false);
|
|
1263
|
+
|
|
1264
|
+
// Update chat state to new URL
|
|
1265
|
+
chatState.serverBase = newUrl;
|
|
1266
|
+
|
|
1267
|
+
// Transfer relevant state from old URL to new URL
|
|
1268
|
+
const oldSt = getState(url);
|
|
1269
|
+
const newSt = getState(newUrl);
|
|
1270
|
+
newSt.activeSessionId = oldSt.activeSessionId;
|
|
1271
|
+
newSt.activeSessionName = oldSt.activeSessionName;
|
|
1272
|
+
newSt.activeAgentName = oldSt.activeAgentName;
|
|
1273
|
+
newSt.thinkingEnabled = oldSt.thinkingEnabled;
|
|
1274
|
+
|
|
1275
|
+
// Update serverOwners to point to the new URL
|
|
1276
|
+
const normOld = normalizeUrl(url);
|
|
1277
|
+
for (const [sfUrl, owner] of Object.entries(serverOwners)) {
|
|
1278
|
+
if (normalizeUrl(sfUrl) === normOld) {
|
|
1279
|
+
serverOwners[newUrl] = owner;
|
|
1280
|
+
delete serverOwners[sfUrl];
|
|
1281
|
+
break;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
await sendLongMessage(token, chatState,
|
|
1286
|
+
`✅ Server resurrected.\n` +
|
|
1287
|
+
`New URL: \`${newUrl}\`\n\n` +
|
|
1288
|
+
`Use \`/status\` to check the session.`);
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
await sendLongMessage(token, chatState,
|
|
1291
|
+
`❌ Resurrection failed: ${err.message}\n` +
|
|
1292
|
+
`Try \`ocmux resurrect\` from the terminal.`);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
async function cmdDisconnect(token, chatId, args, chatState) {
|
|
1297
|
+
const url = chatState.serverBase;
|
|
1298
|
+
if (!url) {
|
|
1299
|
+
await sendLongMessage(token, chatState, '🔌 Not connected to any server.');
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
// Clear ownership if this chat is the owner
|
|
1303
|
+
const key = convKey(chatId, chatState.threadId);
|
|
1304
|
+
for (const [srvUrl, owner] of Object.entries(serverOwners)) {
|
|
1305
|
+
if (String(owner.chatId) === String(chatId) && owner.threadId === chatState.threadId) {
|
|
1306
|
+
delete serverOwners[srvUrl];
|
|
1307
|
+
break;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
chatState.serverBase = null;
|
|
1311
|
+
removeConnection(chatId, chatState.threadId);
|
|
1312
|
+
await sendLongMessage(token, chatState, '👋 Disconnected from `' + url + '`.');
|
|
1313
|
+
log.info(`[chat ${chatId}] Disconnected from server: ${url}`);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
857
1316
|
const COMMANDS = {
|
|
858
1317
|
'help': cmdHelp,
|
|
859
1318
|
'servers': cmdServers,
|
|
1319
|
+
'force-switch': cmdForceSwitch,
|
|
860
1320
|
'sessions': cmdSessions,
|
|
861
1321
|
'session': cmdSessions,
|
|
862
1322
|
'agents': cmdAgents,
|
|
863
1323
|
'models': cmdModels,
|
|
864
1324
|
'think': cmdThink,
|
|
1325
|
+
'record': cmdRecord,
|
|
1326
|
+
'resurrect': cmdResurrect,
|
|
1327
|
+
'queue': cmdQueue,
|
|
1328
|
+
'flush': cmdFlush,
|
|
1329
|
+
'comment': cmdComment,
|
|
1330
|
+
'disconnect': cmdDisconnect,
|
|
865
1331
|
'allow': cmdAllow,
|
|
866
1332
|
'reject': cmdReject,
|
|
867
1333
|
'always': cmdAlways,
|
|
@@ -873,7 +1339,7 @@ const COMMANDS = {
|
|
|
873
1339
|
|
|
874
1340
|
const serverState = {};
|
|
875
1341
|
function getState(url) {
|
|
876
|
-
return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false };
|
|
1342
|
+
return serverState[url] = serverState[url] || { busy: false, serverDead: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false, messageQueue: [] };
|
|
877
1343
|
}
|
|
878
1344
|
|
|
879
1345
|
function sessionLabel(st) {
|
|
@@ -898,6 +1364,7 @@ async function main() {
|
|
|
898
1364
|
process.exit(0);
|
|
899
1365
|
} else if (args[i] === '--dev') {
|
|
900
1366
|
DEV_MODE = true;
|
|
1367
|
+
log.verbose = true;
|
|
901
1368
|
} else if (args[i] === '--verbose') {
|
|
902
1369
|
log.verbose = true;
|
|
903
1370
|
} else if (args[i] === '--think') {
|
|
@@ -955,6 +1422,26 @@ async function main() {
|
|
|
955
1422
|
|
|
956
1423
|
log.info(`tgagentp v${version} started. OpenCode server: ${serverBase}`);
|
|
957
1424
|
|
|
1425
|
+
// When --dev is active, log all message traffic to stderr
|
|
1426
|
+
let msgLogStream = null;
|
|
1427
|
+
if (DEV_MODE) {
|
|
1428
|
+
try {
|
|
1429
|
+
msgLogStream = fs.createWriteStream('/tmp/tgagentp-msg.log', { flags: 'a' });
|
|
1430
|
+
const hdr = JSON.stringify({ ts: new Date().toISOString(), type: 'startup', version });
|
|
1431
|
+
msgLogStream.write(hdr + '\n');
|
|
1432
|
+
} catch (_) { msgLogStream = null; }
|
|
1433
|
+
}
|
|
1434
|
+
const msgLog = (type, chatId, from, text) => {
|
|
1435
|
+
if (!msgLogStream) return;
|
|
1436
|
+
try {
|
|
1437
|
+
const entry = JSON.stringify({
|
|
1438
|
+
ts: new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ''),
|
|
1439
|
+
type, chatId, from: from || '', text: text || '',
|
|
1440
|
+
});
|
|
1441
|
+
msgLogStream.write(entry + '\n');
|
|
1442
|
+
} catch (_) {}
|
|
1443
|
+
};
|
|
1444
|
+
|
|
958
1445
|
if (!process.env.OPENCODE_SERVER_PASSWORD) {
|
|
959
1446
|
log.error('🔓 Server is not password-protected! Set OPENCODE_SERVER_PASSWORD for better security.');
|
|
960
1447
|
}
|
|
@@ -966,8 +1453,6 @@ async function main() {
|
|
|
966
1453
|
log.info('Chat allowlist: not configured (all chats accepted)');
|
|
967
1454
|
}
|
|
968
1455
|
|
|
969
|
-
let lockedChatId = null;
|
|
970
|
-
|
|
971
1456
|
getState(serverBase);
|
|
972
1457
|
|
|
973
1458
|
log.info('Discovering server state...');
|
|
@@ -988,32 +1473,61 @@ async function main() {
|
|
|
988
1473
|
|
|
989
1474
|
log.info('Waiting for Telegram messages... (Ctrl+C to stop)');
|
|
990
1475
|
|
|
991
|
-
//
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
1476
|
+
// Chat-aware server/session mutation callbacks (defined here for token access)
|
|
1477
|
+
const setServerForChat = async (chatState, url) => {
|
|
1478
|
+
const cs = chatState;
|
|
1479
|
+
|
|
1480
|
+
// Disconnect any other chat still connected to this server (one server → one chat)
|
|
1481
|
+
const normalizedUrl = normalizeUrl(url);
|
|
1482
|
+
for (const key of Object.keys(chatStates)) {
|
|
1483
|
+
const state = chatStates[key];
|
|
1484
|
+
if (state === cs) continue;
|
|
1485
|
+
if (state.serverBase && normalizeUrl(state.serverBase) === normalizedUrl) {
|
|
1486
|
+
state.serverBase = null;
|
|
1487
|
+
removeConnection(state.chatId, state.threadId);
|
|
1488
|
+
log.info(`[chat ${state.chatId}] Disconnected from server: ${url} (taken over by chat ${cs.chatId})`);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
995
1491
|
|
|
996
|
-
|
|
997
|
-
|
|
1492
|
+
// Clear old server ownership when switching away from a server
|
|
1493
|
+
const oldUrl = cs.serverBase;
|
|
1494
|
+
cs.serverBase = url;
|
|
1495
|
+
if (oldUrl && oldUrl !== url) {
|
|
1496
|
+
if (serverOwners[oldUrl] &&
|
|
1497
|
+
String(serverOwners[oldUrl].chatId) === String(cs.chatId)) {
|
|
1498
|
+
delete serverOwners[oldUrl];
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
serverOwners[url] = { chatId: cs.chatId, threadId: cs.threadId };
|
|
998
1502
|
getState(url);
|
|
999
|
-
|
|
1503
|
+
|
|
1504
|
+
// Auto-activate tmux window for this server
|
|
1505
|
+
const servers = listServers();
|
|
1506
|
+
const normalized = normalizeUrl(url);
|
|
1507
|
+
const match = servers.find(s => normalizeUrl(s.url) === normalized);
|
|
1508
|
+
if (match) {
|
|
1509
|
+
activateServer(match.dir, match.index, match.url);
|
|
1510
|
+
saveConnection(cs.chatId, cs.threadId, match.dir);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
log.info(`[chat ${cs.chatId}] Switched to server: ${url}`);
|
|
1000
1514
|
|
|
1001
1515
|
// Flush agentp queue for the new active server
|
|
1002
|
-
flushAgentpQueue(url, token,
|
|
1516
|
+
flushAgentpQueue(url, token, cs.chatId, cs.threadId);
|
|
1003
1517
|
|
|
1004
|
-
const [
|
|
1518
|
+
const [srvDiscovered, srvAgents] = await Promise.all([
|
|
1005
1519
|
discoverActiveSession(url),
|
|
1006
1520
|
listAgents(url).catch(() => []),
|
|
1007
1521
|
]);
|
|
1008
|
-
if (
|
|
1009
|
-
getState(url).activeSessionId =
|
|
1010
|
-
getState(url).activeSessionName =
|
|
1011
|
-
log.info(` → session: ${
|
|
1522
|
+
if (srvDiscovered) {
|
|
1523
|
+
getState(url).activeSessionId = srvDiscovered.id;
|
|
1524
|
+
getState(url).activeSessionName = srvDiscovered.name;
|
|
1525
|
+
log.info(` → session: ${srvDiscovered.name || srvDiscovered.id}`);
|
|
1012
1526
|
}
|
|
1013
|
-
const
|
|
1014
|
-
if (
|
|
1015
|
-
getState(url).activeAgentName =
|
|
1016
|
-
log.info(` → agent: ${
|
|
1527
|
+
const defaultSrvAgent = srvAgents.find(a => a.mode === 'primary') || srvAgents[0];
|
|
1528
|
+
if (defaultSrvAgent) {
|
|
1529
|
+
getState(url).activeAgentName = defaultSrvAgent.name;
|
|
1530
|
+
log.info(` → agent: ${defaultSrvAgent.name}`);
|
|
1017
1531
|
}
|
|
1018
1532
|
const p = getState(url).pendingResponse;
|
|
1019
1533
|
if (p) {
|
|
@@ -1023,24 +1537,35 @@ async function main() {
|
|
|
1023
1537
|
return null;
|
|
1024
1538
|
};
|
|
1025
1539
|
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1028
|
-
|
|
1540
|
+
const setActiveSessionForChat = (chatState, id) => {
|
|
1541
|
+
const server = chatState.serverBase;
|
|
1542
|
+
getState(server).activeSessionId = id;
|
|
1543
|
+
log.info(`[chat ${chatState.chatId}] Switched to session: ${id}`);
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1546
|
+
// Start agentp gateway (tiny HTTP server for agentp --tg)
|
|
1547
|
+
const serverBaseResolver = () => {
|
|
1548
|
+
const entries = Object.entries(serverOwners);
|
|
1549
|
+
if (entries.length === 0) return serverBase;
|
|
1550
|
+
const cs = getChatState(entries[0][1].chatId, entries[0][1].threadId);
|
|
1551
|
+
return cs.serverBase;
|
|
1029
1552
|
};
|
|
1553
|
+
startAgentpGateway(token, serverBaseResolver);
|
|
1030
1554
|
|
|
1031
|
-
async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId, replyTo) {
|
|
1555
|
+
async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId, replyTo, threadId) {
|
|
1032
1556
|
const st = getState(url);
|
|
1033
1557
|
const isCancelled = () => getState(url).taskId !== myTaskId;
|
|
1034
1558
|
const cancelRef = { current: null };
|
|
1559
|
+
const chatState = { chatId, threadId };
|
|
1035
1560
|
const sessionCancelRef = { current: null };
|
|
1561
|
+
// Destroy any previous SSE listener so stale forwarding to old chats stops
|
|
1562
|
+
if (st._cancelRef && st._cancelRef.current) { st._cancelRef.current.destroy(); st._cancelRef.current = null; }
|
|
1563
|
+
if (st._sessionReq && st._sessionReq.current) { st._sessionReq.current.destroy(); st._sessionReq.current = null; }
|
|
1036
1564
|
st._cancelRef = cancelRef;
|
|
1037
1565
|
st._sessionReq = sessionCancelRef;
|
|
1038
1566
|
try {
|
|
1039
1567
|
let answer;
|
|
1040
1568
|
if (useSession) {
|
|
1041
|
-
// Dual approach:
|
|
1042
|
-
// 1. SSE for permissions/thinking events (connected before sending)
|
|
1043
|
-
// 2. Synchronous sendToSession (POST /session/:id/message) for reliable answer
|
|
1044
1569
|
const notifiedPerms = new Set();
|
|
1045
1570
|
let thinkingBuf = '';
|
|
1046
1571
|
let sseConnected;
|
|
@@ -1056,7 +1581,7 @@ async function main() {
|
|
|
1056
1581
|
thinkingBuf += chunk;
|
|
1057
1582
|
if (thinkingBuf.length >= 50 || chunk.endsWith('\n')) {
|
|
1058
1583
|
const html = '💭 ' + thinkingBuf.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1059
|
-
sendLongMessage(token,
|
|
1584
|
+
sendLongMessage(token, chatState, html, replyTo, threadId)
|
|
1060
1585
|
.then(() => {})
|
|
1061
1586
|
.catch(() => {});
|
|
1062
1587
|
thinkingBuf = '';
|
|
@@ -1078,7 +1603,7 @@ async function main() {
|
|
|
1078
1603
|
+ (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
|
|
1079
1604
|
+ 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
|
|
1080
1605
|
log.debug(` → sending permission notification to chat ${chatId}`);
|
|
1081
|
-
sendLongMessage(token,
|
|
1606
|
+
sendLongMessage(token, chatState, msg, replyTo, threadId)
|
|
1082
1607
|
.then(() => log.debug(` → permission notification sent OK`))
|
|
1083
1608
|
.catch((err) => log.debug(` → permission notification failed: ${err.message}`));
|
|
1084
1609
|
} else {
|
|
@@ -1090,16 +1615,13 @@ async function main() {
|
|
|
1090
1615
|
getState(url).pendingPermission = null;
|
|
1091
1616
|
},
|
|
1092
1617
|
}, cancelRef).catch(() => {});
|
|
1093
|
-
// Wait for SSE to connect, then send synchronously
|
|
1094
1618
|
await onConnected;
|
|
1095
1619
|
answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName, sessionCancelRef);
|
|
1096
|
-
// Flush any remaining thinking buffer
|
|
1097
1620
|
if (thinkingBuf) {
|
|
1098
1621
|
const html = '💭 ' + thinkingBuf.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1099
|
-
sendLongMessage(token,
|
|
1622
|
+
sendLongMessage(token, chatState, html, replyTo, threadId).catch(() => {});
|
|
1100
1623
|
thinkingBuf = '';
|
|
1101
1624
|
}
|
|
1102
|
-
// Clean up SSE listener
|
|
1103
1625
|
if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
|
|
1104
1626
|
} else {
|
|
1105
1627
|
await sendText(url, `[Telegram] ${text}`);
|
|
@@ -1111,16 +1633,21 @@ async function main() {
|
|
|
1111
1633
|
return;
|
|
1112
1634
|
}
|
|
1113
1635
|
st.pendingPermission = null;
|
|
1114
|
-
|
|
1636
|
+
const chatServer = getChatState(chatId, threadId).serverBase;
|
|
1637
|
+
if (url === chatServer) {
|
|
1115
1638
|
if (answer && answer.trim()) {
|
|
1116
|
-
|
|
1639
|
+
const trimmed = answer.trim();
|
|
1640
|
+
msgLog('out', chatId, '', trimmed);
|
|
1641
|
+
await sendLongMessage(token, chatState, trimmed, replyTo, threadId);
|
|
1642
|
+
bufferMessage(chatId, threadId, 'assistant', trimmed);
|
|
1117
1643
|
}
|
|
1118
1644
|
} else {
|
|
1119
1645
|
getState(url).pendingResponse = answer;
|
|
1120
|
-
|
|
1646
|
+
msgLog('out', chatId, '(switch)', (answer || '').trim());
|
|
1647
|
+
await sendLongMessage(token, chatState, `✅ Server \`${url}\` finished processing. Switch back to see the response.`, replyTo, threadId);
|
|
1648
|
+
if (answer && answer.trim()) bufferMessage(chatId, threadId, 'assistant', answer.trim());
|
|
1121
1649
|
}
|
|
1122
1650
|
} catch (err) {
|
|
1123
|
-
// Clean up SSE listener if it's still active
|
|
1124
1651
|
if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
|
|
1125
1652
|
if (isCancelled()) {
|
|
1126
1653
|
log.debug(` → cancelled, ignoring error from ${url}: ${err.message}`);
|
|
@@ -1128,8 +1655,28 @@ async function main() {
|
|
|
1128
1655
|
return;
|
|
1129
1656
|
}
|
|
1130
1657
|
log.error(`Error processing message (${url}): ${err.message}`);
|
|
1131
|
-
|
|
1132
|
-
|
|
1658
|
+
|
|
1659
|
+
// Detect connection errors (server dead) and requeue the message
|
|
1660
|
+
const isConnError = err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' ||
|
|
1661
|
+
err.code === 'ETIMEDOUT' || err.message.includes('socket hang up') ||
|
|
1662
|
+
err.message.includes('connect ECONNREFUSED');
|
|
1663
|
+
if (isConnError) {
|
|
1664
|
+
st.serverDead = true;
|
|
1665
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
1666
|
+
st.messageQueue.unshift({ text, replyTo });
|
|
1667
|
+
log.info(`Server ${url} appears dead. Re-queued message.`);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
const chatServer = getChatState(chatId, threadId).serverBase;
|
|
1671
|
+
if (url === chatServer) {
|
|
1672
|
+
const msg = isConnError
|
|
1673
|
+
? `⚠️ Server \`${url}\` is unreachable. The message has been requeued and will be sent when the server is available again.\n\n` +
|
|
1674
|
+
`**Options:**\n` +
|
|
1675
|
+
`• Start the server: \`ocmux serve\`\n` +
|
|
1676
|
+
`• Switch to another server: \`/servers switch <name>\`\n` +
|
|
1677
|
+
`• Flush the queue: \`/flush\``
|
|
1678
|
+
: `Error: ${err.message}`;
|
|
1679
|
+
try { await sendLongMessage(token, chatState, msg, replyTo, threadId); } catch (_) {}
|
|
1133
1680
|
}
|
|
1134
1681
|
} finally {
|
|
1135
1682
|
st.pendingPermission = null;
|
|
@@ -1137,83 +1684,244 @@ async function main() {
|
|
|
1137
1684
|
if (st._sessionReq === sessionCancelRef) st._sessionReq = null;
|
|
1138
1685
|
if (!isCancelled()) {
|
|
1139
1686
|
getState(url).busy = false;
|
|
1687
|
+
// Don't drain the queue if the server is dead — messages stay queued
|
|
1688
|
+
if (st.serverDead) return;
|
|
1689
|
+
const q = st.messageQueue || [];
|
|
1690
|
+
st.messageQueue = [];
|
|
1691
|
+
if (q.length > 0) {
|
|
1692
|
+
const next = q[0];
|
|
1693
|
+
st.messageQueue = q.slice(1);
|
|
1694
|
+
const nextText = next.text;
|
|
1695
|
+
const nextReplyTo = next.replyTo;
|
|
1696
|
+
getState(url).busy = true;
|
|
1697
|
+
const nextTaskId = ++st.taskId;
|
|
1698
|
+
log.debug(` → processing queued message (task ${nextTaskId})`);
|
|
1699
|
+
processMessageAsync(chatId, nextText, url, !!st.activeSessionId, st.activeSessionId, nextTaskId, nextReplyTo, threadId);
|
|
1700
|
+
}
|
|
1140
1701
|
}
|
|
1141
1702
|
}
|
|
1142
1703
|
}
|
|
1143
1704
|
|
|
1144
|
-
const cmdCancel = async (token, chatId) => {
|
|
1145
|
-
const
|
|
1705
|
+
const cmdCancel = async (token, chatId, cmdArgs, chatState) => {
|
|
1706
|
+
const cs = chatState || getChatState(chatId);
|
|
1707
|
+
if (!cs.serverBase) {
|
|
1708
|
+
await sendLongMessage(token, cs, '🔌 Not connected to any server.');
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const st = getState(cs.serverBase);
|
|
1146
1712
|
if (!st.busy) {
|
|
1147
|
-
await sendLongMessage(token,
|
|
1713
|
+
await sendLongMessage(token, cs, 'Nothing to cancel.');
|
|
1148
1714
|
return;
|
|
1149
1715
|
}
|
|
1150
1716
|
st.taskId++;
|
|
1151
1717
|
st.busy = false;
|
|
1152
|
-
// Abort the SSE stream so the server stops processing
|
|
1153
1718
|
if (st._cancelRef && st._cancelRef.current) {
|
|
1154
1719
|
st._cancelRef.current.destroy();
|
|
1155
1720
|
st._cancelRef.current = null;
|
|
1156
1721
|
}
|
|
1157
|
-
// Abort the synchronous session request if in progress
|
|
1158
1722
|
if (st._sessionReq && st._sessionReq.current) {
|
|
1159
1723
|
st._sessionReq.current.destroy();
|
|
1160
1724
|
st._sessionReq.current = null;
|
|
1161
1725
|
}
|
|
1162
|
-
await sendLongMessage(token,
|
|
1163
|
-
log.debug(`
|
|
1726
|
+
await sendLongMessage(token, cs, '✅ Cancelled.');
|
|
1727
|
+
log.debug(`[chat ${chatId}] /cancel on ${cs.serverBase}`);
|
|
1164
1728
|
}
|
|
1165
1729
|
|
|
1166
|
-
const cmdStatus = async (token, chatId) => {
|
|
1167
|
-
const
|
|
1730
|
+
const cmdStatus = async (token, chatId, cmdArgs, chatState) => {
|
|
1731
|
+
const cs = chatState || getChatState(chatId);
|
|
1732
|
+
if (!cs.serverBase) {
|
|
1733
|
+
await sendLongMessage(token, cs, '🔌 Not connected to any server.');
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
const st = getState(cs.serverBase);
|
|
1737
|
+
const deadInfo = st.serverDead ? ' ❌ unreachable' : '';
|
|
1168
1738
|
const busyInfo = st.busy ? '⏳ busy' : '✅ idle';
|
|
1169
|
-
const dir = serverDir(serverBase);
|
|
1739
|
+
const dir = serverDir(cs.serverBase);
|
|
1170
1740
|
const lines = ['**Server:**'];
|
|
1171
1741
|
if (dir) lines.push(' • **Path:** `' + dir + '`');
|
|
1172
|
-
lines.push(' • **URL:** `' + serverBase + '`');
|
|
1173
|
-
lines.push(' • **Status:** ' + busyInfo);
|
|
1742
|
+
lines.push(' • **URL:** `' + cs.serverBase + '`');
|
|
1743
|
+
lines.push(' • **Status:** ' + busyInfo + deadInfo);
|
|
1174
1744
|
lines.push('**Session:** ' + sessionLabel(st));
|
|
1175
1745
|
lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
|
|
1176
|
-
await sendLongMessage(token,
|
|
1746
|
+
await sendLongMessage(token, cs, lines.join('\n'));
|
|
1177
1747
|
};
|
|
1178
1748
|
|
|
1179
|
-
const cmdShutdown = async (token, chatId, args) => {
|
|
1180
|
-
const
|
|
1749
|
+
const cmdShutdown = async (token, chatId, args, chatState) => {
|
|
1750
|
+
const cs = chatState || getChatState(chatId);
|
|
1751
|
+
const st = getState(cs.serverBase);
|
|
1752
|
+
|
|
1753
|
+
// Handle /shutdown clear — wipe saved connections before exiting
|
|
1754
|
+
if (args && args.length > 0 && args[0].toLowerCase() === 'clear') {
|
|
1755
|
+
clearConnections();
|
|
1756
|
+
await sendLongMessage(token, cs, '🧹 Connection state cleared.');
|
|
1757
|
+
log.debug('/shutdown clear received, exiting...');
|
|
1758
|
+
try { await getUpdates(token, offset, 1); } catch (_) {}
|
|
1759
|
+
process.exit(0);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1181
1762
|
const isForce = args && args.length > 0 && args[0].toLowerCase() === 'force';
|
|
1182
1763
|
if (st.busy && !isForce) {
|
|
1183
|
-
await sendLongMessage(token,
|
|
1764
|
+
await sendLongMessage(token, cs,
|
|
1184
1765
|
'⏳ Server is busy processing a message.\n' +
|
|
1185
1766
|
'Use `/shutdown force` to shut down anyway (in-flight response will be lost).');
|
|
1186
1767
|
return;
|
|
1187
1768
|
}
|
|
1188
1769
|
const status = st.busy ? '⏳ busy (forced)' : '✅ idle';
|
|
1189
|
-
await sendLongMessage(token,
|
|
1190
|
-
try {
|
|
1191
|
-
fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId));
|
|
1192
|
-
} catch (_) {}
|
|
1770
|
+
await sendLongMessage(token, cs, `👋 Shutting down (state: ${status})...`);
|
|
1193
1771
|
try { await getUpdates(token, offset, 1); } catch (_) {}
|
|
1194
1772
|
log.debug('/shutdown received, exiting...');
|
|
1195
1773
|
process.exit(0);
|
|
1196
1774
|
};
|
|
1197
1775
|
|
|
1198
|
-
const
|
|
1776
|
+
const cmdNote = async (token, chatId, cmdArgs, chatState) => {
|
|
1777
|
+
const text = cmdArgs.join(' ');
|
|
1778
|
+
if (!text) {
|
|
1779
|
+
await sendLongMessage(token, chatState, 'Usage: `/note <message>` — sends a note for the agent to acknowledge.');
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
const url = chatState.serverBase;
|
|
1783
|
+
if (!url) {
|
|
1784
|
+
await sendLongMessage(token, chatState, '🔌 Not connected to any server.');
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
const st = getState(url);
|
|
1788
|
+
if (st.serverDead) {
|
|
1789
|
+
const alive = await isServerAlive(url);
|
|
1790
|
+
if (!alive) {
|
|
1791
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
1792
|
+
st.messageQueue.push({ text: '📝 **Note:** ' + text, replyTo: null });
|
|
1793
|
+
await sendLongMessage(token, chatState, `⚠️ Server unreachable. Note queued (#${st.messageQueue.length}).`);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
st.serverDead = false;
|
|
1797
|
+
}
|
|
1798
|
+
if (st.busy) {
|
|
1799
|
+
await sendLongMessage(token, chatState, '⏳ Server is busy. Use `/cancel` to cancel or note again later.');
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
const intro = 'The following information is for your awareness. Reply with only "Ack" and do not take any action:';
|
|
1803
|
+
st.busy = true;
|
|
1804
|
+
const myTaskId = ++st.taskId;
|
|
1805
|
+
log.debug(` → /note: starting background processing (task ${myTaskId})`);
|
|
1806
|
+
processMessageAsync(chatId, intro + '\n\n' + text, url, !!st.activeSessionId, st.activeSessionId, myTaskId, null, chatState.threadId);
|
|
1807
|
+
};
|
|
1808
|
+
|
|
1809
|
+
const cmds = { ...COMMANDS, status: cmdStatus, cancel: cmdCancel, note: cmdNote };
|
|
1199
1810
|
if (DEV_MODE) cmds.shutdown = cmdShutdown;
|
|
1200
1811
|
|
|
1201
1812
|
let offset = 0;
|
|
1202
1813
|
|
|
1203
|
-
//
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1814
|
+
// Restore chat↔server connections from persistent state
|
|
1815
|
+
let savedData;
|
|
1816
|
+
let connByOldId = {};
|
|
1817
|
+
try { savedData = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8')); } catch {}
|
|
1818
|
+
if (savedData && Array.isArray(savedData.connections)) {
|
|
1819
|
+
// Phase 1: restore chat states and populate serverOwners
|
|
1820
|
+
for (const { chatId, threadId, dir } of savedData.connections) {
|
|
1821
|
+
connByOldId[String(chatId)] = { chatId, threadId, dir };
|
|
1822
|
+
}
|
|
1823
|
+
for (const { chatId, threadId, dir } of savedData.connections) {
|
|
1824
|
+
if (!dir) continue;
|
|
1825
|
+
const cs = getChatState(chatId, threadId);
|
|
1826
|
+
const sf = statefileFor(dir);
|
|
1827
|
+
const state = readState(sf);
|
|
1828
|
+
if (state && state.url) {
|
|
1829
|
+
cs.serverBase = state.url;
|
|
1830
|
+
// Prefer non-thread (private chat) as owner over topic threads
|
|
1831
|
+
const existing = serverOwners[state.url];
|
|
1832
|
+
if (!existing || (!threadId && existing.threadId)) {
|
|
1833
|
+
serverOwners[state.url] = { chatId, threadId };
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
// Phase 2: prune secondary connections per URL (only owner survives)
|
|
1838
|
+
for (const key of Object.keys(chatStates)) {
|
|
1839
|
+
const state = chatStates[key];
|
|
1840
|
+
if (!state.serverBase) continue;
|
|
1841
|
+
const owner = serverOwners[state.serverBase];
|
|
1842
|
+
if (!owner) continue;
|
|
1843
|
+
if (String(owner.chatId) !== String(state.chatId) || owner.threadId !== state.threadId) {
|
|
1844
|
+
const url = state.serverBase;
|
|
1845
|
+
state.serverBase = null;
|
|
1846
|
+
removeConnection(state.chatId, state.threadId);
|
|
1847
|
+
log.info(`[chat ${state.chatId}] Pruned secondary connection to server: ${url}`);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
// Phase 3: send startup notification only to the surviving owner per URL
|
|
1851
|
+
const notifiedUrls = new Set();
|
|
1852
|
+
for (const { chatId, threadId, dir } of savedData.connections) {
|
|
1853
|
+
if (!dir) continue;
|
|
1854
|
+
const cs = getChatState(chatId, threadId);
|
|
1855
|
+
if (!cs.serverBase) continue;
|
|
1856
|
+
const sf = statefileFor(dir);
|
|
1857
|
+
const state = readState(sf);
|
|
1858
|
+
if (state && state.url) {
|
|
1859
|
+
if (notifiedUrls.has(state.url)) {
|
|
1860
|
+
log.info(`[chat ${chatId}] Skipping startup notification for ${dir} (already notified for this URL)`);
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
notifiedUrls.add(state.url);
|
|
1864
|
+
const alive = await isServerAlive(state.url);
|
|
1865
|
+
if (alive) {
|
|
1866
|
+
const lines = [
|
|
1867
|
+
'✅ Bot started.',
|
|
1868
|
+
'**Server:**',
|
|
1869
|
+
' • **Path:** `' + dir + '`',
|
|
1870
|
+
' • **URL:** `' + state.url + '`',
|
|
1871
|
+
' • **Status:** ✅ restored',
|
|
1872
|
+
];
|
|
1873
|
+
try { await sendLongMessage(token, cs, lines.join('\n')); } catch (_) {}
|
|
1874
|
+
log.info(`Restored connection: chat ${chatId} → ${dir}`);
|
|
1875
|
+
} else {
|
|
1876
|
+
log.info(`Saved connection for chat ${chatId} → ${dir}, but server ${state.url} is unreachable`);
|
|
1877
|
+
try {
|
|
1878
|
+
await sendLongMessage(token, cs,
|
|
1879
|
+
'🔄 Bot started. Previously connected to `' + dir + '`.\n' +
|
|
1880
|
+
'⚠️ The server at `' + state.url + '` is not reachable.\n' +
|
|
1881
|
+
'Options: start the server with `ocmux serve`, or switch with `/servers switch <name>`.');
|
|
1882
|
+
} catch (_) {}
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// ── Group migration handler ──────────────────────────────────────
|
|
1889
|
+
// When a regular group is promoted to a supergroup (or topics enabled),
|
|
1890
|
+
// Telegram changes the chat ID. This function updates the stored
|
|
1891
|
+
// connections and allowlist so the bot continues to work transparently.
|
|
1892
|
+
async function handleGroupMigration(oldId, newId) {
|
|
1893
|
+
if (oldId === newId) return;
|
|
1894
|
+
log.info(`Group migration detected: chat ${oldId} → ${newId}`);
|
|
1895
|
+
|
|
1896
|
+
// Update allowlist if the old ID is in it
|
|
1897
|
+
if (allowedChatIds) {
|
|
1898
|
+
const idx = allowedChatIds.indexOf(oldId);
|
|
1899
|
+
if (idx !== -1) {
|
|
1900
|
+
allowedChatIds[idx] = newId;
|
|
1901
|
+
log.info(`Updated allowlist: ${oldId} → ${newId}`);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// Update connections.json: replace all references to oldId with newId
|
|
1906
|
+
try {
|
|
1907
|
+
const data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8'));
|
|
1908
|
+
if (Array.isArray(data.connections)) {
|
|
1909
|
+
let changed = false;
|
|
1910
|
+
for (const conn of data.connections) {
|
|
1911
|
+
if (String(conn.chatId) === oldId) {
|
|
1912
|
+
conn.chatId = newId;
|
|
1913
|
+
changed = true;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
if (changed) {
|
|
1917
|
+
fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2));
|
|
1918
|
+
log.info(`Updated connection: ${oldId} → ${newId}`);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
} catch (_) {}
|
|
1922
|
+
|
|
1923
|
+
// Update in-memory chat state key (the existing state remains, just
|
|
1924
|
+
// the key changes — new messages from newId will hit a new state)
|
|
1217
1925
|
}
|
|
1218
1926
|
|
|
1219
1927
|
// Poll for updates and forward each text message to OpenCode, sending the answer back.
|
|
@@ -1237,12 +1945,22 @@ async function main() {
|
|
|
1237
1945
|
offset = update.update_id + 1;
|
|
1238
1946
|
|
|
1239
1947
|
const msg = update.message;
|
|
1240
|
-
if (!msg
|
|
1241
|
-
|
|
1948
|
+
if (!msg) continue;
|
|
1949
|
+
|
|
1950
|
+
// Handle group → supergroup migration (e.g., when topics are enabled)
|
|
1951
|
+
if (msg.migrate_to_chat_id) {
|
|
1952
|
+
await handleGroupMigration(String(msg.chat.id), String(msg.migrate_to_chat_id));
|
|
1953
|
+
continue;
|
|
1954
|
+
}
|
|
1955
|
+
if (msg.migrate_from_chat_id) {
|
|
1956
|
+
await handleGroupMigration(String(msg.migrate_from_chat_id), String(msg.chat.id));
|
|
1242
1957
|
continue;
|
|
1243
1958
|
}
|
|
1244
1959
|
|
|
1960
|
+
if (typeof msg.text !== 'string') continue;
|
|
1961
|
+
|
|
1245
1962
|
const chatId = String(msg.chat.id);
|
|
1963
|
+
const threadId = msg.message_thread_id || null;
|
|
1246
1964
|
const replyTo = msg.message_id;
|
|
1247
1965
|
const text = msg.text;
|
|
1248
1966
|
const from = msg.from?.username
|
|
@@ -1251,21 +1969,22 @@ async function main() {
|
|
|
1251
1969
|
|
|
1252
1970
|
// Enforce chat allowlist if configured
|
|
1253
1971
|
if (allowedChatIds && !allowedChatIds.includes(chatId)) {
|
|
1254
|
-
|
|
1972
|
+
const chatLabel = msg.chat?.title ? ` (${msg.chat.title})` : '';
|
|
1973
|
+
log.debug(`[chat ${chatId}]${chatLabel} ${from}: blocked (not in allowlist)`);
|
|
1974
|
+
// Log a hint if the old chat ID is known in connections.json
|
|
1975
|
+
const blockedDebug = `[chat ${chatId}]${chatLabel} ${from}: blocked (not in allowlist)`;
|
|
1976
|
+
const known = Object.keys(connByOldId);
|
|
1977
|
+
if (known.length > 0) {
|
|
1978
|
+
log.debug(` Known connections: ${known.join(', ')}. Chat may have been migrated (enable topics → supergroup changes chat ID).`);
|
|
1979
|
+
}
|
|
1255
1980
|
continue;
|
|
1256
1981
|
}
|
|
1257
1982
|
|
|
1258
|
-
//
|
|
1259
|
-
|
|
1260
|
-
lockedChatId = chatId;
|
|
1261
|
-
log.debug(`[chat ${chatId}] ${from}: locked to this chat`);
|
|
1262
|
-
} else if (lockedChatId !== chatId) {
|
|
1263
|
-
log.debug(`[chat ${chatId}] ${from}: blocked (locked to ${lockedChatId})`);
|
|
1264
|
-
continue;
|
|
1265
|
-
}
|
|
1983
|
+
// Per-chat state: get or create for this chat (thread-aware)
|
|
1984
|
+
const cs = getChatState(chatId, threadId);
|
|
1266
1985
|
|
|
1267
1986
|
log.debug(`[chat ${chatId}] ${from}: ${text}`);
|
|
1268
|
-
|
|
1987
|
+
msgLog('in', chatId, from, text);
|
|
1269
1988
|
|
|
1270
1989
|
// Build reply context if this message is a Telegram reply
|
|
1271
1990
|
const replyContext = quoteReply(msg);
|
|
@@ -1277,29 +1996,75 @@ async function main() {
|
|
|
1277
1996
|
const cmdArgs = parts.slice(1);
|
|
1278
1997
|
const handler = cmds[cmd];
|
|
1279
1998
|
if (handler) {
|
|
1999
|
+
// Disconnected guard: only /help, /servers, /force-switch, /start, /comment, and /shutdown work without a server
|
|
2000
|
+
if (!cs.serverBase && cmd !== 'help' && cmd !== 'servers' && cmd !== 'force-switch' && cmd !== 'start' && cmd !== 'comment' && cmd !== 'shutdown') {
|
|
2001
|
+
await sendLongMessage(token, cs,
|
|
2002
|
+
'🔌 Not connected to any server.\n\n' +
|
|
2003
|
+
'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
1280
2006
|
try {
|
|
1281
|
-
await handler(token, chatId, cmdArgs,
|
|
2007
|
+
await handler(token, chatId, cmdArgs, cs, setServerForChat, setActiveSessionForChat);
|
|
1282
2008
|
} catch (err) {
|
|
1283
2009
|
log.error(`Error handling /${cmd}: ${err.message}`);
|
|
1284
|
-
try { await sendLongMessage(token,
|
|
2010
|
+
try { await sendLongMessage(token, cs, `Error: ${err.message}`); } catch (_) {}
|
|
1285
2011
|
}
|
|
1286
2012
|
} else {
|
|
1287
|
-
await sendLongMessage(token,
|
|
1288
|
-
`Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models`);
|
|
2013
|
+
await sendLongMessage(token, cs,
|
|
2014
|
+
`Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /think, /record, /queue, /flush, /note, /comment, /disconnect`);
|
|
1289
2015
|
}
|
|
1290
2016
|
continue;
|
|
1291
2017
|
}
|
|
1292
2018
|
|
|
2019
|
+
// Disconnected guard for non-command messages
|
|
2020
|
+
if (!cs.serverBase) {
|
|
2021
|
+
await sendLongMessage(token, cs,
|
|
2022
|
+
'🔌 Not connected to any server.\n\n' +
|
|
2023
|
+
'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
|
|
2024
|
+
continue;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// Buffer user messages for /record
|
|
2028
|
+
bufferMessage(chatId, threadId, 'user', replyContext + text);
|
|
2029
|
+
|
|
1293
2030
|
// Non-command message ─ process in background
|
|
1294
|
-
const st = getState(serverBase);
|
|
2031
|
+
const st = getState(cs.serverBase);
|
|
2032
|
+
|
|
2033
|
+
// Server health check: detect dead servers and auto-reconnect
|
|
2034
|
+
if (st.serverDead) {
|
|
2035
|
+
const alive = await isServerAlive(cs.serverBase);
|
|
2036
|
+
if (alive) {
|
|
2037
|
+
st.serverDead = false;
|
|
2038
|
+
log.info(`Server ${cs.serverBase} is back online`);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
if (st.serverDead) {
|
|
2042
|
+
// Server is dead ─ auto-queue and notify
|
|
2043
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
2044
|
+
st.messageQueue.push({ text: replyContext + text, replyTo });
|
|
2045
|
+
const qlen = st.messageQueue.length;
|
|
2046
|
+
const servers = listServers();
|
|
2047
|
+
const suggestions = [];
|
|
2048
|
+
suggestions.push(`• Start the server: \`ocmux serve\``);
|
|
2049
|
+
if (servers.length > 0) {
|
|
2050
|
+
suggestions.push(`• Switch to another server: \`/servers switch <name>\``);
|
|
2051
|
+
}
|
|
2052
|
+
suggestions.push(`• Flush the queue: \`/flush\``);
|
|
2053
|
+
await sendLongMessage(token, cs,
|
|
2054
|
+
`⚠️ Server \`${cs.serverBase}\` is not reachable.\n\n` +
|
|
2055
|
+
`Your message has been queued (#${qlen}). It will be sent when the server becomes available.\n\n` +
|
|
2056
|
+
`**Options:**\n${suggestions.join('\n')}`);
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1295
2060
|
if (st.busy) {
|
|
1296
|
-
await sendLongMessage(token,
|
|
2061
|
+
await sendLongMessage(token, cs, '⏳ Server is busy. Use `/cancel` to cancel or `/queue <message>` to queue your message.');
|
|
1297
2062
|
continue;
|
|
1298
2063
|
}
|
|
1299
2064
|
st.busy = true;
|
|
1300
2065
|
const myTaskId = ++st.taskId;
|
|
1301
2066
|
log.debug(` → started background processing (task ${myTaskId})`);
|
|
1302
|
-
processMessageAsync(chatId, replyContext + text, serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId, replyTo);
|
|
2067
|
+
processMessageAsync(chatId, replyContext + text, cs.serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId, replyTo, threadId);
|
|
1303
2068
|
}
|
|
1304
2069
|
}
|
|
1305
2070
|
}
|