agentp 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/tgagentp CHANGED
@@ -1,26 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const http = require('http');
3
4
  const https = require('https');
4
5
  const fs = require('fs');
5
6
  const path = require('path');
6
7
  const { version } = require('../package.json');
7
8
  const { sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, listenForSessionEvents, listAgents, listProviders } = require('../lib/opencode.js');
8
9
 
9
- // Monkey-patch stderr to prefix all output with timestamps
10
- {
11
- const origWrite = process.stderr.write.bind(process.stderr);
12
- process.stderr.write = function (chunk, encoding, cb) {
13
- const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
14
- const str = typeof chunk === 'string' ? chunk : chunk.toString(encoding || 'utf8');
15
- const lines = str.split('\n');
16
- const prefixed = lines.map(l => l ? `[${ts}] ${l}` : l).join('\n');
17
- return origWrite(prefixed, encoding, cb);
18
- };
19
- }
10
+ // Logger: info→stdout, error→stderr (always), debug→stderr (--verbose only)
11
+ const log = {
12
+ verbose: false,
13
+ info(msg) { process.stdout.write(`${new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '')} ${msg}\n`); },
14
+ error(msg) { process.stderr.write(`${msg}\n`); },
15
+ debug(msg) { if (this.verbose) process.stderr.write(`${msg}\n`); },
16
+ };
20
17
 
21
18
  const STARTUP_CHAT_FILE = '/tmp/tgagentp-startup-chat';
19
+ const TGAGENTP_PORT = parseInt(process.env.TGAGENTP_PORT || '0', 10);
20
+ const PORT_FILE = '/tmp/tgagentp-port';
21
+ const TGAGENTP_DEBOUNCE_MS = parseInt(process.env.TGAGENTP_DEBOUNCE_MS || '5000', 10);
22
22
  const { listServers, activateServer } = require('../lib/ocmux.js');
23
23
 
24
+ // Agentp gateway message queue, keyed by server URL
25
+ const agentpQueues = {};
26
+
27
+ function validateAgentpAuth(req) {
28
+ const password = process.env.OPENCODE_SERVER_PASSWORD;
29
+ if (!password) return true;
30
+ const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
31
+ const expected = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
32
+ return req.headers.authorization === expected;
33
+ }
34
+
35
+ function flushAgentpQueue(server, token, chatId) {
36
+ if (!chatId) return;
37
+ const q = agentpQueues[server];
38
+ if (!q || q.messages.length === 0) return;
39
+ const msgs = q.messages.splice(0);
40
+ q.notified = false;
41
+ if (q.notifyTimer) { clearTimeout(q.notifyTimer); q.notifyTimer = null; }
42
+ sendLongMessage(token, chatId,
43
+ `📨 Delivering ${msgs.length} queued agentp message(s):`)
44
+ .then(() => {
45
+ for (const msg of msgs) {
46
+ sendLongMessage(token, chatId, msg.trim()).catch(() => {});
47
+ }
48
+ })
49
+ .catch(() => {});
50
+ }
51
+
24
52
  // ---------------------------------------------------------------------------
25
53
  // Telegram Bot API helpers
26
54
  // ---------------------------------------------------------------------------
@@ -63,9 +91,10 @@ function getUpdates(token, offset, timeout) {
63
91
  });
64
92
  }
65
93
 
66
- function sendTelegramMessage(token, chatId, text, parseMode) {
94
+ function sendTelegramMessage(token, chatId, text, parseMode, replyTo) {
67
95
  const params = { chat_id: chatId, text };
68
96
  if (parseMode) params.parse_mode = parseMode;
97
+ if (replyTo) params.reply_to_message_id = replyTo;
69
98
  return telegramRequest(token, 'sendMessage', params);
70
99
  }
71
100
 
@@ -75,7 +104,7 @@ function convertMarkdownToHtml(text) {
75
104
 
76
105
  return parts.map((part, i) => {
77
106
  if (i % 2 === 1) {
78
- // Code block — strip language tag, escape entities, wrap in <pre>
107
+ // Code block ─ strip language tag, escape entities, wrap in <pre>
79
108
  const nl = part.indexOf('\n');
80
109
  const inner = nl >= 0 ? part.slice(nl + 1) : part;
81
110
  return '<pre>' + inner
@@ -173,12 +202,32 @@ function splitMessage(text, maxLength = 4096) {
173
202
  // Converts Markdown to Telegram HTML formatting.
174
203
  // Splits the raw text first, then converts each chunk independently
175
204
  // to avoid broken HTML tags across messages.
176
- async function sendLongMessage(token, chatId, text) {
205
+ async function sendLongMessage(token, chatId, text, replyTo) {
177
206
  const chunks = splitMessage(text);
178
- for (const chunk of chunks) {
179
- const html = convertMarkdownToHtml(chunk);
180
- await sendTelegramMessage(token, chatId, html, 'HTML');
207
+ for (let i = 0; i < chunks.length; i++) {
208
+ const html = convertMarkdownToHtml(chunks[i]);
209
+ await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined);
210
+ }
211
+ }
212
+
213
+ // Build a quoted reply context from a Telegram reply_to_message.
214
+ // Returns empty string if the message isn't a reply or has no text.
215
+ // Uses a dynamic code-fence length (max consecutive backticks + 1)
216
+ // so nested backticks never break the structure.
217
+ function quoteReply(msg) {
218
+ const rm = msg.reply_to_message;
219
+ if (!rm || !rm.text) return '';
220
+ const replyFrom = rm.from?.username
221
+ ? `@${rm.from.username}`
222
+ : [rm.from?.first_name, rm.from?.last_name].filter(Boolean).join(' ') || 'unknown';
223
+ const senderType = rm.from?.is_bot ? 'assistant' : 'user';
224
+ let maxBackticks = 0;
225
+ for (let i = 0, cur = 0; i < rm.text.length; i++) {
226
+ if (rm.text[i] === '`') { cur++; maxBackticks = Math.max(maxBackticks, cur); }
227
+ else cur = 0;
181
228
  }
229
+ const fence = '`'.repeat(maxBackticks + 1);
230
+ return `In response to ${replyFrom} (${senderType}):\n\n${fence}\n${rm.text}\n${fence}\n\n`;
182
231
  }
183
232
 
184
233
  // ---------------------------------------------------------------------------
@@ -190,24 +239,25 @@ async function cmdHelp(token, chatId, args) {
190
239
  const topic = args[0].toLowerCase();
191
240
  if (topic === 'servers') {
192
241
  await sendLongMessage(token, chatId,
193
- '`/servers` — list all opencode servers (ocmux sessions)\n' +
194
- '`/servers switch <name>` — switch target server\n\n' +
242
+ '`/servers` ─ list all opencode servers (ocmux sessions)\n' +
243
+ '`/servers switch <name>` ─ switch target server\n\n' +
195
244
  '`<name>` can be a full path or basename of the project directory.');
196
245
  return;
197
246
  }
198
247
  if (topic === 'sessions') {
199
248
  await sendLongMessage(token, chatId,
200
- '`/sessions` — list active sessions (numbered, newest first, max 50)\n' +
201
- '`/sessions switch <number-or-name>` — send messages to a specific session\n' +
202
- '`/sessions new [name]` — create a new session\n' +
203
- '`/sessions rename <name>` — rename the active session\n\n' +
249
+ '`/sessions` ─ list active sessions (numbered, newest first, max 50)\n' +
250
+ '`/sessions switch <number-or-name>` ─ send messages to a specific session\n' +
251
+ '`/sessions new [name]` ─ create a new session\n' +
252
+ '`/sessions rename <name>` ─ rename the active session\n\n' +
204
253
  'Switch by number (from the list) or by case-insensitive partial name match.\n' +
205
254
  'Use `/sessions` to find session numbers.');
206
255
  return;
207
256
  }
208
257
  if (topic === 'shutdown') {
209
258
  await sendLongMessage(token, chatId,
210
- '`/shutdown` — stop tgagentp process\n\n' +
259
+ '`/shutdown` ─ stop tgagentp process\n' +
260
+ '`/shutdown force` ─ force shutdown even if busy (in-flight work will be lost)\n\n' +
211
261
  'Only available when tgagentp is started with `--dev` flag.\n' +
212
262
  'Useful when running tgagentp in a restart loop:\n' +
213
263
  '`while true; do tgagentp --dev; done`');
@@ -215,62 +265,74 @@ async function cmdHelp(token, chatId, args) {
215
265
  }
216
266
  if (topic === 'agents') {
217
267
  await sendLongMessage(token, chatId,
218
- '`/agents` — list all available agents (name, mode, assigned model, description).\n' +
219
- '`/agents switch <name>` — switch the active agent. Subsequent messages will use this agent.\n\n' +
268
+ '`/agents` ─ list primary agents (with ▶ marker for the active one).\n' +
269
+ '`/agents switch <name>` ─ switch the active agent. Subsequent messages will use this agent.\n\n' +
220
270
  'Agent switching requires an active session (`/sessions switch` or `/sessions new`).');
221
271
  return;
222
272
  }
223
273
  if (topic === 'session') {
224
274
  await sendLongMessage(token, chatId,
225
275
  '`/session` is an alias for `/sessions`.\n\n' +
226
- '`/sessions` — list active sessions\n' +
227
- '`/sessions switch <number-or-name>` — switch to a session\n' +
228
- '`/sessions new [name]` — create a new session\n' +
229
- '`/sessions rename <name>` — rename the active session');
276
+ '`/sessions` ─ list active sessions\n' +
277
+ '`/sessions switch <number-or-name>` ─ switch to a session\n' +
278
+ '`/sessions new [name]` ─ create a new session\n' +
279
+ '`/sessions rename <name>` ─ rename the active session');
230
280
  return;
231
281
  }
232
282
  if (topic === 'models') {
233
283
  await sendLongMessage(token, chatId,
234
- '`/models` — list all providers and their models.\n' +
284
+ '`/models` ─ list all providers and their models.\n' +
235
285
  'Shows provider name, connection status, default model, and up to 5 models per provider\n' +
236
286
  'with status, context limit, and input/output costs.');
237
287
  return;
238
288
  }
239
289
  if (topic === 'allow' || topic === 'reject' || topic === 'always') {
240
290
  await sendLongMessage(token, chatId,
241
- '`/allow` — approve a permission request once\n' +
242
- '`/reject` — deny a permission request\n' +
243
- '`/always` — approve and remember for the session\n\n' +
291
+ '`/allow` ─ approve a permission request once\n' +
292
+ '`/reject` ─ deny a permission request\n' +
293
+ '`/always` ─ approve and remember for the session\n\n' +
244
294
  'These commands respond to OpenCode permission prompts forwarded by tgagentp.\n' +
245
295
  'When OpenCode needs approval (e.g. to run a shell command), tgagentp sends a\n' +
246
296
  '🔐 notification to the chat. Use one of these commands to answer.');
247
297
  return;
248
298
  }
299
+ if (topic === 'think') {
300
+ await sendLongMessage(token, chatId,
301
+ '`/think` ─ toggle thinking message forwarding\n' +
302
+ '`/think on` ─ forward model thinking to the chat in real-time\n' +
303
+ '`/think off` ─ stop forwarding thinking messages (default)\n' +
304
+ '`/think switch` ─ toggle current state\n\n' +
305
+ 'When enabled, the model\'s internal reasoning steps are sent to the chat\n' +
306
+ 'as they are generated, prefixed with 💭.\n' +
307
+ 'Can also be enabled at startup with `--think` flag.');
308
+ return;
309
+ }
249
310
  await sendLongMessage(token, chatId,
250
- 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, `/help allow`, or just `/help`.');
311
+ 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, `/help allow`, `/help think`, or just `/help`.');
251
312
  return;
252
313
  }
253
314
  await sendLongMessage(token, chatId,
254
- '`/help` — show this message\n' +
255
- '`/help <topic>` — detailed help\n' +
256
- '`/status` — show current server and session\n' +
257
- '`/cancel` — cancel the running prompt\n' +
258
- '`/servers` — list running opencode servers\n' +
259
- '`/servers switch <name>` — switch to a different server\n' +
260
- '`/sessions` — list/create/switch/rename sessions\n' +
261
- '`/sessions switch <number-or-name>` — switch to a session\n' +
262
- '`/sessions new [name]` — create a new session\n' +
263
- '`/sessions rename <name>` — rename the active session\n' +
264
- '`/session` — alias for `/sessions`\n' +
265
- '`/agents` — list available agents\n' +
266
- '`/agents switch <name>` — switch active agent\n' +
267
- '`/models` — list providers and models\n' +
268
- '`/allow` / `/reject` / `/always` — respond to permission requests\n' +
269
- '`/shutdown` — stop tgagentp (only with `--dev` flag)\n\n' +
315
+ '`/help` ─ show this message\n' +
316
+ '`/help <topic>` ─ detailed help\n' +
317
+ '`/status` ─ show current server and session\n' +
318
+ '`/cancel` ─ cancel the running prompt\n' +
319
+ '`/servers` ─ list running opencode servers\n' +
320
+ '`/servers switch <name>` ─ switch to a different server\n' +
321
+ '`/sessions` ─ list/create/switch/rename sessions\n' +
322
+ '`/sessions switch <number-or-name>` ─ switch to a session\n' +
323
+ '`/sessions new [name]` ─ create a new session\n' +
324
+ '`/sessions rename <name>` ─ rename the active session\n' +
325
+ '`/session` ─ alias for `/sessions`\n' +
326
+ '`/agents` ─ list available agents\n' +
327
+ '`/agents switch <name>` ─ switch active agent\n' +
328
+ '`/models` ─ list providers and models\n' +
329
+ '`/think` ─ toggle thinking message forwarding\n' +
330
+ '`/allow` / `/reject` / `/always` ─ respond to permission requests\n' +
331
+ '`/shutdown` ─ stop tgagentp (only with `--dev` flag)\n\n' +
270
332
  'Everything else is forwarded to the active server as `[Telegram] <text>`.');
271
333
  }
272
334
 
273
- async function cmdServers(token, chatId, args, setServer) {
335
+ async function cmdServers(token, chatId, args, setServer, setActiveSession, serverBase) {
274
336
  if (args.length > 0 && args[0].toLowerCase() === 'switch') {
275
337
  if (args.length < 2) {
276
338
  await sendLongMessage(token, chatId,
@@ -463,10 +525,10 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
463
525
  await sendLongMessage(token, chatId,
464
526
  `Unknown subcommand: /sessions ${sub}\n\n` +
465
527
  'Usage:\n' +
466
- ' `/sessions` — list sessions\n' +
467
- ' `/sessions switch <number-or-name>` — switch to a session\n' +
468
- ' `/sessions new [name]` — create a new session\n' +
469
- ' `/sessions rename <name>` — rename the active session');
528
+ ' `/sessions` ─ list sessions\n' +
529
+ ' `/sessions switch <number-or-name>` ─ switch to a session\n' +
530
+ ' `/sessions new [name]` ─ create a new session\n' +
531
+ ' `/sessions rename <name>` ─ rename the active session');
470
532
  return;
471
533
  }
472
534
 
@@ -566,10 +628,14 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
566
628
  }
567
629
  const target = matches[0];
568
630
  getState(server).activeAgentName = target.name;
569
- const mode = target.mode === 'primary' ? '' : ` (${target.mode})`;
570
- const model = target.model ? ` — \`${target.model.providerID}/${target.model.modelID}\`` : '';
631
+ const st = getState(server);
632
+ if (st.activeSessionId) {
633
+ updateSession(server, st.activeSessionId, undefined, target.name);
634
+ selectSession(server, st.activeSessionId);
635
+ }
636
+ const model = target.model ? ` ─ \`${target.model.providerID}/${target.model.modelID}\`` : '';
571
637
  await sendLongMessage(token, chatId,
572
- `Switched to agent: **${target.name}**${mode}${model}\nSubsequent messages will use this agent.`);
638
+ `Switched to agent: **${target.name}**${model}\nSubsequent messages will use this agent.`);
573
639
  } catch (err) {
574
640
  await sendLongMessage(token, chatId, `Error switching agent: ${err.message}`);
575
641
  }
@@ -582,13 +648,19 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
582
648
  await sendLongMessage(token, chatId, 'No agents available.');
583
649
  return;
584
650
  }
585
- const lines = agents.map(a => {
586
- const mode = a.mode === 'primary' ? '' : ` (${a.mode})`;
587
- const model = a.model ? ` — \`${a.model.providerID}/${a.model.modelID}\`` : '';
651
+ const primary = agents.filter(a => a.mode === 'primary');
652
+ if (primary.length === 0) {
653
+ await sendLongMessage(token, chatId, 'No primary agents available.');
654
+ return;
655
+ }
656
+ const activeAgentName = getState(server).activeAgentName;
657
+ const lines = primary.map(a => {
658
+ const bullet = a.name === activeAgentName ? '▶ ' : ' · ';
659
+ const model = a.model ? ` ─ \`${a.model.providerID}/${a.model.modelID}\`` : '';
588
660
  const desc = a.description ? `. ${a.description}` : '';
589
- return '**' + a.name + '**' + mode + model + desc;
661
+ return bullet + '**' + a.name + '**' + model + desc;
590
662
  });
591
- await sendLongMessage(token, chatId, 'Agents:\n' + lines.join('\n'));
663
+ await sendLongMessage(token, chatId, 'Primary agents:\n' + lines.join('\n'));
592
664
  } catch (err) {
593
665
  await sendLongMessage(token, chatId, `Error listing agents: ${err.message}`);
594
666
  }
@@ -634,6 +706,88 @@ async function cmdModels(token, chatId, args, setServer, setActiveSession, serve
634
706
 
635
707
  const PERM_RESPONSE_HELP = ' No pending permission. Use `/status` to check the current state.';
636
708
 
709
+ // ---------------------------------------------------------------------------
710
+ // Agentp gateway ─ tiny HTTP server for agentp --tg
711
+ // ---------------------------------------------------------------------------
712
+
713
+ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
714
+ return new Promise((resolve) => {
715
+ const server = http.createServer((req, res) => {
716
+ try {
717
+ if (req.method !== 'POST' || req.url !== '/send') {
718
+ res.writeHead(405);
719
+ res.end('Method Not Allowed');
720
+ return;
721
+ }
722
+ if (!validateAgentpAuth(req)) {
723
+ res.writeHead(401);
724
+ res.end('Unauthorized');
725
+ return;
726
+ }
727
+ let body = '';
728
+ req.on('data', (chunk) => { body += chunk; });
729
+ req.on('end', () => {
730
+ try {
731
+ let data;
732
+ try { data = JSON.parse(body); } catch {
733
+ res.writeHead(400);
734
+ res.end('Bad Request');
735
+ return;
736
+ }
737
+ const { text, server } = data;
738
+ if (typeof text !== 'string' || typeof server !== 'string') {
739
+ res.writeHead(400);
740
+ res.end('Bad Request: text and server are required');
741
+ return;
742
+ }
743
+ res.writeHead(200);
744
+ res.end('OK');
745
+
746
+ const chatId = chatIdResolver();
747
+ if (!chatId) return;
748
+ const activeUrl = normalizeUrl(serverBaseResolver());
749
+ const targetUrl = normalizeUrl(server);
750
+
751
+ if (targetUrl === activeUrl) {
752
+ sendLongMessage(token, chatId, text.trim()).catch(() => {});
753
+ } else {
754
+ if (!agentpQueues[server]) {
755
+ agentpQueues[server] = { messages: [], notifyTimer: null, notified: false };
756
+ }
757
+ const q = agentpQueues[server];
758
+ q.messages.push(text);
759
+ if (!q.notifyTimer) {
760
+ q.notifyTimer = setTimeout(() => {
761
+ q.notifyTimer = null;
762
+ if (q.messages.length > 0 && !q.notified) {
763
+ q.notified = true;
764
+ sendLongMessage(token, chatId,
765
+ `📥 ${q.messages.length} agentp message(s) queued for server \`${server}\`. Switch servers to view them.`)
766
+ .catch(() => {});
767
+ }
768
+ }, TGAGENTP_DEBOUNCE_MS);
769
+ }
770
+ }
771
+ } catch (err) {
772
+ log.error(`Agentp gateway handler error: ${err.message}`);
773
+ }
774
+ });
775
+ } catch (err) {
776
+ log.error(`Agentp gateway handler error: ${err.message}`);
777
+ }
778
+ });
779
+ server.on('error', (err) => {
780
+ log.error(`Agentp gateway server error: ${err.message}`);
781
+ });
782
+ server.listen(TGAGENTP_PORT, '127.0.0.1', () => {
783
+ const port = server.address().port;
784
+ try { fs.writeFileSync(PORT_FILE, String(port)); } catch (_) {}
785
+ log.info(`Agentp gateway listening on port ${port}`);
786
+ resolve(port);
787
+ });
788
+ });
789
+ }
790
+
637
791
  async function cmdAllow(token, chatId, args, setServer, setActiveSession, server) {
638
792
  const st = getState(server);
639
793
  if (!st.pendingPermission) {
@@ -644,7 +798,7 @@ async function cmdAllow(token, chatId, args, setServer, setActiveSession, server
644
798
  try {
645
799
  await respondToPermission(p.server, p.sessionId, p.permissionId, 'once');
646
800
  await sendLongMessage(token, chatId, '✅ Permission approved (once).');
647
- process.stderr.write(` → /allow: ${p.type} ${p.pattern || ''}\n`);
801
+ log.debug(` → /allow: ${p.type} ${p.pattern || ''}`);
648
802
  } catch (err) {
649
803
  await sendLongMessage(token, chatId, `Error: ${err.message}`);
650
804
  }
@@ -660,7 +814,7 @@ async function cmdReject(token, chatId, args, setServer, setActiveSession, serve
660
814
  try {
661
815
  await respondToPermission(p.server, p.sessionId, p.permissionId, 'reject');
662
816
  await sendLongMessage(token, chatId, '⛔ Permission denied.');
663
- process.stderr.write(` → /reject: ${p.type} ${p.pattern || ''}\n`);
817
+ log.debug(` → /reject: ${p.type} ${p.pattern || ''}`);
664
818
  } catch (err) {
665
819
  await sendLongMessage(token, chatId, `Error: ${err.message}`);
666
820
  }
@@ -676,12 +830,30 @@ async function cmdAlways(token, chatId, args, setServer, setActiveSession, serve
676
830
  try {
677
831
  await respondToPermission(p.server, p.sessionId, p.permissionId, 'always');
678
832
  await sendLongMessage(token, chatId, '✅ Permission approved (always).');
679
- process.stderr.write(` → /always: ${p.type} ${p.pattern || ''}\n`);
833
+ log.debug(` → /always: ${p.type} ${p.pattern || ''}`);
680
834
  } catch (err) {
681
835
  await sendLongMessage(token, chatId, `Error: ${err.message}`);
682
836
  }
683
837
  }
684
838
 
839
+ async function cmdThink(token, chatId, args, setServer, setActiveSession, server) {
840
+ const st = getState(server);
841
+ const sub = args && args.length > 0 ? args[0].toLowerCase() : 'switch';
842
+ if (sub === 'on') {
843
+ st.thinkingEnabled = true;
844
+ await sendLongMessage(token, chatId, '💭 Thinking messages **on** (forwarded to chat).');
845
+ } else if (sub === 'off') {
846
+ st.thinkingEnabled = false;
847
+ await sendLongMessage(token, chatId, '💭 Thinking messages **off**.');
848
+ } else if (sub === 'switch') {
849
+ st.thinkingEnabled = !st.thinkingEnabled;
850
+ await sendLongMessage(token, chatId,
851
+ `💭 Thinking messages ${st.thinkingEnabled ? '**on**' : '**off**'}.`);
852
+ } else {
853
+ await sendLongMessage(token, chatId, 'Usage: `/think [on|off|switch]` (default: switch).');
854
+ }
855
+ }
856
+
685
857
  const COMMANDS = {
686
858
  'help': cmdHelp,
687
859
  'servers': cmdServers,
@@ -689,6 +861,7 @@ const COMMANDS = {
689
861
  'session': cmdSessions,
690
862
  'agents': cmdAgents,
691
863
  'models': cmdModels,
864
+ 'think': cmdThink,
692
865
  'allow': cmdAllow,
693
866
  'reject': cmdReject,
694
867
  'always': cmdAlways,
@@ -700,7 +873,7 @@ const COMMANDS = {
700
873
 
701
874
  const serverState = {};
702
875
  function getState(url) {
703
- return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null };
876
+ return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false };
704
877
  }
705
878
 
706
879
  function sessionLabel(st) {
@@ -725,6 +898,10 @@ async function main() {
725
898
  process.exit(0);
726
899
  } else if (args[i] === '--dev') {
727
900
  DEV_MODE = true;
901
+ } else if (args[i] === '--verbose') {
902
+ log.verbose = true;
903
+ } else if (args[i] === '--think') {
904
+ getState(serverBase).thinkingEnabled = true;
728
905
  } else if (args[i] === '--help') {
729
906
  console.error('Usage: tgagentp [options] [url]');
730
907
  console.error('');
@@ -732,9 +909,11 @@ async function main() {
732
909
  console.error('Incoming Telegram messages are forwarded to OpenCode; answers are sent back to the chat.');
733
910
  console.error('');
734
911
  console.error('Options:');
735
- console.error(' --dev Enable development mode (adds /shutdown command)');
736
- console.error(' --version Show version');
737
- console.error(' --help Show this help message');
912
+ console.error(' --dev Enable development mode (adds /shutdown command)');
913
+ console.error(' --version Show version');
914
+ console.error(' --verbose Show detailed logs (errors, trace, debug) on stderr');
915
+ console.error(' --think Start with thinking messages enabled (default: off)');
916
+ console.error(' --help Show this help message');
738
917
  console.error('');
739
918
  console.error('Arguments:');
740
919
  console.error(' url OpenCode TUI server URL or port number (default: 4096)');
@@ -742,10 +921,15 @@ async function main() {
742
921
  console.error('');
743
922
  console.error('Environment variables:');
744
923
  console.error(' TELEGRAM_BOT_TOKEN Telegram bot token (required)');
745
- console.error(' OPENCODE_SERVER_PASSWORD OpenCode server password (optional)');
924
+ console.error(' OPENCODE_SERVER_PASSWORD OpenCode server password (optional, also used for agentp gateway auth)');
746
925
  console.error(' OPENCODE_SERVER_USERNAME OpenCode server username (optional, default: opencode)');
747
926
  console.error(' TGAGENTP_ALLOWED_CHAT_IDS Comma-separated Telegram chat IDs (optional)');
748
927
  console.error(' Example: "123456,-789012"');
928
+ console.error(' TGAGENTP_PORT Agentp gateway listen port (optional, default: random)');
929
+ console.error(' TGAGENTP_DEBOUNCE_MS Debounce interval for queued agentp messages (optional, default: 5000)');
930
+ console.error('');
931
+ console.error('Also starts an agentp gateway (tiny HTTP server on localhost) that');
932
+ console.error('accepts POST /send from agentp --tg and forwards answers to Telegram.');
749
933
  console.error('');
750
934
  console.error('See also: agentp, ocmux');
751
935
  console.error('');
@@ -769,24 +953,24 @@ async function main() {
769
953
  process.exit(1);
770
954
  }
771
955
 
772
- process.stderr.write(`tgagentp v${version} started. OpenCode server: ${serverBase}\n`);
956
+ log.info(`tgagentp v${version} started. OpenCode server: ${serverBase}`);
773
957
 
774
958
  if (!process.env.OPENCODE_SERVER_PASSWORD) {
775
- process.stderr.write('🔓 ⚠️ Server is not password-protected! Set OPENCODE_SERVER_PASSWORD for better security.\n');
959
+ log.error('🔓 Server is not password-protected! Set OPENCODE_SERVER_PASSWORD for better security.');
776
960
  }
777
961
 
778
962
  const allowedChatIds = parseAllowlist(process.env.TGAGENTP_ALLOWED_CHAT_IDS);
779
963
  if (allowedChatIds) {
780
- process.stderr.write(`Chat allowlist: ${allowedChatIds.length} chat(s) configured\n`);
964
+ log.info(`Chat allowlist: ${allowedChatIds.length} chat(s) configured`);
781
965
  } else {
782
- process.stderr.write('Chat allowlist: not configured (all chats accepted)\n');
966
+ log.info('Chat allowlist: not configured (all chats accepted)');
783
967
  }
784
968
 
785
969
  let lockedChatId = null;
786
970
 
787
971
  getState(serverBase);
788
972
 
789
- process.stderr.write('Discovering server state...\n');
973
+ log.info('Discovering server state...');
790
974
  const [discovered, agents] = await Promise.all([
791
975
  discoverActiveSession(serverBase),
792
976
  listAgents(serverBase).catch(() => []),
@@ -794,20 +978,29 @@ async function main() {
794
978
  if (discovered) {
795
979
  getState(serverBase).activeSessionId = discovered.id;
796
980
  getState(serverBase).activeSessionName = discovered.name;
797
- process.stderr.write(` → session: ${discovered.name || discovered.id}\n`);
981
+ log.info(` → session: ${discovered.name || discovered.id}`);
798
982
  }
799
983
  const defaultAgent = agents.find(a => a.mode === 'primary') || agents[0];
800
984
  if (defaultAgent) {
801
985
  getState(serverBase).activeAgentName = defaultAgent.name;
802
- process.stderr.write(` → agent: ${defaultAgent.name}\n`);
986
+ log.info(` → agent: ${defaultAgent.name}`);
803
987
  }
804
988
 
805
- process.stderr.write('Waiting for Telegram messages... (Ctrl+C to stop)\n');
989
+ log.info('Waiting for Telegram messages... (Ctrl+C to stop)');
990
+
991
+ // Start agentp gateway (tiny HTTP server for agentp --tg)
992
+ const chatIdResolver = () => lockedChatId;
993
+ const serverBaseResolver = () => serverBase;
994
+ startAgentpGateway(token, chatIdResolver, serverBaseResolver);
806
995
 
807
996
  const setServer = async (url) => {
808
997
  serverBase = url;
809
998
  getState(url);
810
- process.stderr.write(`Switched to server: ${url}\n`);
999
+ log.info(`Switched to server: ${url}`);
1000
+
1001
+ // Flush agentp queue for the new active server
1002
+ flushAgentpQueue(url, token, lockedChatId);
1003
+
811
1004
  const [discovered, agents] = await Promise.all([
812
1005
  discoverActiveSession(url),
813
1006
  listAgents(url).catch(() => []),
@@ -815,12 +1008,12 @@ async function main() {
815
1008
  if (discovered) {
816
1009
  getState(url).activeSessionId = discovered.id;
817
1010
  getState(url).activeSessionName = discovered.name;
818
- process.stderr.write(` → session: ${discovered.name || discovered.id}\n`);
1011
+ log.info(` → session: ${discovered.name || discovered.id}`);
819
1012
  }
820
1013
  const defaultAgent = agents.find(a => a.mode === 'primary') || agents[0];
821
1014
  if (defaultAgent) {
822
1015
  getState(url).activeAgentName = defaultAgent.name;
823
- process.stderr.write(` → agent: ${defaultAgent.name}\n`);
1016
+ log.info(` → agent: ${defaultAgent.name}`);
824
1017
  }
825
1018
  const p = getState(url).pendingResponse;
826
1019
  if (p) {
@@ -832,24 +1025,47 @@ async function main() {
832
1025
 
833
1026
  const setActiveSession = (id) => {
834
1027
  getState(serverBase).activeSessionId = id;
835
- process.stderr.write(`Switched to session: ${id}\n`);
1028
+ log.info(`Switched to session: ${id}`);
836
1029
  };
837
1030
 
838
- async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId) {
1031
+ async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId, replyTo) {
839
1032
  const st = getState(url);
840
1033
  const isCancelled = () => getState(url).taskId !== myTaskId;
841
1034
  const cancelRef = { current: null };
1035
+ const sessionCancelRef = { current: null };
842
1036
  st._cancelRef = cancelRef;
1037
+ st._sessionReq = sessionCancelRef;
843
1038
  try {
844
1039
  let answer;
845
1040
  if (useSession) {
846
- // Event-driven flow: start SSE listener first, then send message async
1041
+ // Dual approach:
1042
+ // 1. SSE for permissions/thinking events (connected before sending)
1043
+ // 2. Synchronous sendToSession (POST /session/:id/message) for reliable answer
847
1044
  const notifiedPerms = new Set();
848
- const ssePromise = listenForSessionEvents(url, sessionId, {
1045
+ let thinkingBuf = '';
1046
+ let sseConnected;
1047
+ const onConnected = Promise.race([
1048
+ new Promise((resolve) => { sseConnected = resolve; }),
1049
+ new Promise((resolve) => setTimeout(resolve, 5000)),
1050
+ ]);
1051
+ listenForSessionEvents(url, sessionId, {
1052
+ onText: null,
1053
+ onConnected: sseConnected,
1054
+ onThinking: (chunk) => {
1055
+ if (!getState(url).thinkingEnabled) { thinkingBuf = ''; return; }
1056
+ thinkingBuf += chunk;
1057
+ if (thinkingBuf.length >= 50 || chunk.endsWith('\n')) {
1058
+ const html = '💭 ' + thinkingBuf.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1059
+ sendLongMessage(token, chatId, html, replyTo)
1060
+ .then(() => {})
1061
+ .catch(() => {});
1062
+ thinkingBuf = '';
1063
+ }
1064
+ },
849
1065
  onPermission: (perm) => {
850
1066
  const st2 = getState(url);
851
1067
  const permDump = JSON.stringify(perm);
852
- process.stderr.write(` → permission_asked callback: ${permDump}\n`);
1068
+ log.debug(` → permission_asked callback: ${permDump}`);
853
1069
  st2.pendingPermission = { server: url, sessionId, permissionId: perm.id, type: perm.permission || 'unspecified', pattern: perm.patterns || perm.pattern, title: perm.title };
854
1070
  if (!notifiedPerms.has(perm.id)) {
855
1071
  notifiedPerms.add(perm.id);
@@ -861,60 +1077,64 @@ async function main() {
861
1077
  + (patternStr ? '**Target:** `' + patternStr + '`\n' : '')
862
1078
  + (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
863
1079
  + 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
864
- process.stderr.write(` → sending permission notification to chat ${chatId}\n`);
865
- sendLongMessage(token, chatId, msg)
866
- .then(() => process.stderr.write(` → permission notification sent OK\n`))
867
- .catch((err) => process.stderr.write(` → permission notification failed: ${err.message}\n`));
1080
+ log.debug(` → sending permission notification to chat ${chatId}`);
1081
+ sendLongMessage(token, chatId, msg, replyTo)
1082
+ .then(() => log.debug(` → permission notification sent OK`))
1083
+ .catch((err) => log.debug(` → permission notification failed: ${err.message}`));
868
1084
  } else {
869
- process.stderr.write(` → permission ${perm.id} already notified, updating pendingPermission only\n`);
1085
+ log.debug(` → permission ${perm.id} already notified, updating pendingPermission only`);
870
1086
  }
871
1087
  },
872
1088
  onPermissionReplied: () => {
873
- process.stderr.write(` → permission_replied: clearing pendingPermission\n`);
1089
+ log.debug(` → permission_replied: clearing pendingPermission`);
874
1090
  getState(url).pendingPermission = null;
875
1091
  },
876
- }, cancelRef);
877
- // Send message (SSE is already listening — no race)
878
- try {
879
- await sendToSessionAsync(url, sessionId, `[Telegram] ${text}`, st.activeAgentName);
880
- } catch (err) {
881
- // Abort SSE listener on send failure to avoid resource leak
882
- if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
883
- throw err;
1092
+ }, cancelRef).catch(() => {});
1093
+ // Wait for SSE to connect, then send synchronously
1094
+ await onConnected;
1095
+ answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName, sessionCancelRef);
1096
+ // Flush any remaining thinking buffer
1097
+ if (thinkingBuf) {
1098
+ const html = '💭 ' + thinkingBuf.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1099
+ sendLongMessage(token, chatId, html, replyTo).catch(() => {});
1100
+ thinkingBuf = '';
884
1101
  }
885
- // Wait for the SSE listener to collect the full answer
886
- answer = await ssePromise;
1102
+ // Clean up SSE listener
1103
+ if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
887
1104
  } else {
888
1105
  await sendText(url, `[Telegram] ${text}`);
889
1106
  answer = await listenForFinalAnswer(url, null, cancelRef);
890
1107
  }
891
1108
  if (isCancelled()) {
892
- process.stderr.write(` → cancelled, discarding response from ${url}\n`);
1109
+ log.debug(` → cancelled, discarding response from ${url}`);
893
1110
  st.pendingPermission = null;
894
1111
  return;
895
1112
  }
896
1113
  st.pendingPermission = null;
897
1114
  if (url === serverBase) {
898
1115
  if (answer && answer.trim()) {
899
- await sendLongMessage(token, chatId, answer.trim());
1116
+ await sendLongMessage(token, chatId, answer.trim(), replyTo);
900
1117
  }
901
1118
  } else {
902
1119
  getState(url).pendingResponse = answer;
903
- await sendLongMessage(token, chatId, `✅ Server \`${url}\` finished processing. Switch back to see the response.`);
1120
+ await sendLongMessage(token, chatId, `✅ Server \`${url}\` finished processing. Switch back to see the response.`, replyTo);
904
1121
  }
905
1122
  } catch (err) {
1123
+ // Clean up SSE listener if it's still active
1124
+ if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
906
1125
  if (isCancelled()) {
907
- process.stderr.write(` → cancelled, ignoring error from ${url}: ${err.message}\n`);
1126
+ log.debug(` → cancelled, ignoring error from ${url}: ${err.message}`);
908
1127
  st.pendingPermission = null;
909
1128
  return;
910
1129
  }
911
- process.stderr.write(`Error processing message (${url}): ${err.message}\n`);
1130
+ log.error(`Error processing message (${url}): ${err.message}`);
912
1131
  if (url === serverBase) {
913
- try { await sendLongMessage(token, chatId, `Error: ${err.message}`); } catch (_) {}
1132
+ try { await sendLongMessage(token, chatId, `Error: ${err.message}`, replyTo); } catch (_) {}
914
1133
  }
915
1134
  } finally {
916
1135
  st.pendingPermission = null;
917
1136
  if (st._cancelRef === cancelRef) st._cancelRef = null;
1137
+ if (st._sessionReq === sessionCancelRef) st._sessionReq = null;
918
1138
  if (!isCancelled()) {
919
1139
  getState(url).busy = false;
920
1140
  }
@@ -934,8 +1154,13 @@ async function main() {
934
1154
  st._cancelRef.current.destroy();
935
1155
  st._cancelRef.current = null;
936
1156
  }
1157
+ // Abort the synchronous session request if in progress
1158
+ if (st._sessionReq && st._sessionReq.current) {
1159
+ st._sessionReq.current.destroy();
1160
+ st._sessionReq.current = null;
1161
+ }
937
1162
  await sendLongMessage(token, chatId, '✅ Cancelled.');
938
- process.stderr.write(` → /cancel on ${serverBase}\n`);
1163
+ log.debug(` → /cancel on ${serverBase}`);
939
1164
  }
940
1165
 
941
1166
  const cmdStatus = async (token, chatId) => {
@@ -951,13 +1176,22 @@ async function main() {
951
1176
  await sendLongMessage(token, chatId, lines.join('\n'));
952
1177
  };
953
1178
 
954
- const cmdShutdown = async (token, chatId) => {
955
- await sendLongMessage(token, chatId, '👋 Shutting down...');
1179
+ const cmdShutdown = async (token, chatId, args) => {
1180
+ const st = getState(serverBase);
1181
+ const isForce = args && args.length > 0 && args[0].toLowerCase() === 'force';
1182
+ if (st.busy && !isForce) {
1183
+ await sendLongMessage(token, chatId,
1184
+ '⏳ Server is busy processing a message.\n' +
1185
+ 'Use `/shutdown force` to shut down anyway (in-flight response will be lost).');
1186
+ return;
1187
+ }
1188
+ const status = st.busy ? '⏳ busy (forced)' : '✅ idle';
1189
+ await sendLongMessage(token, chatId, `👋 Shutting down (state: ${status})...`);
956
1190
  try {
957
1191
  fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId));
958
1192
  } catch (_) {}
959
1193
  try { await getUpdates(token, offset, 1); } catch (_) {}
960
- process.stderr.write('/shutdown received, exiting...\n');
1194
+ log.debug('/shutdown received, exiting...');
961
1195
  process.exit(0);
962
1196
  };
963
1197
 
@@ -978,6 +1212,7 @@ async function main() {
978
1212
  lines.push('**Session:** ' + sessionLabel(st));
979
1213
  lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
980
1214
  try { await sendLongMessage(token, savedChatId, lines.join('\n')); } catch (_) {}
1215
+ lockedChatId = savedChatId;
981
1216
  try { fs.unlinkSync(STARTUP_CHAT_FILE); } catch (_) {}
982
1217
  }
983
1218
 
@@ -987,13 +1222,13 @@ async function main() {
987
1222
  try {
988
1223
  result = await getUpdates(token, offset, 30);
989
1224
  } catch (err) {
990
- process.stderr.write(`Polling error: ${err.message}\n`);
1225
+ log.error(`Polling error: ${err.message}`);
991
1226
  await new Promise((r) => setTimeout(r, 5000));
992
1227
  continue;
993
1228
  }
994
1229
 
995
1230
  if (!result.ok) {
996
- process.stderr.write(`Telegram API error: ${result.description || JSON.stringify(result)}\n`);
1231
+ log.error(`Telegram API error: ${result.description || JSON.stringify(result)}`);
997
1232
  await new Promise((r) => setTimeout(r, 5000));
998
1233
  continue;
999
1234
  }
@@ -1008,6 +1243,7 @@ async function main() {
1008
1243
  }
1009
1244
 
1010
1245
  const chatId = String(msg.chat.id);
1246
+ const replyTo = msg.message_id;
1011
1247
  const text = msg.text;
1012
1248
  const from = msg.from?.username
1013
1249
  ? `@${msg.from.username}`
@@ -1015,22 +1251,25 @@ async function main() {
1015
1251
 
1016
1252
  // Enforce chat allowlist if configured
1017
1253
  if (allowedChatIds && !allowedChatIds.includes(chatId)) {
1018
- process.stderr.write(`[chat ${chatId}] ${from}: blocked (not in allowlist)\n`);
1254
+ log.debug(`[chat ${chatId}] ${from}: blocked (not in allowlist)`);
1019
1255
  continue;
1020
1256
  }
1021
1257
 
1022
1258
  // Lock to the first chat that connects (single-user guard)
1023
1259
  if (lockedChatId === null) {
1024
1260
  lockedChatId = chatId;
1025
- process.stderr.write(`[chat ${chatId}] ${from}: locked to this chat\n`);
1261
+ log.debug(`[chat ${chatId}] ${from}: locked to this chat`);
1026
1262
  } else if (lockedChatId !== chatId) {
1027
- process.stderr.write(`[chat ${chatId}] ${from}: blocked (locked to ${lockedChatId})\n`);
1263
+ log.debug(`[chat ${chatId}] ${from}: blocked (locked to ${lockedChatId})`);
1028
1264
  continue;
1029
1265
  }
1030
1266
 
1031
- process.stderr.write(`[chat ${chatId}] ${from}: ${text}\n`);
1267
+ log.debug(`[chat ${chatId}] ${from}: ${text}`);
1032
1268
  try { fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId)); } catch (_) {}
1033
1269
 
1270
+ // Build reply context if this message is a Telegram reply
1271
+ const replyContext = quoteReply(msg);
1272
+
1034
1273
  // Intercept /commands
1035
1274
  if (text.startsWith('/')) {
1036
1275
  const parts = text.slice(1).split(' ');
@@ -1041,7 +1280,7 @@ async function main() {
1041
1280
  try {
1042
1281
  await handler(token, chatId, cmdArgs, setServer, setActiveSession, serverBase);
1043
1282
  } catch (err) {
1044
- process.stderr.write(`Error handling /${cmd}: ${err.message}\n`);
1283
+ log.error(`Error handling /${cmd}: ${err.message}`);
1045
1284
  try { await sendLongMessage(token, chatId, `Error: ${err.message}`); } catch (_) {}
1046
1285
  }
1047
1286
  } else {
@@ -1051,7 +1290,7 @@ async function main() {
1051
1290
  continue;
1052
1291
  }
1053
1292
 
1054
- // Non-command message — process in background
1293
+ // Non-command message ─ process in background
1055
1294
  const st = getState(serverBase);
1056
1295
  if (st.busy) {
1057
1296
  await sendLongMessage(token, chatId, '⏳ Server is busy processing a previous message. Please wait for it to finish, or use /cancel to cancel it.');
@@ -1059,14 +1298,14 @@ async function main() {
1059
1298
  }
1060
1299
  st.busy = true;
1061
1300
  const myTaskId = ++st.taskId;
1062
- process.stderr.write(` → started background processing (task ${myTaskId})\n`);
1063
- processMessageAsync(chatId, text, serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId);
1301
+ log.debug(` → started background processing (task ${myTaskId})`);
1302
+ processMessageAsync(chatId, replyContext + text, serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId, replyTo);
1064
1303
  }
1065
1304
  }
1066
1305
  }
1067
1306
 
1068
1307
  process.on('SIGINT', () => {
1069
- process.stderr.write('\ntgagentp stopped.\n');
1308
+ log.info('tgagentp stopped.');
1070
1309
  process.exit(0);
1071
1310
  });
1072
1311