agentp 0.11.11 → 1.12.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
@@ -264,46 +264,80 @@ 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') {
@@ -390,7 +424,7 @@ async function cmdHelp(token, chatId, args, chatState) {
390
424
  if (topic === 'disconnect') {
391
425
  await sendLongMessage(token, chatState,
392
426
  '`/disconnect` ─ disconnect this chat from its server\n\n' +
393
- '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.');
394
428
  return;
395
429
  }
396
430
  if (topic === 'status') {
@@ -453,7 +487,7 @@ async function cmdHelp(token, chatId, args, chatState) {
453
487
  return;
454
488
  }
455
489
  await sendLongMessage(token, chatState,
456
- '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`.');
457
491
  return;
458
492
  }
459
493
  await sendLongMessage(token, chatState,
@@ -462,16 +496,16 @@ async function cmdHelp(token, chatId, args, chatState) {
462
496
  '`/status` ─ show current server and session\n' +
463
497
  '`/cancel` ─ cancel the running prompt\n' +
464
498
  '`/servers` ─ list running opencode servers\n' +
465
- '`/servers switch <name>` ─ switch to a different server\n' +
466
- '`/force-switch <name>` ─ take over a server from another chat\n' +
467
- '`/sessions` ─ list/create/switch/rename sessions\n' +
468
- '`/sessions switch <number-or-name>` ─ switch to a session\n' +
469
- '`/sessions new [name]` ─ create a new session\n' +
470
- '`/sessions rename <name>` ─ rename the active session\n' +
471
- '`/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' +
472
505
  '`/agents` ─ list available agents\n' +
473
- '`/agents switch <name>` ─ switch active agent\n' +
506
+ '`/agent <name>` ─ switch active agent\n' +
474
507
  '`/models` ─ list providers and models\n' +
508
+ '`/model <provider/model>` ─ switch session model\n' +
475
509
  '`/think` ─ toggle thinking message forwarding\n' +
476
510
  '`/record` ─ record Telegram conversation for agentp context\n' +
477
511
  '`/queue <message>` ─ queue a message when server is busy\n' +
@@ -489,90 +523,18 @@ async function cmdHelp(token, chatId, args, chatState) {
489
523
  'Everything else is forwarded to the active server as `[Telegram] <text>`.');
490
524
  }
491
525
 
492
- async function cmdForceSwitch(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
493
- return cmdServers(token, chatId, ['switch', '--force', ...args], chatState, setServerForChat, setActiveSessionForChat);
494
- }
495
-
496
526
  async function cmdServers(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
497
- const isForce = args.length > 0 && args[0].toLowerCase() === 'force-switch';
498
- if (args.length > 0 && (args[0].toLowerCase() === 'switch' || isForce)) {
499
- const forceIdx = args.indexOf('--force');
500
- const force = forceIdx !== -1 || isForce;
501
- const switchArgs = args.slice(1).filter(a => a !== '--force');
502
- if (switchArgs.length < 1) {
503
- await sendLongMessage(token, chatState,
504
- 'Usage: `/servers switch <name>`\n' +
505
- 'To force-switch (take over a server from another chat), use `/force-switch <name>`.\n' +
506
- '`<name>` can be a full directory path or a basename.');
507
- return;
508
- }
509
- const name = switchArgs.join(' ');
510
- const servers = listServers();
511
- if (servers.length === 0) {
512
- await sendLongMessage(token, chatState, 'No servers running.');
513
- return;
514
- }
515
-
516
- // Phase 1: case-insensitive exact match on dir or basename
517
- let matches = servers.filter(s => s.dir.toLowerCase() === name.toLowerCase());
518
- if (matches.length === 0) {
519
- matches = servers.filter(s => path.basename(s.dir).toLowerCase() === name.toLowerCase());
520
- }
521
- // Fallback: case-insensitive substring match
522
- if (matches.length === 0) {
523
- const lowerName = name.toLowerCase();
524
- matches = servers.filter(s => s.dir.toLowerCase().includes(lowerName) || path.basename(s.dir).toLowerCase().includes(lowerName));
525
- }
526
-
527
- if (matches.length === 1) {
528
- // Phase 1 found a single match — use it directly
529
- } else if (matches.length === 0) {
530
- await sendLongMessage(token, chatState, `No server found matching "${name}".`);
531
- return;
532
- } else {
533
- // Phase 2: case-sensitive exact match to disambiguate
534
- let exact = servers.filter(s => s.dir === name);
535
- if (exact.length === 0) {
536
- exact = servers.filter(s => path.basename(s.dir) === name);
537
- }
538
- if (exact.length === 1) {
539
- matches = exact;
540
- } else {
541
- const list = matches.map(s => ` ${s.url} → ${s.dir}`).join('\n');
542
- await sendLongMessage(token, chatState,
543
- `Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
544
- return;
545
- }
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);
546
534
  }
547
- const target = matches[0];
548
- const existing = serverOwners[target.url];
549
- if (existing && existing.chatId !== chatState.chatId && !force) {
550
- await sendLongMessage(token, chatState,
551
- `⚠️ Server \`${target.url}\` is already connected to another chat.\n\n` +
552
- `Use \`/servers force-switch ${name}\` to take over (the other chat will be disconnected).`);
553
- return;
535
+ if (sub === 'force-switch') {
536
+ return cmdServer(token, chatId, ['--force', ...args.slice(1)], chatState, setServerForChat, setActiveSessionForChat);
554
537
  }
555
- if (existing && existing.chatId === chatState.chatId) {
556
- // Already connected — re-run setup to refresh state
557
- }
558
- if (force && existing && existing.chatId !== chatState.chatId) {
559
- // Reset the old owner's server and notify them
560
- const oldState = getChatState(existing.chatId, existing.threadId);
561
- oldState.serverBase = null;
562
- sendLongMessage(token, oldState,
563
- `⚠️ Server \`${target.url}\` has been taken over by another chat.\n\n` +
564
- `Use \`/servers\` to see available servers and reconnect.`).catch(() => {});
565
- }
566
- const pending = await setServerForChat(chatState, target.url);
567
- activateServer(target.dir, target.index, target.url);
568
- let msg = `Switched to server: ${target.url} → ${target.dir}`;
569
- if (pending && pending.trim()) {
570
- await sendLongMessage(token, chatState, msg);
571
- await sendLongMessage(token, chatState, pending.trim());
572
- } else {
573
- await sendLongMessage(token, chatState, msg);
574
- }
575
- return;
576
538
  }
577
539
 
578
540
  // Default: list servers (alive + dead)
@@ -650,106 +612,19 @@ async function discoverActiveSession(server) {
650
612
 
651
613
  async function cmdSessions(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
652
614
  const server = chatState.serverBase;
615
+ // Backward compatibility: redirect old subcommands to singular commands
653
616
  if (args.length > 0) {
654
617
  const sub = args[0].toLowerCase();
655
-
656
- if (sub === 'new') {
657
- const title = args.slice(1).join(' ') || undefined;
658
- try {
659
- const session = await createSession(server, title);
660
- const name = session.title || session.slug || session.id;
661
- setActiveSessionForChat(chatState, session.id, name);
662
- selectSession(server, session.id);
663
- await sendLongMessage(token, chatState,
664
- `Created session: \`${name}\`\nIt is now the active session.`);
665
- } catch (err) {
666
- await sendLongMessage(token, chatState, `Error creating session: ${err.message}`);
667
- }
668
- return;
618
+ if (sub === 'switch' || sub === 'new' || sub === 'rename') {
619
+ return cmdSession(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat);
669
620
  }
670
-
671
- if (sub === 'rename') {
672
- if (args.length < 2) {
673
- await sendLongMessage(token, chatState, 'Usage: `/sessions rename <name>`');
674
- return;
675
- }
676
- const st = getState(server);
677
- if (!st.activeSessionId) {
678
- await sendLongMessage(token, chatState, 'No active session. Use `/sessions switch` or `/sessions new` first.');
679
- return;
680
- }
681
- const newTitle = args.slice(1).join(' ');
682
- try {
683
- const session = await updateSession(server, st.activeSessionId, newTitle);
684
- const name = session.title || session.slug || session.id;
685
- await sendLongMessage(token, chatState,
686
- `Renamed session to: \`${name}\``);
687
- } catch (err) {
688
- await sendLongMessage(token, chatState, `Error renaming session: ${err.message}`);
689
- }
690
- return;
691
- }
692
-
693
- if (sub === 'switch') {
694
- if (args.length < 2) {
695
- await sendLongMessage(token, chatState,
696
- 'Usage: `/sessions switch <number-or-name>`\n' +
697
- 'Use `/sessions` to see numbered list. You can switch by number or by name (case-insensitive partial match).');
698
- return;
699
- }
700
- const query = args.slice(1).join(' ');
701
- try {
702
- const projectDir = serverDir(server);
703
- const sessions = await listSessions(server, projectDir);
704
- const activeId = getState(server).activeSessionId;
705
- const filtered = sessions.filter(s =>
706
- (s.agent || s.directory || s.id === activeId) && (!projectDir || s.directory === projectDir));
707
- const sorted = sortedSessions(filtered, 50);
708
-
709
- // Try by position number first
710
- const num = parseInt(query, 10);
711
- if (/^\d+$/.test(query) && num >= 1 && num <= sorted.length) {
712
- const target = sorted[num - 1];
713
- setActiveSessionForChat(chatState, target.id, sessionName(target));
714
- selectSession(server, target.id);
715
- await sendLongMessage(token, chatState,
716
- `Switched to session #${num}: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
717
- return;
718
- }
719
-
720
- // Try by name (case-insensitive partial match)
721
- const q = query.toLowerCase();
722
- const matches = sorted.filter(s => sessionName(s).toLowerCase().includes(q));
723
- if (matches.length === 0) {
724
- await sendLongMessage(token, chatState,
725
- `No session matching "${query}". Use /sessions to list active sessions.`);
726
- return;
727
- }
728
- if (matches.length > 1) {
729
- const list = matches.map(s => ` \`${sessionName(s)}\``).join('\n');
730
- await sendLongMessage(token, chatState,
731
- `Multiple matches for "${query}":\n${list}\n\nPlease be more specific or use a number.`);
732
- return;
733
- }
734
- const target = matches[0];
735
- setActiveSessionForChat(chatState, target.id);
736
- selectSession(server, target.id);
737
- await sendLongMessage(token, chatState,
738
- `Switched to session: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
739
- } catch (err) {
740
- await sendLongMessage(token, chatState, `Error switching session: ${err.message}`);
741
- }
742
- return;
743
- }
744
-
745
- // Unknown subcommand
746
621
  await sendLongMessage(token, chatState,
747
622
  `Unknown subcommand: /sessions ${sub}\n\n` +
748
623
  'Usage:\n' +
749
624
  ' `/sessions` ─ list sessions\n' +
750
- ' `/sessions switch <number-or-name>` ─ switch to a session\n' +
751
- ' `/sessions new [name]` ─ create a new session\n' +
752
- ' `/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');
753
628
  return;
754
629
  }
755
630
 
@@ -823,48 +698,9 @@ async function cmdSessions(token, chatId, args, chatState, setServerForChat, set
823
698
 
824
699
  async function cmdAgents(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
825
700
  const server = chatState.serverBase;
701
+ // Backward compatibility: redirect /agents switch to /agent
826
702
  if (args.length > 0 && args[0].toLowerCase() === 'switch') {
827
- if (args.length < 2) {
828
- await sendLongMessage(token, chatState,
829
- 'Usage: `/agents switch <name>`\n' +
830
- 'Use `/agents` to list available agents.');
831
- return;
832
- }
833
- const name = args.slice(1).join(' ');
834
- try {
835
- const agents = await listAgents(server);
836
- if (!agents || agents.length === 0) {
837
- await sendLongMessage(token, chatState, 'No agents available.');
838
- return;
839
- }
840
- let matches = agents.filter(a => a.name === name);
841
- if (matches.length === 0) {
842
- matches = agents.filter(a => a.name.toLowerCase().includes(name.toLowerCase()));
843
- }
844
- if (matches.length === 0) {
845
- await sendLongMessage(token, chatState, `No agent matching "${name}".`);
846
- return;
847
- }
848
- if (matches.length > 1) {
849
- const list = matches.map(a => ` \`${a.name}\``).join('\n');
850
- await sendLongMessage(token, chatState,
851
- `Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
852
- return;
853
- }
854
- const target = matches[0];
855
- getState(server).activeAgentName = target.name;
856
- const st = getState(server);
857
- if (st.activeSessionId) {
858
- updateSession(server, st.activeSessionId, undefined, target.name);
859
- selectSession(server, st.activeSessionId);
860
- }
861
- const model = target.model ? ` ─ \`${target.model.providerID}/${target.model.modelID}\`` : '';
862
- await sendLongMessage(token, chatState,
863
- `Switched to agent: **${target.name}**${model}\nSubsequent messages will use this agent.`);
864
- } catch (err) {
865
- await sendLongMessage(token, chatState, `Error switching agent: ${err.message}`);
866
- }
867
- return;
703
+ return cmdAgent(token, chatId, args.slice(1), chatState, setServerForChat, setActiveSessionForChat);
868
704
  }
869
705
 
870
706
  try {
@@ -1181,20 +1017,38 @@ async function processTelegramCommands(text, server, token, chatState) {
1181
1017
  const filesSent = [];
1182
1018
  const dir = serverDir(server);
1183
1019
 
1020
+ // Resolve a file path: absolute paths used directly, relative paths resolved
1021
+ // against the server's working directory. Returns null and sends an error
1022
+ // message if the resolved path is outside the server's working directory.
1023
+ function resolveUploadPath(p, errPrefix) {
1024
+ const absolute = path.isAbsolute(p) ? p : path.resolve(dir || '', p);
1025
+ if (!dir) return absolute; // no server dir — trust the path
1026
+ let realDir, realPath;
1027
+ try { realDir = fs.realpathSync(dir); } catch { realDir = dir; }
1028
+ try { realPath = fs.realpathSync(absolute); } catch { realPath = absolute; }
1029
+ if (!realPath.startsWith(realDir + path.sep) && realPath !== realDir) {
1030
+ sendLongMessage(token, chatState,
1031
+ `❌ ${errPrefix}: path outside project directory.\n` +
1032
+ ` Path: \`${p}\`\n` +
1033
+ ` Project: \`${realDir}\`\n\n` +
1034
+ `Use a full path (starting with /) or a path relative to the project root.`).catch(() => {});
1035
+ return null;
1036
+ }
1037
+ return absolute;
1038
+ }
1039
+
1184
1040
  for (let i = 0; i < lines.length; i++) {
1185
1041
  const payload = parseTelegramLine(lines[i]);
1186
1042
  if (!payload) continue;
1187
1043
 
1188
1044
  if (payload.command === 'upload' && payload.path) {
1189
1045
  toStrip.add(i);
1190
- const resolved = dir ? path.resolve(dir, payload.path) : path.resolve(payload.path);
1191
- if (dir && !resolved.startsWith(dir + path.sep)) {
1192
- log.error(`processTelegramCommands: path outside project rejected: ${payload.path}`);
1193
- continue;
1194
- }
1046
+ const resolved = resolveUploadPath(payload.path, 'Upload');
1047
+ if (!resolved) continue;
1195
1048
  try {
1196
1049
  if (!fs.existsSync(resolved)) {
1197
- log.error(`processTelegramCommands: file not found: ${resolved}`);
1050
+ await sendLongMessage(token, chatState,
1051
+ `❌ File not found: \`${resolved}\``);
1198
1052
  continue;
1199
1053
  }
1200
1054
  const fileData = fs.readFileSync(resolved);
@@ -1203,22 +1057,21 @@ async function processTelegramCommands(text, server, token, chatState) {
1203
1057
  await sendTelegramDocument(token, chatState, fileData, fileName, caption);
1204
1058
  filesSent.push(fileName);
1205
1059
  } catch (err) {
1206
- log.error(`processTelegramCommands: failed to send file ${resolved}: ${err.message}`);
1060
+ await sendLongMessage(token, chatState,
1061
+ `❌ Failed to upload \`${resolved}\`: ${err.message}`);
1207
1062
  }
1208
1063
  } else if (payload.command === 'download' && payload.fileId && payload.path) {
1209
1064
  toStrip.add(i);
1210
- const resolved = dir ? path.resolve(dir, payload.path) : path.resolve(payload.path);
1211
- if (dir && !resolved.startsWith(dir + path.sep)) {
1212
- log.error(`processTelegramCommands: download path outside project rejected: ${payload.path}`);
1213
- continue;
1214
- }
1065
+ const resolved = resolveUploadPath(payload.path, 'Download');
1066
+ if (!resolved) continue;
1215
1067
  try {
1216
1068
  const fileData = await downloadTelegramFile(token, payload.fileId);
1217
1069
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
1218
1070
  fs.writeFileSync(resolved, fileData);
1219
1071
  filesSent.push(path.basename(resolved));
1220
1072
  } catch (err) {
1221
- log.error(`processTelegramCommands: failed to download file: ${err.message}`);
1073
+ await sendLongMessage(token, chatState,
1074
+ `❌ Failed to download file to \`${resolved}\`: ${err.message}`);
1222
1075
  }
1223
1076
  } else if (payload.command === 'help') {
1224
1077
  toStrip.add(i);
@@ -1638,7 +1491,7 @@ async function cmdServe(token, chatId, args, chatState, setServerForChat, setAct
1638
1491
  } else {
1639
1492
  await sendLongMessage(token, chatState,
1640
1493
  `⚠️ Server already exists for \`${relPath}\`.\n` +
1641
- 'Use `/servers switch <name>` to connect to it.');
1494
+ 'Use `/server <name>` to connect to it.');
1642
1495
  }
1643
1496
  return;
1644
1497
  }
@@ -1836,6 +1689,210 @@ async function cmdModel(token, chatId, args, chatState, setServerForChat, setAct
1836
1689
  }
1837
1690
  }
1838
1691
 
1692
+ async function cmdServer(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1693
+ if (args.length === 0) {
1694
+ await sendLongMessage(token, chatState,
1695
+ 'Usage: `/server <name>`\n' +
1696
+ 'To force-switch (take over a server from another chat), use `/server --force <name>`.\n' +
1697
+ '`<name>` can be a full directory path or a basename.\n' +
1698
+ 'Use `/servers` to list available servers.');
1699
+ return;
1700
+ }
1701
+ const forceIdx = args.indexOf('--force');
1702
+ const force = forceIdx !== -1;
1703
+ const nameArgs = args.filter((a, i) => a !== '--force');
1704
+ if (nameArgs.length === 0) {
1705
+ await sendLongMessage(token, chatState,
1706
+ 'Usage: `/server <name>`\n' +
1707
+ 'To force-switch, use `/server --force <name>`.');
1708
+ return;
1709
+ }
1710
+ const name = nameArgs.join(' ');
1711
+ const servers = listServers();
1712
+ if (servers.length === 0) {
1713
+ await sendLongMessage(token, chatState, 'No servers running.');
1714
+ return;
1715
+ }
1716
+ let matches = servers.filter(s => s.dir.toLowerCase() === name.toLowerCase());
1717
+ if (matches.length === 0) {
1718
+ matches = servers.filter(s => path.basename(s.dir).toLowerCase() === name.toLowerCase());
1719
+ }
1720
+ if (matches.length === 0) {
1721
+ const lowerName = name.toLowerCase();
1722
+ matches = servers.filter(s => s.dir.toLowerCase().includes(lowerName) || path.basename(s.dir).toLowerCase().includes(lowerName));
1723
+ }
1724
+ if (matches.length === 0) {
1725
+ await sendLongMessage(token, chatState, `No server found matching "${name}".`);
1726
+ return;
1727
+ }
1728
+ if (matches.length > 1) {
1729
+ let exact = servers.filter(s => s.dir === name);
1730
+ if (exact.length === 0) exact = servers.filter(s => path.basename(s.dir) === name);
1731
+ if (exact.length === 1) {
1732
+ matches = exact;
1733
+ } else {
1734
+ const list = matches.map(s => ` ${s.url} → ${s.dir}`).join('\n');
1735
+ await sendLongMessage(token, chatState,
1736
+ `Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
1737
+ return;
1738
+ }
1739
+ }
1740
+ const target = matches[0];
1741
+ const existing = serverOwners[target.url];
1742
+ if (existing && existing.chatId !== chatState.chatId && !force) {
1743
+ await sendLongMessage(token, chatState,
1744
+ `⚠️ Server \`${target.url}\` is already connected to another chat.\n\n` +
1745
+ `Use \`/server --force ${name}\` to take over (the other chat will be disconnected).`);
1746
+ return;
1747
+ }
1748
+ if (force && existing && existing.chatId !== chatState.chatId) {
1749
+ const oldState = getChatState(existing.chatId, existing.threadId);
1750
+ oldState.serverBase = null;
1751
+ sendLongMessage(token, oldState,
1752
+ `⚠️ Server \`${target.url}\` has been taken over by another chat.\n\n` +
1753
+ `Use \`/servers\` to see available servers and reconnect.`).catch(() => {});
1754
+ }
1755
+ const pending = await setServerForChat(chatState, target.url);
1756
+ activateServer(target.dir, target.index, target.url);
1757
+ let msg = `Switched to server: ${target.url} → ${target.dir}`;
1758
+ if (pending && pending.trim()) {
1759
+ await sendLongMessage(token, chatState, msg);
1760
+ await sendLongMessage(token, chatState, pending.trim());
1761
+ } else {
1762
+ await sendLongMessage(token, chatState, msg);
1763
+ }
1764
+ }
1765
+
1766
+ async function cmdSession(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1767
+ const server = chatState.serverBase;
1768
+ if (args.length === 0) {
1769
+ await sendLongMessage(token, chatState,
1770
+ '`/session <name-or-number>` ─ switch to a session\n' +
1771
+ '`/session new [name]` ─ create a new session\n' +
1772
+ '`/session rename <name>` ─ rename the active session\n' +
1773
+ 'Use `/sessions` to list available sessions.');
1774
+ return;
1775
+ }
1776
+ const sub = args[0].toLowerCase();
1777
+ if (sub === 'new') {
1778
+ const title = args.slice(1).join(' ') || undefined;
1779
+ try {
1780
+ const session = await createSession(server, title);
1781
+ const name = session.title || session.slug || session.id;
1782
+ setActiveSessionForChat(chatState, session.id, name);
1783
+ selectSession(server, session.id);
1784
+ await sendLongMessage(token, chatState,
1785
+ `Created session: \`${name}\`\nIt is now the active session.`);
1786
+ } catch (err) {
1787
+ await sendLongMessage(token, chatState, `Error creating session: ${err.message}`);
1788
+ }
1789
+ return;
1790
+ }
1791
+ if (sub === 'rename') {
1792
+ if (args.length < 2) {
1793
+ await sendLongMessage(token, chatState, 'Usage: `/session rename <name>`');
1794
+ return;
1795
+ }
1796
+ const st = getState(server);
1797
+ if (!st.activeSessionId) {
1798
+ await sendLongMessage(token, chatState, 'No active session. Use `/session <name>` to select one first.');
1799
+ return;
1800
+ }
1801
+ const newTitle = args.slice(1).join(' ');
1802
+ try {
1803
+ const session = await updateSession(server, st.activeSessionId, newTitle);
1804
+ const name = session.title || session.slug || session.id;
1805
+ await sendLongMessage(token, chatState, `Renamed session to: \`${name}\``);
1806
+ } catch (err) {
1807
+ await sendLongMessage(token, chatState, `Error renaming session: ${err.message}`);
1808
+ }
1809
+ return;
1810
+ }
1811
+ // Switch to session by name or number
1812
+ const query = args.join(' ');
1813
+ try {
1814
+ const projectDir = serverDir(server);
1815
+ const sessions = await listSessions(server, projectDir);
1816
+ const activeId = getState(server).activeSessionId;
1817
+ const filtered = sessions.filter(s =>
1818
+ (s.agent || s.directory || s.id === activeId) && (!projectDir || s.directory === projectDir));
1819
+ const sorted = sortedSessions(filtered, 50);
1820
+ const num = parseInt(query, 10);
1821
+ if (/^\d+$/.test(query) && num >= 1 && num <= sorted.length) {
1822
+ const target = sorted[num - 1];
1823
+ setActiveSessionForChat(chatState, target.id, sessionName(target));
1824
+ selectSession(server, target.id);
1825
+ await sendLongMessage(token, chatState,
1826
+ `Switched to session #${num}: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
1827
+ return;
1828
+ }
1829
+ const q = query.toLowerCase();
1830
+ const matches = sorted.filter(s => sessionName(s).toLowerCase().includes(q));
1831
+ if (matches.length === 0) {
1832
+ await sendLongMessage(token, chatState,
1833
+ `No session matching "${query}". Use /sessions to list active sessions.`);
1834
+ return;
1835
+ }
1836
+ if (matches.length > 1) {
1837
+ const list = matches.map(s => ` \`${sessionName(s)}\``).join('\n');
1838
+ await sendLongMessage(token, chatState,
1839
+ `Multiple matches for "${query}":\n${list}\n\nPlease be more specific or use a number.`);
1840
+ return;
1841
+ }
1842
+ const target = matches[0];
1843
+ setActiveSessionForChat(chatState, target.id);
1844
+ selectSession(server, target.id);
1845
+ await sendLongMessage(token, chatState,
1846
+ `Switched to session: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
1847
+ } catch (err) {
1848
+ await sendLongMessage(token, chatState, `Error switching session: ${err.message}`);
1849
+ }
1850
+ }
1851
+
1852
+ async function cmdAgent(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1853
+ const server = chatState.serverBase;
1854
+ if (args.length === 0) {
1855
+ await sendLongMessage(token, chatState,
1856
+ 'Usage: `/agent <name>`\n' +
1857
+ 'Use `/agents` to list available agents.');
1858
+ return;
1859
+ }
1860
+ const name = args.join(' ');
1861
+ try {
1862
+ const agents = await listAgents(server);
1863
+ if (!agents || agents.length === 0) {
1864
+ await sendLongMessage(token, chatState, 'No agents available.');
1865
+ return;
1866
+ }
1867
+ let matches = agents.filter(a => a.name === name);
1868
+ if (matches.length === 0) {
1869
+ matches = agents.filter(a => a.name.toLowerCase().includes(name.toLowerCase()));
1870
+ }
1871
+ if (matches.length === 0) {
1872
+ await sendLongMessage(token, chatState, `No agent matching "${name}".`);
1873
+ return;
1874
+ }
1875
+ if (matches.length > 1) {
1876
+ const list = matches.map(a => ` \`${a.name}\``).join('\n');
1877
+ await sendLongMessage(token, chatState,
1878
+ `Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
1879
+ return;
1880
+ }
1881
+ const target = matches[0];
1882
+ getState(server).activeAgentName = target.name;
1883
+ const st = getState(server);
1884
+ if (st.activeSessionId) {
1885
+ updateSession(server, st.activeSessionId, undefined, target.name);
1886
+ selectSession(server, st.activeSessionId);
1887
+ }
1888
+ const model = target.model ? ` ─ \`${target.model.providerID}/${target.model.modelID}\`` : '';
1889
+ await sendLongMessage(token, chatState,
1890
+ `Switched to agent: **${target.name}**${model}\nSubsequent messages will use this agent.`);
1891
+ } catch (err) {
1892
+ await sendLongMessage(token, chatState, `Error switching agent: ${err.message}`);
1893
+ }
1894
+ }
1895
+
1839
1896
  async function cmdMarkdown(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat, replyTo, replyToMsgId) {
1840
1897
  let entry = null;
1841
1898
  if (replyToMsgId) {
@@ -1859,10 +1916,11 @@ async function cmdMarkdown(token, chatId, args, chatState, setServerForChat, set
1859
1916
  const COMMANDS = {
1860
1917
  'help': cmdHelp,
1861
1918
  'servers': cmdServers,
1862
- 'force-switch': cmdForceSwitch,
1919
+ 'server': cmdServer,
1863
1920
  'sessions': cmdSessions,
1864
- 'session': cmdSessions,
1921
+ 'session': cmdSession,
1865
1922
  'agents': cmdAgents,
1923
+ 'agent': cmdAgent,
1866
1924
  'models': cmdModels,
1867
1925
  'model': cmdModel,
1868
1926
  'think': cmdThink,
@@ -2113,8 +2171,13 @@ async function main() {
2113
2171
  'Do NOT use CLI tools to generate them — just include them directly in your response:\n\n' +
2114
2172
  'To send a file to the user:\n' +
2115
2173
  '[Telegram]{"command":"upload","path":"<relative-path>","msg":"<optional caption>"}\n\n' +
2174
+ '**Important:** `path` is relative to the project root (the OpenCode server\'s working directory).\n' +
2175
+ 'If the project uses a git worktree, the worktree directory IS the project root.\n' +
2176
+ 'Use CLI commands like `cat ./path/to/file` to verify the file exists before uploading.\n\n' +
2116
2177
  'To download a file the user sent:\n' +
2117
2178
  '[Telegram]{"command":"download","fileId":"<id>","path":"<relative-destination>"}\n\n' +
2179
+ '**Important:** `path` is also relative to the project root (the OpenCode server\'s working directory).\n' +
2180
+ 'Use `ls` or `pwd` to confirm the current working directory before specifying download paths.\n\n' +
2118
2181
  'For more details about a command:\n' +
2119
2182
  '[Telegram]{"command":"help","topic":"<upload|download|help>"}';
2120
2183
 
@@ -2133,6 +2196,7 @@ async function main() {
2133
2196
  const monRef = { current: null };
2134
2197
  st._monitorRef = monRef;
2135
2198
  st._monitorStarted = true;
2199
+ st._monitoredSessionId = sessionId;
2136
2200
 
2137
2201
  const notifiedPerms = new Set();
2138
2202
 
@@ -2198,6 +2262,7 @@ async function main() {
2198
2262
  if (getState(server)._monitorRef === monRef) {
2199
2263
  getState(server)._monitorRef = null;
2200
2264
  getState(server)._monitorStarted = false;
2265
+ getState(server)._monitoredSessionId = null;
2201
2266
  }
2202
2267
  });
2203
2268
  }
@@ -2230,6 +2295,20 @@ async function main() {
2230
2295
 
2231
2296
  async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId, replyTo, threadId) {
2232
2297
  const st = getState(url);
2298
+ // Auto-discover active session if not set
2299
+ if (!sessionId) {
2300
+ try {
2301
+ const discovered = await discoverActiveSession(url);
2302
+ if (discovered) {
2303
+ st.activeSessionId = discovered.id;
2304
+ st.activeSessionName = discovered.name;
2305
+ sessionId = discovered.id;
2306
+ useSession = true;
2307
+ }
2308
+ } catch (err) {
2309
+ log.debug(`processMessageAsync: auto-discover error: ${err.message}`);
2310
+ }
2311
+ }
2233
2312
  const isCancelled = () => getState(url).taskId !== myTaskId;
2234
2313
  const cancelRef = { current: null };
2235
2314
  const chatState = { chatId, threadId };
@@ -2240,11 +2319,12 @@ async function main() {
2240
2319
  st._cancelRef = cancelRef;
2241
2320
  st._sessionReq = sessionCancelRef;
2242
2321
 
2243
- // Ensure the persistent session monitor is running so question/permission
2244
- // events from any source (agentp, tgagentp, TUI) are caught, not just
2245
- // those triggered by this message's per-request SSE listener.
2246
- if (useSession && sessionId && !st._monitorStarted) {
2247
- startSessionMonitor(url, sessionId);
2322
+ // Ensure the persistent session monitor is running and tracking the current session.
2323
+ // Always restart when sessionId changes so TUI-initiated session switches are followed.
2324
+ if (useSession && sessionId) {
2325
+ if (!st._monitorStarted || st._monitoredSessionId !== sessionId) {
2326
+ startSessionMonitor(url, sessionId);
2327
+ }
2248
2328
  }
2249
2329
 
2250
2330
  try {
@@ -2338,7 +2418,7 @@ async function main() {
2338
2418
  ? `⚠️ Server \`${url}\` is unreachable. The message has been requeued and will be sent when the server is available again.\n\n` +
2339
2419
  `**Options:**\n` +
2340
2420
  `• Restart it: \`/resurrect\`\n` +
2341
- `• Switch to another server: \`/servers switch <name>\`\n` +
2421
+ `• Switch to another server: \`/server <name>\`\n` +
2342
2422
  `• Flush the queue: \`/flush\``
2343
2423
  : `Error: ${err.message}`;
2344
2424
  try { await sendLongMessage(token, chatState, msg, replyTo, threadId); } catch (_) {}
@@ -2576,7 +2656,7 @@ async function main() {
2576
2656
  await sendLongMessage(token, cs,
2577
2657
  '🔄 Bot started. Previously connected to `' + dir + '`.\n' +
2578
2658
  '⚠️ The server at `' + state.url + '` is not reachable.\n' +
2579
- 'Use `/resurrect` to restart it, or `/servers switch <name>` to connect elsewhere.');
2659
+ 'Use `/resurrect` to restart it, or `/server <name>` to connect elsewhere.');
2580
2660
  } catch (_) {}
2581
2661
  }
2582
2662
  }
@@ -2725,7 +2805,7 @@ async function main() {
2725
2805
  `Your file upload has been queued (#${qlen}). It will be sent when the server becomes available.\n\n` +
2726
2806
  `**Options:**\n` +
2727
2807
  `• Restart it: \`/resurrect\`\n` +
2728
- `• Switch to another server: \`/servers switch <name>\`\n` +
2808
+ `• Switch to another server: \`/server <name>\`\n` +
2729
2809
  `• Flush the queue: \`/flush\``);
2730
2810
  continue;
2731
2811
  }
@@ -2875,11 +2955,11 @@ async function main() {
2875
2955
  const cmdArgs = parts.slice(1);
2876
2956
  const handler = cmds[cmd];
2877
2957
  if (handler) {
2878
- // Disconnected guard: only /help, /servers, /force-switch, /start, /comment, /shutdown, /serve, /new, and /resurrect work without a server
2879
- if (!cs.serverBase && cmd !== 'help' && cmd !== 'servers' && cmd !== 'force-switch' && cmd !== 'start' && cmd !== 'comment' && cmd !== 'shutdown' && cmd !== 'serve' && cmd !== 'new' && cmd !== 'resurrect') {
2958
+ // Disconnected guard: /help, /servers, /server, /start, /comment, /shutdown, /serve, /new, and /resurrect work without a server
2959
+ if (!cs.serverBase && cmd !== 'help' && cmd !== 'servers' && cmd !== 'server' && cmd !== 'start' && cmd !== 'comment' && cmd !== 'shutdown' && cmd !== 'serve' && cmd !== 'new' && cmd !== 'resurrect') {
2880
2960
  await sendLongMessage(token, cs,
2881
2961
  '🔌 Not connected to any server.\n\n' +
2882
- 'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
2962
+ 'Use `/servers` to see available servers and `/server <name>` to connect.');
2883
2963
  continue;
2884
2964
  }
2885
2965
  try {
@@ -2891,7 +2971,7 @@ async function main() {
2891
2971
  }
2892
2972
  } else {
2893
2973
  await sendLongMessage(token, cs,
2894
- `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /model, /think, /record, /queue, /flush, /note, /comment, /disconnect, /serve, /new, /markdown`);
2974
+ `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`);
2895
2975
  }
2896
2976
  continue;
2897
2977
  }
@@ -2900,7 +2980,7 @@ async function main() {
2900
2980
  if (!cs.serverBase) {
2901
2981
  await sendLongMessage(token, cs,
2902
2982
  '🔌 Not connected to any server.\n\n' +
2903
- 'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
2983
+ 'Use `/servers` to see available servers and `/server <name>` to connect.');
2904
2984
  continue;
2905
2985
  }
2906
2986
 
@@ -2927,7 +3007,7 @@ async function main() {
2927
3007
  const suggestions = [];
2928
3008
  suggestions.push(`• Restart it: \`/resurrect\``);
2929
3009
  if (servers.length > 0) {
2930
- suggestions.push(`• Switch to another server: \`/servers switch <name>\``);
3010
+ suggestions.push(`• Switch to another server: \`/server <name>\``);
2931
3011
  }
2932
3012
  suggestions.push(`• Flush the queue: \`/flush\``);
2933
3013
  await sendLongMessage(token, cs,