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 +8 -11
- package/bin/tgagentp +430 -66
- package/docs/specification.md +46 -1
- package/lib/file-share.js +59 -0
- package/lib/opencode.js +22 -1
- package/lib/tui-cmd.js +30 -0
- package/package.json +1 -1
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
|
|
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
|
|
391
|
-
'
|
|
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
|
|
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'
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
req.
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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(' ');
|
package/docs/specification.md
CHANGED
|
@@ -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.
|
|
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 }
|