agentp 0.11.1 → 0.11.3

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
@@ -230,17 +230,7 @@ Non-text Telegram updates (photos, stickers, etc.) are silently ignored.
230
230
  |---|---|
231
231
  | `/help [topic]` | Show general help or help for a topic (`servers`, `sessions`, `agents`, `models`, `allow`, `think`, `record`, `queue`) |
232
232
  | `/servers` | List all running ocmux-served projects with URL + status (✅ idle / ⏳ busy) |
233
- | `/servers switch <name>` | Switch active server; matches by full path, basename, or substring |
234
- | `/sessions` | List recent sessions for the current server (max 50, with date headings) |
235
- | `/sessions switch <number\|name>` | Switch active session by position or partial name match |
236
- | `/agents` | List primary agents (▶ marker for the active one) |
237
- | `/agents switch <name>` | Switch active agent — persists on session and refreshes TUI |
238
- | `/models` | List connected providers with model counts, context limits, and costs |
239
- | `/status` | Show current server path, URL, busy status, and active session |
240
- | `/cancel` | Cancel the current AI response for the active server |
241
- | `/think [on\|off\|switch]` | Toggle forwarding of model thinking messages to the chat |
242
- | `/record [stop]` | Toggle recording of Telegram conversation for agentp context; `/record stop` clears and stops |
243
- | `/servers switch <name> [--force]` | Switch to a server; `--force` takes over from another chat |
233
+ | `/servers switch <name> [--force]` | Switch active server; matches by full path, basename, or substring; `--force` takes over from another chat |
244
234
  | `/sessions` | List recent sessions for the current server (max 50, with date headings) |
245
235
  | `/sessions switch <number\|name>` | Switch active session by position or partial name match |
246
236
  | `/agents` | List primary agents (▶ marker for the active one) |
@@ -256,8 +246,15 @@ Non-text Telegram updates (photos, stickers, etc.) are silently ignored.
256
246
  | `/allow` | Approve a permission request once |
257
247
  | `/reject` | Deny a permission request |
258
248
  | `/always` | Approve and remember for the session |
249
+ | `/answer <number>` | Respond to a question asked by the AI (structured multiple-choice) |
259
250
  | `/shutdown [force\|clear]` | (requires `--dev`) Stop tgagentp; `clear` also wipes saved connections |
260
251
 
252
+ #### TUI Command Passthrough
253
+
254
+ Messages starting with `//` are forwarded to the OpenCode TUI as raw keystrokes (not text sent to the AI):
255
+
256
+ - **`//<command>`** — Sends `/command` directly to the TUI prompt. Useful for TUI-level commands like `/init`, `/clear`, `/history`, etc. The AI response is captured and forwarded to Telegram (or a confirmation is sent if the command is quick).
257
+
261
258
  ### Chat-server ownership
262
259
 
263
260
  Each server can be owned by at most one chat at a time. New chats start disconnected. Use `/servers switch <name>` to connect; `--force` takes over and notifies the previous owner. Connections are persisted to `/tmp/tgagentp-connections.json` and restored automatically on restart (server URL is re-discovered from `.ocmux.json`).
package/bin/tgagentp CHANGED
@@ -5,7 +5,7 @@ const https = require('https');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { version } = require('../package.json');
8
- const { getAuthHeaders, sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, listenForSessionEvents, listAgents, listProviders, isServerAlive } = require('../lib/opencode.js');
8
+ const { getAuthHeaders, sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, respondToQuestion, listenForSessionEvents, listAgents, listProviders, isServerAlive } = require('../lib/opencode.js');
9
9
 
10
10
  // Logger: info→stdout, error→stderr (always), debug→stderr (--verbose only)
11
11
  const log = {
@@ -19,7 +19,8 @@ const TGAGENTP_PORT = parseInt(process.env.TGAGENTP_PORT || '0', 10);
19
19
  const PORT_FILE = '/tmp/tgagentp-port';
20
20
  const TGAGENTP_DEBOUNCE_MS = parseInt(process.env.TGAGENTP_DEBOUNCE_MS || '5000', 10);
21
21
  const CONNECTIONS_FILE = '/tmp/tgagentp-connections.json';
22
- const { listServers, activateServer, resurrectServer, statefileFor, readState } = require('../lib/ocmux.js');
22
+ const { listServers, activateServer, resurrectServer, statefileFor, readState, tuiPaneId } = require('../lib/ocmux.js');
23
+ const { executeTuiCommand } = require('../lib/tui-cmd.js');
23
24
 
24
25
  // Persist chat↔server directory mappings across restarts
25
26
  function saveConnection(chatId, threadId, dir) {
@@ -309,6 +310,8 @@ async function sendLongMessage(token, chatIdOrState, text, replyTo, threadId) {
309
310
  // Returns empty string if the message isn't a reply or has no text.
310
311
  // Uses a dynamic code-fence length (max consecutive backticks + 1)
311
312
  // so nested backticks never break the structure.
313
+ const { ensureSharedDir, saveUploadedFile, formatFileSize } = require('../lib/file-share.js');
314
+
312
315
  function quoteReply(msg) {
313
316
  const rm = msg.reply_to_message;
314
317
  if (!rm || !rm.text) return '';
@@ -325,6 +328,58 @@ function quoteReply(msg) {
325
328
  return `In response to ${replyFrom} (${senderType}):\n\n${fence}\n${rm.text}\n${fence}\n\n`;
326
329
  }
327
330
 
331
+ // ---------------------------------------------------------------------------
332
+ // File upload helpers
333
+ // ---------------------------------------------------------------------------
334
+
335
+ // Download a file from Telegram's file storage.
336
+ // Returns a Buffer of the file contents.
337
+ async function downloadTelegramFile(token, fileId) {
338
+ const fileInfo = await telegramRequest(token, 'getFile', { file_id: fileId });
339
+ if (!fileInfo.ok || !fileInfo.result?.file_path) {
340
+ throw new Error('Failed to get file info from Telegram');
341
+ }
342
+ const filePath = fileInfo.result.file_path;
343
+ return new Promise((resolve, reject) => {
344
+ const req = https.get(`https://api.telegram.org/file/bot${token}/${filePath}`, (res) => {
345
+ const chunks = [];
346
+ res.on('data', (chunk) => chunks.push(chunk));
347
+ res.on('end', () => resolve(Buffer.concat(chunks)));
348
+ });
349
+ req.on('error', reject);
350
+ });
351
+ }
352
+
353
+ // Handle an uploaded file from Telegram.
354
+ // Downloads the file, saves it, and returns a notification message.
355
+ async function handleFileUpload(token, msg, projectDir) {
356
+ const doc = msg.document;
357
+ const photo = msg.photo;
358
+ if (!doc && !photo) return null;
359
+
360
+ let fileId;
361
+ let fileName;
362
+ let fileSize;
363
+
364
+ if (doc) {
365
+ fileId = doc.file_id;
366
+ fileName = doc.file_name || 'upload.bin';
367
+ fileSize = doc.file_size || 0;
368
+ } else if (photo) {
369
+ // Use the largest photo
370
+ const largest = photo[photo.length - 1];
371
+ fileId = largest.file_id;
372
+ fileName = 'photo.jpg';
373
+ fileSize = largest.file_size || 0;
374
+ }
375
+
376
+ const fileData = await downloadTelegramFile(token, fileId);
377
+ ensureSharedDir(projectDir);
378
+ const relativePath = saveUploadedFile(projectDir, fileName, fileData);
379
+ const sizeStr = fileSize > 0 ? ` (${formatFileSize(fileSize)})` : '';
380
+ return `📎 File uploaded: \`${relativePath}\`${sizeStr}`;
381
+ }
382
+
328
383
  // ---------------------------------------------------------------------------
329
384
  // Telegram command handlers
330
385
  // ---------------------------------------------------------------------------
@@ -383,14 +438,17 @@ async function cmdHelp(token, chatId, args, chatState) {
383
438
  'with status, context limit, and input/output costs.');
384
439
  return;
385
440
  }
386
- if (topic === 'allow' || topic === 'reject' || topic === 'always') {
441
+ if (topic === 'allow' || topic === 'reject' || topic === 'always' || topic === 'answer') {
387
442
  await sendLongMessage(token, chatState,
388
443
  '`/allow` ─ approve a permission request once\n' +
389
444
  '`/reject` ─ deny a permission request\n' +
390
- '`/always` ─ approve and remember for the session\n\n' +
391
- 'These commands respond to OpenCode permission prompts forwarded by tgagentp.\n' +
445
+ '`/always` ─ approve and remember for the session\n' +
446
+ '`/answer <number>` ─ respond to a question from the AI\n\n' +
447
+ 'These commands respond to OpenCode prompts forwarded by tgagentp.\n' +
392
448
  'When OpenCode needs approval (e.g. to run a shell command), tgagentp sends a\n' +
393
- '🔐 notification to the chat. Use one of these commands to answer.');
449
+ '🔐 notification to the chat. Use one of these commands to answer.\n' +
450
+ 'When the AI asks a multiple-choice question, tgagentp sends a ❓ notification\n' +
451
+ 'with numbered options. Use `/answer <number>` to respond.');
394
452
  return;
395
453
  }
396
454
  if (topic === 'think') {
@@ -600,7 +658,17 @@ async function cmdServers(token, chatId, args, chatState, setServerForChat, setA
600
658
  }
601
659
  const activeUrl = chatState.serverBase ? normalizeUrl(chatState.serverBase) : null;
602
660
  const lines = servers.map(s => {
603
- const bullet = activeUrl && normalizeUrl(s.url) === activeUrl ? '▶ ' : ' · ';
661
+ const normalizedUrl = normalizeUrl(s.url);
662
+ const isActive = activeUrl && normalizedUrl === activeUrl;
663
+ const isOwned = Object.keys(serverOwners).some(url => normalizeUrl(url) === normalizedUrl);
664
+ let bullet;
665
+ if (isActive) {
666
+ bullet = '▶ ';
667
+ } else if (isOwned) {
668
+ bullet = ' · ';
669
+ } else {
670
+ bullet = '🔌 ';
671
+ }
604
672
  return bullet + `${s.url} → ${s.dir} (${s.status})`;
605
673
  });
606
674
  const prefix = chatState.serverBase ? 'Active servers:' : 'Available servers:';
@@ -805,7 +873,7 @@ async function cmdSessions(token, chatId, args, chatState, setServerForChat, set
805
873
 
806
874
  const name = sessionName(s);
807
875
  const pos = idx + 1;
808
- const bullet = s.id === activeId ? '▶ ' : ' · ';
876
+ const bullet = s.id === activeId ? '▶ ' : '🔌 ';
809
877
  lines.push('`' + bullet + pos + ' ' + timeLabel + ' – ' + name.replace(/`/g, "'") + '`');
810
878
  });
811
879
 
@@ -934,7 +1002,7 @@ function startAgentpGateway(token, serverBaseResolver) {
934
1002
  return new Promise((resolve) => {
935
1003
  const server = http.createServer((req, res) => {
936
1004
  try {
937
- if (req.method !== 'POST' || req.url !== '/send') {
1005
+ if (req.method !== 'POST') {
938
1006
  res.writeHead(405);
939
1007
  res.end('Method Not Allowed');
940
1008
  return;
@@ -944,59 +1012,14 @@ function startAgentpGateway(token, serverBaseResolver) {
944
1012
  res.end('Unauthorized');
945
1013
  return;
946
1014
  }
947
- let body = '';
948
- req.on('data', (chunk) => { body += chunk; });
949
- req.on('end', () => {
950
- try {
951
- let data;
952
- try { data = JSON.parse(body); } catch {
953
- res.writeHead(400);
954
- res.end('Bad Request');
955
- return;
956
- }
957
- const { text, server } = data;
958
- if (typeof text !== 'string' || typeof server !== 'string') {
959
- res.writeHead(400);
960
- res.end('Bad Request: text and server are required');
961
- return;
962
- }
963
-
964
- // Find the chat that owns this server
965
- const targetUrl = normalizeUrl(server);
966
- const owningChatId = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === targetUrl)?.[1];
967
-
968
- // Return recorded conversation buffer for the owning chat
969
- const buffered = owningChatId ? flushRecorded(owningChatId.chatId, owningChatId.threadId) : [];
970
- res.writeHead(200, { 'Content-Type': 'application/json' });
971
- res.end(JSON.stringify({ buffered }));
972
-
973
- if (!owningChatId) return;
974
- const activeUrl = normalizeUrl(serverBaseResolver());
975
-
976
- if (targetUrl === activeUrl) {
977
- sendLongMessage(token, owningChatId, text.trim()).catch(() => {});
978
- } else {
979
- if (!agentpQueues[server]) {
980
- agentpQueues[server] = { messages: [], notifyTimer: null, notified: false };
981
- }
982
- const q = agentpQueues[server];
983
- q.messages.push(text);
984
- if (!q.notifyTimer) {
985
- q.notifyTimer = setTimeout(() => {
986
- q.notifyTimer = null;
987
- if (q.messages.length > 0 && !q.notified) {
988
- q.notified = true;
989
- sendLongMessage(token, owningChatId,
990
- `📥 ${q.messages.length} agentp message(s) queued for server \`${server}\`. Switch servers to view them.`)
991
- .catch(() => {});
992
- }
993
- }, TGAGENTP_DEBOUNCE_MS);
994
- }
995
- }
996
- } catch (err) {
997
- log.error(`Agentp gateway handler error: ${err.message}`);
998
- }
999
- });
1015
+ if (req.url === '/send') {
1016
+ handleSendRequest(req, res, token, serverBaseResolver);
1017
+ } else if (req.url === '/send-file') {
1018
+ handleSendFileRequest(req, res, token, serverBaseResolver);
1019
+ } else {
1020
+ res.writeHead(404);
1021
+ res.end('Not Found');
1022
+ }
1000
1023
  } catch (err) {
1001
1024
  log.error(`Agentp gateway handler error: ${err.message}`);
1002
1025
  }
@@ -1013,6 +1036,193 @@ function startAgentpGateway(token, serverBaseResolver) {
1013
1036
  });
1014
1037
  }
1015
1038
 
1039
+ function handleSendRequest(req, res, token, serverBaseResolver) {
1040
+ let body = '';
1041
+ req.on('data', (chunk) => { body += chunk; });
1042
+ req.on('end', () => {
1043
+ try {
1044
+ let data;
1045
+ try { data = JSON.parse(body); } catch {
1046
+ res.writeHead(400);
1047
+ res.end('Bad Request');
1048
+ return;
1049
+ }
1050
+ const { text, server } = data;
1051
+ if (typeof text !== 'string' || typeof server !== 'string') {
1052
+ res.writeHead(400);
1053
+ res.end('Bad Request: text and server are required');
1054
+ return;
1055
+ }
1056
+
1057
+ // Find the chat that owns this server
1058
+ const targetUrl = normalizeUrl(server);
1059
+ const owningChatId = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === targetUrl)?.[1];
1060
+
1061
+ // Return recorded conversation buffer for the owning chat
1062
+ const buffered = owningChatId ? flushRecorded(owningChatId.chatId, owningChatId.threadId) : [];
1063
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1064
+ res.end(JSON.stringify({ buffered }));
1065
+
1066
+ if (!owningChatId) return;
1067
+ const activeUrl = normalizeUrl(serverBaseResolver());
1068
+
1069
+ if (targetUrl === activeUrl) {
1070
+ sendLongMessage(token, owningChatId, text.trim()).catch(() => {});
1071
+ } else {
1072
+ if (!agentpQueues[server]) {
1073
+ agentpQueues[server] = { messages: [], notifyTimer: null, notified: false };
1074
+ }
1075
+ const q = agentpQueues[server];
1076
+ q.messages.push(text);
1077
+ if (!q.notifyTimer) {
1078
+ q.notifyTimer = setTimeout(() => {
1079
+ q.notifyTimer = null;
1080
+ if (q.messages.length > 0 && !q.notified) {
1081
+ q.notified = true;
1082
+ sendLongMessage(token, owningChatId,
1083
+ `📥 ${q.messages.length} agentp message(s) queued for server \`${server}\`. Switch servers to view them.`)
1084
+ .catch(() => {});
1085
+ }
1086
+ }, TGAGENTP_DEBOUNCE_MS);
1087
+ }
1088
+ }
1089
+ } catch (err) {
1090
+ log.error(`Agentp gateway handler error: ${err.message}`);
1091
+ }
1092
+ });
1093
+ }
1094
+
1095
+ function handleSendFileRequest(req, res, token, serverBaseResolver) {
1096
+ let body = '';
1097
+ req.on('data', (chunk) => { body += chunk; });
1098
+ req.on('end', () => {
1099
+ try {
1100
+ let data;
1101
+ try { data = JSON.parse(body); } catch {
1102
+ res.writeHead(400);
1103
+ res.end('Bad Request');
1104
+ return;
1105
+ }
1106
+ const { filePath, server, message } = data;
1107
+ if (typeof filePath !== 'string' || typeof server !== 'string') {
1108
+ res.writeHead(400);
1109
+ res.end('Bad Request: filePath and server are required');
1110
+ return;
1111
+ }
1112
+
1113
+ // Find the chat that owns this server
1114
+ const targetUrl = normalizeUrl(server);
1115
+ const owningChatId = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === targetUrl)?.[1];
1116
+
1117
+ if (!owningChatId) {
1118
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1119
+ res.end(JSON.stringify({ sent: false, reason: 'no owner' }));
1120
+ return;
1121
+ }
1122
+
1123
+ // Resolve the file path relative to the server directory
1124
+ const dir = serverDir(server);
1125
+ if (!dir) {
1126
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1127
+ res.end(JSON.stringify({ sent: false, reason: 'no server dir' }));
1128
+ return;
1129
+ }
1130
+ const fullPath = path.resolve(dir, filePath);
1131
+ if (!fs.existsSync(fullPath)) {
1132
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1133
+ res.end(JSON.stringify({ sent: false, reason: 'file not found' }));
1134
+ return;
1135
+ }
1136
+
1137
+ const fileData = fs.readFileSync(fullPath);
1138
+ const fileName = path.basename(fullPath);
1139
+
1140
+ // Send file to Telegram
1141
+ sendTelegramDocument(token, owningChatId, fileData, fileName, message).then(() => {
1142
+ // Clean up: only delete files from telegram-shared/downloads/
1143
+ // to prevent accidental deletion of project files
1144
+ const downloadsDir = path.join(dir, 'telegram-shared', 'downloads');
1145
+ if (fullPath.startsWith(downloadsDir + path.sep)) {
1146
+ try { fs.unlinkSync(fullPath); } catch (_) {}
1147
+ }
1148
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1149
+ res.end(JSON.stringify({ sent: true }));
1150
+ }).catch((err) => {
1151
+ log.error(`Failed to send file: ${err.message}`);
1152
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1153
+ res.end(JSON.stringify({ sent: false, reason: err.message }));
1154
+ });
1155
+ } catch (err) {
1156
+ log.error(`Agentp gateway handler error: ${err.message}`);
1157
+ res.writeHead(500);
1158
+ res.end('Internal Server Error');
1159
+ }
1160
+ });
1161
+ }
1162
+
1163
+ // Send a document to Telegram using multipart/form-data.
1164
+ async function sendTelegramDocument(token, chatIdOrState, fileData, fileName, caption) {
1165
+ const chatId = typeof chatIdOrState === 'object' && chatIdOrState.chatId != null
1166
+ ? chatIdOrState.chatId : chatIdOrState;
1167
+ const threadId = typeof chatIdOrState === 'object' ? chatIdOrState.threadId : undefined;
1168
+
1169
+ const boundary = '----tgagentpBoundary' + Math.random().toString(36).slice(2);
1170
+ const parts = [];
1171
+
1172
+ // chat_id field
1173
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`));
1174
+
1175
+ // message_thread_id field (if present)
1176
+ if (threadId) {
1177
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="message_thread_id"\r\n\r\n${threadId}\r\n`));
1178
+ }
1179
+
1180
+ // document field (the file)
1181
+ const mimeType = 'application/octet-stream';
1182
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${fileName}"\r\nContent-Type: ${mimeType}\r\n\r\n`));
1183
+ parts.push(fileData);
1184
+ parts.push(Buffer.from('\r\n'));
1185
+
1186
+ // caption field (optional)
1187
+ if (caption) {
1188
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="caption"\r\n\r\n${caption}\r\n`));
1189
+ }
1190
+
1191
+ // closing boundary
1192
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
1193
+
1194
+ const body = Buffer.concat(parts);
1195
+
1196
+ return new Promise((resolve, reject) => {
1197
+ const options = {
1198
+ hostname: 'api.telegram.org',
1199
+ port: 443,
1200
+ path: `/bot${token}/sendDocument`,
1201
+ method: 'POST',
1202
+ headers: {
1203
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
1204
+ 'Content-Length': body.length,
1205
+ },
1206
+ };
1207
+ const req = https.request(options, (res) => {
1208
+ let data = '';
1209
+ res.on('data', (chunk) => { data += chunk; });
1210
+ res.on('end', () => {
1211
+ try {
1212
+ const result = JSON.parse(data);
1213
+ if (result.ok) resolve(result);
1214
+ else reject(new Error(result.description || 'Telegram API error'));
1215
+ } catch (e) {
1216
+ reject(new Error(`Failed to parse Telegram response: ${data}`));
1217
+ }
1218
+ });
1219
+ });
1220
+ req.on('error', reject);
1221
+ req.write(body);
1222
+ req.end();
1223
+ });
1224
+ }
1225
+
1016
1226
  async function cmdAllow(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1017
1227
  const st = getState(chatState.serverBase);
1018
1228
  if (!st.pendingPermission) {
@@ -1061,6 +1271,24 @@ async function cmdAlways(token, chatId, args, chatState, setServerForChat, setAc
1061
1271
  }
1062
1272
  }
1063
1273
 
1274
+ async function cmdAnswer(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1275
+ const st = getState(chatState.serverBase);
1276
+ if (!st.pendingQuestion) {
1277
+ await sendLongMessage(token, chatState, 'No pending question. Use `/status` to check the current state.');
1278
+ return;
1279
+ }
1280
+ const q = st.pendingQuestion;
1281
+ const n = parseInt(args[0], 10);
1282
+ if (isNaN(n) || n < 1 || n > q.options.length) {
1283
+ await sendLongMessage(token, chatState, `Invalid answer. Use a number between 1 and ${q.options.length}.`);
1284
+ return;
1285
+ }
1286
+ const answer = q.options[n - 1].text || q.options[n - 1];
1287
+ await respondToQuestion(q.server, q.sessionId, q.questionId, answer);
1288
+ await sendLongMessage(token, chatState, `✅ Answered: ${answer}`);
1289
+ st.pendingQuestion = null;
1290
+ }
1291
+
1064
1292
  async function cmdThink(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1065
1293
  const st = getState(chatState.serverBase);
1066
1294
  const sub = args && args.length > 0 ? args[0].toLowerCase() : 'switch';
@@ -1331,6 +1559,7 @@ const COMMANDS = {
1331
1559
  'allow': cmdAllow,
1332
1560
  'reject': cmdReject,
1333
1561
  'always': cmdAlways,
1562
+ 'answer': cmdAnswer,
1334
1563
  };
1335
1564
 
1336
1565
  // --------------------------------------------------------------------------
@@ -1339,7 +1568,7 @@ const COMMANDS = {
1339
1568
 
1340
1569
  const serverState = {};
1341
1570
  function getState(url) {
1342
- return serverState[url] = serverState[url] || { busy: false, serverDead: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false, messageQueue: [] };
1571
+ 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, messageQueue: [] };
1343
1572
  }
1344
1573
 
1345
1574
  function sessionLabel(st) {
@@ -1614,6 +1843,21 @@ async function main() {
1614
1843
  log.debug(` → permission_replied: clearing pendingPermission`);
1615
1844
  getState(url).pendingPermission = null;
1616
1845
  },
1846
+ onQuestion: (q) => {
1847
+ const st2 = getState(url);
1848
+ const qDump = JSON.stringify(q);
1849
+ log.debug(` → question_asked callback: ${qDump}`);
1850
+ st2.pendingQuestion = { server: url, sessionId, questionId: q.id, question: q.question || 'Unknown question', options: q.options || [] };
1851
+ const options = (q.options || []).map((opt, i) => `${i + 1}. ${opt.text || opt}`).join('\n');
1852
+ const msg = '❓ **Question from the AI**\n\n'
1853
+ + (q.question || 'Choose an option:') + '\n\n'
1854
+ + (options ? options + '\n\n' : '')
1855
+ + 'Use `/answer <number>` to respond.';
1856
+ log.debug(` → sending question notification to chat ${chatId}`);
1857
+ sendLongMessage(token, chatState, msg, replyTo, threadId)
1858
+ .then(() => log.debug(` → question notification sent OK`))
1859
+ .catch((err) => log.debug(` → question notification failed: ${err.message}`));
1860
+ },
1617
1861
  }, cancelRef).catch(() => {});
1618
1862
  await onConnected;
1619
1863
  answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName, sessionCancelRef);
@@ -1963,12 +2207,9 @@ async function main() {
1963
2207
  continue;
1964
2208
  }
1965
2209
 
1966
- if (typeof msg.text !== 'string') continue;
1967
-
1968
2210
  const chatId = String(msg.chat.id);
1969
2211
  const threadId = msg.message_thread_id || null;
1970
2212
  const replyTo = msg.message_id;
1971
- const text = msg.text;
1972
2213
  const from = msg.from?.username
1973
2214
  ? `@${msg.from.username}`
1974
2215
  : [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(' ') || 'unknown';
@@ -1989,12 +2230,135 @@ async function main() {
1989
2230
  // Per-chat state: get or create for this chat (thread-aware)
1990
2231
  const cs = getChatState(chatId, threadId);
1991
2232
 
2233
+ // Handle file uploads (documents and photos)
2234
+ if (msg.document || msg.photo) {
2235
+ if (!cs.serverBase) {
2236
+ await sendLongMessage(token, cs, '🔌 Not connected to any server. Upload ignored.');
2237
+ continue;
2238
+ }
2239
+ const projectDir = serverDir(cs.serverBase);
2240
+ if (!projectDir) {
2241
+ await sendLongMessage(token, cs, '❌ Could not find project directory for this server.');
2242
+ continue;
2243
+ }
2244
+ try {
2245
+ const notification = await handleFileUpload(token, msg, projectDir);
2246
+ // Verify the file is actually on disk before notifying
2247
+ const relativePath = notification.match(/`([^`]+)`/)?.[1];
2248
+ if (relativePath) {
2249
+ const fullPath = path.join(projectDir, relativePath);
2250
+ let retries = 5;
2251
+ while (!fs.existsSync(fullPath) && retries > 0) {
2252
+ await new Promise(r => setTimeout(r, 100));
2253
+ retries--;
2254
+ }
2255
+ if (!fs.existsSync(fullPath)) {
2256
+ log.error(`File not found after upload: ${fullPath}`);
2257
+ await sendLongMessage(token, cs, `❌ File upload failed: file not saved to disk.`);
2258
+ continue;
2259
+ }
2260
+ }
2261
+ const userText = msg.caption || msg.text || '';
2262
+ const fullMessage = notification + (userText ? '\n\nUser message: ' + userText : '');
2263
+ log.debug(`[chat ${chatId}] ${from}: [file upload]`);
2264
+ msgLog('in', chatId, from, '[file upload]');
2265
+
2266
+ // Buffer the upload notification for /record
2267
+ bufferMessage(chatId, threadId, 'user', fullMessage);
2268
+
2269
+ const st = getState(cs.serverBase);
2270
+ if (st.serverDead) {
2271
+ const alive = await isServerAlive(cs.serverBase);
2272
+ if (alive) st.serverDead = false;
2273
+ }
2274
+ if (st.serverDead) {
2275
+ if (!st.messageQueue) st.messageQueue = [];
2276
+ st.messageQueue.push({ text: fullMessage, replyTo });
2277
+ const qlen = st.messageQueue.length;
2278
+ await sendLongMessage(token, cs,
2279
+ `⚠️ Server \`${cs.serverBase}\` is not reachable.\n\n` +
2280
+ `Your file upload has been queued (#${qlen}). It will be sent when the server becomes available.\n\n` +
2281
+ `**Options:**\n` +
2282
+ `• Start the server: \`ocmux serve\`\n` +
2283
+ `• Switch to another server: \`/servers switch <name>\`\n` +
2284
+ `• Flush the queue: \`/flush\``);
2285
+ continue;
2286
+ }
2287
+
2288
+ if (st.busy) {
2289
+ if (!st.messageQueue) st.messageQueue = [];
2290
+ st.messageQueue.push({ text: fullMessage, replyTo });
2291
+ await sendLongMessage(token, cs, `⏳ Server is busy. File upload queued (#${st.messageQueue.length}).`);
2292
+ continue;
2293
+ }
2294
+
2295
+ // Auto-switch tmux window
2296
+ const srvList = listServers();
2297
+ const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(cs.serverBase));
2298
+ if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
2299
+
2300
+ st.busy = true;
2301
+ const myTaskId = ++st.taskId;
2302
+ log.debug(` → processing file upload notification (task ${myTaskId})`);
2303
+ processMessageAsync(chatId, fullMessage, cs.serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId, replyTo, threadId);
2304
+ } catch (err) {
2305
+ log.error(`File upload error: ${err.message}`);
2306
+ await sendLongMessage(token, cs, `❌ File upload failed: ${err.message}`);
2307
+ }
2308
+ continue;
2309
+ }
2310
+
2311
+ if (typeof msg.text !== 'string') continue;
2312
+
2313
+ const text = msg.text;
2314
+
1992
2315
  log.debug(`[chat ${chatId}] ${from}: ${text}`);
1993
2316
  msgLog('in', chatId, from, text);
1994
2317
 
1995
2318
  // Build reply context if this message is a Telegram reply
1996
2319
  const replyContext = quoteReply(msg);
1997
2320
 
2321
+ // Intercept //commands (raw TUI passthrough via tmux send-keys)
2322
+ if (text.startsWith('//')) {
2323
+ const tuiCmd = text.slice(1);
2324
+ if (!cs.serverBase) {
2325
+ await sendLongMessage(token, cs, '🔌 Not connected to any server.');
2326
+ continue;
2327
+ }
2328
+ const st = getState(cs.serverBase);
2329
+ if (st.busy) {
2330
+ await sendLongMessage(token, cs, '⏳ Server is busy. Use `/cancel` to cancel first.');
2331
+ continue;
2332
+ }
2333
+ const srvList = listServers();
2334
+ const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(cs.serverBase));
2335
+ if (!srvMatch) {
2336
+ await sendLongMessage(token, cs, '❌ Server not found in tmux.');
2337
+ continue;
2338
+ }
2339
+ activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
2340
+ const paneId = tuiPaneId(srvMatch.index);
2341
+ if (!paneId) {
2342
+ await sendLongMessage(token, cs, '❌ TUI pane not found.');
2343
+ continue;
2344
+ }
2345
+ st.busy = true;
2346
+ try {
2347
+ const answer = await executeTuiCommand(paneId, cs.serverBase, tuiCmd);
2348
+ if (answer && answer.trim()) {
2349
+ await sendLongMessage(token, cs, answer.trim());
2350
+ } else {
2351
+ await sendLongMessage(token, cs, `✅ \`${tuiCmd}\` submitted.`);
2352
+ }
2353
+ } catch (err) {
2354
+ log.error(`Error executing TUI command ${tuiCmd}: ${err.message}`);
2355
+ try { await sendLongMessage(token, cs, `Error executing \`${tuiCmd}\`: ${err.message}`); } catch (_) {}
2356
+ } finally {
2357
+ st.busy = false;
2358
+ }
2359
+ continue;
2360
+ }
2361
+
1998
2362
  // Intercept /commands
1999
2363
  if (text.startsWith('/')) {
2000
2364
  const parts = text.slice(1).split(' ');
@@ -16,7 +16,7 @@ All tools share `lib/opencode.js` (HTTP session API client) and `lib/ocmux.js` (
16
16
 
17
17
  ### `package.json`
18
18
 
19
- **Version:** 0.13.0
19
+ **Version:** 0.11.3
20
20
 
21
21
  Fields:
22
22
  - `"bin"` — registers `agentp`, `ocmux`, `tgagentp`
@@ -117,6 +117,7 @@ Shared by `bin/agentp` and `bin/tgagentp`. Functions:
117
117
  | `sendToSession(server, id, text, agent?, cancelRef?)` | POST /session/:id/message | Synchronous message; optional abort |
118
118
  | `sendToSessionAsync(server, id, text, agent?)` | POST /session/:id/prompt_async | Non-blocking (204); answer via SSE |
119
119
  | `respondToPermission(server, id, permissionId, response)` | POST /session/:id/permissions/:id | Permission response |
120
+ | `respondToQuestion(server, id, questionId, answer)` | POST /session/:id/questions/:id | Question response |
120
121
  | `selectSession(server, id)` | POST /session/:id/select | TUI navigation |
121
122
  | `listAgents(server)` | GET /agent | Returns parsed array |
122
123
  | `listProviders(server)` | GET /provider | Returns parsed array |
@@ -342,6 +343,50 @@ Both command and non-command paths in the message loop check `cs.serverBase`:
342
343
 
343
344
  ---
344
345
 
346
+ ## `//command` TUI Passthrough
347
+
348
+ Messages starting with `//` are forwarded to the TUI as raw keystrokes (not AI text). The `//` prefix is stripped, a trailing space is appended to activate the TUI as-you-type menu, and `tmux send-keys` sends the keys to the TUI pane. An SSE listener connects before the Enter key to catch the AI response.
349
+
350
+ **Flow:**
351
+ 1. Chat message: `//init`
352
+ 2. Strips `//` → `init`
353
+ 3. Prepends `/` → `/init`
354
+ 4. Appends space → `/init ` (selects TUI menu item)
355
+ 5. `tmux send-keys` to TUI pane: `C-u` (clear line), type `/init `, Enter
356
+ 6. SSE listener starts before Enter (15s timeout)
357
+ 7. If AI responds: forwards full answer to Telegram
358
+ 8. If timeout: sends `✅ /init submitted.` confirmation
359
+
360
+ **Busy guard:** `//command` is blocked when `st.busy` is true (same "use /cancel" message as regular messages).
361
+
362
+ **Commands that trigger AI responses:** `//init`, `//clear`, `//history` (and any other TUI command that produces an AI answer). Quick commands like `/exit` timeout without an answer.
363
+
364
+ ---
365
+
366
+ ## Question Handling
367
+
368
+ When the AI asks a structured multiple-choice question (via `question.asked` SSE event), tgagentp forwards the question to Telegram with numbered options.
369
+
370
+ **Notification format:**
371
+ ```
372
+ ❓ **Question from the AI**
373
+
374
+ What would you like to test?
375
+
376
+ 1. Option A
377
+ 2. Option B
378
+ 3. Option C
379
+
380
+ Use /answer <number> to respond.
381
+ ```
382
+
383
+ **Response:**
384
+ - `/answer <number>` → `POST /session/:id/questions/:id` with the selected option's value
385
+ - Only one question can be pending at a time per server
386
+ - The question is stored per-server in `chatStates.pendingQuestion`
387
+
388
+ ---
389
+
345
390
  ## Logging Conventions (tgagentp)
346
391
 
347
392
  - **stdout** — informational messages (startup, discovery, session switches)
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Ensure telegram-shared/{uploads,downloads}/ directories exist
7
+ // and telegram-shared/ is in .gitignore.
8
+ function ensureSharedDir(projectDir) {
9
+ const sharedDir = path.join(projectDir, 'telegram-shared');
10
+ const uploadsDir = path.join(sharedDir, 'uploads');
11
+ const downloadsDir = path.join(sharedDir, 'downloads');
12
+ if (!fs.existsSync(uploadsDir)) {
13
+ fs.mkdirSync(uploadsDir, { recursive: true });
14
+ }
15
+ if (!fs.existsSync(downloadsDir)) {
16
+ fs.mkdirSync(downloadsDir, { recursive: true });
17
+ }
18
+ // Ensure .gitignore has telegram-shared/
19
+ const gitignorePath = path.join(projectDir, '.gitignore');
20
+ let gitignoreContent = '';
21
+ try {
22
+ gitignoreContent = fs.readFileSync(gitignorePath, 'utf8');
23
+ } catch (_) {
24
+ // .gitignore doesn't exist, will create it
25
+ }
26
+ if (!gitignoreContent.split('\n').some(line => line.trim() === 'telegram-shared/' || line.trim() === 'telegram-shared')) {
27
+ const append = gitignoreContent.endsWith('\n') || gitignoreContent === '' ? '' : '\n';
28
+ fs.writeFileSync(gitignorePath, gitignoreContent + append + 'telegram-shared/\n');
29
+ }
30
+ return sharedDir;
31
+ }
32
+
33
+ // Save an uploaded file to the project's telegram-shared/uploads/ directory.
34
+ // Returns the relative path (e.g., 'telegram-shared/uploads/20250610_143022_notes.md').
35
+ function saveUploadedFile(projectDir, fileName, fileData) {
36
+ const uploadsDir = path.join(projectDir, 'telegram-shared', 'uploads');
37
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
38
+ const safeName = fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
39
+ const uniqueName = `${timestamp}_${safeName}`;
40
+ const filePath = path.join(uploadsDir, uniqueName);
41
+ fs.writeFileSync(filePath, fileData);
42
+ // Ensure data is flushed to disk so the file is immediately visible
43
+ const fd = fs.openSync(filePath, 'r');
44
+ fs.fsyncSync(fd);
45
+ fs.closeSync(fd);
46
+ return path.join('telegram-shared', 'uploads', uniqueName);
47
+ }
48
+
49
+ function formatFileSize(bytes) {
50
+ if (bytes < 1024) return `${bytes}B`;
51
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
52
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
53
+ }
54
+
55
+ module.exports = {
56
+ ensureSharedDir,
57
+ saveUploadedFile,
58
+ formatFileSize,
59
+ };
package/lib/opencode.js CHANGED
@@ -318,7 +318,7 @@ async function respondToPermission(server, sessionId, permissionId, response, re
318
318
 
319
319
  // Listen for session events via SSE, collecting assistant text parts and detecting
320
320
  // permission requests. Resolves with the full collected text when the session goes idle.
321
- // callbacks: { onText(chunk), onPermission(permission), onThinking(chunk), onConnected() }
321
+ // callbacks: { onText(chunk), onPermission(permission), onThinking(chunk), onConnected(), onQuestion(question) }
322
322
  // cancelRef — allows aborting the SSE stream via req.destroy()
323
323
  // logFn — optional logger; if provided, used instead of process.stderr.write
324
324
  function listenForSessionEvents(server, sessionId, callbacks, cancelRef, logFn) {
@@ -413,6 +413,18 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef, logFn)
413
413
  continue;
414
414
  }
415
415
 
416
+ if (event.type === 'question.asked') {
417
+ const qStr = JSON.stringify(props);
418
+ sseLog(` [SSE] QUESTION ASKED: ${qStr}\n`);
419
+ if (callbacks.onQuestion) {
420
+ sseLog(` [SSE] calling onQuestion callback\n`);
421
+ callbacks.onQuestion(props);
422
+ } else {
423
+ sseLog(` [SSE] WARNING: no onQuestion callback registered\n`);
424
+ }
425
+ continue;
426
+ }
427
+
416
428
  // Completion signals
417
429
  if (event.type === 'session.idle') {
418
430
  clearTimeout(safetyTimer);
@@ -510,6 +522,14 @@ function isServerAlive(url) {
510
522
  });
511
523
  }
512
524
 
525
+ // Respond to a question (choice) asked by the AI.
526
+ async function respondToQuestion(server, sessionId, questionId, answer) {
527
+ const url = `${server}/session/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}`;
528
+ const body = JSON.stringify({ answer });
529
+ const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
530
+ if (status !== 200) throw new Error(`Failed to respond to question: ${status}`);
531
+ }
532
+
513
533
  module.exports = {
514
534
  getAuthHeaders,
515
535
  makeRequest,
@@ -519,6 +539,7 @@ module.exports = {
519
539
  submitPrompt,
520
540
  sendText,
521
541
  listenForFinalAnswer,
542
+ respondToQuestion,
522
543
  listSessions,
523
544
  getSession,
524
545
  createSession,
package/lib/tui-cmd.js ADDED
@@ -0,0 +1,30 @@
1
+ const child_process = require('child_process')
2
+ const { listenForFinalAnswer } = require('./opencode.js')
3
+
4
+ async function executeTuiCommand(paneId, server, command) {
5
+ const commandWithSpace = command + ' '
6
+ const cancelRef = { current: null }
7
+
8
+ const answerPromise = listenForFinalAnswer(server, null, cancelRef)
9
+
10
+ child_process.spawnSync('tmux', ['send-keys', '-t', paneId, 'C-u'])
11
+ child_process.spawnSync('tmux', ['send-keys', '-t', paneId, '-l', commandWithSpace])
12
+ child_process.spawnSync('tmux', ['send-keys', '-t', paneId, 'Enter'])
13
+
14
+ const timeoutMs = 15000
15
+ const timeoutPromise = new Promise((_, reject) => {
16
+ setTimeout(() => reject(new Error('timeout')), timeoutMs)
17
+ })
18
+
19
+ try {
20
+ return await Promise.race([answerPromise, timeoutPromise])
21
+ } catch {
22
+ if (cancelRef.current) {
23
+ cancelRef.current.destroy()
24
+ cancelRef.current = null
25
+ }
26
+ return null
27
+ }
28
+ }
29
+
30
+ module.exports = { executeTuiCommand }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",