agentp 0.10.0 → 0.11.1

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,46 +56,86 @@ 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).
52
80
  // ---------------------------------------------------------------------------
53
- // /record — ring buffer for Telegram conversation context
81
+ // Per-chat state
54
82
  // ---------------------------------------------------------------------------
55
83
 
56
84
  const MAX_RECORDED_MSGS = 100;
57
85
  const MAX_RECORDED_BYTES = 100 * 1024;
58
86
 
59
- const recorder = { active: false, messages: [], bytes: 0 };
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;
60
111
 
61
- function bufferMessage(role, text) {
62
- if (!recorder.active) return;
63
- recorder.messages.push({ role, text });
64
- recorder.bytes += Buffer.byteLength(text, 'utf8');
65
- while (recorder.messages.length > MAX_RECORDED_MSGS || recorder.bytes > MAX_RECORDED_BYTES) {
66
- const oldest = recorder.messages.shift();
67
- recorder.bytes -= Buffer.byteLength(oldest.text, 'utf8');
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');
68
131
  }
69
132
  }
70
133
 
71
- function flushRecorded() {
72
- if (!recorder.active) return [];
73
- const msgs = recorder.messages.splice(0);
74
- recorder.bytes = 0;
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;
75
139
  return msgs;
76
140
  }
77
141
 
@@ -117,10 +181,11 @@ function getUpdates(token, offset, timeout) {
117
181
  });
118
182
  }
119
183
 
120
- function sendTelegramMessage(token, chatId, text, parseMode, replyTo) {
184
+ function sendTelegramMessage(token, chatId, text, parseMode, replyTo, threadId) {
121
185
  const params = { chat_id: chatId, text };
122
186
  if (parseMode) params.parse_mode = parseMode;
123
187
  if (replyTo) params.reply_to_message_id = replyTo;
188
+ if (threadId) params.message_thread_id = threadId;
124
189
  return telegramRequest(token, 'sendMessage', params);
125
190
  }
126
191
 
@@ -228,11 +293,15 @@ function splitMessage(text, maxLength = 4096) {
228
293
  // Converts Markdown to Telegram HTML formatting.
229
294
  // Splits the raw text first, then converts each chunk independently
230
295
  // to avoid broken HTML tags across messages.
231
- 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);
232
301
  const chunks = splitMessage(text);
233
302
  for (let i = 0; i < chunks.length; i++) {
234
303
  const html = convertMarkdownToHtml(chunks[i]);
235
- await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined);
304
+ await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined, effectiveThreadId);
236
305
  }
237
306
  }
238
307
 
@@ -260,18 +329,20 @@ function quoteReply(msg) {
260
329
  // Telegram command handlers
261
330
  // ---------------------------------------------------------------------------
262
331
 
263
- async function cmdHelp(token, chatId, args) {
332
+ async function cmdHelp(token, chatId, args, chatState) {
264
333
  if (args.length > 0) {
265
334
  const topic = args[0].toLowerCase();
266
335
  if (topic === 'servers') {
267
- await sendLongMessage(token, chatId,
336
+ await sendLongMessage(token, chatState,
268
337
  '`/servers` ─ list all opencode servers (ocmux sessions)\n' +
269
- '`/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. ' +
270
341
  '`<name>` can be a full path or basename of the project directory.');
271
342
  return;
272
343
  }
273
344
  if (topic === 'sessions') {
274
- await sendLongMessage(token, chatId,
345
+ await sendLongMessage(token, chatState,
275
346
  '`/sessions` ─ list active sessions (numbered, newest first, max 50)\n' +
276
347
  '`/sessions switch <number-or-name>` ─ send messages to a specific session\n' +
277
348
  '`/sessions new [name]` ─ create a new session\n' +
@@ -281,7 +352,7 @@ async function cmdHelp(token, chatId, args) {
281
352
  return;
282
353
  }
283
354
  if (topic === 'shutdown') {
284
- await sendLongMessage(token, chatId,
355
+ await sendLongMessage(token, chatState,
285
356
  '`/shutdown` ─ stop tgagentp process\n' +
286
357
  '`/shutdown force` ─ force shutdown even if busy (in-flight work will be lost)\n\n' +
287
358
  'Only available when tgagentp is started with `--dev` flag.\n' +
@@ -290,14 +361,14 @@ async function cmdHelp(token, chatId, args) {
290
361
  return;
291
362
  }
292
363
  if (topic === 'agents') {
293
- await sendLongMessage(token, chatId,
364
+ await sendLongMessage(token, chatState,
294
365
  '`/agents` ─ list primary agents (with ▶ marker for the active one).\n' +
295
366
  '`/agents switch <name>` ─ switch the active agent. Subsequent messages will use this agent.\n\n' +
296
367
  'Agent switching requires an active session (`/sessions switch` or `/sessions new`).');
297
368
  return;
298
369
  }
299
370
  if (topic === 'session') {
300
- await sendLongMessage(token, chatId,
371
+ await sendLongMessage(token, chatState,
301
372
  '`/session` is an alias for `/sessions`.\n\n' +
302
373
  '`/sessions` ─ list active sessions\n' +
303
374
  '`/sessions switch <number-or-name>` ─ switch to a session\n' +
@@ -306,14 +377,14 @@ async function cmdHelp(token, chatId, args) {
306
377
  return;
307
378
  }
308
379
  if (topic === 'models') {
309
- await sendLongMessage(token, chatId,
380
+ await sendLongMessage(token, chatState,
310
381
  '`/models` ─ list all providers and their models.\n' +
311
382
  'Shows provider name, connection status, default model, and up to 5 models per provider\n' +
312
383
  'with status, context limit, and input/output costs.');
313
384
  return;
314
385
  }
315
386
  if (topic === 'allow' || topic === 'reject' || topic === 'always') {
316
- await sendLongMessage(token, chatId,
387
+ await sendLongMessage(token, chatState,
317
388
  '`/allow` ─ approve a permission request once\n' +
318
389
  '`/reject` ─ deny a permission request\n' +
319
390
  '`/always` ─ approve and remember for the session\n\n' +
@@ -323,7 +394,7 @@ async function cmdHelp(token, chatId, args) {
323
394
  return;
324
395
  }
325
396
  if (topic === 'think') {
326
- await sendLongMessage(token, chatId,
397
+ await sendLongMessage(token, chatState,
327
398
  '`/think` ─ toggle thinking message forwarding\n' +
328
399
  '`/think on` ─ forward model thinking to the chat in real-time\n' +
329
400
  '`/think off` ─ stop forwarding thinking messages (default)\n' +
@@ -334,36 +405,87 @@ async function cmdHelp(token, chatId, args) {
334
405
  return;
335
406
  }
336
407
  if (topic === 'queue') {
337
- await sendLongMessage(token, chatId,
408
+ await sendLongMessage(token, chatState,
338
409
  '`/queue <message>` ─ queue a message when the server is busy\n\n' +
339
410
  'When the server is processing a previous message, queuing allows you to\n' +
340
411
  'continue working without waiting. Once the current task finishes, the next\n' +
341
412
  'queued message is automatically sent and the answer will reply to the\n' +
342
- 'original queued message.');
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');
343
456
  return;
344
457
  }
345
458
  if (topic === 'record') {
346
- await sendLongMessage(token, chatId,
347
- '`/record` ─ toggle recording of messages for agentp context\n' +
348
- '`/record stop` ─ stop recording and clear buffer\n\n' +
349
- 'When recording is active, all user and assistant messages are buffered in memory\n' +
350
- '(max 100 messages / 100KB). The next `agentp` invocation retrieves the buffered\n' +
351
- 'conversation and prepends it to its `--qa` stdout output, so OpenCode can\n' +
352
- 'see the full conversation context.\n\n' +
353
- 'Buffered messages are NOT sent back to Telegram (they are already there).');
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.');
354
475
  return;
355
476
  }
356
- await sendLongMessage(token, chatId,
357
- 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, `/help allow`, `/help think`, `/help record`, `/help queue`, or just `/help`.');
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`.');
358
479
  return;
359
480
  }
360
- await sendLongMessage(token, chatId,
481
+ await sendLongMessage(token, chatState,
361
482
  '`/help` ─ show this message\n' +
362
483
  '`/help <topic>` ─ detailed help\n' +
363
484
  '`/status` ─ show current server and session\n' +
364
485
  '`/cancel` ─ cancel the running prompt\n' +
365
486
  '`/servers` ─ list running opencode servers\n' +
366
487
  '`/servers switch <name>` ─ switch to a different server\n' +
488
+ '`/force-switch <name>` ─ take over a server from another chat\n' +
367
489
  '`/sessions` ─ list/create/switch/rename sessions\n' +
368
490
  '`/sessions switch <number-or-name>` ─ switch to a session\n' +
369
491
  '`/sessions new [name]` ─ create a new session\n' +
@@ -375,51 +497,97 @@ async function cmdHelp(token, chatId, args) {
375
497
  '`/think` ─ toggle thinking message forwarding\n' +
376
498
  '`/record` ─ record Telegram conversation for agentp context\n' +
377
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' +
378
504
  '`/allow` / `/reject` / `/always` ─ respond to permission requests\n' +
379
505
  '`/shutdown` ─ stop tgagentp (only with `--dev` flag)\n\n' +
380
506
  'Everything else is forwarded to the active server as `[Telegram] <text>`.');
381
507
  }
382
508
 
383
- async function cmdServers(token, chatId, args, setServer, setActiveSession, serverBase) {
384
- if (args.length > 0 && args[0].toLowerCase() === 'switch') {
385
- if (args.length < 2) {
386
- 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,
387
521
  'Usage: `/servers switch <name>`\n' +
522
+ 'To force-switch (take over a server from another chat), use `/force-switch <name>`.\n' +
388
523
  '`<name>` can be a full directory path or a basename.');
389
524
  return;
390
525
  }
391
- const name = args.slice(1).join(' ');
526
+ const name = switchArgs.join(' ');
392
527
  const servers = listServers();
393
528
  if (servers.length === 0) {
394
- await sendLongMessage(token, chatId, 'No servers running.');
529
+ await sendLongMessage(token, chatState, 'No servers running.');
395
530
  return;
396
531
  }
397
- 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());
398
535
  if (matches.length === 0) {
399
- matches = servers.filter(s => path.basename(s.dir) === name);
536
+ matches = servers.filter(s => path.basename(s.dir).toLowerCase() === name.toLowerCase());
400
537
  }
538
+ // Fallback: case-insensitive substring match
401
539
  if (matches.length === 0) {
402
- 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));
403
542
  }
404
- if (matches.length === 0) {
405
- 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}".`);
406
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
+ }
407
563
  }
408
- if (matches.length > 1) {
409
- const list = matches.map(s => ` ${s.url} → ${s.dir}`).join('\n');
410
- await sendLongMessage(token, chatId,
411
- `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).`);
412
570
  return;
413
571
  }
414
- const target = matches[0];
415
- 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);
416
584
  activateServer(target.dir, target.index, target.url);
417
585
  let msg = `Switched to server: ${target.url} → ${target.dir}`;
418
586
  if (pending && pending.trim()) {
419
- await sendLongMessage(token, chatId, msg);
420
- await sendLongMessage(token, chatId, pending.trim());
587
+ await sendLongMessage(token, chatState, msg);
588
+ await sendLongMessage(token, chatState, pending.trim());
421
589
  } else {
422
- await sendLongMessage(token, chatId, msg);
590
+ await sendLongMessage(token, chatState, msg);
423
591
  }
424
592
  return;
425
593
  }
@@ -427,15 +595,16 @@ async function cmdServers(token, chatId, args, setServer, setActiveSession, serv
427
595
  // Default: list servers
428
596
  const servers = listServers();
429
597
  if (servers.length === 0) {
430
- await sendLongMessage(token, chatId, 'No servers running.');
598
+ await sendLongMessage(token, chatState, 'No servers running.');
431
599
  return;
432
600
  }
433
- const activeUrl = normalizeUrl(serverBase);
601
+ const activeUrl = chatState.serverBase ? normalizeUrl(chatState.serverBase) : null;
434
602
  const lines = servers.map(s => {
435
- const bullet = normalizeUrl(s.url) === activeUrl ? '▶ ' : ' · ';
603
+ const bullet = activeUrl && normalizeUrl(s.url) === activeUrl ? '▶ ' : ' · ';
436
604
  return bullet + `${s.url} → ${s.dir} (${s.status})`;
437
605
  });
438
- 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'));
439
608
  }
440
609
 
441
610
  function sessionName(s) {
@@ -476,7 +645,8 @@ async function discoverActiveSession(server) {
476
645
  }
477
646
  }
478
647
 
479
- async function cmdSessions(token, chatId, args, setServer, setActiveSession, server) {
648
+ async function cmdSessions(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
649
+ const server = chatState.serverBase;
480
650
  if (args.length > 0) {
481
651
  const sub = args[0].toLowerCase();
482
652
 
@@ -485,41 +655,41 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
485
655
  try {
486
656
  const session = await createSession(server, title);
487
657
  const name = session.title || session.slug || session.id;
488
- setActiveSession(session.id);
658
+ setActiveSessionForChat(chatState, session.id);
489
659
  selectSession(server, session.id);
490
- await sendLongMessage(token, chatId,
660
+ await sendLongMessage(token, chatState,
491
661
  `Created session: \`${name}\`\nIt is now the active session.`);
492
662
  } catch (err) {
493
- await sendLongMessage(token, chatId, `Error creating session: ${err.message}`);
663
+ await sendLongMessage(token, chatState, `Error creating session: ${err.message}`);
494
664
  }
495
665
  return;
496
666
  }
497
667
 
498
668
  if (sub === 'rename') {
499
669
  if (args.length < 2) {
500
- await sendLongMessage(token, chatId, 'Usage: `/sessions rename <name>`');
670
+ await sendLongMessage(token, chatState, 'Usage: `/sessions rename <name>`');
501
671
  return;
502
672
  }
503
673
  const st = getState(server);
504
674
  if (!st.activeSessionId) {
505
- 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.');
506
676
  return;
507
677
  }
508
678
  const newTitle = args.slice(1).join(' ');
509
679
  try {
510
680
  const session = await updateSession(server, st.activeSessionId, newTitle);
511
681
  const name = session.title || session.slug || session.id;
512
- await sendLongMessage(token, chatId,
682
+ await sendLongMessage(token, chatState,
513
683
  `Renamed session to: \`${name}\``);
514
684
  } catch (err) {
515
- await sendLongMessage(token, chatId, `Error renaming session: ${err.message}`);
685
+ await sendLongMessage(token, chatState, `Error renaming session: ${err.message}`);
516
686
  }
517
687
  return;
518
688
  }
519
689
 
520
690
  if (sub === 'switch') {
521
691
  if (args.length < 2) {
522
- await sendLongMessage(token, chatId,
692
+ await sendLongMessage(token, chatState,
523
693
  'Usage: `/sessions switch <number-or-name>`\n' +
524
694
  'Use `/sessions` to see numbered list. You can switch by number or by name (case-insensitive partial match).');
525
695
  return;
@@ -537,9 +707,9 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
537
707
  const num = parseInt(query, 10);
538
708
  if (/^\d+$/.test(query) && num >= 1 && num <= sorted.length) {
539
709
  const target = sorted[num - 1];
540
- setActiveSession(target.id);
710
+ setActiveSessionForChat(chatId, target.id);
541
711
  selectSession(server, target.id);
542
- await sendLongMessage(token, chatId,
712
+ await sendLongMessage(token, chatState,
543
713
  `Switched to session #${num}: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
544
714
  return;
545
715
  }
@@ -548,29 +718,29 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
548
718
  const q = query.toLowerCase();
549
719
  const matches = sorted.filter(s => sessionName(s).toLowerCase().includes(q));
550
720
  if (matches.length === 0) {
551
- await sendLongMessage(token, chatId,
721
+ await sendLongMessage(token, chatState,
552
722
  `No session matching "${query}". Use /sessions to list active sessions.`);
553
723
  return;
554
724
  }
555
725
  if (matches.length > 1) {
556
726
  const list = matches.map(s => ` \`${sessionName(s)}\``).join('\n');
557
- await sendLongMessage(token, chatId,
727
+ await sendLongMessage(token, chatState,
558
728
  `Multiple matches for "${query}":\n${list}\n\nPlease be more specific or use a number.`);
559
729
  return;
560
730
  }
561
731
  const target = matches[0];
562
- setActiveSession(target.id);
732
+ setActiveSessionForChat(chatId, target.id);
563
733
  selectSession(server, target.id);
564
- await sendLongMessage(token, chatId,
734
+ await sendLongMessage(token, chatState,
565
735
  `Switched to session: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
566
736
  } catch (err) {
567
- await sendLongMessage(token, chatId, `Error switching session: ${err.message}`);
737
+ await sendLongMessage(token, chatState, `Error switching session: ${err.message}`);
568
738
  }
569
739
  return;
570
740
  }
571
741
 
572
742
  // Unknown subcommand
573
- await sendLongMessage(token, chatId,
743
+ await sendLongMessage(token, chatState,
574
744
  `Unknown subcommand: /sessions ${sub}\n\n` +
575
745
  'Usage:\n' +
576
746
  ' `/sessions` ─ list sessions\n' +
@@ -585,7 +755,7 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
585
755
  const projectDir = serverDir(server);
586
756
  const sessions = await listSessions(server, projectDir);
587
757
  if (!sessions || sessions.length === 0) {
588
- await sendLongMessage(token, chatId, 'No active sessions.');
758
+ await sendLongMessage(token, chatState, 'No active sessions.');
589
759
  return;
590
760
  }
591
761
 
@@ -595,7 +765,7 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
595
765
  (s.agent || s.directory || s.id === activeId) && (!projectDir || s.directory === projectDir));
596
766
  const sorted = sortedSessions(filtered, 50);
597
767
  if (sorted.length === 0) {
598
- await sendLongMessage(token, chatId, 'No active sessions.');
768
+ await sendLongMessage(token, chatState, 'No active sessions.');
599
769
  return;
600
770
  }
601
771
 
@@ -639,16 +809,17 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
639
809
  lines.push('`' + bullet + pos + ' ' + timeLabel + ' – ' + name.replace(/`/g, "'") + '`');
640
810
  });
641
811
 
642
- await sendLongMessage(token, chatId, 'Sessions:\n' + lines.join('\n'));
812
+ await sendLongMessage(token, chatState, 'Sessions:\n' + lines.join('\n'));
643
813
  } catch (err) {
644
- await sendLongMessage(token, chatId, `Error listing sessions: ${err.message}`);
814
+ await sendLongMessage(token, chatState, `Error listing sessions: ${err.message}`);
645
815
  }
646
816
  }
647
817
 
648
- async function cmdAgents(token, chatId, args, setServer, setActiveSession, server) {
818
+ async function cmdAgents(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
819
+ const server = chatState.serverBase;
649
820
  if (args.length > 0 && args[0].toLowerCase() === 'switch') {
650
821
  if (args.length < 2) {
651
- await sendLongMessage(token, chatId,
822
+ await sendLongMessage(token, chatState,
652
823
  'Usage: `/agents switch <name>`\n' +
653
824
  'Use `/agents` to list available agents.');
654
825
  return;
@@ -657,7 +828,7 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
657
828
  try {
658
829
  const agents = await listAgents(server);
659
830
  if (!agents || agents.length === 0) {
660
- await sendLongMessage(token, chatId, 'No agents available.');
831
+ await sendLongMessage(token, chatState, 'No agents available.');
661
832
  return;
662
833
  }
663
834
  let matches = agents.filter(a => a.name === name);
@@ -665,12 +836,12 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
665
836
  matches = agents.filter(a => a.name.toLowerCase().includes(name.toLowerCase()));
666
837
  }
667
838
  if (matches.length === 0) {
668
- await sendLongMessage(token, chatId, `No agent matching "${name}".`);
839
+ await sendLongMessage(token, chatState, `No agent matching "${name}".`);
669
840
  return;
670
841
  }
671
842
  if (matches.length > 1) {
672
843
  const list = matches.map(a => ` \`${a.name}\``).join('\n');
673
- await sendLongMessage(token, chatId,
844
+ await sendLongMessage(token, chatState,
674
845
  `Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
675
846
  return;
676
847
  }
@@ -682,10 +853,10 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
682
853
  selectSession(server, st.activeSessionId);
683
854
  }
684
855
  const model = target.model ? ` ─ \`${target.model.providerID}/${target.model.modelID}\`` : '';
685
- await sendLongMessage(token, chatId,
856
+ await sendLongMessage(token, chatState,
686
857
  `Switched to agent: **${target.name}**${model}\nSubsequent messages will use this agent.`);
687
858
  } catch (err) {
688
- await sendLongMessage(token, chatId, `Error switching agent: ${err.message}`);
859
+ await sendLongMessage(token, chatState, `Error switching agent: ${err.message}`);
689
860
  }
690
861
  return;
691
862
  }
@@ -693,12 +864,12 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
693
864
  try {
694
865
  const agents = await listAgents(server);
695
866
  if (!agents || agents.length === 0) {
696
- await sendLongMessage(token, chatId, 'No agents available.');
867
+ await sendLongMessage(token, chatState, 'No agents available.');
697
868
  return;
698
869
  }
699
870
  const primary = agents.filter(a => a.mode === 'primary');
700
871
  if (primary.length === 0) {
701
- await sendLongMessage(token, chatId, 'No primary agents available.');
872
+ await sendLongMessage(token, chatState, 'No primary agents available.');
702
873
  return;
703
874
  }
704
875
  const activeAgentName = getState(server).activeAgentName;
@@ -708,13 +879,14 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
708
879
  const desc = a.description ? `. ${a.description}` : '';
709
880
  return bullet + '**' + a.name + '**' + model + desc;
710
881
  });
711
- await sendLongMessage(token, chatId, 'Primary agents:\n' + lines.join('\n'));
882
+ await sendLongMessage(token, chatState, 'Primary agents:\n' + lines.join('\n'));
712
883
  } catch (err) {
713
- await sendLongMessage(token, chatId, `Error listing agents: ${err.message}`);
884
+ await sendLongMessage(token, chatState, `Error listing agents: ${err.message}`);
714
885
  }
715
886
  }
716
887
 
717
- async function cmdModels(token, chatId, args, setServer, setActiveSession, server) {
888
+ async function cmdModels(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
889
+ const server = chatState.serverBase;
718
890
  try {
719
891
  const result = await listProviders(server);
720
892
  const all = result.all || [];
@@ -722,7 +894,7 @@ async function cmdModels(token, chatId, args, setServer, setActiveSession, serve
722
894
 
723
895
  const filtered = all.filter(p => connectedIds.includes(p.id));
724
896
  if (filtered.length === 0) {
725
- await sendLongMessage(token, chatId, 'No connected providers.');
897
+ await sendLongMessage(token, chatState, 'No connected providers.');
726
898
  return;
727
899
  }
728
900
 
@@ -746,9 +918,9 @@ async function cmdModels(token, chatId, args, setServer, setActiveSession, serve
746
918
  }
747
919
  }
748
920
 
749
- await sendLongMessage(token, chatId, 'Connected providers:\n' + lines.join('\n'));
921
+ await sendLongMessage(token, chatState, 'Connected providers:\n' + lines.join('\n'));
750
922
  } catch (err) {
751
- await sendLongMessage(token, chatId, `Error listing models: ${err.message}`);
923
+ await sendLongMessage(token, chatState, `Error listing models: ${err.message}`);
752
924
  }
753
925
  }
754
926
 
@@ -758,7 +930,7 @@ const PERM_RESPONSE_HELP = ' No pending permission. Use `/status` to check the c
758
930
  // Agentp gateway ─ tiny HTTP server for agentp --tg
759
931
  // ---------------------------------------------------------------------------
760
932
 
761
- function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
933
+ function startAgentpGateway(token, serverBaseResolver) {
762
934
  return new Promise((resolve) => {
763
935
  const server = http.createServer((req, res) => {
764
936
  try {
@@ -788,19 +960,21 @@ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
788
960
  res.end('Bad Request: text and server are required');
789
961
  return;
790
962
  }
791
- const chatId = chatIdResolver();
792
963
 
793
- // Return recorded conversation buffer before forwarding
794
- const buffered = flushRecorded();
964
+ // Find the chat that owns this server
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) : [];
795
970
  res.writeHead(200, { 'Content-Type': 'application/json' });
796
971
  res.end(JSON.stringify({ buffered }));
797
972
 
798
- if (!chatId) return;
973
+ if (!owningChatId) return;
799
974
  const activeUrl = normalizeUrl(serverBaseResolver());
800
- const targetUrl = normalizeUrl(server);
801
975
 
802
976
  if (targetUrl === activeUrl) {
803
- sendLongMessage(token, chatId, text.trim()).catch(() => {});
977
+ sendLongMessage(token, owningChatId, text.trim()).catch(() => {});
804
978
  } else {
805
979
  if (!agentpQueues[server]) {
806
980
  agentpQueues[server] = { messages: [], notifyTimer: null, notified: false };
@@ -812,7 +986,7 @@ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
812
986
  q.notifyTimer = null;
813
987
  if (q.messages.length > 0 && !q.notified) {
814
988
  q.notified = true;
815
- sendLongMessage(token, chatId,
989
+ sendLongMessage(token, owningChatId,
816
990
  `📥 ${q.messages.length} agentp message(s) queued for server \`${server}\`. Switch servers to view them.`)
817
991
  .catch(() => {});
818
992
  }
@@ -839,115 +1013,321 @@ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
839
1013
  });
840
1014
  }
841
1015
 
842
- async function cmdAllow(token, chatId, args, setServer, setActiveSession, server) {
843
- const st = getState(server);
1016
+ async function cmdAllow(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1017
+ const st = getState(chatState.serverBase);
844
1018
  if (!st.pendingPermission) {
845
- await sendLongMessage(token, chatId, '/allow' + PERM_RESPONSE_HELP);
1019
+ await sendLongMessage(token, chatState, '/allow' + PERM_RESPONSE_HELP);
846
1020
  return;
847
1021
  }
848
1022
  const p = st.pendingPermission;
849
1023
  try {
850
1024
  await respondToPermission(p.server, p.sessionId, p.permissionId, 'once');
851
- await sendLongMessage(token, chatId, '✅ Permission approved (once).');
1025
+ await sendLongMessage(token, chatState, '✅ Permission approved (once).');
852
1026
  log.debug(` → /allow: ${p.type} ${p.pattern || ''}`);
853
1027
  } catch (err) {
854
- await sendLongMessage(token, chatId, `Error: ${err.message}`);
1028
+ await sendLongMessage(token, chatState, `Error: ${err.message}`);
855
1029
  }
856
1030
  }
857
1031
 
858
- async function cmdReject(token, chatId, args, setServer, setActiveSession, server) {
859
- const st = getState(server);
1032
+ async function cmdReject(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1033
+ const st = getState(chatState.serverBase);
860
1034
  if (!st.pendingPermission) {
861
- await sendLongMessage(token, chatId, '/reject' + PERM_RESPONSE_HELP);
1035
+ await sendLongMessage(token, chatState, '/reject' + PERM_RESPONSE_HELP);
862
1036
  return;
863
1037
  }
864
1038
  const p = st.pendingPermission;
865
1039
  try {
866
1040
  await respondToPermission(p.server, p.sessionId, p.permissionId, 'reject');
867
- await sendLongMessage(token, chatId, '⛔ Permission denied.');
1041
+ await sendLongMessage(token, chatState, '⛔ Permission denied.');
868
1042
  log.debug(` → /reject: ${p.type} ${p.pattern || ''}`);
869
1043
  } catch (err) {
870
- await sendLongMessage(token, chatId, `Error: ${err.message}`);
1044
+ await sendLongMessage(token, chatState, `Error: ${err.message}`);
871
1045
  }
872
1046
  }
873
1047
 
874
- async function cmdAlways(token, chatId, args, setServer, setActiveSession, server) {
875
- const st = getState(server);
1048
+ async function cmdAlways(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1049
+ const st = getState(chatState.serverBase);
876
1050
  if (!st.pendingPermission) {
877
- await sendLongMessage(token, chatId, '/always' + PERM_RESPONSE_HELP);
1051
+ await sendLongMessage(token, chatState, '/always' + PERM_RESPONSE_HELP);
878
1052
  return;
879
1053
  }
880
1054
  const p = st.pendingPermission;
881
1055
  try {
882
1056
  await respondToPermission(p.server, p.sessionId, p.permissionId, 'always');
883
- await sendLongMessage(token, chatId, '✅ Permission approved (always).');
1057
+ await sendLongMessage(token, chatState, '✅ Permission approved (always).');
884
1058
  log.debug(` → /always: ${p.type} ${p.pattern || ''}`);
885
1059
  } catch (err) {
886
- await sendLongMessage(token, chatId, `Error: ${err.message}`);
1060
+ await sendLongMessage(token, chatState, `Error: ${err.message}`);
887
1061
  }
888
1062
  }
889
1063
 
890
- async function cmdThink(token, chatId, args, setServer, setActiveSession, server) {
891
- const st = getState(server);
1064
+ async function cmdThink(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1065
+ const st = getState(chatState.serverBase);
892
1066
  const sub = args && args.length > 0 ? args[0].toLowerCase() : 'switch';
893
1067
  if (sub === 'on') {
894
1068
  st.thinkingEnabled = true;
895
- await sendLongMessage(token, chatId, '💭 Thinking messages **on** (forwarded to chat).');
1069
+ await sendLongMessage(token, chatState, '💭 Thinking messages **on** (forwarded to chat).');
896
1070
  } else if (sub === 'off') {
897
1071
  st.thinkingEnabled = false;
898
- await sendLongMessage(token, chatId, '💭 Thinking messages **off**.');
1072
+ await sendLongMessage(token, chatState, '💭 Thinking messages **off**.');
899
1073
  } else if (sub === 'switch') {
900
1074
  st.thinkingEnabled = !st.thinkingEnabled;
901
- await sendLongMessage(token, chatId,
1075
+ await sendLongMessage(token, chatState,
902
1076
  `💭 Thinking messages ${st.thinkingEnabled ? '**on**' : '**off**'}.`);
903
1077
  } else {
904
- await sendLongMessage(token, chatId, 'Usage: `/think [on|off|switch]` (default: switch).');
1078
+ await sendLongMessage(token, chatState, 'Usage: `/think [on|off|switch]` (default: switch).');
905
1079
  }
906
1080
  }
907
1081
 
908
- async function cmdQueue(token, chatId, args, _, __, serverBase, replyTo) {
909
- const st = getState(serverBase);
1082
+ async function cmdQueue(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat, replyTo) {
1083
+ const st = getState(chatState.serverBase);
910
1084
  if (!st.busy) {
911
- await sendLongMessage(token, chatId, 'Server is not busy. No need to queue — send your message directly.');
1085
+ await sendLongMessage(token, chatState, 'Server is not busy. No need to queue — send your message directly.');
912
1086
  return;
913
1087
  }
914
1088
  const text = args.join(' ').trim();
915
1089
  if (!text) {
916
- await sendLongMessage(token, chatId, 'Usage: `/queue <message>` — queue a message for when the server is idle.');
1090
+ await sendLongMessage(token, chatState, 'Usage: `/queue <message>` — queue a message for when the server is idle.');
917
1091
  return;
918
1092
  }
919
1093
  if (!st.messageQueue) st.messageQueue = [];
920
1094
  st.messageQueue.push({ text, replyTo });
921
- await sendLongMessage(token, chatId, `📥 Queued. ${st.messageQueue.length} message(s) in queue. Will be sent when the current task finishes.`);
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';
922
1101
  }
923
1102
 
924
- async function cmdRecord(token, chatId, args) {
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
925
1156
  if (args.length > 0 && args[0].toLowerCase() === 'stop') {
926
- recorder.active = false;
927
- recorder.messages = [];
928
- recorder.bytes = 0;
929
- await sendLongMessage(token, chatId, '⏹ Recording stopped. Buffer cleared.');
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.`);
1195
+ }
1196
+ }
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.');
930
1300
  return;
931
1301
  }
932
- recorder.active = !recorder.active;
933
- if (!recorder.active) {
934
- recorder.messages = [];
935
- recorder.bytes = 0;
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
+ }
936
1309
  }
937
- await sendLongMessage(token, chatId,
938
- recorder.active ? '🔴 Recording started. Messages will be cached for agentp.' : '⏹ Recording stopped. Buffer cleared.');
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}`);
939
1314
  }
940
1315
 
941
1316
  const COMMANDS = {
942
1317
  'help': cmdHelp,
943
1318
  'servers': cmdServers,
1319
+ 'force-switch': cmdForceSwitch,
944
1320
  'sessions': cmdSessions,
945
1321
  'session': cmdSessions,
946
1322
  'agents': cmdAgents,
947
1323
  'models': cmdModels,
948
1324
  'think': cmdThink,
949
1325
  'record': cmdRecord,
1326
+ 'resurrect': cmdResurrect,
950
1327
  'queue': cmdQueue,
1328
+ 'flush': cmdFlush,
1329
+ 'comment': cmdComment,
1330
+ 'disconnect': cmdDisconnect,
951
1331
  'allow': cmdAllow,
952
1332
  'reject': cmdReject,
953
1333
  'always': cmdAlways,
@@ -959,7 +1339,7 @@ const COMMANDS = {
959
1339
 
960
1340
  const serverState = {};
961
1341
  function getState(url) {
962
- return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false, messageQueue: [] };
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: [] };
963
1343
  }
964
1344
 
965
1345
  function sessionLabel(st) {
@@ -984,6 +1364,7 @@ async function main() {
984
1364
  process.exit(0);
985
1365
  } else if (args[i] === '--dev') {
986
1366
  DEV_MODE = true;
1367
+ log.verbose = true;
987
1368
  } else if (args[i] === '--verbose') {
988
1369
  log.verbose = true;
989
1370
  } else if (args[i] === '--think') {
@@ -1041,6 +1422,26 @@ async function main() {
1041
1422
 
1042
1423
  log.info(`tgagentp v${version} started. OpenCode server: ${serverBase}`);
1043
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
+
1044
1445
  if (!process.env.OPENCODE_SERVER_PASSWORD) {
1045
1446
  log.error('🔓 Server is not password-protected! Set OPENCODE_SERVER_PASSWORD for better security.');
1046
1447
  }
@@ -1052,8 +1453,6 @@ async function main() {
1052
1453
  log.info('Chat allowlist: not configured (all chats accepted)');
1053
1454
  }
1054
1455
 
1055
- let lockedChatId = null;
1056
-
1057
1456
  getState(serverBase);
1058
1457
 
1059
1458
  log.info('Discovering server state...');
@@ -1074,32 +1473,61 @@ async function main() {
1074
1473
 
1075
1474
  log.info('Waiting for Telegram messages... (Ctrl+C to stop)');
1076
1475
 
1077
- // Start agentp gateway (tiny HTTP server for agentp --tg)
1078
- const chatIdResolver = () => lockedChatId;
1079
- const serverBaseResolver = () => serverBase;
1080
- 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
+ }
1081
1491
 
1082
- const setServer = async (url) => {
1083
- 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 };
1084
1502
  getState(url);
1085
- 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}`);
1086
1514
 
1087
1515
  // Flush agentp queue for the new active server
1088
- flushAgentpQueue(url, token, lockedChatId);
1516
+ flushAgentpQueue(url, token, cs.chatId, cs.threadId);
1089
1517
 
1090
- const [discovered, agents] = await Promise.all([
1518
+ const [srvDiscovered, srvAgents] = await Promise.all([
1091
1519
  discoverActiveSession(url),
1092
1520
  listAgents(url).catch(() => []),
1093
1521
  ]);
1094
- if (discovered) {
1095
- getState(url).activeSessionId = discovered.id;
1096
- getState(url).activeSessionName = discovered.name;
1097
- 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}`);
1098
1526
  }
1099
- const defaultAgent = agents.find(a => a.mode === 'primary') || agents[0];
1100
- if (defaultAgent) {
1101
- getState(url).activeAgentName = defaultAgent.name;
1102
- 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}`);
1103
1531
  }
1104
1532
  const p = getState(url).pendingResponse;
1105
1533
  if (p) {
@@ -1109,24 +1537,35 @@ async function main() {
1109
1537
  return null;
1110
1538
  };
1111
1539
 
1112
- const setActiveSession = (id) => {
1113
- getState(serverBase).activeSessionId = id;
1114
- 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}`);
1115
1544
  };
1116
1545
 
1117
- async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId, replyTo) {
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;
1552
+ };
1553
+ startAgentpGateway(token, serverBaseResolver);
1554
+
1555
+ async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId, replyTo, threadId) {
1118
1556
  const st = getState(url);
1119
1557
  const isCancelled = () => getState(url).taskId !== myTaskId;
1120
1558
  const cancelRef = { current: null };
1559
+ const chatState = { chatId, threadId };
1121
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; }
1122
1564
  st._cancelRef = cancelRef;
1123
1565
  st._sessionReq = sessionCancelRef;
1124
1566
  try {
1125
1567
  let answer;
1126
1568
  if (useSession) {
1127
- // Dual approach:
1128
- // 1. SSE for permissions/thinking events (connected before sending)
1129
- // 2. Synchronous sendToSession (POST /session/:id/message) for reliable answer
1130
1569
  const notifiedPerms = new Set();
1131
1570
  let thinkingBuf = '';
1132
1571
  let sseConnected;
@@ -1142,7 +1581,7 @@ async function main() {
1142
1581
  thinkingBuf += chunk;
1143
1582
  if (thinkingBuf.length >= 50 || chunk.endsWith('\n')) {
1144
1583
  const html = '💭 ' + thinkingBuf.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1145
- sendLongMessage(token, chatId, html, replyTo)
1584
+ sendLongMessage(token, chatState, html, replyTo, threadId)
1146
1585
  .then(() => {})
1147
1586
  .catch(() => {});
1148
1587
  thinkingBuf = '';
@@ -1164,7 +1603,7 @@ async function main() {
1164
1603
  + (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
1165
1604
  + 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
1166
1605
  log.debug(` → sending permission notification to chat ${chatId}`);
1167
- sendLongMessage(token, chatId, msg, replyTo)
1606
+ sendLongMessage(token, chatState, msg, replyTo, threadId)
1168
1607
  .then(() => log.debug(` → permission notification sent OK`))
1169
1608
  .catch((err) => log.debug(` → permission notification failed: ${err.message}`));
1170
1609
  } else {
@@ -1176,16 +1615,13 @@ async function main() {
1176
1615
  getState(url).pendingPermission = null;
1177
1616
  },
1178
1617
  }, cancelRef).catch(() => {});
1179
- // Wait for SSE to connect, then send synchronously
1180
1618
  await onConnected;
1181
1619
  answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName, sessionCancelRef);
1182
- // Flush any remaining thinking buffer
1183
1620
  if (thinkingBuf) {
1184
1621
  const html = '💭 ' + thinkingBuf.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1185
- sendLongMessage(token, chatId, html, replyTo).catch(() => {});
1622
+ sendLongMessage(token, chatState, html, replyTo, threadId).catch(() => {});
1186
1623
  thinkingBuf = '';
1187
1624
  }
1188
- // Clean up SSE listener
1189
1625
  if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
1190
1626
  } else {
1191
1627
  await sendText(url, `[Telegram] ${text}`);
@@ -1197,19 +1633,21 @@ async function main() {
1197
1633
  return;
1198
1634
  }
1199
1635
  st.pendingPermission = null;
1200
- if (url === serverBase) {
1636
+ const chatServer = getChatState(chatId, threadId).serverBase;
1637
+ if (url === chatServer) {
1201
1638
  if (answer && answer.trim()) {
1202
1639
  const trimmed = answer.trim();
1203
- await sendLongMessage(token, chatId, trimmed, replyTo);
1204
- bufferMessage('assistant', trimmed);
1640
+ msgLog('out', chatId, '', trimmed);
1641
+ await sendLongMessage(token, chatState, trimmed, replyTo, threadId);
1642
+ bufferMessage(chatId, threadId, 'assistant', trimmed);
1205
1643
  }
1206
1644
  } else {
1207
1645
  getState(url).pendingResponse = answer;
1208
- await sendLongMessage(token, chatId, `✅ Server \`${url}\` finished processing. Switch back to see the response.`, replyTo);
1209
- if (answer && answer.trim()) bufferMessage('assistant', answer.trim());
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());
1210
1649
  }
1211
1650
  } catch (err) {
1212
- // Clean up SSE listener if it's still active
1213
1651
  if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
1214
1652
  if (isCancelled()) {
1215
1653
  log.debug(` → cancelled, ignoring error from ${url}: ${err.message}`);
@@ -1217,8 +1655,28 @@ async function main() {
1217
1655
  return;
1218
1656
  }
1219
1657
  log.error(`Error processing message (${url}): ${err.message}`);
1220
- if (url === serverBase) {
1221
- 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 (_) {}
1222
1680
  }
1223
1681
  } finally {
1224
1682
  st.pendingPermission = null;
@@ -1226,98 +1684,250 @@ async function main() {
1226
1684
  if (st._sessionReq === sessionCancelRef) st._sessionReq = null;
1227
1685
  if (!isCancelled()) {
1228
1686
  getState(url).busy = false;
1229
- // Drain message queue for this server
1687
+ // Don't drain the queue if the server is dead — messages stay queued
1688
+ if (st.serverDead) return;
1230
1689
  const q = st.messageQueue || [];
1231
1690
  st.messageQueue = [];
1232
1691
  if (q.length > 0) {
1233
1692
  const next = q[0];
1234
- // Re-queue remaining items (if any) for the next round
1235
1693
  st.messageQueue = q.slice(1);
1236
1694
  const nextText = next.text;
1237
1695
  const nextReplyTo = next.replyTo;
1238
- // Mark busy again and process
1239
1696
  getState(url).busy = true;
1240
1697
  const nextTaskId = ++st.taskId;
1241
1698
  log.debug(` → processing queued message (task ${nextTaskId})`);
1242
- processMessageAsync(chatId, nextText, url, !!st.activeSessionId, st.activeSessionId, nextTaskId, nextReplyTo);
1699
+ processMessageAsync(chatId, nextText, url, !!st.activeSessionId, st.activeSessionId, nextTaskId, nextReplyTo, threadId);
1243
1700
  }
1244
1701
  }
1245
1702
  }
1246
1703
  }
1247
1704
 
1248
- const cmdCancel = async (token, chatId) => {
1249
- 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);
1250
1712
  if (!st.busy) {
1251
- await sendLongMessage(token, chatId, 'Nothing to cancel.');
1713
+ await sendLongMessage(token, cs, 'Nothing to cancel.');
1252
1714
  return;
1253
1715
  }
1254
1716
  st.taskId++;
1255
1717
  st.busy = false;
1256
- // Abort the SSE stream so the server stops processing
1257
1718
  if (st._cancelRef && st._cancelRef.current) {
1258
1719
  st._cancelRef.current.destroy();
1259
1720
  st._cancelRef.current = null;
1260
1721
  }
1261
- // Abort the synchronous session request if in progress
1262
1722
  if (st._sessionReq && st._sessionReq.current) {
1263
1723
  st._sessionReq.current.destroy();
1264
1724
  st._sessionReq.current = null;
1265
1725
  }
1266
- await sendLongMessage(token, chatId, '✅ Cancelled.');
1267
- log.debug(` → /cancel on ${serverBase}`);
1726
+ await sendLongMessage(token, cs, '✅ Cancelled.');
1727
+ log.debug(`[chat ${chatId}] /cancel on ${cs.serverBase}`);
1268
1728
  }
1269
1729
 
1270
- const cmdStatus = async (token, chatId) => {
1271
- 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' : '';
1272
1738
  const busyInfo = st.busy ? '⏳ busy' : '✅ idle';
1273
- const dir = serverDir(serverBase);
1739
+ const dir = serverDir(cs.serverBase);
1274
1740
  const lines = ['**Server:**'];
1275
1741
  if (dir) lines.push(' • **Path:** `' + dir + '`');
1276
- lines.push(' • **URL:** `' + serverBase + '`');
1277
- lines.push(' • **Status:** ' + busyInfo);
1742
+ lines.push(' • **URL:** `' + cs.serverBase + '`');
1743
+ lines.push(' • **Status:** ' + busyInfo + deadInfo);
1278
1744
  lines.push('**Session:** ' + sessionLabel(st));
1279
1745
  lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
1280
- await sendLongMessage(token, chatId, lines.join('\n'));
1746
+ await sendLongMessage(token, cs, lines.join('\n'));
1281
1747
  };
1282
1748
 
1283
- const cmdShutdown = async (token, chatId, args) => {
1284
- 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
+
1285
1762
  const isForce = args && args.length > 0 && args[0].toLowerCase() === 'force';
1286
1763
  if (st.busy && !isForce) {
1287
- await sendLongMessage(token, chatId,
1764
+ await sendLongMessage(token, cs,
1288
1765
  '⏳ Server is busy processing a message.\n' +
1289
1766
  'Use `/shutdown force` to shut down anyway (in-flight response will be lost).');
1290
1767
  return;
1291
1768
  }
1292
1769
  const status = st.busy ? '⏳ busy (forced)' : '✅ idle';
1293
- await sendLongMessage(token, chatId, `👋 Shutting down (state: ${status})...`);
1294
- try {
1295
- fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId));
1296
- } catch (_) {}
1770
+ await sendLongMessage(token, cs, `👋 Shutting down (state: ${status})...`);
1297
1771
  try { await getUpdates(token, offset, 1); } catch (_) {}
1298
1772
  log.debug('/shutdown received, exiting...');
1299
1773
  process.exit(0);
1300
1774
  };
1301
1775
 
1302
- 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
+
1803
+ // Auto-switch tmux window to this chat's server
1804
+ const srvList = listServers();
1805
+ const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(url));
1806
+ if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
1807
+
1808
+ const intro = 'The following information is for your awareness. Reply with only "Ack" and do not take any action:';
1809
+ st.busy = true;
1810
+ const myTaskId = ++st.taskId;
1811
+ log.debug(` → /note: starting background processing (task ${myTaskId})`);
1812
+ processMessageAsync(chatId, intro + '\n\n' + text, url, !!st.activeSessionId, st.activeSessionId, myTaskId, null, chatState.threadId);
1813
+ };
1814
+
1815
+ const cmds = { ...COMMANDS, status: cmdStatus, cancel: cmdCancel, note: cmdNote };
1303
1816
  if (DEV_MODE) cmds.shutdown = cmdShutdown;
1304
1817
 
1305
1818
  let offset = 0;
1306
1819
 
1307
- // Startup greeting to last-known chat
1308
- const savedChatId = (() => { try { return fs.readFileSync(STARTUP_CHAT_FILE, 'utf8').trim(); } catch { return null; } })();
1309
- if (savedChatId) {
1310
- const st = getState(serverBase);
1311
- const dir = serverDir(serverBase);
1312
- const lines = ['✅ Bot started.', '**Server:**'];
1313
- if (dir) lines.push(' • **Path:** `' + dir + '`');
1314
- lines.push(' • **URL:** `' + serverBase + '`');
1315
- lines.push(' • **Status:** ✅ idle');
1316
- lines.push('**Session:** ' + sessionLabel(st));
1317
- lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
1318
- try { await sendLongMessage(token, savedChatId, lines.join('\n')); } catch (_) {}
1319
- lockedChatId = savedChatId;
1320
- try { fs.unlinkSync(STARTUP_CHAT_FILE); } catch (_) {}
1820
+ // Restore chat↔server connections from persistent state
1821
+ let savedData;
1822
+ let connByOldId = {};
1823
+ try { savedData = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8')); } catch {}
1824
+ if (savedData && Array.isArray(savedData.connections)) {
1825
+ // Phase 1: restore chat states and populate serverOwners
1826
+ for (const { chatId, threadId, dir } of savedData.connections) {
1827
+ connByOldId[String(chatId)] = { chatId, threadId, dir };
1828
+ }
1829
+ for (const { chatId, threadId, dir } of savedData.connections) {
1830
+ if (!dir) continue;
1831
+ const cs = getChatState(chatId, threadId);
1832
+ const sf = statefileFor(dir);
1833
+ const state = readState(sf);
1834
+ if (state && state.url) {
1835
+ cs.serverBase = state.url;
1836
+ // Prefer non-thread (private chat) as owner over topic threads
1837
+ const existing = serverOwners[state.url];
1838
+ if (!existing || (!threadId && existing.threadId)) {
1839
+ serverOwners[state.url] = { chatId, threadId };
1840
+ }
1841
+ }
1842
+ }
1843
+ // Phase 2: prune secondary connections per URL (only owner survives)
1844
+ for (const key of Object.keys(chatStates)) {
1845
+ const state = chatStates[key];
1846
+ if (!state.serverBase) continue;
1847
+ const owner = serverOwners[state.serverBase];
1848
+ if (!owner) continue;
1849
+ if (String(owner.chatId) !== String(state.chatId) || owner.threadId !== state.threadId) {
1850
+ const url = state.serverBase;
1851
+ state.serverBase = null;
1852
+ removeConnection(state.chatId, state.threadId);
1853
+ log.info(`[chat ${state.chatId}] Pruned secondary connection to server: ${url}`);
1854
+ }
1855
+ }
1856
+ // Phase 3: send startup notification only to the surviving owner per URL
1857
+ const notifiedUrls = new Set();
1858
+ for (const { chatId, threadId, dir } of savedData.connections) {
1859
+ if (!dir) continue;
1860
+ const cs = getChatState(chatId, threadId);
1861
+ if (!cs.serverBase) continue;
1862
+ const sf = statefileFor(dir);
1863
+ const state = readState(sf);
1864
+ if (state && state.url) {
1865
+ if (notifiedUrls.has(state.url)) {
1866
+ log.info(`[chat ${chatId}] Skipping startup notification for ${dir} (already notified for this URL)`);
1867
+ continue;
1868
+ }
1869
+ notifiedUrls.add(state.url);
1870
+ const alive = await isServerAlive(state.url);
1871
+ if (alive) {
1872
+ const lines = [
1873
+ '✅ Bot started.',
1874
+ '**Server:**',
1875
+ ' • **Path:** `' + dir + '`',
1876
+ ' • **URL:** `' + state.url + '`',
1877
+ ' • **Status:** ✅ restored',
1878
+ ];
1879
+ try { await sendLongMessage(token, cs, lines.join('\n')); } catch (_) {}
1880
+ log.info(`Restored connection: chat ${chatId} → ${dir}`);
1881
+ } else {
1882
+ log.info(`Saved connection for chat ${chatId} → ${dir}, but server ${state.url} is unreachable`);
1883
+ try {
1884
+ await sendLongMessage(token, cs,
1885
+ '🔄 Bot started. Previously connected to `' + dir + '`.\n' +
1886
+ '⚠️ The server at `' + state.url + '` is not reachable.\n' +
1887
+ 'Options: start the server with `ocmux serve`, or switch with `/servers switch <name>`.');
1888
+ } catch (_) {}
1889
+ }
1890
+ }
1891
+ }
1892
+ }
1893
+
1894
+ // ── Group migration handler ──────────────────────────────────────
1895
+ // When a regular group is promoted to a supergroup (or topics enabled),
1896
+ // Telegram changes the chat ID. This function updates the stored
1897
+ // connections and allowlist so the bot continues to work transparently.
1898
+ async function handleGroupMigration(oldId, newId) {
1899
+ if (oldId === newId) return;
1900
+ log.info(`Group migration detected: chat ${oldId} → ${newId}`);
1901
+
1902
+ // Update allowlist if the old ID is in it
1903
+ if (allowedChatIds) {
1904
+ const idx = allowedChatIds.indexOf(oldId);
1905
+ if (idx !== -1) {
1906
+ allowedChatIds[idx] = newId;
1907
+ log.info(`Updated allowlist: ${oldId} → ${newId}`);
1908
+ }
1909
+ }
1910
+
1911
+ // Update connections.json: replace all references to oldId with newId
1912
+ try {
1913
+ const data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8'));
1914
+ if (Array.isArray(data.connections)) {
1915
+ let changed = false;
1916
+ for (const conn of data.connections) {
1917
+ if (String(conn.chatId) === oldId) {
1918
+ conn.chatId = newId;
1919
+ changed = true;
1920
+ }
1921
+ }
1922
+ if (changed) {
1923
+ fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2));
1924
+ log.info(`Updated connection: ${oldId} → ${newId}`);
1925
+ }
1926
+ }
1927
+ } catch (_) {}
1928
+
1929
+ // Update in-memory chat state key (the existing state remains, just
1930
+ // the key changes — new messages from newId will hit a new state)
1321
1931
  }
1322
1932
 
1323
1933
  // Poll for updates and forward each text message to OpenCode, sending the answer back.
@@ -1341,12 +1951,22 @@ async function main() {
1341
1951
  offset = update.update_id + 1;
1342
1952
 
1343
1953
  const msg = update.message;
1344
- if (!msg || typeof msg.text !== 'string') {
1345
- // Ignore non-text messages (photos, stickers, etc.)
1954
+ if (!msg) continue;
1955
+
1956
+ // Handle group → supergroup migration (e.g., when topics are enabled)
1957
+ if (msg.migrate_to_chat_id) {
1958
+ await handleGroupMigration(String(msg.chat.id), String(msg.migrate_to_chat_id));
1959
+ continue;
1960
+ }
1961
+ if (msg.migrate_from_chat_id) {
1962
+ await handleGroupMigration(String(msg.migrate_from_chat_id), String(msg.chat.id));
1346
1963
  continue;
1347
1964
  }
1348
1965
 
1966
+ if (typeof msg.text !== 'string') continue;
1967
+
1349
1968
  const chatId = String(msg.chat.id);
1969
+ const threadId = msg.message_thread_id || null;
1350
1970
  const replyTo = msg.message_id;
1351
1971
  const text = msg.text;
1352
1972
  const from = msg.from?.username
@@ -1355,21 +1975,22 @@ async function main() {
1355
1975
 
1356
1976
  // Enforce chat allowlist if configured
1357
1977
  if (allowedChatIds && !allowedChatIds.includes(chatId)) {
1358
- log.debug(`[chat ${chatId}] ${from}: blocked (not in allowlist)`);
1978
+ const chatLabel = msg.chat?.title ? ` (${msg.chat.title})` : '';
1979
+ log.debug(`[chat ${chatId}]${chatLabel} ${from}: blocked (not in allowlist)`);
1980
+ // Log a hint if the old chat ID is known in connections.json
1981
+ const blockedDebug = `[chat ${chatId}]${chatLabel} ${from}: blocked (not in allowlist)`;
1982
+ const known = Object.keys(connByOldId);
1983
+ if (known.length > 0) {
1984
+ log.debug(` Known connections: ${known.join(', ')}. Chat may have been migrated (enable topics → supergroup changes chat ID).`);
1985
+ }
1359
1986
  continue;
1360
1987
  }
1361
1988
 
1362
- // Lock to the first chat that connects (single-user guard)
1363
- if (lockedChatId === null) {
1364
- lockedChatId = chatId;
1365
- log.debug(`[chat ${chatId}] ${from}: locked to this chat`);
1366
- } else if (lockedChatId !== chatId) {
1367
- log.debug(`[chat ${chatId}] ${from}: blocked (locked to ${lockedChatId})`);
1368
- continue;
1369
- }
1989
+ // Per-chat state: get or create for this chat (thread-aware)
1990
+ const cs = getChatState(chatId, threadId);
1370
1991
 
1371
1992
  log.debug(`[chat ${chatId}] ${from}: ${text}`);
1372
- try { fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId)); } catch (_) {}
1993
+ msgLog('in', chatId, from, text);
1373
1994
 
1374
1995
  // Build reply context if this message is a Telegram reply
1375
1996
  const replyContext = quoteReply(msg);
@@ -1381,32 +2002,81 @@ async function main() {
1381
2002
  const cmdArgs = parts.slice(1);
1382
2003
  const handler = cmds[cmd];
1383
2004
  if (handler) {
2005
+ // Disconnected guard: only /help, /servers, /force-switch, /start, /comment, and /shutdown work without a server
2006
+ if (!cs.serverBase && cmd !== 'help' && cmd !== 'servers' && cmd !== 'force-switch' && cmd !== 'start' && cmd !== 'comment' && cmd !== 'shutdown') {
2007
+ await sendLongMessage(token, cs,
2008
+ '🔌 Not connected to any server.\n\n' +
2009
+ 'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
2010
+ continue;
2011
+ }
1384
2012
  try {
1385
- await handler(token, chatId, cmdArgs, setServer, setActiveSession, serverBase);
2013
+ await handler(token, chatId, cmdArgs, cs, setServerForChat, setActiveSessionForChat);
1386
2014
  } catch (err) {
1387
2015
  log.error(`Error handling /${cmd}: ${err.message}`);
1388
- try { await sendLongMessage(token, chatId, `Error: ${err.message}`); } catch (_) {}
2016
+ try { await sendLongMessage(token, cs, `Error: ${err.message}`); } catch (_) {}
1389
2017
  }
1390
2018
  } else {
1391
- await sendLongMessage(token, chatId,
1392
- `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /think, /record, /queue`);
2019
+ await sendLongMessage(token, cs,
2020
+ `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /think, /record, /queue, /flush, /note, /comment, /disconnect`);
1393
2021
  }
1394
2022
  continue;
1395
2023
  }
1396
2024
 
2025
+ // Disconnected guard for non-command messages
2026
+ if (!cs.serverBase) {
2027
+ await sendLongMessage(token, cs,
2028
+ '🔌 Not connected to any server.\n\n' +
2029
+ 'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
2030
+ continue;
2031
+ }
2032
+
1397
2033
  // Buffer user messages for /record
1398
- bufferMessage('user', replyContext + text);
2034
+ bufferMessage(chatId, threadId, 'user', replyContext + text);
1399
2035
 
1400
2036
  // Non-command message ─ process in background
1401
- const st = getState(serverBase);
2037
+ const st = getState(cs.serverBase);
2038
+
2039
+ // Server health check: detect dead servers and auto-reconnect
2040
+ if (st.serverDead) {
2041
+ const alive = await isServerAlive(cs.serverBase);
2042
+ if (alive) {
2043
+ st.serverDead = false;
2044
+ log.info(`Server ${cs.serverBase} is back online`);
2045
+ }
2046
+ }
2047
+ if (st.serverDead) {
2048
+ // Server is dead ─ auto-queue and notify
2049
+ if (!st.messageQueue) st.messageQueue = [];
2050
+ st.messageQueue.push({ text: replyContext + text, replyTo });
2051
+ const qlen = st.messageQueue.length;
2052
+ const servers = listServers();
2053
+ const suggestions = [];
2054
+ suggestions.push(`• Start the server: \`ocmux serve\``);
2055
+ if (servers.length > 0) {
2056
+ suggestions.push(`• Switch to another server: \`/servers switch <name>\``);
2057
+ }
2058
+ suggestions.push(`• Flush the queue: \`/flush\``);
2059
+ await sendLongMessage(token, cs,
2060
+ `⚠️ Server \`${cs.serverBase}\` is not reachable.\n\n` +
2061
+ `Your message has been queued (#${qlen}). It will be sent when the server becomes available.\n\n` +
2062
+ `**Options:**\n${suggestions.join('\n')}`);
2063
+ continue;
2064
+ }
2065
+
1402
2066
  if (st.busy) {
1403
- await sendLongMessage(token, chatId, '⏳ Server is busy. Use `/cancel` to cancel or `/queue <message>` to queue your message.');
2067
+ await sendLongMessage(token, cs, '⏳ Server is busy. Use `/cancel` to cancel or `/queue <message>` to queue your message.');
1404
2068
  continue;
1405
2069
  }
2070
+
2071
+ // Auto-switch tmux window to this chat's server
2072
+ const srvList = listServers();
2073
+ const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(cs.serverBase));
2074
+ if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
2075
+
1406
2076
  st.busy = true;
1407
2077
  const myTaskId = ++st.taskId;
1408
2078
  log.debug(` → started background processing (task ${myTaskId})`);
1409
- processMessageAsync(chatId, replyContext + text, serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId, replyTo);
2079
+ processMessageAsync(chatId, replyContext + text, cs.serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId, replyTo, threadId);
1410
2080
  }
1411
2081
  }
1412
2082
  }