agentp 0.11.10 → 0.12.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
@@ -4,7 +4,7 @@ const http = require('http');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { version } = require('../package.json');
7
- const { getAuthHeaders, sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, respondToQuestion, listenForSessionEvents, listAgents, listProviders, isServerAlive } = require('../lib/opencode.js');
7
+ const { getAuthHeaders, makeRequest, buildJsonRequest, sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, respondToQuestion, listenForSessionEvents, listAgents, listProviders, isServerAlive } = require('../lib/opencode.js');
8
8
 
9
9
  // Logger: info→stdout, error→stderr (always), debug→stderr (--verbose only)
10
10
  const log = {
@@ -264,53 +264,103 @@ async function cmdHelp(token, chatId, args, chatState) {
264
264
  const topic = args[0].toLowerCase();
265
265
  if (topic === 'servers') {
266
266
  await sendLongMessage(token, chatState,
267
- '`/servers` ─ list all opencode servers (ocmux sessions)\n' +
268
- '`/servers switch <name>` ─ switch target server\n' +
269
- '`/force-switch <name>` ─ take over a server from another chat\n\n' +
270
- 'Name matching: case-insensitive first, then case-sensitive. ' +
271
- '`<name>` can be a full path or basename of the project directory.');
267
+ '`/servers` ─ list running servers\n' +
268
+ ' • Shows alive and dead servers\n' +
269
+ ' • Your server marked with ▶\n\n' +
270
+ '`/server <name>` ─ switch to a server by directory path or basename\n' +
271
+ '`/server --force <name>` ─ take over a server from another chat');
272
+ return;
273
+ }
274
+ if (topic === 'agents') {
275
+ await sendLongMessage(token, chatState,
276
+ '`/agents` ─ list primary agents\n' +
277
+ ' • Current agent marked with ▶\n' +
278
+ ' • Shows model and description\n\n' +
279
+ '`/agent <name>` ─ switch to an agent');
280
+ return;
281
+ }
282
+ if (topic === 'sessions') {
283
+ await sendLongMessage(token, chatState,
284
+ '`/sessions` ─ list sessions\n' +
285
+ ' • Primary-agent sessions shown by default (includes subagent sessions to maintain context)\n' +
286
+ ' • Active session marked with ▶\n' +
287
+ ' • Grouped by recency: Today, this week, older months\n\n' +
288
+ '`/session <name-or-number>` ─ switch to a session by name or position\n' +
289
+ '`/session new [name]` ─ create a new session\n' +
290
+ '`/session rename <name>` ─ rename the active session');
272
291
  return;
273
292
  }
274
293
  if (topic === 'sessions') {
275
294
  await sendLongMessage(token, chatState,
276
295
  '`/sessions` ─ list active sessions (numbered, newest first, max 50)\n' +
277
- '`/sessions switch <number-or-name>` ─ send messages to a specific session\n' +
278
- '`/sessions new [name]` ─ create a new session\n' +
279
- '`/sessions rename <name>` ─ rename the active session\n\n' +
296
+ '`/session <name-or-number>` ─ switch to a session by name or position\n' +
297
+ '`/session new [name]` ─ create a new session\n' +
298
+ '`/session rename <name>` ─ rename the active session\n\n' +
280
299
  'Switch by number (from the list) or by case-insensitive partial name match.\n' +
281
300
  'Use `/sessions` to find session numbers.');
282
301
  return;
283
302
  }
284
- if (topic === 'shutdown') {
303
+ if (topic === 'servers') {
304
+ await sendLongMessage(token, chatState,
305
+ '`/servers` ─ list running servers\n' +
306
+ ' • Shows alive and dead servers\n' +
307
+ ' • Your server marked with ▶\n\n' +
308
+ '`/server <name>` ─ switch to a server by directory path or basename\n' +
309
+ '`/server --force <name>` ─ take over a server from another chat');
310
+ return;
311
+ }
312
+ if (topic === 'server') {
285
313
  await sendLongMessage(token, chatState,
286
- '`/shutdown` ─ stop tgagentp process\n' +
287
- '`/shutdown force` ─ force shutdown even if busy (in-flight work will be lost)\n\n' +
288
- 'Only available when tgagentp is started with `--dev` flag.\n' +
289
- 'Useful when running tgagentp in a restart loop:\n' +
290
- '`while true; do tgagentp --dev; done`');
314
+ '`/server <name>` ─ switch to a server by directory path or basename.\n' +
315
+ '`/server --force <name>` ─ take over a server from another chat.\n\n' +
316
+ 'Matches by full directory path, basename, or case-insensitive substring.\n' +
317
+ 'Use `/servers` to see available servers.');
291
318
  return;
292
319
  }
293
320
  if (topic === 'agents') {
294
321
  await sendLongMessage(token, chatState,
295
322
  '`/agents` ─ list primary agents (with ▶ marker for the active one).\n' +
296
- '`/agents switch <name>` ─ switch the active agent. Subsequent messages will use this agent.\n\n' +
297
- 'Agent switching requires an active session (`/sessions switch` or `/sessions new`).');
323
+ '`/agent <name>` ─ switch the active agent. Subsequent messages will use this agent.\n\n' +
324
+ 'Agent switching requires an active session (`/session <name>` or `/session new`).');
325
+ return;
326
+ }
327
+ if (topic === 'agent') {
328
+ await sendLongMessage(token, chatState,
329
+ '`/agent <name>` ─ switch the active agent.\n\n' +
330
+ 'Matches by exact name first, then case-insensitive substring.\n' +
331
+ 'The agent is synced to the server session via API.\n' +
332
+ 'Use `/agents` to list available agents.');
298
333
  return;
299
334
  }
300
335
  if (topic === 'session') {
301
336
  await sendLongMessage(token, chatState,
302
- '`/session` is an alias for `/sessions`.\n\n' +
303
- '`/sessions` ─ list active sessions\n' +
304
- '`/sessions switch <number-or-name>` ─ switch to a session\n' +
305
- '`/sessions new [name]` ─ create a new session\n' +
306
- '`/sessions rename <name>` ─ rename the active session');
337
+ '`/session <name-or-number>` ─ switch to a session by name or position\n' +
338
+ '`/session new [name]` ─ create a new session\n' +
339
+ '`/session rename <name>` ─ rename the active session\n\n' +
340
+ 'Use `/sessions` to list available sessions with numbers.');
307
341
  return;
308
342
  }
309
343
  if (topic === 'models') {
310
344
  await sendLongMessage(token, chatState,
311
- '`/models` ─ list all providers and their models.\n' +
312
- 'Shows provider name, connection status, default model, and up to 5 models per provider\n' +
313
- 'with status, context limit, and input/output costs.');
345
+ '`/models` ─ list connected providers.\n' +
346
+ ' • Shows provider names with model count: `**OpenAI** (*5 models*)`\n' +
347
+ ' • The current provider is marked with ▶\n' +
348
+ ' • Includes hint to drill down: `/models <provider>`\n\n' +
349
+ '`/models <provider>` ─ list all models for a matching provider.\n' +
350
+ ' • Partial name/ID matching supported\n' +
351
+ ' • Current model marked with ▶\n' +
352
+ ' • Shows context limit and input/output costs in parentheses\n\n' +
353
+ 'Use `/model <providerID/modelID>` or `/model <modelID>` to switch the active session model.');
354
+ return;
355
+ }
356
+ if (topic === 'model') {
357
+ await sendLongMessage(token, chatState,
358
+ '`/model <providerID/modelID>` ─ switch the active session to a specific model.\n' +
359
+ '`/model <modelID>` ─ search all connected providers for a model by name.\n\n' +
360
+ 'Examples:\n' +
361
+ ' `/model opencode/big-pickle` ─ explicit provider/model\n' +
362
+ ' `/model gpt-4` ─ search across all providers\n\n' +
363
+ 'Use `/models` to see available providers and models.');
314
364
  return;
315
365
  }
316
366
  if (topic === 'allow' || topic === 'reject' || topic === 'always' || topic === 'answer') {
@@ -374,7 +424,7 @@ async function cmdHelp(token, chatId, args, chatState) {
374
424
  if (topic === 'disconnect') {
375
425
  await sendLongMessage(token, chatState,
376
426
  '`/disconnect` ─ disconnect this chat from its server\n\n' +
377
- 'Clears the server connection for this chat. Use `/servers switch <name>` to reconnect.');
427
+ 'Clears the server connection for this chat. Use `/server <name>` to reconnect.');
378
428
  return;
379
429
  }
380
430
  if (topic === 'status') {
@@ -437,7 +487,7 @@ async function cmdHelp(token, chatId, args, chatState) {
437
487
  return;
438
488
  }
439
489
  await sendLongMessage(token, chatState,
440
- '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`, `/help serve`, `/help new`, `/help markdown`, or just `/help`.');
490
+ 'Unknown topic. Try `/help servers`, `/help server`, `/help sessions`, `/help session`, `/help agents`, `/help agent`, `/help models`, `/help model`, `/help allow`, `/help think`, `/help record`, `/help queue`, `/help flush`, `/help note`, `/help comment`, `/help disconnect`, `/help serve`, `/help new`, `/help markdown`, or just `/help`.');
441
491
  return;
442
492
  }
443
493
  await sendLongMessage(token, chatState,
@@ -446,16 +496,16 @@ async function cmdHelp(token, chatId, args, chatState) {
446
496
  '`/status` ─ show current server and session\n' +
447
497
  '`/cancel` ─ cancel the running prompt\n' +
448
498
  '`/servers` ─ list running opencode servers\n' +
449
- '`/servers switch <name>` ─ switch to a different server\n' +
450
- '`/force-switch <name>` ─ take over a server from another chat\n' +
451
- '`/sessions` ─ list/create/switch/rename sessions\n' +
452
- '`/sessions switch <number-or-name>` ─ switch to a session\n' +
453
- '`/sessions new [name]` ─ create a new session\n' +
454
- '`/sessions rename <name>` ─ rename the active session\n' +
455
- '`/session` ─ alias for `/sessions`\n' +
499
+ '`/server <name>` ─ switch to a different server\n' +
500
+ '`/server --force <name>` ─ take over a server from another chat\n' +
501
+ '`/sessions` ─ list sessions\n' +
502
+ '`/session <name-or-number>` ─ switch to a session\n' +
503
+ '`/session new [name]` ─ create a new session\n' +
504
+ '`/session rename <name>` ─ rename the active session\n' +
456
505
  '`/agents` ─ list available agents\n' +
457
- '`/agents switch <name>` ─ switch active agent\n' +
506
+ '`/agent <name>` ─ switch active agent\n' +
458
507
  '`/models` ─ list providers and models\n' +
508
+ '`/model <provider/model>` ─ switch session model\n' +
459
509
  '`/think` ─ toggle thinking message forwarding\n' +
460
510
  '`/record` ─ record Telegram conversation for agentp context\n' +
461
511
  '`/queue <message>` ─ queue a message when server is busy\n' +
@@ -473,90 +523,18 @@ async function cmdHelp(token, chatId, args, chatState) {
473
523
  'Everything else is forwarded to the active server as `[Telegram] <text>`.');
474
524
  }
475
525
 
476
- async function cmdForceSwitch(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
477
- return cmdServers(token, chatId, ['switch', '--force', ...args], chatState, setServerForChat, setActiveSessionForChat);
478
- }
479
-
480
526
  async function cmdServers(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
481
- const isForce = args.length > 0 && args[0].toLowerCase() === 'force-switch';
482
- if (args.length > 0 && (args[0].toLowerCase() === 'switch' || isForce)) {
483
- const forceIdx = args.indexOf('--force');
484
- const force = forceIdx !== -1 || isForce;
485
- const switchArgs = args.slice(1).filter(a => a !== '--force');
486
- if (switchArgs.length < 1) {
487
- await sendLongMessage(token, chatState,
488
- 'Usage: `/servers switch <name>`\n' +
489
- 'To force-switch (take over a server from another chat), use `/force-switch <name>`.\n' +
490
- '`<name>` can be a full directory path or a basename.');
491
- return;
492
- }
493
- const name = switchArgs.join(' ');
494
- const servers = listServers();
495
- if (servers.length === 0) {
496
- await sendLongMessage(token, chatState, 'No servers running.');
497
- return;
498
- }
499
-
500
- // Phase 1: case-insensitive exact match on dir or basename
501
- let matches = servers.filter(s => s.dir.toLowerCase() === name.toLowerCase());
502
- if (matches.length === 0) {
503
- matches = servers.filter(s => path.basename(s.dir).toLowerCase() === name.toLowerCase());
504
- }
505
- // Fallback: case-insensitive substring match
506
- if (matches.length === 0) {
507
- const lowerName = name.toLowerCase();
508
- matches = servers.filter(s => s.dir.toLowerCase().includes(lowerName) || path.basename(s.dir).toLowerCase().includes(lowerName));
509
- }
510
-
511
- if (matches.length === 1) {
512
- // Phase 1 found a single match — use it directly
513
- } else if (matches.length === 0) {
514
- await sendLongMessage(token, chatState, `No server found matching "${name}".`);
515
- return;
516
- } else {
517
- // Phase 2: case-sensitive exact match to disambiguate
518
- let exact = servers.filter(s => s.dir === name);
519
- if (exact.length === 0) {
520
- exact = servers.filter(s => path.basename(s.dir) === name);
521
- }
522
- if (exact.length === 1) {
523
- matches = exact;
524
- } else {
525
- const list = matches.map(s => ` ${s.url} → ${s.dir}`).join('\n');
526
- await sendLongMessage(token, chatState,
527
- `Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
528
- return;
529
- }
530
- }
531
- const target = matches[0];
532
- const existing = serverOwners[target.url];
533
- if (existing && existing.chatId !== chatState.chatId && !force) {
534
- await sendLongMessage(token, chatState,
535
- `⚠️ Server \`${target.url}\` is already connected to another chat.\n\n` +
536
- `Use \`/servers force-switch ${name}\` to take over (the other chat will be disconnected).`);
537
- return;
527
+ // Backward compatibility: redirect old subcommands to singular commands
528
+ if (args.length > 0) {
529
+ const sub = args[0].toLowerCase();
530
+ if (sub === 'switch') {
531
+ const rest = args.slice(1).filter(a => a !== '--force');
532
+ const force = args.includes('--force');
533
+ return cmdServer(token, chatId, force ? ['--force', ...rest] : rest, chatState, setServerForChat, setActiveSessionForChat);
538
534
  }
539
- if (existing && existing.chatId === chatState.chatId) {
540
- // Already connected — re-run setup to refresh state
541
- }
542
- if (force && existing && existing.chatId !== chatState.chatId) {
543
- // Reset the old owner's server and notify them
544
- const oldState = getChatState(existing.chatId, existing.threadId);
545
- oldState.serverBase = null;
546
- sendLongMessage(token, oldState,
547
- `⚠️ Server \`${target.url}\` has been taken over by another chat.\n\n` +
548
- `Use \`/servers\` to see available servers and reconnect.`).catch(() => {});
549
- }
550
- const pending = await setServerForChat(chatState, target.url);
551
- activateServer(target.dir, target.index, target.url);
552
- let msg = `Switched to server: ${target.url} → ${target.dir}`;
553
- if (pending && pending.trim()) {
554
- await sendLongMessage(token, chatState, msg);
555
- await sendLongMessage(token, chatState, pending.trim());
556
- } else {
557
- await sendLongMessage(token, chatState, msg);
535
+ if (sub === 'force-switch') {
536
+ return cmdServer(token, chatId, ['--force', ...args.slice(1)], chatState, setServerForChat, setActiveSessionForChat);
558
537
  }
559
- return;
560
538
  }
561
539
 
562
540
  // Default: list servers (alive + dead)
@@ -634,106 +612,19 @@ async function discoverActiveSession(server) {
634
612
 
635
613
  async function cmdSessions(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
636
614
  const server = chatState.serverBase;
615
+ // Backward compatibility: redirect old subcommands to singular commands
637
616
  if (args.length > 0) {
638
617
  const sub = args[0].toLowerCase();
639
-
640
- if (sub === 'new') {
641
- const title = args.slice(1).join(' ') || undefined;
642
- try {
643
- const session = await createSession(server, title);
644
- const name = session.title || session.slug || session.id;
645
- setActiveSessionForChat(chatState, session.id, name);
646
- selectSession(server, session.id);
647
- await sendLongMessage(token, chatState,
648
- `Created session: \`${name}\`\nIt is now the active session.`);
649
- } catch (err) {
650
- await sendLongMessage(token, chatState, `Error creating session: ${err.message}`);
651
- }
652
- return;
653
- }
654
-
655
- if (sub === 'rename') {
656
- if (args.length < 2) {
657
- await sendLongMessage(token, chatState, 'Usage: `/sessions rename <name>`');
658
- return;
659
- }
660
- const st = getState(server);
661
- if (!st.activeSessionId) {
662
- await sendLongMessage(token, chatState, 'No active session. Use `/sessions switch` or `/sessions new` first.');
663
- return;
664
- }
665
- const newTitle = args.slice(1).join(' ');
666
- try {
667
- const session = await updateSession(server, st.activeSessionId, newTitle);
668
- const name = session.title || session.slug || session.id;
669
- await sendLongMessage(token, chatState,
670
- `Renamed session to: \`${name}\``);
671
- } catch (err) {
672
- await sendLongMessage(token, chatState, `Error renaming session: ${err.message}`);
673
- }
674
- return;
675
- }
676
-
677
- if (sub === 'switch') {
678
- if (args.length < 2) {
679
- await sendLongMessage(token, chatState,
680
- 'Usage: `/sessions switch <number-or-name>`\n' +
681
- 'Use `/sessions` to see numbered list. You can switch by number or by name (case-insensitive partial match).');
682
- return;
683
- }
684
- const query = args.slice(1).join(' ');
685
- try {
686
- const projectDir = serverDir(server);
687
- const sessions = await listSessions(server, projectDir);
688
- const activeId = getState(server).activeSessionId;
689
- const filtered = sessions.filter(s =>
690
- (s.agent || s.directory || s.id === activeId) && (!projectDir || s.directory === projectDir));
691
- const sorted = sortedSessions(filtered, 50);
692
-
693
- // Try by position number first
694
- const num = parseInt(query, 10);
695
- if (/^\d+$/.test(query) && num >= 1 && num <= sorted.length) {
696
- const target = sorted[num - 1];
697
- setActiveSessionForChat(chatState, target.id, sessionName(target));
698
- selectSession(server, target.id);
699
- await sendLongMessage(token, chatState,
700
- `Switched to session #${num}: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
701
- return;
702
- }
703
-
704
- // Try by name (case-insensitive partial match)
705
- const q = query.toLowerCase();
706
- const matches = sorted.filter(s => sessionName(s).toLowerCase().includes(q));
707
- if (matches.length === 0) {
708
- await sendLongMessage(token, chatState,
709
- `No session matching "${query}". Use /sessions to list active sessions.`);
710
- return;
711
- }
712
- if (matches.length > 1) {
713
- const list = matches.map(s => ` \`${sessionName(s)}\``).join('\n');
714
- await sendLongMessage(token, chatState,
715
- `Multiple matches for "${query}":\n${list}\n\nPlease be more specific or use a number.`);
716
- return;
717
- }
718
- const target = matches[0];
719
- setActiveSessionForChat(chatState, target.id);
720
- selectSession(server, target.id);
721
- await sendLongMessage(token, chatState,
722
- `Switched to session: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
723
- } catch (err) {
724
- await sendLongMessage(token, chatState, `Error switching session: ${err.message}`);
725
- }
726
- return;
618
+ if (sub === 'switch' || sub === 'new' || sub === 'rename') {
619
+ return cmdSession(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat);
727
620
  }
728
-
729
- // Unknown subcommand
730
621
  await sendLongMessage(token, chatState,
731
622
  `Unknown subcommand: /sessions ${sub}\n\n` +
732
623
  'Usage:\n' +
733
624
  ' `/sessions` ─ list sessions\n' +
734
- ' `/sessions switch <number-or-name>` ─ switch to a session\n' +
735
- ' `/sessions new [name]` ─ create a new session\n' +
736
- ' `/sessions rename <name>` ─ rename the active session');
625
+ ' `/session <name-or-number>` ─ switch to a session\n' +
626
+ ' `/session new [name]` ─ create a new session\n' +
627
+ ' `/session rename <name>` ─ rename the active session');
737
628
  return;
738
629
  }
739
630
 
@@ -807,48 +698,9 @@ async function cmdSessions(token, chatId, args, chatState, setServerForChat, set
807
698
 
808
699
  async function cmdAgents(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
809
700
  const server = chatState.serverBase;
701
+ // Backward compatibility: redirect /agents switch to /agent
810
702
  if (args.length > 0 && args[0].toLowerCase() === 'switch') {
811
- if (args.length < 2) {
812
- await sendLongMessage(token, chatState,
813
- 'Usage: `/agents switch <name>`\n' +
814
- 'Use `/agents` to list available agents.');
815
- return;
816
- }
817
- const name = args.slice(1).join(' ');
818
- try {
819
- const agents = await listAgents(server);
820
- if (!agents || agents.length === 0) {
821
- await sendLongMessage(token, chatState, 'No agents available.');
822
- return;
823
- }
824
- let matches = agents.filter(a => a.name === name);
825
- if (matches.length === 0) {
826
- matches = agents.filter(a => a.name.toLowerCase().includes(name.toLowerCase()));
827
- }
828
- if (matches.length === 0) {
829
- await sendLongMessage(token, chatState, `No agent matching "${name}".`);
830
- return;
831
- }
832
- if (matches.length > 1) {
833
- const list = matches.map(a => ` \`${a.name}\``).join('\n');
834
- await sendLongMessage(token, chatState,
835
- `Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
836
- return;
837
- }
838
- const target = matches[0];
839
- getState(server).activeAgentName = target.name;
840
- const st = getState(server);
841
- if (st.activeSessionId) {
842
- updateSession(server, st.activeSessionId, undefined, target.name);
843
- selectSession(server, st.activeSessionId);
844
- }
845
- const model = target.model ? ` ─ \`${target.model.providerID}/${target.model.modelID}\`` : '';
846
- await sendLongMessage(token, chatState,
847
- `Switched to agent: **${target.name}**${model}\nSubsequent messages will use this agent.`);
848
- } catch (err) {
849
- await sendLongMessage(token, chatState, `Error switching agent: ${err.message}`);
850
- }
851
- return;
703
+ return cmdAgent(token, chatId, args.slice(1), chatState, setServerForChat, setActiveSessionForChat);
852
704
  }
853
705
 
854
706
  try {
@@ -888,27 +740,124 @@ async function cmdModels(token, chatId, args, chatState, setServerForChat, setAc
888
740
  return;
889
741
  }
890
742
 
891
- const lines = [];
892
-
893
- for (const provider of filtered) {
894
- lines.push('');
895
- lines.push('**' + provider.name + '** ✅');
743
+ // Determine current model: session model first (the real in-use model,
744
+ // including manual /model TUI switches), then fall back to the agent's
745
+ // configured default model.
746
+ let currentProviderID = null;
747
+ let currentModelID = null;
748
+ const st = getState(server);
749
+ let activeSid = st.activeSessionId;
750
+ log.info(`[cmdModels] activeSessionId=${activeSid} activeAgentName=${st.activeAgentName}`);
751
+ // Auto-discover active session if not set
752
+ if (!activeSid) {
753
+ try {
754
+ const discovered = await discoverActiveSession(server);
755
+ if (discovered) {
756
+ st.activeSessionId = discovered.id;
757
+ st.activeSessionName = discovered.name;
758
+ activeSid = discovered.id;
759
+ log.info(`[cmdModels] auto-discovered session: ${discovered.name || discovered.id}`);
760
+ }
761
+ } catch (err) {
762
+ log.info(`[cmdModels] auto-discover error: ${err.message}`);
763
+ }
764
+ }
765
+ if (activeSid) {
766
+ try {
767
+ const sessions = await listSessions(server);
768
+ const session = sessions.find(s => String(s.id) === String(activeSid));
769
+ if (session && session.model) {
770
+ const m = session.model;
771
+ log.info(`[cmdModels] session model: ${JSON.stringify(m)}`);
772
+ if (typeof m === 'object' && m.providerID) {
773
+ currentProviderID = m.providerID;
774
+ currentModelID = m.modelID || m.id;
775
+ } else if (typeof m === 'string') {
776
+ const parts = m.split('/');
777
+ if (parts.length === 2) {
778
+ currentProviderID = parts[0];
779
+ currentModelID = parts[1];
780
+ }
781
+ }
782
+ } else {
783
+ log.info(`[cmdModels] session found=${!!session}, has model=${session && !!session.model}`);
784
+ }
785
+ } catch (err) {
786
+ log.info(`[cmdModels] listSessions error: ${err.message}`);
787
+ }
788
+ }
789
+ // Fallback: active agent's configured model
790
+ if (!currentModelID) {
791
+ const activeAgentName = st.activeAgentName;
792
+ if (activeAgentName) {
793
+ try {
794
+ const agents = await listAgents(server);
795
+ const activeAgent = agents.find(a => a.name === activeAgentName);
796
+ if (activeAgent && activeAgent.model) {
797
+ const am = activeAgent.model;
798
+ log.info(`[cmdModels] agent model: ${JSON.stringify(am)}`);
799
+ currentProviderID = am.providerID;
800
+ currentModelID = am.modelID || am.id;
801
+ } else {
802
+ log.info(`[cmdModels] agent found=${!!activeAgent}, has model=${activeAgent && !!activeAgent.model}`);
803
+ }
804
+ } catch (err) {
805
+ log.info(`[cmdModels] listAgents error: ${err.message}`);
806
+ }
807
+ }
808
+ }
809
+ log.info(`[cmdModels] result: provider=${currentProviderID} model=${currentModelID}`);
896
810
 
811
+ // /models <provider> — list all models for a matching provider
812
+ if (args.length > 0) {
813
+ const query = args.join(' ');
814
+ const matches = filtered.filter(p =>
815
+ p.name.toLowerCase().includes(query.toLowerCase()) ||
816
+ p.id.toLowerCase().includes(query.toLowerCase())
817
+ );
818
+ if (matches.length === 0) {
819
+ await sendLongMessage(token, chatState, `No provider matching "${query}".`);
820
+ return;
821
+ }
822
+ if (matches.length > 1) {
823
+ const list = matches.map(p => ' `' + p.name + '`').join('\n');
824
+ await sendLongMessage(token, chatState,
825
+ `Multiple matches for "${query}":\n${list}\n\nPlease be more specific.`);
826
+ return;
827
+ }
828
+ const provider = matches[0];
897
829
  const modelEntries = Object.entries(provider.models || {});
898
- if (modelEntries.length === 0) continue;
899
-
900
- const shown = modelEntries.slice(0, 5);
901
- for (const [id, model] of shown) {
902
- const ctx = model.limit ? Math.round(model.limit.context / 1000) + 'K' : '';
903
- const cost = model.cost ? ` $${model.cost.input}/${model.cost.output}` : '';
904
- lines.push(' `' + id + '`' + (ctx ? ' ' + ctx : '') + cost);
830
+ if (modelEntries.length === 0) {
831
+ await sendLongMessage(token, chatState,
832
+ '**' + provider.name + '** has no models configured.');
833
+ return;
905
834
  }
906
- if (modelEntries.length > 5) {
907
- lines.push(' *…and ' + (modelEntries.length - 5) + ' more*');
835
+ const lines = ['**' + provider.name + '** models:'];
836
+ for (const [id, model] of modelEntries) {
837
+ const isCurrent = currentProviderID === provider.id && currentModelID === id;
838
+ const bullet = isCurrent ? '\u25b6 ' : '\u00b7 ';
839
+ const extras = [];
840
+ if (model.limit) extras.push(Math.round(model.limit.context / 1000) + 'K ctx');
841
+ if (model.cost) extras.push(`$${model.cost.input}/$${model.cost.output}`);
842
+ const suffix = extras.length > 0 ? ` (*${extras.join(', ')}*)` : '';
843
+ lines.push(bullet + '`' + id + '`' + suffix);
908
844
  }
845
+ await sendLongMessage(token, chatState, lines.join('\n'));
846
+ return;
909
847
  }
910
848
 
911
- await sendLongMessage(token, chatState, 'Connected providers:\n' + lines.join('\n'));
849
+ // Default: list providers with model count per provider
850
+ const lines = ['**Connected providers:**'];
851
+ for (const provider of filtered) {
852
+ const modelCount = Object.keys(provider.models || {}).length;
853
+ const isCurrentProvider = currentProviderID === provider.id;
854
+ const bullet = isCurrentProvider ? '\u25b6 ' : '\u00b7 ';
855
+ lines.push(bullet + '**' + provider.name + '**' +
856
+ (modelCount > 0 ? ` (*${modelCount} models*)` : ' *No models*'));
857
+ }
858
+ lines.push('');
859
+ lines.push('\u{1f4a1} Use `/models <provider>` to list the models it provides.');
860
+ await sendLongMessage(token, chatState, lines.join('\n'));
912
861
  } catch (err) {
913
862
  await sendLongMessage(token, chatState, `Error listing models: ${err.message}`);
914
863
  }
@@ -1525,7 +1474,7 @@ async function cmdServe(token, chatId, args, chatState, setServerForChat, setAct
1525
1474
  } else {
1526
1475
  await sendLongMessage(token, chatState,
1527
1476
  `⚠️ Server already exists for \`${relPath}\`.\n` +
1528
- 'Use `/servers switch <name>` to connect to it.');
1477
+ 'Use `/server <name>` to connect to it.');
1529
1478
  }
1530
1479
  return;
1531
1480
  }
@@ -1614,6 +1563,319 @@ async function cmdNew(token, chatId, args, chatState, setServerForChat, setActiv
1614
1563
  log.info(`[chat ${chatId}] Created ${targetDir} → ${url}`);
1615
1564
  }
1616
1565
 
1566
+ async function cmdModel(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1567
+ const server = chatState.serverBase;
1568
+ const st = getState(server);
1569
+ // Auto-discover active session if not set
1570
+ if (!st.activeSessionId) {
1571
+ try {
1572
+ const discovered = await discoverActiveSession(server);
1573
+ if (discovered) {
1574
+ st.activeSessionId = discovered.id;
1575
+ st.activeSessionName = discovered.name;
1576
+ }
1577
+ } catch (err) {
1578
+ log.info(`[cmdModel] auto-discover error: ${err.message}`);
1579
+ }
1580
+ }
1581
+ if (args.length === 0) {
1582
+ await sendLongMessage(token, chatState,
1583
+ 'Usage: `/model <providerID/modelID>` or `/model <modelID>` to search.\n' +
1584
+ 'Example: `/model opencode/big-pickle` or `/model gpt-4`');
1585
+ return;
1586
+ }
1587
+ if (!st.activeSessionId) {
1588
+ await sendLongMessage(token, chatState, 'No active session. Use `/sessions` to select one.');
1589
+ return;
1590
+ }
1591
+ const query = args[0];
1592
+ let providerID, modelID;
1593
+
1594
+ // Try "providerID/modelID" format first
1595
+ if (query.includes('/')) {
1596
+ const parts = query.split('/');
1597
+ const rawProviderID = parts[0];
1598
+ modelID = parts[1];
1599
+ providerID = rawProviderID;
1600
+
1601
+ // Validate provider ID exists, or try partial match (e.g. "go" → "opencode-go")
1602
+ try {
1603
+ const result = await listProviders(server);
1604
+ const all = result.all || [];
1605
+ const connectedIds = result.connected || [];
1606
+ const connected = all.filter(p => connectedIds.includes(p.id));
1607
+ const exists = connected.some(p => p.id === rawProviderID);
1608
+ if (!exists) {
1609
+ const match = connected.find(p =>
1610
+ (p.name && p.name.toLowerCase().includes(rawProviderID.toLowerCase())) ||
1611
+ (p.id && p.id.toLowerCase().includes(rawProviderID.toLowerCase()))
1612
+ );
1613
+ if (match) {
1614
+ providerID = match.id;
1615
+ log.info(`[cmdModel] resolved "${rawProviderID}" → "${match.id}"`);
1616
+ } else {
1617
+ await sendLongMessage(token, chatState,
1618
+ `Provider "${rawProviderID}" not found. Use \`/models\` to list available providers.`);
1619
+ return;
1620
+ }
1621
+ }
1622
+ // Verify model exists under this provider
1623
+ const provider = all.find(p => p.id === providerID);
1624
+ if (!provider || !provider.models || !provider.models[modelID]) {
1625
+ const availableModels = provider && provider.models ? Object.keys(provider.models).join(', ') : 'unknown';
1626
+ log.info(`[cmdModel] model "${modelID}" not found in ${providerID}, available: ${availableModels}`);
1627
+ await sendLongMessage(token, chatState,
1628
+ `Model "${modelID}" not found under provider \`${providerID}\`. Use \`/models\` to list available models.`);
1629
+ return;
1630
+ }
1631
+ } catch (err) {
1632
+ log.info(`[cmdModel] provider validation error: ${err.message}`);
1633
+ // Continue with what we have — may still work
1634
+ }
1635
+ } else {
1636
+ // Search across all connected providers to resolve to providerID/modelID
1637
+ try {
1638
+ const result = await listProviders(server);
1639
+ const all = result.all || [];
1640
+ const connectedIds = result.connected || [];
1641
+ const connected = all.filter(p => connectedIds.includes(p.id));
1642
+ for (const provider of connected) {
1643
+ const models = Object.keys(provider.models || {});
1644
+ const match = models.find(m => m.toLowerCase() === query.toLowerCase());
1645
+ if (match) {
1646
+ providerID = provider.id;
1647
+ modelID = match;
1648
+ break;
1649
+ }
1650
+ }
1651
+ } catch (err) {
1652
+ log.info(`[cmdModel] listProviders error: ${err.message}`);
1653
+ }
1654
+ if (!modelID) {
1655
+ await sendLongMessage(token, chatState,
1656
+ `No model matching "${query}" found in any connected provider. Use \`/models\` to list available models.`);
1657
+ return;
1658
+ }
1659
+ }
1660
+
1661
+ // Send an async no-reply message with the desired model.
1662
+ // This triggers createUserMessage which fires ModelSwitched when the model differs,
1663
+ // persisting it as the session's current model.
1664
+ try {
1665
+ await sendToSessionAsync(server, st.activeSessionId, '.', null, { providerID, modelID }, true);
1666
+ const label = providerID ? `\`${providerID}/${modelID}\`` : `\`${modelID}\``;
1667
+ await sendLongMessage(token, chatState,
1668
+ `✅ Switched to model ${label}.`);
1669
+ log.info(`[chat ${chatId}] Switched to model ${providerID || '?'}/${modelID}`);
1670
+ } catch (err) {
1671
+ await sendLongMessage(token, chatState, `Error switching model: ${err.message}`);
1672
+ }
1673
+ }
1674
+
1675
+ async function cmdServer(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1676
+ if (args.length === 0) {
1677
+ await sendLongMessage(token, chatState,
1678
+ 'Usage: `/server <name>`\n' +
1679
+ 'To force-switch (take over a server from another chat), use `/server --force <name>`.\n' +
1680
+ '`<name>` can be a full directory path or a basename.\n' +
1681
+ 'Use `/servers` to list available servers.');
1682
+ return;
1683
+ }
1684
+ const forceIdx = args.indexOf('--force');
1685
+ const force = forceIdx !== -1;
1686
+ const nameArgs = args.filter((a, i) => a !== '--force');
1687
+ if (nameArgs.length === 0) {
1688
+ await sendLongMessage(token, chatState,
1689
+ 'Usage: `/server <name>`\n' +
1690
+ 'To force-switch, use `/server --force <name>`.');
1691
+ return;
1692
+ }
1693
+ const name = nameArgs.join(' ');
1694
+ const servers = listServers();
1695
+ if (servers.length === 0) {
1696
+ await sendLongMessage(token, chatState, 'No servers running.');
1697
+ return;
1698
+ }
1699
+ let matches = servers.filter(s => s.dir.toLowerCase() === name.toLowerCase());
1700
+ if (matches.length === 0) {
1701
+ matches = servers.filter(s => path.basename(s.dir).toLowerCase() === name.toLowerCase());
1702
+ }
1703
+ if (matches.length === 0) {
1704
+ const lowerName = name.toLowerCase();
1705
+ matches = servers.filter(s => s.dir.toLowerCase().includes(lowerName) || path.basename(s.dir).toLowerCase().includes(lowerName));
1706
+ }
1707
+ if (matches.length === 0) {
1708
+ await sendLongMessage(token, chatState, `No server found matching "${name}".`);
1709
+ return;
1710
+ }
1711
+ if (matches.length > 1) {
1712
+ let exact = servers.filter(s => s.dir === name);
1713
+ if (exact.length === 0) exact = servers.filter(s => path.basename(s.dir) === name);
1714
+ if (exact.length === 1) {
1715
+ matches = exact;
1716
+ } else {
1717
+ const list = matches.map(s => ` ${s.url} → ${s.dir}`).join('\n');
1718
+ await sendLongMessage(token, chatState,
1719
+ `Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
1720
+ return;
1721
+ }
1722
+ }
1723
+ const target = matches[0];
1724
+ const existing = serverOwners[target.url];
1725
+ if (existing && existing.chatId !== chatState.chatId && !force) {
1726
+ await sendLongMessage(token, chatState,
1727
+ `⚠️ Server \`${target.url}\` is already connected to another chat.\n\n` +
1728
+ `Use \`/server --force ${name}\` to take over (the other chat will be disconnected).`);
1729
+ return;
1730
+ }
1731
+ if (force && existing && existing.chatId !== chatState.chatId) {
1732
+ const oldState = getChatState(existing.chatId, existing.threadId);
1733
+ oldState.serverBase = null;
1734
+ sendLongMessage(token, oldState,
1735
+ `⚠️ Server \`${target.url}\` has been taken over by another chat.\n\n` +
1736
+ `Use \`/servers\` to see available servers and reconnect.`).catch(() => {});
1737
+ }
1738
+ const pending = await setServerForChat(chatState, target.url);
1739
+ activateServer(target.dir, target.index, target.url);
1740
+ let msg = `Switched to server: ${target.url} → ${target.dir}`;
1741
+ if (pending && pending.trim()) {
1742
+ await sendLongMessage(token, chatState, msg);
1743
+ await sendLongMessage(token, chatState, pending.trim());
1744
+ } else {
1745
+ await sendLongMessage(token, chatState, msg);
1746
+ }
1747
+ }
1748
+
1749
+ async function cmdSession(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1750
+ const server = chatState.serverBase;
1751
+ if (args.length === 0) {
1752
+ await sendLongMessage(token, chatState,
1753
+ '`/session <name-or-number>` ─ switch to a session\n' +
1754
+ '`/session new [name]` ─ create a new session\n' +
1755
+ '`/session rename <name>` ─ rename the active session\n' +
1756
+ 'Use `/sessions` to list available sessions.');
1757
+ return;
1758
+ }
1759
+ const sub = args[0].toLowerCase();
1760
+ if (sub === 'new') {
1761
+ const title = args.slice(1).join(' ') || undefined;
1762
+ try {
1763
+ const session = await createSession(server, title);
1764
+ const name = session.title || session.slug || session.id;
1765
+ setActiveSessionForChat(chatState, session.id, name);
1766
+ selectSession(server, session.id);
1767
+ await sendLongMessage(token, chatState,
1768
+ `Created session: \`${name}\`\nIt is now the active session.`);
1769
+ } catch (err) {
1770
+ await sendLongMessage(token, chatState, `Error creating session: ${err.message}`);
1771
+ }
1772
+ return;
1773
+ }
1774
+ if (sub === 'rename') {
1775
+ if (args.length < 2) {
1776
+ await sendLongMessage(token, chatState, 'Usage: `/session rename <name>`');
1777
+ return;
1778
+ }
1779
+ const st = getState(server);
1780
+ if (!st.activeSessionId) {
1781
+ await sendLongMessage(token, chatState, 'No active session. Use `/session <name>` to select one first.');
1782
+ return;
1783
+ }
1784
+ const newTitle = args.slice(1).join(' ');
1785
+ try {
1786
+ const session = await updateSession(server, st.activeSessionId, newTitle);
1787
+ const name = session.title || session.slug || session.id;
1788
+ await sendLongMessage(token, chatState, `Renamed session to: \`${name}\``);
1789
+ } catch (err) {
1790
+ await sendLongMessage(token, chatState, `Error renaming session: ${err.message}`);
1791
+ }
1792
+ return;
1793
+ }
1794
+ // Switch to session by name or number
1795
+ const query = args.join(' ');
1796
+ try {
1797
+ const projectDir = serverDir(server);
1798
+ const sessions = await listSessions(server, projectDir);
1799
+ const activeId = getState(server).activeSessionId;
1800
+ const filtered = sessions.filter(s =>
1801
+ (s.agent || s.directory || s.id === activeId) && (!projectDir || s.directory === projectDir));
1802
+ const sorted = sortedSessions(filtered, 50);
1803
+ const num = parseInt(query, 10);
1804
+ if (/^\d+$/.test(query) && num >= 1 && num <= sorted.length) {
1805
+ const target = sorted[num - 1];
1806
+ setActiveSessionForChat(chatState, target.id, sessionName(target));
1807
+ selectSession(server, target.id);
1808
+ await sendLongMessage(token, chatState,
1809
+ `Switched to session #${num}: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
1810
+ return;
1811
+ }
1812
+ const q = query.toLowerCase();
1813
+ const matches = sorted.filter(s => sessionName(s).toLowerCase().includes(q));
1814
+ if (matches.length === 0) {
1815
+ await sendLongMessage(token, chatState,
1816
+ `No session matching "${query}". Use /sessions to list active sessions.`);
1817
+ return;
1818
+ }
1819
+ if (matches.length > 1) {
1820
+ const list = matches.map(s => ` \`${sessionName(s)}\``).join('\n');
1821
+ await sendLongMessage(token, chatState,
1822
+ `Multiple matches for "${query}":\n${list}\n\nPlease be more specific or use a number.`);
1823
+ return;
1824
+ }
1825
+ const target = matches[0];
1826
+ setActiveSessionForChat(chatState, target.id);
1827
+ selectSession(server, target.id);
1828
+ await sendLongMessage(token, chatState,
1829
+ `Switched to session: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
1830
+ } catch (err) {
1831
+ await sendLongMessage(token, chatState, `Error switching session: ${err.message}`);
1832
+ }
1833
+ }
1834
+
1835
+ async function cmdAgent(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1836
+ const server = chatState.serverBase;
1837
+ if (args.length === 0) {
1838
+ await sendLongMessage(token, chatState,
1839
+ 'Usage: `/agent <name>`\n' +
1840
+ 'Use `/agents` to list available agents.');
1841
+ return;
1842
+ }
1843
+ const name = args.join(' ');
1844
+ try {
1845
+ const agents = await listAgents(server);
1846
+ if (!agents || agents.length === 0) {
1847
+ await sendLongMessage(token, chatState, 'No agents available.');
1848
+ return;
1849
+ }
1850
+ let matches = agents.filter(a => a.name === name);
1851
+ if (matches.length === 0) {
1852
+ matches = agents.filter(a => a.name.toLowerCase().includes(name.toLowerCase()));
1853
+ }
1854
+ if (matches.length === 0) {
1855
+ await sendLongMessage(token, chatState, `No agent matching "${name}".`);
1856
+ return;
1857
+ }
1858
+ if (matches.length > 1) {
1859
+ const list = matches.map(a => ` \`${a.name}\``).join('\n');
1860
+ await sendLongMessage(token, chatState,
1861
+ `Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
1862
+ return;
1863
+ }
1864
+ const target = matches[0];
1865
+ getState(server).activeAgentName = target.name;
1866
+ const st = getState(server);
1867
+ if (st.activeSessionId) {
1868
+ updateSession(server, st.activeSessionId, undefined, target.name);
1869
+ selectSession(server, st.activeSessionId);
1870
+ }
1871
+ const model = target.model ? ` ─ \`${target.model.providerID}/${target.model.modelID}\`` : '';
1872
+ await sendLongMessage(token, chatState,
1873
+ `Switched to agent: **${target.name}**${model}\nSubsequent messages will use this agent.`);
1874
+ } catch (err) {
1875
+ await sendLongMessage(token, chatState, `Error switching agent: ${err.message}`);
1876
+ }
1877
+ }
1878
+
1617
1879
  async function cmdMarkdown(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat, replyTo, replyToMsgId) {
1618
1880
  let entry = null;
1619
1881
  if (replyToMsgId) {
@@ -1637,11 +1899,13 @@ async function cmdMarkdown(token, chatId, args, chatState, setServerForChat, set
1637
1899
  const COMMANDS = {
1638
1900
  'help': cmdHelp,
1639
1901
  'servers': cmdServers,
1640
- 'force-switch': cmdForceSwitch,
1902
+ 'server': cmdServer,
1641
1903
  'sessions': cmdSessions,
1642
- 'session': cmdSessions,
1904
+ 'session': cmdSession,
1643
1905
  'agents': cmdAgents,
1906
+ 'agent': cmdAgent,
1644
1907
  'models': cmdModels,
1908
+ 'model': cmdModel,
1645
1909
  'think': cmdThink,
1646
1910
  'record': cmdRecord,
1647
1911
  'resurrect': cmdResurrect,
@@ -1851,6 +2115,7 @@ async function main() {
1851
2115
  if (srvDiscovered) {
1852
2116
  getState(url).activeSessionId = srvDiscovered.id;
1853
2117
  getState(url).activeSessionName = srvDiscovered.name;
2118
+ startSessionMonitor(url, srvDiscovered.id);
1854
2119
  log.info(` → session: ${srvDiscovered.name || srvDiscovered.id}`);
1855
2120
  }
1856
2121
  const defaultSrvAgent = srvAgents.find(a => a.mode === 'primary') || srvAgents[0];
@@ -1872,7 +2137,10 @@ async function main() {
1872
2137
  getState(server).activeSessionId = id;
1873
2138
  if (name) getState(server).activeSessionName = name;
1874
2139
  log.info(`[chat ${chatState.chatId}] Switched to session: ${name || id}`);
1875
- if (id !== prevId) sendGreeting(chatState);
2140
+ if (id !== prevId) {
2141
+ sendGreeting(chatState);
2142
+ startSessionMonitor(server, id);
2143
+ }
1876
2144
  };
1877
2145
 
1878
2146
  const TELEGRAM_GREETING_BODY = (sessionName) =>
@@ -1891,6 +2159,90 @@ async function main() {
1891
2159
  'For more details about a command:\n' +
1892
2160
  '[Telegram]{"command":"help","topic":"<upload|download|help>"}';
1893
2161
 
2162
+ /**
2163
+ * Start a persistent SSE session monitor that catches question.asked,
2164
+ * permission_asked, and permission_replied events in the background,
2165
+ * even when the AI was triggered by agentp (--tg) rather than by tgagentp.
2166
+ * Automatically restarted when the session or server changes.
2167
+ */
2168
+ function startSessionMonitor(server, sessionId) {
2169
+ const st = getState(server);
2170
+ if (st._monitorRef && st._monitorRef.current) {
2171
+ st._monitorRef.current.destroy();
2172
+ st._monitorRef.current = null;
2173
+ }
2174
+ const monRef = { current: null };
2175
+ st._monitorRef = monRef;
2176
+ st._monitorStarted = true;
2177
+
2178
+ const notifiedPerms = new Set();
2179
+
2180
+ listenForSessionEvents(server, sessionId, {
2181
+ onText: null,
2182
+ onConnected: null,
2183
+ onThinking: null,
2184
+ onPermission: (perm) => {
2185
+ const st2 = getState(server);
2186
+ const permDump = JSON.stringify(perm);
2187
+ log.debug(` [monitor] -> permission_asked callback: ${permDump}`);
2188
+ st2.pendingPermission = { server, sessionId, permissionId: perm.id, type: perm.permission || 'unspecified', pattern: perm.patterns || perm.pattern, title: perm.title };
2189
+ const ownerChat = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === normalizeUrl(server))?.[1];
2190
+ if (ownerChat && !notifiedPerms.has(perm.id)) {
2191
+ notifiedPerms.add(perm.id);
2192
+ const toolStr = perm.permission || 'unspecified';
2193
+ const patternArr = perm.patterns || (perm.pattern ? [perm.pattern] : []);
2194
+ const patternStr = patternArr.join(', ');
2195
+ const msg = '\u{1f510}**Permission needed**\n'
2196
+ + '**Tool:** `' + toolStr + '`\n'
2197
+ + (patternStr ? '**Target:** `' + patternStr + '`\n' : '')
2198
+ + (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
2199
+ + 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
2200
+ log.debug(` [monitor] -> sending permission notification to chat ${ownerChat.chatId}`);
2201
+ sendLongMessage(token, { chatId: ownerChat.chatId, threadId: ownerChat.threadId }, msg)
2202
+ .then(() => log.debug(` [monitor] -> permission notification sent OK`))
2203
+ .catch((err) => log.debug(` [monitor] -> permission notification failed: ${err.message}`));
2204
+ } else if (ownerChat) {
2205
+ log.debug(` [monitor] -> permission ${perm.id} already notified, updating pendingPermission only`);
2206
+ } else {
2207
+ log.debug(` [monitor] -> no owner chat found for server ${server}, skipping notification`);
2208
+ }
2209
+ },
2210
+ onPermissionReplied: () => {
2211
+ log.debug(` [monitor] -> permission_replied: clearing pendingPermission`);
2212
+ getState(server).pendingPermission = null;
2213
+ },
2214
+ onQuestion: (q) => {
2215
+ const st2 = getState(server);
2216
+ const qDump = JSON.stringify(q);
2217
+ log.debug(` [monitor] -> question_asked callback: ${qDump}`);
2218
+ st2.pendingQuestion = { server, sessionId, questionId: q.id, question: q.question || 'Unknown question', options: q.options || [] };
2219
+ const ownerChat = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === normalizeUrl(server))?.[1];
2220
+ if (ownerChat) {
2221
+ const options = (q.options || []).map((opt, i) => `${i + 1}. ${opt.text || opt}`).join('\n');
2222
+ const msg = '\u2753**Question from the AI**\n\n'
2223
+ + (q.question || 'Choose an option:') + '\n\n'
2224
+ + (options ? options + '\n\n' : '')
2225
+ + 'Use `/answer <number>` to respond.';
2226
+ log.debug(` [monitor] -> sending question notification to chat ${ownerChat.chatId}`);
2227
+ sendLongMessage(token, { chatId: ownerChat.chatId, threadId: ownerChat.threadId }, msg)
2228
+ .then(() => log.debug(` [monitor] -> question notification sent OK`))
2229
+ .catch((err) => log.debug(` [monitor] -> question notification failed: ${err.message}`));
2230
+ } else {
2231
+ log.debug(` [monitor] -> no owner chat found for server ${server}, skipping question notification`);
2232
+ }
2233
+ },
2234
+ }, monRef).then(() => {
2235
+ // Normal completion (90s safety timeout or stream ended)
2236
+ }, (err) => {
2237
+ log.debug(` [monitor] SSE listener for session ${sessionId} ended: ${err.message}`);
2238
+ }).finally(() => {
2239
+ if (getState(server)._monitorRef === monRef) {
2240
+ getState(server)._monitorRef = null;
2241
+ getState(server)._monitorStarted = false;
2242
+ }
2243
+ });
2244
+ }
2245
+
1894
2246
  async function sendGreeting(chatState) {
1895
2247
  const st = getState(chatState.serverBase);
1896
2248
  if (!st.activeSessionId) {
@@ -1928,10 +2280,17 @@ async function main() {
1928
2280
  if (st._sessionReq && st._sessionReq.current) { st._sessionReq.current.destroy(); st._sessionReq.current = null; }
1929
2281
  st._cancelRef = cancelRef;
1930
2282
  st._sessionReq = sessionCancelRef;
2283
+
2284
+ // Ensure the persistent session monitor is running so question/permission
2285
+ // events from any source (agentp, tgagentp, TUI) are caught, not just
2286
+ // those triggered by this message's per-request SSE listener.
2287
+ if (useSession && sessionId && !st._monitorStarted) {
2288
+ startSessionMonitor(url, sessionId);
2289
+ }
2290
+
1931
2291
  try {
1932
2292
  let answer;
1933
2293
  if (useSession) {
1934
- const notifiedPerms = new Set();
1935
2294
  let sseConnected;
1936
2295
  const onConnected = Promise.race([
1937
2296
  new Promise((resolve) => { sseConnected = resolve; }),
@@ -1953,48 +2312,9 @@ async function main() {
1953
2312
  .catch(() => {});
1954
2313
  }
1955
2314
  },
1956
- onPermission: (perm) => {
1957
- const st2 = getState(url);
1958
- const permDump = JSON.stringify(perm);
1959
- log.debug(` → permission_asked callback: ${permDump}`);
1960
- st2.pendingPermission = { server: url, sessionId, permissionId: perm.id, type: perm.permission || 'unspecified', pattern: perm.patterns || perm.pattern, title: perm.title };
1961
- if (!notifiedPerms.has(perm.id)) {
1962
- notifiedPerms.add(perm.id);
1963
- const toolStr = perm.permission || 'unspecified';
1964
- const patternArr = perm.patterns || (perm.pattern ? [perm.pattern] : []);
1965
- const patternStr = patternArr.join(', ');
1966
- const msg = '🔐 **Permission needed**\n'
1967
- + '**Tool:** `' + toolStr + '`\n'
1968
- + (patternStr ? '**Target:** `' + patternStr + '`\n' : '')
1969
- + (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
1970
- + 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
1971
- log.debug(` → sending permission notification to chat ${chatId}`);
1972
- sendLongMessage(token, chatState, msg, replyTo, threadId)
1973
- .then(() => log.debug(` → permission notification sent OK`))
1974
- .catch((err) => log.debug(` → permission notification failed: ${err.message}`));
1975
- } else {
1976
- log.debug(` → permission ${perm.id} already notified, updating pendingPermission only`);
1977
- }
1978
- },
1979
- onPermissionReplied: () => {
1980
- log.debug(` → permission_replied: clearing pendingPermission`);
1981
- getState(url).pendingPermission = null;
1982
- },
1983
- onQuestion: (q) => {
1984
- const st2 = getState(url);
1985
- const qDump = JSON.stringify(q);
1986
- log.debug(` → question_asked callback: ${qDump}`);
1987
- st2.pendingQuestion = { server: url, sessionId, questionId: q.id, question: q.question || 'Unknown question', options: q.options || [] };
1988
- const options = (q.options || []).map((opt, i) => `${i + 1}. ${opt.text || opt}`).join('\n');
1989
- const msg = '❓ **Question from the AI**\n\n'
1990
- + (q.question || 'Choose an option:') + '\n\n'
1991
- + (options ? options + '\n\n' : '')
1992
- + 'Use `/answer <number>` to respond.';
1993
- log.debug(` → sending question notification to chat ${chatId}`);
1994
- sendLongMessage(token, chatState, msg, replyTo, threadId)
1995
- .then(() => log.debug(` → question notification sent OK`))
1996
- .catch((err) => log.debug(` → question notification failed: ${err.message}`));
1997
- },
2315
+ // onPermission, onPermissionReplied, and onQuestion are handled by
2316
+ // the persistent session monitor (startSessionMonitor) so they are
2317
+ // caught even when agentp (--tg) triggers the AI response.
1998
2318
  }, cancelRef).catch(() => {});
1999
2319
  await onConnected;
2000
2320
  answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName, sessionCancelRef);
@@ -2003,8 +2323,8 @@ async function main() {
2003
2323
  sendLongMessage(token, chatState, html, replyTo, threadId).catch(() => {});
2004
2324
  st._thinkingBuf = '';
2005
2325
  }
2006
- // Keep the SSE listener alive so subsequent permission/question events
2007
- // that arrive after sendToSession returns are still forwarded to Telegram.
2326
+ // Keep the per-request SSE listener alive for streaming text events.
2327
+ // Questions and permissions are handled by the persistent monitor.
2008
2328
  } else {
2009
2329
  await sendText(url, `[Telegram] ${text}`);
2010
2330
  answer = await listenForFinalAnswer(url, null, cancelRef);
@@ -2059,7 +2379,7 @@ async function main() {
2059
2379
  ? `⚠️ Server \`${url}\` is unreachable. The message has been requeued and will be sent when the server is available again.\n\n` +
2060
2380
  `**Options:**\n` +
2061
2381
  `• Restart it: \`/resurrect\`\n` +
2062
- `• Switch to another server: \`/servers switch <name>\`\n` +
2382
+ `• Switch to another server: \`/server <name>\`\n` +
2063
2383
  `• Flush the queue: \`/flush\``
2064
2384
  : `Error: ${err.message}`;
2065
2385
  try { await sendLongMessage(token, chatState, msg, replyTo, threadId); } catch (_) {}
@@ -2143,24 +2463,32 @@ async function main() {
2143
2463
  // Handle /shutdown clear — wipe saved connections before exiting
2144
2464
  if (args && args.length > 0 && args[0].toLowerCase() === 'clear') {
2145
2465
  clearConnections();
2146
- await sendLongMessage(token, cs, '🧹 Connection state cleared.');
2147
- log.debug('/shutdown clear received, exiting...');
2148
- try { await getUpdates(token, offset, 1); } catch (_) {}
2466
+ await sendLongMessage(token, cs, '\u{1f9f9} Connection state cleared.').catch(() => {});
2467
+ log.info('/shutdown clear: exiting');
2149
2468
  process.exit(0);
2150
2469
  }
2151
2470
 
2152
- const isForce = args && args.length > 0 && args[0].toLowerCase() === 'force';
2471
+ // Parse numeric exit code from args (default 0). Uses regex /^\d+$/
2472
+ // to robustly match only pure-digit arguments.
2473
+ let exitCode = 0;
2474
+ for (const arg of (args || [])) {
2475
+ if (/^\d+$/.test(arg)) {
2476
+ exitCode = parseInt(arg, 10);
2477
+ break;
2478
+ }
2479
+ }
2480
+
2481
+ const isForce = args && args.some(a => a.toLowerCase() === 'force');
2153
2482
  if (st.busy && !isForce) {
2154
2483
  await sendLongMessage(token, cs,
2155
- '⏳ Server is busy processing a message.\n' +
2156
- 'Use `/shutdown force` to shut down anyway (in-flight response will be lost).');
2484
+ '\u23f3 Server is busy processing a message.\n' +
2485
+ 'Use `/shutdown force` to shut down anyway (in-flight response will be lost).').catch(() => {});
2157
2486
  return;
2158
2487
  }
2159
- const status = st.busy ? '⏳ busy (forced)' : '✅ idle';
2160
- await sendLongMessage(token, cs, `👋 Shutting down (state: ${status})...`);
2161
- try { await getUpdates(token, offset, 1); } catch (_) {}
2162
- log.debug('/shutdown received, exiting...');
2163
- process.exit(0);
2488
+ const status = st.busy ? '\u23f3 busy (forced)' : '\u2705 idle';
2489
+ await sendLongMessage(token, cs, `\u{1f44b} Shutting down (state: ${status}, exit ${exitCode})...`).catch(() => {});
2490
+ log.info(`/shutdown: exiting with code ${exitCode}`);
2491
+ process.exit(exitCode);
2164
2492
  };
2165
2493
 
2166
2494
  const cmdNote = async (token, chatId, cmdArgs, chatState) => {
@@ -2289,7 +2617,7 @@ async function main() {
2289
2617
  await sendLongMessage(token, cs,
2290
2618
  '🔄 Bot started. Previously connected to `' + dir + '`.\n' +
2291
2619
  '⚠️ The server at `' + state.url + '` is not reachable.\n' +
2292
- 'Use `/resurrect` to restart it, or `/servers switch <name>` to connect elsewhere.');
2620
+ 'Use `/resurrect` to restart it, or `/server <name>` to connect elsewhere.');
2293
2621
  } catch (_) {}
2294
2622
  }
2295
2623
  }
@@ -2335,6 +2663,18 @@ async function main() {
2335
2663
  // the key changes — new messages from newId will hit a new state)
2336
2664
  }
2337
2665
 
2666
+ // Drain stale updates from previous sessions before entering the poll loop.
2667
+ // Using timeout=1 to return immediately without long-polling.
2668
+ try {
2669
+ const drainResult = await getUpdates(token, 0, 1);
2670
+ if (drainResult && drainResult.ok && drainResult.result) {
2671
+ for (const u of drainResult.result) {
2672
+ offset = Math.max(offset, u.update_id + 1);
2673
+ }
2674
+ log.info(`Drained ${drainResult.result.length} stale update(s), offset ${offset}`);
2675
+ }
2676
+ } catch (_) {}
2677
+
2338
2678
  // Poll for updates and forward each text message to OpenCode, sending the answer back.
2339
2679
  while (true) {
2340
2680
  let result;
@@ -2426,7 +2766,7 @@ async function main() {
2426
2766
  `Your file upload has been queued (#${qlen}). It will be sent when the server becomes available.\n\n` +
2427
2767
  `**Options:**\n` +
2428
2768
  `• Restart it: \`/resurrect\`\n` +
2429
- `• Switch to another server: \`/servers switch <name>\`\n` +
2769
+ `• Switch to another server: \`/server <name>\`\n` +
2430
2770
  `• Flush the queue: \`/flush\``);
2431
2771
  continue;
2432
2772
  }
@@ -2576,11 +2916,11 @@ async function main() {
2576
2916
  const cmdArgs = parts.slice(1);
2577
2917
  const handler = cmds[cmd];
2578
2918
  if (handler) {
2579
- // Disconnected guard: only /help, /servers, /force-switch, /start, /comment, /shutdown, /serve, /new, and /resurrect work without a server
2580
- if (!cs.serverBase && cmd !== 'help' && cmd !== 'servers' && cmd !== 'force-switch' && cmd !== 'start' && cmd !== 'comment' && cmd !== 'shutdown' && cmd !== 'serve' && cmd !== 'new' && cmd !== 'resurrect') {
2919
+ // Disconnected guard: /help, /servers, /server, /start, /comment, /shutdown, /serve, /new, and /resurrect work without a server
2920
+ if (!cs.serverBase && cmd !== 'help' && cmd !== 'servers' && cmd !== 'server' && cmd !== 'start' && cmd !== 'comment' && cmd !== 'shutdown' && cmd !== 'serve' && cmd !== 'new' && cmd !== 'resurrect') {
2581
2921
  await sendLongMessage(token, cs,
2582
2922
  '🔌 Not connected to any server.\n\n' +
2583
- 'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
2923
+ 'Use `/servers` to see available servers and `/server <name>` to connect.');
2584
2924
  continue;
2585
2925
  }
2586
2926
  try {
@@ -2592,7 +2932,7 @@ async function main() {
2592
2932
  }
2593
2933
  } else {
2594
2934
  await sendLongMessage(token, cs,
2595
- `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /think, /record, /queue, /flush, /note, /comment, /disconnect, /serve, /new, /markdown`);
2935
+ `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /server, /sessions, /session, /agents, /agent, /models, /model, /think, /record, /queue, /flush, /note, /comment, /disconnect, /serve, /new, /markdown`);
2596
2936
  }
2597
2937
  continue;
2598
2938
  }
@@ -2601,7 +2941,7 @@ async function main() {
2601
2941
  if (!cs.serverBase) {
2602
2942
  await sendLongMessage(token, cs,
2603
2943
  '🔌 Not connected to any server.\n\n' +
2604
- 'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
2944
+ 'Use `/servers` to see available servers and `/server <name>` to connect.');
2605
2945
  continue;
2606
2946
  }
2607
2947
 
@@ -2628,7 +2968,7 @@ async function main() {
2628
2968
  const suggestions = [];
2629
2969
  suggestions.push(`• Restart it: \`/resurrect\``);
2630
2970
  if (servers.length > 0) {
2631
- suggestions.push(`• Switch to another server: \`/servers switch <name>\``);
2971
+ suggestions.push(`• Switch to another server: \`/server <name>\``);
2632
2972
  }
2633
2973
  suggestions.push(`• Flush the queue: \`/flush\``);
2634
2974
  await sendLongMessage(token, cs,