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/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 { listServers, activateServer } = require('../lib/ocmux.js');
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
- sendLongMessage(token, chatId,
43
- `📨 Delivering ${msgs.length} queued agentp message(s):`)
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, chatId, msg.trim()).catch(() => {});
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, chatId, text, replyTo) {
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, chatId,
336
+ await sendLongMessage(token, chatState,
242
337
  '`/servers` ─ list all opencode servers (ocmux sessions)\n' +
243
- '`/servers switch <name>` ─ switch target server\n\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, chatId,
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, chatId,
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, chatId,
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, chatId,
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, chatId,
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, chatId,
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, chatId,
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
- await sendLongMessage(token, chatId,
311
- 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, `/help allow`, `/help think`, or just `/help`.');
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, chatId,
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 cmdServers(token, chatId, args, setServer, setActiveSession, serverBase) {
336
- if (args.length > 0 && args[0].toLowerCase() === 'switch') {
337
- if (args.length < 2) {
338
- await sendLongMessage(token, chatId,
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 = args.slice(1).join(' ');
526
+ const name = switchArgs.join(' ');
344
527
  const servers = listServers();
345
528
  if (servers.length === 0) {
346
- await sendLongMessage(token, chatId, 'No servers running.');
529
+ await sendLongMessage(token, chatState, 'No servers running.');
347
530
  return;
348
531
  }
349
- let matches = servers.filter(s => s.dir === name);
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
- matches = servers.filter(s => s.dir.includes(name) || path.basename(s.dir).includes(name));
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
- if (matches.length === 0) {
357
- await sendLongMessage(token, chatId, `No server found matching "${name}".`);
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
- if (matches.length > 1) {
361
- const list = matches.map(s => ` ${s.url} → ${s.dir}`).join('\n');
362
- await sendLongMessage(token, chatId,
363
- `Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
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
- const target = matches[0];
367
- const pending = await setServer(target.url);
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, chatId, msg);
372
- await sendLongMessage(token, chatId, pending.trim());
587
+ await sendLongMessage(token, chatState, msg);
588
+ await sendLongMessage(token, chatState, pending.trim());
373
589
  } else {
374
- await sendLongMessage(token, chatId, msg);
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, chatId, 'No servers running.');
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
- await sendLongMessage(token, chatId, 'Active servers:\n' + lines.join('\n'));
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, setServer, setActiveSession, server) {
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
- setActiveSession(session.id);
658
+ setActiveSessionForChat(chatState, session.id);
441
659
  selectSession(server, session.id);
442
- await sendLongMessage(token, chatId,
660
+ await sendLongMessage(token, chatState,
443
661
  `Created session: \`${name}\`\nIt is now the active session.`);
444
662
  } catch (err) {
445
- await sendLongMessage(token, chatId, `Error creating session: ${err.message}`);
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, chatId, 'Usage: `/sessions rename <name>`');
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, chatId, 'No active session. Use `/sessions switch` or `/sessions new` first.');
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, chatId,
682
+ await sendLongMessage(token, chatState,
465
683
  `Renamed session to: \`${name}\``);
466
684
  } catch (err) {
467
- await sendLongMessage(token, chatId, `Error renaming session: ${err.message}`);
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, chatId,
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
- setActiveSession(target.id);
710
+ setActiveSessionForChat(chatId, target.id);
493
711
  selectSession(server, target.id);
494
- await sendLongMessage(token, chatId,
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, chatId,
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, chatId,
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
- setActiveSession(target.id);
732
+ setActiveSessionForChat(chatId, target.id);
515
733
  selectSession(server, target.id);
516
- await sendLongMessage(token, chatId,
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, chatId, `Error switching session: ${err.message}`);
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, chatId,
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, chatId, 'No active sessions.');
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, chatId, 'No active sessions.');
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, chatId, 'Sessions:\n' + lines.join('\n'));
812
+ await sendLongMessage(token, chatState, 'Sessions:\n' + lines.join('\n'));
595
813
  } catch (err) {
596
- await sendLongMessage(token, chatId, `Error listing sessions: ${err.message}`);
814
+ await sendLongMessage(token, chatState, `Error listing sessions: ${err.message}`);
597
815
  }
598
816
  }
599
817
 
600
- async function cmdAgents(token, chatId, args, setServer, setActiveSession, server) {
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, chatId,
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, chatId, 'No agents available.');
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, chatId, `No agent matching "${name}".`);
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, chatId,
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, chatId,
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, chatId, `Error switching agent: ${err.message}`);
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, chatId, 'No agents available.');
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, chatId, 'No primary agents available.');
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, chatId, 'Primary agents:\n' + lines.join('\n'));
882
+ await sendLongMessage(token, chatState, 'Primary agents:\n' + lines.join('\n'));
664
883
  } catch (err) {
665
- await sendLongMessage(token, chatId, `Error listing agents: ${err.message}`);
884
+ await sendLongMessage(token, chatState, `Error listing agents: ${err.message}`);
666
885
  }
667
886
  }
668
887
 
669
- async function cmdModels(token, chatId, args, setServer, setActiveSession, server) {
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, chatId, 'No connected providers.');
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, chatId, 'Connected providers:\n' + lines.join('\n'));
921
+ await sendLongMessage(token, chatState, 'Connected providers:\n' + lines.join('\n'));
702
922
  } catch (err) {
703
- await sendLongMessage(token, chatId, `Error listing models: ${err.message}`);
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, chatIdResolver, serverBaseResolver) {
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
- const chatId = chatIdResolver();
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, chatId, text.trim()).catch(() => {});
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, chatId,
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, setServer, setActiveSession, server) {
792
- const st = getState(server);
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, chatId, '/allow' + PERM_RESPONSE_HELP);
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, chatId, '✅ Permission approved (once).');
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, chatId, `Error: ${err.message}`);
1028
+ await sendLongMessage(token, chatState, `Error: ${err.message}`);
804
1029
  }
805
1030
  }
806
1031
 
807
- async function cmdReject(token, chatId, args, setServer, setActiveSession, server) {
808
- const st = getState(server);
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, chatId, '/reject' + PERM_RESPONSE_HELP);
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, chatId, '⛔ Permission denied.');
1041
+ await sendLongMessage(token, chatState, '⛔ Permission denied.');
817
1042
  log.debug(` → /reject: ${p.type} ${p.pattern || ''}`);
818
1043
  } catch (err) {
819
- await sendLongMessage(token, chatId, `Error: ${err.message}`);
1044
+ await sendLongMessage(token, chatState, `Error: ${err.message}`);
820
1045
  }
821
1046
  }
822
1047
 
823
- async function cmdAlways(token, chatId, args, setServer, setActiveSession, server) {
824
- const st = getState(server);
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, chatId, '/always' + PERM_RESPONSE_HELP);
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, chatId, '✅ Permission approved (always).');
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, chatId, `Error: ${err.message}`);
1060
+ await sendLongMessage(token, chatState, `Error: ${err.message}`);
836
1061
  }
837
1062
  }
838
1063
 
839
- async function cmdThink(token, chatId, args, setServer, setActiveSession, server) {
840
- const st = getState(server);
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, chatId, '💭 Thinking messages **on** (forwarded to chat).');
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, chatId, '💭 Thinking messages **off**.');
1072
+ await sendLongMessage(token, chatState, '💭 Thinking messages **off**.');
848
1073
  } else if (sub === 'switch') {
849
1074
  st.thinkingEnabled = !st.thinkingEnabled;
850
- await sendLongMessage(token, chatId,
1075
+ await sendLongMessage(token, chatState,
851
1076
  `💭 Thinking messages ${st.thinkingEnabled ? '**on**' : '**off**'}.`);
852
1077
  } else {
853
- await sendLongMessage(token, chatId, 'Usage: `/think [on|off|switch]` (default: switch).');
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
- // Start agentp gateway (tiny HTTP server for agentp --tg)
992
- const chatIdResolver = () => lockedChatId;
993
- const serverBaseResolver = () => serverBase;
994
- startAgentpGateway(token, chatIdResolver, serverBaseResolver);
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
- const setServer = async (url) => {
997
- serverBase = url;
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
- log.info(`Switched to server: ${url}`);
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, lockedChatId);
1516
+ flushAgentpQueue(url, token, cs.chatId, cs.threadId);
1003
1517
 
1004
- const [discovered, agents] = await Promise.all([
1518
+ const [srvDiscovered, srvAgents] = await Promise.all([
1005
1519
  discoverActiveSession(url),
1006
1520
  listAgents(url).catch(() => []),
1007
1521
  ]);
1008
- if (discovered) {
1009
- getState(url).activeSessionId = discovered.id;
1010
- getState(url).activeSessionName = discovered.name;
1011
- log.info(` → session: ${discovered.name || discovered.id}`);
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 defaultAgent = agents.find(a => a.mode === 'primary') || agents[0];
1014
- if (defaultAgent) {
1015
- getState(url).activeAgentName = defaultAgent.name;
1016
- log.info(` → agent: ${defaultAgent.name}`);
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 setActiveSession = (id) => {
1027
- getState(serverBase).activeSessionId = id;
1028
- log.info(`Switched to session: ${id}`);
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1059
- sendLongMessage(token, chatId, html, replyTo)
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, chatId, msg, replyTo)
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1099
- sendLongMessage(token, chatId, html, replyTo).catch(() => {});
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
- if (url === serverBase) {
1636
+ const chatServer = getChatState(chatId, threadId).serverBase;
1637
+ if (url === chatServer) {
1115
1638
  if (answer && answer.trim()) {
1116
- await sendLongMessage(token, chatId, answer.trim(), replyTo);
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
- await sendLongMessage(token, chatId, `✅ Server \`${url}\` finished processing. Switch back to see the response.`, replyTo);
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
- if (url === serverBase) {
1132
- try { await sendLongMessage(token, chatId, `Error: ${err.message}`, replyTo); } catch (_) {}
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 st = getState(serverBase);
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, chatId, 'Nothing to cancel.');
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, chatId, '✅ Cancelled.');
1163
- log.debug(` → /cancel on ${serverBase}`);
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 st = getState(serverBase);
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, chatId, lines.join('\n'));
1746
+ await sendLongMessage(token, cs, lines.join('\n'));
1177
1747
  };
1178
1748
 
1179
- const cmdShutdown = async (token, chatId, args) => {
1180
- const st = getState(serverBase);
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, chatId,
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, chatId, `👋 Shutting down (state: ${status})...`);
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 cmds = { ...COMMANDS, status: cmdStatus, cancel: cmdCancel };
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
- // Startup greeting to last-known chat
1204
- const savedChatId = (() => { try { return fs.readFileSync(STARTUP_CHAT_FILE, 'utf8').trim(); } catch { return null; } })();
1205
- if (savedChatId) {
1206
- const st = getState(serverBase);
1207
- const dir = serverDir(serverBase);
1208
- const lines = ['✅ Bot started.', '**Server:**'];
1209
- if (dir) lines.push(' • **Path:** `' + dir + '`');
1210
- lines.push(' • **URL:** `' + serverBase + '`');
1211
- lines.push(' • **Status:** ✅ idle');
1212
- lines.push('**Session:** ' + sessionLabel(st));
1213
- lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
1214
- try { await sendLongMessage(token, savedChatId, lines.join('\n')); } catch (_) {}
1215
- lockedChatId = savedChatId;
1216
- try { fs.unlinkSync(STARTUP_CHAT_FILE); } catch (_) {}
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 || typeof msg.text !== 'string') {
1241
- // Ignore non-text messages (photos, stickers, etc.)
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
- log.debug(`[chat ${chatId}] ${from}: blocked (not in allowlist)`);
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
- // Lock to the first chat that connects (single-user guard)
1259
- if (lockedChatId === null) {
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
- try { fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId)); } catch (_) {}
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, setServer, setActiveSession, serverBase);
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, chatId, `Error: ${err.message}`); } catch (_) {}
2010
+ try { await sendLongMessage(token, cs, `Error: ${err.message}`); } catch (_) {}
1285
2011
  }
1286
2012
  } else {
1287
- await sendLongMessage(token, chatId,
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, chatId, '⏳ Server is busy processing a previous message. Please wait for it to finish, or use /cancel to cancel it.');
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
  }