agentp 0.11.7 → 0.11.8

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/README.md CHANGED
@@ -7,9 +7,9 @@
7
7
 
8
8
  This package provides three CLI tools:
9
9
 
10
- - **`agentp`** — pipes prompt text into a running OpenCode TUI session and streams the assistant final answer back to stdout
10
+ - **`agentp`** — pipes prompt text into a running OpenCode server and streams the assistant final answer back to stdout
11
11
  - **`ocmux`** — manages OpenCode server + TUI sessions in tmux (create, switch, kill, list)
12
- - **`tgagentp`** — bridges a Telegram bot chat with all running OpenCode TUI sessions (receives messages from Telegram, routes them to the active server, sends answers back). Supports slash commands for multi-server management, session switching, agent/model listing, and remote restart.
12
+ - **`tgagentp`** — bridges a Telegram bot chat with all running OpenCode servers (receives messages from Telegram, routes them to the active server, sends answers back). Supports slash commands for multi-server management, session switching, agent/model listing, including file sharing from the chat.
13
13
 
14
14
  It is designed for prompt-driven workflows where you want to do things like:
15
15
 
@@ -61,6 +61,8 @@ Options:
61
61
  - `--no-tg`: do not forward to Telegram
62
62
  - `--flush`: flush tgagentp's recorded buffer without prepending it to output
63
63
  - `--getLast <n>`: retrieve last n assistant answers from session history
64
+ - `--session <name>`: target a specific session by name (exact or partial match)
65
+ - `--new`: create a new session with the given title (requires `--session`)
64
66
  - `--version`: show version
65
67
  - `--help`: show help message
66
68
 
@@ -150,8 +152,6 @@ Useful to grab recent answers without sending a new prompt.
150
152
 
151
153
  ## ocmux
152
154
 
153
- ## ocmux
154
-
155
155
  Manage OpenCode server + TUI in tmux for project directories.
156
156
 
157
157
  ```bash
@@ -235,6 +235,8 @@ Non-text Telegram updates (photos, stickers, etc.) are silently ignored.
235
235
  | `/help [topic]` | Show general help or help for a topic (`servers`, `sessions`, `agents`, `models`, `allow`, `think`, `record`, `queue`) |
236
236
  | `/servers` | List all running ocmux-served projects with URL + status (✅ idle / ⏳ busy) |
237
237
  | `/servers switch <name> [--force]` | Switch active server; matches by full path, basename, or substring; `--force` takes over from another chat |
238
+ | `/serve <path>` | Start a server in an existing directory under `TGAGENTP_ROOT` |
239
+ | `/new <path>` | Create a directory, init git, and start a server under `TGAGENTP_ROOT` |
238
240
  | `/sessions` | List recent sessions for the current server (max 50, with date headings) |
239
241
  | `/sessions switch <number\|name>` | Switch active session by position or partial name match |
240
242
  | `/agents` | List primary agents (▶ marker for the active one) |
@@ -251,6 +253,7 @@ Non-text Telegram updates (photos, stickers, etc.) are silently ignored.
251
253
  | `/reject` | Deny a permission request |
252
254
  | `/always` | Approve and remember for the session |
253
255
  | `/answer <number>` | Respond to a question asked by the AI (structured multiple-choice) |
256
+ | `/markdown` | Send the original markdown of the last response as a `.md` file; reply to a message to get that specific response |
254
257
  | `/shutdown [force\|clear]` | (requires `--dev`) Stop tgagentp; `clear` also wipes saved connections |
255
258
 
256
259
  #### TUI Command Passthrough
@@ -328,6 +331,7 @@ Chat-to-server directory mappings are saved to `/tmp/tgagentp-connections.json`
328
331
  | `TGAGENTP_ALLOWED_CHAT_IDS` | Optional. Comma-separated Telegram chat IDs that are allowed to use the bot. |
329
332
  | `TGAGENTP_PORT` | Optional. Agentp gateway listen port (default: `0` = random). |
330
333
  | `TGAGENTP_DEBOUNCE_MS` | Optional. Debounce interval for queued-message notifications (default: `5000`). |
334
+ | `TGAGENTP_ROOT` | Optional. Root directory for `/serve` and `/new` commands (must be writable). |
331
335
 
332
336
  ## License
333
337
 
package/bin/tgagentp CHANGED
@@ -162,6 +162,33 @@ function flushRecorded(chatId, threadId) {
162
162
  return msgs;
163
163
  }
164
164
 
165
+ // Ring buffer of recent responses keyed by Telegram message ID.
166
+ // Stored per chatId+threadId for /markdown command lookups.
167
+ const responseHistory = {}; // convKey → [{ messageId, text }]
168
+
169
+ function recordResponseMessage(chatId, threadId, messageIds, text) {
170
+ const key = convKey(chatId, threadId);
171
+ if (!responseHistory[key]) responseHistory[key] = [];
172
+ responseHistory[key].push({ messageIds, text });
173
+ if (responseHistory[key].length > 20) responseHistory[key].shift();
174
+ }
175
+
176
+ function getResponseByMessageId(chatId, threadId, messageId) {
177
+ const key = convKey(chatId, threadId);
178
+ const list = responseHistory[key] || [];
179
+ return list.find(e => e.messageIds.includes(messageId));
180
+ }
181
+
182
+ function getRecentResponse(chatId, threadId, index) {
183
+ const key = convKey(chatId, threadId);
184
+ const list = responseHistory[key] || [];
185
+ if (list.length === 0) return null;
186
+ // index 1 = most recent, 2 = second most recent, etc.
187
+ const idx = list.length - (index || 1);
188
+ if (idx < 0 || idx >= list.length) return null;
189
+ return list[idx];
190
+ }
191
+
165
192
  // ---------------------------------------------------------------------------
166
193
  // Telegram Bot API helpers
167
194
  // ---------------------------------------------------------------------------
@@ -179,7 +206,7 @@ function parseAllowlist(raw) {
179
206
  // Returns empty string if the message isn't a reply or has no text.
180
207
  // Uses a dynamic code-fence length (max consecutive backticks + 1)
181
208
  // so nested backticks never break the structure.
182
- const { ensureSharedDir, saveUploadedFile, formatFileSize } = require('../lib/file-share.js');
209
+ const { formatFileSize } = require('../lib/file-share.js');
183
210
 
184
211
  function quoteReply(msg) {
185
212
  const rm = msg.reply_to_message;
@@ -202,7 +229,7 @@ function quoteReply(msg) {
202
229
  // ---------------------------------------------------------------------------
203
230
 
204
231
  // Handle an uploaded file from Telegram.
205
- // Downloads the file, saves it, and returns a notification message.
232
+ // Returns a notification message with fileId so the agent can download it on demand.
206
233
  async function handleFileUpload(token, msg, projectDir) {
207
234
  const doc = msg.document;
208
235
  const photo = msg.photo;
@@ -217,18 +244,15 @@ async function handleFileUpload(token, msg, projectDir) {
217
244
  fileName = doc.file_name || 'upload.bin';
218
245
  fileSize = doc.file_size || 0;
219
246
  } else if (photo) {
220
- // Use the largest photo
221
247
  const largest = photo[photo.length - 1];
222
248
  fileId = largest.file_id;
223
249
  fileName = 'photo.jpg';
224
250
  fileSize = largest.file_size || 0;
225
251
  }
226
252
 
227
- const fileData = await downloadTelegramFile(token, fileId);
228
- ensureSharedDir(projectDir);
229
- const relativePath = saveUploadedFile(projectDir, fileName, fileData);
230
253
  const sizeStr = fileSize > 0 ? ` (${formatFileSize(fileSize)})` : '';
231
- return `📎 File uploaded: \`${relativePath}\`${sizeStr}`;
254
+ return `📎 File uploaded: \`${fileName}\`${sizeStr}\n` +
255
+ `Use \`[Telegram]{"command":"download","fileId":"${fileId}","path":"<destination>"}\` to save it.`;
232
256
  }
233
257
 
234
258
  // ---------------------------------------------------------------------------
@@ -405,8 +429,15 @@ async function cmdHelp(token, chatId, args, chatState) {
405
429
  hint);
406
430
  return;
407
431
  }
432
+ if (topic === 'markdown') {
433
+ await sendLongMessage(token, chatState,
434
+ '`/markdown` ─ send the original markdown of the most recent response as a `.md` file\n\n' +
435
+ 'Reply to any message and use `/markdown` to get the original markdown of the response that generated that message ' +
436
+ '(works even if the response was split into multiple Telegram messages).');
437
+ return;
438
+ }
408
439
  await sendLongMessage(token, chatState,
409
- '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`, or just `/help`.');
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`.');
410
441
  return;
411
442
  }
412
443
  await sendLongMessage(token, chatState,
@@ -433,6 +464,7 @@ async function cmdHelp(token, chatId, args, chatState) {
433
464
  '`/comment <text>` ─ save a comment in the chat (not forwarded to the agent)\n' +
434
465
  '`/disconnect` ─ disconnect this chat from its server\n' +
435
466
  '`/allow` / `/reject` / `/always` ─ respond to permission requests\n' +
467
+ '`/markdown` ─ send the original markdown of the last response as a `.md` file\n' +
436
468
  '`/shutdown` ─ stop tgagentp (only with `--dev` flag)\n' +
437
469
  (tgagentpRootValid
438
470
  ? '`/serve <path>` ─ start a server in an existing directory\n' +
@@ -557,6 +589,7 @@ function sessionName(s) {
557
589
  }
558
590
 
559
591
  function normalizeUrl(url) {
592
+ if (!url) return url;
560
593
  return url.replace(/\/+$/, '').replace(/^http:\/\/127\.0\.0\.1:/, 'http://localhost:');
561
594
  }
562
595
 
@@ -600,7 +633,7 @@ async function cmdSessions(token, chatId, args, chatState, setServerForChat, set
600
633
  try {
601
634
  const session = await createSession(server, title);
602
635
  const name = session.title || session.slug || session.id;
603
- setActiveSessionForChat(chatState, session.id);
636
+ setActiveSessionForChat(chatState, session.id, name);
604
637
  selectSession(server, session.id);
605
638
  await sendLongMessage(token, chatState,
606
639
  `Created session: \`${name}\`\nIt is now the active session.`);
@@ -652,7 +685,7 @@ async function cmdSessions(token, chatId, args, chatState, setServerForChat, set
652
685
  const num = parseInt(query, 10);
653
686
  if (/^\d+$/.test(query) && num >= 1 && num <= sorted.length) {
654
687
  const target = sorted[num - 1];
655
- setActiveSessionForChat(chatId, target.id);
688
+ setActiveSessionForChat(chatState, target.id, sessionName(target));
656
689
  selectSession(server, target.id);
657
690
  await sendLongMessage(token, chatState,
658
691
  `Switched to session #${num}: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
@@ -674,7 +707,7 @@ async function cmdSessions(token, chatId, args, chatState, setServerForChat, set
674
707
  return;
675
708
  }
676
709
  const target = matches[0];
677
- setActiveSessionForChat(chatId, target.id);
710
+ setActiveSessionForChat(chatState, target.id);
678
711
  selectSession(server, target.id);
679
712
  await sendLongMessage(token, chatState,
680
713
  `Switched to session: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
@@ -704,10 +737,13 @@ async function cmdSessions(token, chatId, args, chatState, setServerForChat, set
704
737
  return;
705
738
  }
706
739
 
707
- // Show user sessions (have agent or directory) and always include the active session
740
+ // Show primary-agent sessions and always include the active session
741
+ const agents = await listAgents(server).catch(() => []);
742
+ const primaryAgents = new Set(agents.filter(a => a.mode === 'primary').map(a => a.name));
743
+ const hasPrimaryAgent = (s) => s.agent && primaryAgents.has(s.agent);
708
744
  const activeId = getState(server).activeSessionId;
709
745
  const filtered = sessions.filter(s =>
710
- (s.agent || s.directory || s.id === activeId) && (!projectDir || s.directory === projectDir));
746
+ (hasPrimaryAgent(s) || s.id === activeId) && (!projectDir || s.directory === projectDir));
711
747
  const sorted = sortedSessions(filtered, 50);
712
748
  if (sorted.length === 0) {
713
749
  await sendLongMessage(token, chatState, 'No active sessions.');
@@ -891,8 +927,6 @@ function startAgentpGateway(token, serverBaseResolver) {
891
927
  }
892
928
  if (req.url === '/send') {
893
929
  handleSendRequest(req, res, token, serverBaseResolver);
894
- } else if (req.url === '/send-file') {
895
- handleSendFileRequest(req, res, token, serverBaseResolver);
896
930
  } else {
897
931
  res.writeHead(404);
898
932
  res.end('Not Found');
@@ -944,7 +978,9 @@ function handleSendRequest(req, res, token, serverBaseResolver) {
944
978
  const activeUrl = normalizeUrl(serverBaseResolver());
945
979
 
946
980
  if (targetUrl === activeUrl) {
947
- sendLongMessage(token, owningChatId, text.trim()).catch(() => {});
981
+ processTelegramCommands(text, server, token, owningChatId).then((cleaned) => {
982
+ if (cleaned) sendLongMessage(token, owningChatId, cleaned).catch(() => {});
983
+ });
948
984
  } else {
949
985
  if (!agentpQueues[server]) {
950
986
  agentpQueues[server] = { messages: [], notifyTimer: null, notified: false };
@@ -969,72 +1005,122 @@ function handleSendRequest(req, res, token, serverBaseResolver) {
969
1005
  });
970
1006
  }
971
1007
 
972
- function handleSendFileRequest(req, res, token, serverBaseResolver) {
973
- let body = '';
974
- req.on('data', (chunk) => { body += chunk; });
975
- req.on('end', () => {
976
- try {
977
- let data;
978
- try { data = JSON.parse(body); } catch {
979
- res.writeHead(400);
980
- res.end('Bad Request');
981
- return;
982
- }
983
- const { filePath, server, message } = data;
984
- if (typeof filePath !== 'string' || typeof server !== 'string') {
985
- res.writeHead(400);
986
- res.end('Bad Request: filePath and server are required');
987
- return;
988
- }
1008
+ // Scan response text for [Telegram]{...} structured messages, process them,
1009
+ // and return the text with those lines removed (or replaced, for help commands).
1010
+ // Supported commands:
1011
+ // "upload" — send a file to the Telegram chat.
1012
+ // Format: [Telegram]{"command":"upload","path":"<rel-path>","msg":"<caption>"}
1013
+ // "download" — save a Telegram file to the project directory.
1014
+ // Format: [Telegram]{"command":"download","fileId":"<id>","path":"<rel-path>"}
1015
+ // "help" — get help about available commands.
1016
+ // Format: [Telegram]{"command":"help","topic":"<upload|download|help>"}
1017
+ // The [Telegram]{...} must be the only content on its line.
1018
+
1019
+ // Parse a single [Telegram]{...} line. Returns { payload } or null.
1020
+ function parseTelegramLine(line) {
1021
+ const m = line.match(/^\[Telegram\](\{.*\})\s*$/);
1022
+ if (!m) return null;
1023
+ let payload;
1024
+ try { payload = JSON.parse(m[1]); } catch { return null; }
1025
+ if (!payload || typeof payload !== 'object') return null;
1026
+ return payload;
1027
+ }
989
1028
 
990
- // Find the chat that owns this server
991
- const targetUrl = normalizeUrl(server);
992
- const owningChatId = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === targetUrl)?.[1];
1029
+ const TELEGRAM_COMMANDS_HELP = {
1030
+ all: 'Available commands:\n\n' +
1031
+ '• upload — send a file to Telegram.\n' +
1032
+ ' [Telegram]{"command":"upload","path":"<relative-path>","msg":"<optional caption>"}\n\n' +
1033
+ '• download — save a file from Telegram to the project.\n' +
1034
+ ' [Telegram]{"command":"download","fileId":"<telegram-file-id>","path":"<relative-destination>"}\n\n' +
1035
+ '• help — show this help or details for a specific command.\n' +
1036
+ ' [Telegram]{"command":"help","topic":"<command>"}',
1037
+ upload: 'Upload a file to the Telegram chat.\n\n' +
1038
+ 'Format: [Telegram]{"command":"upload","path":"<relative-path>","msg":"<optional caption>"}\n\n' +
1039
+ '- path: file path relative to the project root (required)\n' +
1040
+ '- msg: optional caption sent with the file',
1041
+ download: 'Download a file the user uploaded to Telegram.\n\n' +
1042
+ 'Format: [Telegram]{"command":"download","fileId":"<telegram-file-id>","path":"<relative-destination>"}\n\n' +
1043
+ '- fileId: the Telegram file identifier (provided when the user uploads a file)\n' +
1044
+ '- path: destination path relative to the project root (required)',
1045
+ help: 'Get help about Telegram commands.\n\n' +
1046
+ 'Format: [Telegram]{"command":"help","topic":"<command>"}\n\n' +
1047
+ '- topic: one of "upload", "download", "help". Omit for general help.',
1048
+ };
993
1049
 
994
- if (!owningChatId) {
995
- res.writeHead(200, { 'Content-Type': 'application/json' });
996
- res.end(JSON.stringify({ sent: false, reason: 'no owner' }));
997
- return;
998
- }
1050
+ function getHelpText(topic) {
1051
+ if (topic && TELEGRAM_COMMANDS_HELP[topic]) return TELEGRAM_COMMANDS_HELP[topic];
1052
+ return TELEGRAM_COMMANDS_HELP.all;
1053
+ }
999
1054
 
1000
- // Resolve the file path relative to the server directory
1001
- const dir = serverDir(server);
1002
- if (!dir) {
1003
- res.writeHead(200, { 'Content-Type': 'application/json' });
1004
- res.end(JSON.stringify({ sent: false, reason: 'no server dir' }));
1005
- return;
1055
+ async function processTelegramCommands(text, server, token, chatState) {
1056
+ if (!text) return text;
1057
+ const lines = text.split('\n');
1058
+ const toStrip = new Set();
1059
+ const filesSent = [];
1060
+ const dir = serverDir(server);
1061
+
1062
+ for (let i = 0; i < lines.length; i++) {
1063
+ const payload = parseTelegramLine(lines[i]);
1064
+ if (!payload) continue;
1065
+
1066
+ if (payload.command === 'upload' && payload.path) {
1067
+ toStrip.add(i);
1068
+ const resolved = dir ? path.resolve(dir, payload.path) : path.resolve(payload.path);
1069
+ if (dir && !resolved.startsWith(dir + path.sep)) {
1070
+ log.error(`processTelegramCommands: path outside project rejected: ${payload.path}`);
1071
+ continue;
1006
1072
  }
1007
- const fullPath = path.resolve(dir, filePath);
1008
- if (!fs.existsSync(fullPath)) {
1009
- res.writeHead(200, { 'Content-Type': 'application/json' });
1010
- res.end(JSON.stringify({ sent: false, reason: 'file not found' }));
1011
- return;
1073
+ try {
1074
+ if (!fs.existsSync(resolved)) {
1075
+ log.error(`processTelegramCommands: file not found: ${resolved}`);
1076
+ continue;
1077
+ }
1078
+ const fileData = fs.readFileSync(resolved);
1079
+ const fileName = path.basename(resolved);
1080
+ const caption = typeof payload.msg === 'string' ? payload.msg : undefined;
1081
+ await sendTelegramDocument(token, chatState, fileData, fileName, caption);
1082
+ filesSent.push(fileName);
1083
+ } catch (err) {
1084
+ log.error(`processTelegramCommands: failed to send file ${resolved}: ${err.message}`);
1085
+ }
1086
+ } else if (payload.command === 'download' && payload.fileId && payload.path) {
1087
+ toStrip.add(i);
1088
+ const resolved = dir ? path.resolve(dir, payload.path) : path.resolve(payload.path);
1089
+ if (dir && !resolved.startsWith(dir + path.sep)) {
1090
+ log.error(`processTelegramCommands: download path outside project rejected: ${payload.path}`);
1091
+ continue;
1012
1092
  }
1093
+ try {
1094
+ const fileData = await downloadTelegramFile(token, payload.fileId);
1095
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
1096
+ fs.writeFileSync(resolved, fileData);
1097
+ filesSent.push(path.basename(resolved));
1098
+ } catch (err) {
1099
+ log.error(`processTelegramCommands: failed to download file: ${err.message}`);
1100
+ }
1101
+ } else if (payload.command === 'help') {
1102
+ toStrip.add(i);
1103
+ const topic = typeof payload.topic === 'string' ? payload.topic : undefined;
1104
+ const st = getState(server);
1105
+ if (st.activeSessionId) {
1106
+ sendToSession(server, st.activeSessionId, getHelpText(topic)).catch(() => {});
1107
+ }
1108
+ } else {
1109
+ toStrip.add(i); // unrecognized command — strip silently
1110
+ }
1111
+ }
1013
1112
 
1014
- const fileData = fs.readFileSync(fullPath);
1015
- const fileName = path.basename(fullPath);
1113
+ if (toStrip.size === 0) return text;
1016
1114
 
1017
- // Send file to Telegram
1018
- sendTelegramDocument(token, owningChatId, fileData, fileName, message).then(() => {
1019
- // Clean up: only delete files from telegram-shared/downloads/
1020
- // to prevent accidental deletion of project files
1021
- const downloadsDir = path.join(dir, 'telegram-shared', 'downloads');
1022
- if (fullPath.startsWith(downloadsDir + path.sep)) {
1023
- try { fs.unlinkSync(fullPath); } catch (_) {}
1024
- }
1025
- res.writeHead(200, { 'Content-Type': 'application/json' });
1026
- res.end(JSON.stringify({ sent: true }));
1027
- }).catch((err) => {
1028
- log.error(`Failed to send file: ${err.message}`);
1029
- res.writeHead(500, { 'Content-Type': 'application/json' });
1030
- res.end(JSON.stringify({ sent: false, reason: err.message }));
1031
- });
1032
- } catch (err) {
1033
- log.error(`Agentp gateway handler error: ${err.message}`);
1034
- res.writeHead(500);
1035
- res.end('Internal Server Error');
1036
- }
1037
- });
1115
+ if (filesSent.length > 0) {
1116
+ log.info(`processTelegramCommands: processed ${filesSent.length} file(s): ${filesSent.join(', ')}`);
1117
+ }
1118
+
1119
+ const out = [];
1120
+ for (let i = 0; i < lines.length; i++) {
1121
+ if (!toStrip.has(i)) out.push(lines[i]);
1122
+ }
1123
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
1038
1124
  }
1039
1125
 
1040
1126
  async function cmdAllow(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
@@ -1490,6 +1576,26 @@ async function cmdNew(token, chatId, args, chatState, setServerForChat, setActiv
1490
1576
  log.info(`[chat ${chatId}] Created ${targetDir} → ${url}`);
1491
1577
  }
1492
1578
 
1579
+ async function cmdMarkdown(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat, replyTo, replyToMsgId) {
1580
+ let entry = null;
1581
+ if (replyToMsgId) {
1582
+ entry = getResponseByMessageId(chatId, chatState.threadId, replyToMsgId);
1583
+ if (!entry) {
1584
+ await sendLongMessage(token, chatState, '❌ Could not find the original response for this message. It may be too old or was not sent by tgagentp.');
1585
+ return;
1586
+ }
1587
+ } else {
1588
+ entry = getRecentResponse(chatId, chatState.threadId, 1);
1589
+ if (!entry) {
1590
+ await sendLongMessage(token, chatState, '❌ No recent responses available. Send a message to the agent first.');
1591
+ return;
1592
+ }
1593
+ }
1594
+ const fileData = Buffer.from(entry.text, 'utf8');
1595
+ const fileName = 'response-' + Date.now() + '.md';
1596
+ await sendTelegramDocument(token, chatState, fileData, fileName, 'Original markdown response');
1597
+ }
1598
+
1493
1599
  const COMMANDS = {
1494
1600
  'help': cmdHelp,
1495
1601
  'servers': cmdServers,
@@ -1511,6 +1617,7 @@ const COMMANDS = {
1511
1617
  'answer': cmdAnswer,
1512
1618
  'serve': cmdServe,
1513
1619
  'new': cmdNew,
1620
+ 'markdown': cmdMarkdown,
1514
1621
  };
1515
1622
 
1516
1623
  // --------------------------------------------------------------------------
@@ -1519,7 +1626,8 @@ const COMMANDS = {
1519
1626
 
1520
1627
  const serverState = {};
1521
1628
  function getState(url) {
1522
- return serverState[url] = serverState[url] || { busy: false, serverDead: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, pendingQuestion: null, thinkingEnabled: false, _thinkingBuf: '', messageQueue: [] };
1629
+ const key = normalizeUrl(url);
1630
+ return serverState[key] = serverState[key] || { busy: false, serverDead: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, pendingQuestion: null, thinkingEnabled: false, _thinkingBuf: '', messageQueue: [] };
1523
1631
  }
1524
1632
 
1525
1633
  function sessionLabel(st) {
@@ -1681,6 +1789,9 @@ async function main() {
1681
1789
  serverOwners[url] = { chatId: cs.chatId, threadId: cs.threadId };
1682
1790
  getState(url);
1683
1791
 
1792
+ // Send Telegram bridge awareness note to the new session
1793
+ sendGreeting(cs).catch(() => {});
1794
+
1684
1795
  // Auto-activate tmux window for this server
1685
1796
  const servers = listServers();
1686
1797
  const normalized = normalizeUrl(url);
@@ -1717,12 +1828,48 @@ async function main() {
1717
1828
  return null;
1718
1829
  };
1719
1830
 
1720
- const setActiveSessionForChat = (chatState, id) => {
1831
+ const setActiveSessionForChat = (chatState, id, name) => {
1721
1832
  const server = chatState.serverBase;
1833
+ const prevId = getState(server).activeSessionId;
1722
1834
  getState(server).activeSessionId = id;
1723
- log.info(`[chat ${chatState.chatId}] Switched to session: ${id}`);
1835
+ if (name) getState(server).activeSessionName = name;
1836
+ log.info(`[chat ${chatState.chatId}] Switched to session: ${name || id}`);
1837
+ if (id !== prevId) sendGreeting(chatState);
1724
1838
  };
1725
1839
 
1840
+ const TELEGRAM_GREETING_BODY = (sessionName) =>
1841
+ '[Telegram] The following information is for your awareness. Reply with only "Ack" and do not take any action:\n\n' +
1842
+ 'Your responses are being relayed through a Telegram bridge ' +
1843
+ '(current session: ' + sessionName + ').\n' +
1844
+ 'The user sees your messages and can reply from there. ' +
1845
+ 'Their messages appear here prefixed with [Telegram].\n\n' +
1846
+ 'You can use these structured commands in your own response text ' +
1847
+ '(on their own line, with no other content on that line). ' +
1848
+ 'Do NOT use CLI tools to generate them — just include them directly in your response:\n\n' +
1849
+ 'To send a file to the user:\n' +
1850
+ '[Telegram]{"command":"upload","path":"<relative-path>","msg":"<optional caption>"}\n\n' +
1851
+ 'To download a file the user sent:\n' +
1852
+ '[Telegram]{"command":"download","fileId":"<id>","path":"<relative-destination>"}\n\n' +
1853
+ 'For more details about a command:\n' +
1854
+ '[Telegram]{"command":"help","topic":"<upload|download|help>"}';
1855
+
1856
+ async function sendGreeting(chatState) {
1857
+ const st = getState(chatState.serverBase);
1858
+ if (!st.activeSessionId) {
1859
+ try {
1860
+ const discovered = await discoverActiveSession(chatState.serverBase);
1861
+ if (discovered) { st.activeSessionId = discovered.id; st.activeSessionName = discovered.name; }
1862
+ } catch {}
1863
+ }
1864
+ if (!st.activeSessionId) return;
1865
+ const greeting = TELEGRAM_GREETING_BODY(st.activeSessionName || st.activeSessionId);
1866
+ try {
1867
+ await sendToSession(chatState.serverBase, st.activeSessionId, greeting);
1868
+ } catch (err) {
1869
+ log.debug(`sendGreeting error: ${err.message}`);
1870
+ }
1871
+ }
1872
+
1726
1873
  // Start agentp gateway (tiny HTTP server for agentp --tg)
1727
1874
  const serverBaseResolver = () => {
1728
1875
  const entries = Object.entries(serverOwners);
@@ -1813,7 +1960,7 @@ async function main() {
1813
1960
  }, cancelRef).catch(() => {});
1814
1961
  await onConnected;
1815
1962
  answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName, sessionCancelRef);
1816
- if (st._thinkingBuf) {
1963
+ if (st.thinkingEnabled && st._thinkingBuf) {
1817
1964
  const html = '💭 ' + st._thinkingBuf.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1818
1965
  sendLongMessage(token, chatState, html, replyTo, threadId).catch(() => {});
1819
1966
  st._thinkingBuf = '';
@@ -1834,9 +1981,13 @@ async function main() {
1834
1981
  if (url === chatServer) {
1835
1982
  if (answer && answer.trim()) {
1836
1983
  const trimmed = answer.trim();
1837
- msgLog('out', chatId, '', trimmed);
1838
- await sendLongMessage(token, chatState, trimmed, replyTo, threadId);
1839
- bufferMessage(chatId, threadId, 'assistant', trimmed);
1984
+ const cleaned = await processTelegramCommands(trimmed, url, token, chatState);
1985
+ msgLog('out', chatId, '', cleaned);
1986
+ if (cleaned) {
1987
+ const msgIds = await sendLongMessage(token, chatState, cleaned, replyTo, threadId);
1988
+ if (msgIds && msgIds.length > 0) recordResponseMessage(chatId, threadId, msgIds, cleaned);
1989
+ bufferMessage(chatId, threadId, 'assistant', cleaned);
1990
+ }
1840
1991
  }
1841
1992
  } else {
1842
1993
  getState(url).pendingResponse = answer;
@@ -2215,21 +2366,6 @@ async function main() {
2215
2366
  }
2216
2367
  try {
2217
2368
  const notification = await handleFileUpload(token, msg, projectDir);
2218
- // Verify the file is actually on disk before notifying
2219
- const relativePath = notification.match(/`([^`]+)`/)?.[1];
2220
- if (relativePath) {
2221
- const fullPath = path.join(projectDir, relativePath);
2222
- let retries = 5;
2223
- while (!fs.existsSync(fullPath) && retries > 0) {
2224
- await new Promise(r => setTimeout(r, 100));
2225
- retries--;
2226
- }
2227
- if (!fs.existsSync(fullPath)) {
2228
- log.error(`File not found after upload: ${fullPath}`);
2229
- await sendLongMessage(token, cs, `❌ File upload failed: file not saved to disk.`);
2230
- continue;
2231
- }
2232
- }
2233
2369
  const userText = msg.caption || msg.text || '';
2234
2370
  const fullMessage = notification + (userText ? '\n\nUser message: ' + userText : '');
2235
2371
  log.debug(`[chat ${chatId}] ${from}: [file upload]`);
@@ -2330,11 +2466,16 @@ async function main() {
2330
2466
  fileName = 'photo.jpg';
2331
2467
  fileSize = largest.file_size || 0;
2332
2468
  }
2333
- const fileData = await downloadTelegramFile(token, fileId);
2334
- ensureSharedDir(projectDir);
2335
- const relativePath = saveUploadedFile(projectDir, fileName, fileData);
2336
2469
  const sizeStr = fileSize > 0 ? ` (${formatFileSize(fileSize)})` : '';
2337
- await sendLongMessage(token, cs, `📎 File downloaded: \`${relativePath}\`${sizeStr}`);
2470
+ const notification = `📎 File available: \`${fileName}\`${sizeStr}\n` +
2471
+ `Use \`[Telegram]{"command":"download","fileId":"${fileId}","path":"<destination>"}\` to save it.`;
2472
+ await sendLongMessage(token, cs, notification);
2473
+ // Also send the notification to the agent's session so it knows the fileId
2474
+ const st = getState(cs.serverBase);
2475
+ if (st.activeSessionId) {
2476
+ sendToSession(cs.serverBase, st.activeSessionId,
2477
+ '[Telegram] ' + notification).catch(() => {});
2478
+ }
2338
2479
  } catch (err) {
2339
2480
  log.error(`Download on reply error: ${err.message}`);
2340
2481
  await sendLongMessage(token, cs, `❌ File download failed: ${err.message}`);
@@ -2372,7 +2513,12 @@ async function main() {
2372
2513
  try {
2373
2514
  const answer = await executeTuiCommand(paneId, cs.serverBase, tuiCmd);
2374
2515
  if (answer && answer.trim()) {
2375
- await sendLongMessage(token, cs, answer.trim());
2516
+ const cleaned = await processTelegramCommands(answer.trim(), cs.serverBase, token, cs);
2517
+ if (cleaned) {
2518
+ await sendLongMessage(token, cs, cleaned);
2519
+ } else {
2520
+ await sendLongMessage(token, cs, `✅ \`${tuiCmd}\` submitted.`);
2521
+ }
2376
2522
  } else {
2377
2523
  await sendLongMessage(token, cs, `✅ \`${tuiCmd}\` submitted.`);
2378
2524
  }
@@ -2400,14 +2546,15 @@ async function main() {
2400
2546
  continue;
2401
2547
  }
2402
2548
  try {
2403
- await handler(token, chatId, cmdArgs, cs, setServerForChat, setActiveSessionForChat);
2549
+ const replyToMsgId = msg.reply_to_message ? msg.reply_to_message.message_id : 0;
2550
+ await handler(token, chatId, cmdArgs, cs, setServerForChat, setActiveSessionForChat, replyTo, replyToMsgId);
2404
2551
  } catch (err) {
2405
2552
  log.error(`Error handling /${cmd}: ${err.message}`);
2406
2553
  try { await sendLongMessage(token, cs, `Error: ${err.message}`); } catch (_) {}
2407
2554
  }
2408
2555
  } else {
2409
2556
  await sendLongMessage(token, cs,
2410
- `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /think, /record, /queue, /flush, /note, /comment, /disconnect, /serve, /new`);
2557
+ `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /think, /record, /queue, /flush, /note, /comment, /disconnect, /serve, /new, /markdown`);
2411
2558
  }
2412
2559
  continue;
2413
2560
  }
@@ -2476,7 +2623,11 @@ process.on('SIGINT', () => {
2476
2623
  process.exit(0);
2477
2624
  });
2478
2625
 
2479
- main().catch((err) => {
2480
- console.error('Error:', err.message);
2481
- process.exit(1);
2482
- });
2626
+ if (require.main === module) {
2627
+ main().catch((err) => {
2628
+ console.error('Error:', err.message);
2629
+ process.exit(1);
2630
+ });
2631
+ }
2632
+
2633
+ module.exports = { processTelegramCommands, parseTelegramLine, getHelpText, normalizeUrl };
@@ -104,10 +104,13 @@ async function sendLongMessage(token, chatIdOrState, text, replyTo, threadId) {
104
104
  ? chatIdOrState.chatId : chatIdOrState;
105
105
  const effectiveThreadId = threadId || (typeof chatIdOrState === 'object' ? chatIdOrState.threadId : undefined);
106
106
  const chunks = splitMessage(text);
107
+ let msgIds = [];
107
108
  for (let i = 0; i < chunks.length; i++) {
108
109
  const html = convertMarkdownToHtml(chunks[i]);
109
- await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined, effectiveThreadId);
110
+ const result = await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined, effectiveThreadId);
111
+ if (result && result.result) msgIds.push(result.result.message_id);
110
112
  }
113
+ return msgIds;
111
114
  }
112
115
 
113
116
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.11.7",
3
+ "version": "0.11.8",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",