agentp 0.8.0 → 0.10.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,5 +1,6 @@
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');
@@ -15,8 +16,65 @@ const log = {
15
16
  };
16
17
 
17
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);
18
22
  const { listServers, activateServer } = require('../lib/ocmux.js');
19
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
+
52
+ // ---------------------------------------------------------------------------
53
+ // /record — ring buffer for Telegram conversation context
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const MAX_RECORDED_MSGS = 100;
57
+ const MAX_RECORDED_BYTES = 100 * 1024;
58
+
59
+ const recorder = { active: false, messages: [], bytes: 0 };
60
+
61
+ function bufferMessage(role, text) {
62
+ if (!recorder.active) return;
63
+ recorder.messages.push({ role, text });
64
+ recorder.bytes += Buffer.byteLength(text, 'utf8');
65
+ while (recorder.messages.length > MAX_RECORDED_MSGS || recorder.bytes > MAX_RECORDED_BYTES) {
66
+ const oldest = recorder.messages.shift();
67
+ recorder.bytes -= Buffer.byteLength(oldest.text, 'utf8');
68
+ }
69
+ }
70
+
71
+ function flushRecorded() {
72
+ if (!recorder.active) return [];
73
+ const msgs = recorder.messages.splice(0);
74
+ recorder.bytes = 0;
75
+ return msgs;
76
+ }
77
+
20
78
  // ---------------------------------------------------------------------------
21
79
  // Telegram Bot API helpers
22
80
  // ---------------------------------------------------------------------------
@@ -72,7 +130,7 @@ function convertMarkdownToHtml(text) {
72
130
 
73
131
  return parts.map((part, i) => {
74
132
  if (i % 2 === 1) {
75
- // Code block — strip language tag, escape entities, wrap in <pre>
133
+ // Code block ─ strip language tag, escape entities, wrap in <pre>
76
134
  const nl = part.indexOf('\n');
77
135
  const inner = nl >= 0 ? part.slice(nl + 1) : part;
78
136
  return '<pre>' + inner
@@ -194,7 +252,7 @@ function quoteReply(msg) {
194
252
  if (rm.text[i] === '`') { cur++; maxBackticks = Math.max(maxBackticks, cur); }
195
253
  else cur = 0;
196
254
  }
197
- const fence = '`'.repeat(maxBackticks + 1);
255
+ const fence = '`'.repeat(Math.max(3, maxBackticks + 1));
198
256
  return `In response to ${replyFrom} (${senderType}):\n\n${fence}\n${rm.text}\n${fence}\n\n`;
199
257
  }
200
258
 
@@ -207,25 +265,25 @@ async function cmdHelp(token, chatId, args) {
207
265
  const topic = args[0].toLowerCase();
208
266
  if (topic === 'servers') {
209
267
  await sendLongMessage(token, chatId,
210
- '`/servers` — list all opencode servers (ocmux sessions)\n' +
211
- '`/servers switch <name>` — switch target server\n\n' +
268
+ '`/servers` ─ list all opencode servers (ocmux sessions)\n' +
269
+ '`/servers switch <name>` ─ switch target server\n\n' +
212
270
  '`<name>` can be a full path or basename of the project directory.');
213
271
  return;
214
272
  }
215
273
  if (topic === 'sessions') {
216
274
  await sendLongMessage(token, chatId,
217
- '`/sessions` — list active sessions (numbered, newest first, max 50)\n' +
218
- '`/sessions switch <number-or-name>` — send messages to a specific session\n' +
219
- '`/sessions new [name]` — create a new session\n' +
220
- '`/sessions rename <name>` — rename the active session\n\n' +
275
+ '`/sessions` ─ list active sessions (numbered, newest first, max 50)\n' +
276
+ '`/sessions switch <number-or-name>` ─ send messages to a specific session\n' +
277
+ '`/sessions new [name]` ─ create a new session\n' +
278
+ '`/sessions rename <name>` ─ rename the active session\n\n' +
221
279
  'Switch by number (from the list) or by case-insensitive partial name match.\n' +
222
280
  'Use `/sessions` to find session numbers.');
223
281
  return;
224
282
  }
225
283
  if (topic === 'shutdown') {
226
284
  await sendLongMessage(token, chatId,
227
- '`/shutdown` — stop tgagentp process\n' +
228
- '`/shutdown force` — force shutdown even if busy (in-flight work will be lost)\n\n' +
285
+ '`/shutdown` ─ stop tgagentp process\n' +
286
+ '`/shutdown force` ─ force shutdown even if busy (in-flight work will be lost)\n\n' +
229
287
  'Only available when tgagentp is started with `--dev` flag.\n' +
230
288
  'Useful when running tgagentp in a restart loop:\n' +
231
289
  '`while true; do tgagentp --dev; done`');
@@ -233,32 +291,32 @@ async function cmdHelp(token, chatId, args) {
233
291
  }
234
292
  if (topic === 'agents') {
235
293
  await sendLongMessage(token, chatId,
236
- '`/agents` — list all available agents (name, mode, assigned model, description).\n' +
237
- '`/agents switch <name>` — switch the active agent. Subsequent messages will use this agent.\n\n' +
294
+ '`/agents` ─ list primary agents (with ▶ marker for the active one).\n' +
295
+ '`/agents switch <name>` ─ switch the active agent. Subsequent messages will use this agent.\n\n' +
238
296
  'Agent switching requires an active session (`/sessions switch` or `/sessions new`).');
239
297
  return;
240
298
  }
241
299
  if (topic === 'session') {
242
300
  await sendLongMessage(token, chatId,
243
301
  '`/session` is an alias for `/sessions`.\n\n' +
244
- '`/sessions` — list active sessions\n' +
245
- '`/sessions switch <number-or-name>` — switch to a session\n' +
246
- '`/sessions new [name]` — create a new session\n' +
247
- '`/sessions rename <name>` — rename the active session');
302
+ '`/sessions` ─ list active sessions\n' +
303
+ '`/sessions switch <number-or-name>` ─ switch to a session\n' +
304
+ '`/sessions new [name]` ─ create a new session\n' +
305
+ '`/sessions rename <name>` ─ rename the active session');
248
306
  return;
249
307
  }
250
308
  if (topic === 'models') {
251
309
  await sendLongMessage(token, chatId,
252
- '`/models` — list all providers and their models.\n' +
310
+ '`/models` ─ list all providers and their models.\n' +
253
311
  'Shows provider name, connection status, default model, and up to 5 models per provider\n' +
254
312
  'with status, context limit, and input/output costs.');
255
313
  return;
256
314
  }
257
315
  if (topic === 'allow' || topic === 'reject' || topic === 'always') {
258
316
  await sendLongMessage(token, chatId,
259
- '`/allow` — approve a permission request once\n' +
260
- '`/reject` — deny a permission request\n' +
261
- '`/always` — approve and remember for the session\n\n' +
317
+ '`/allow` ─ approve a permission request once\n' +
318
+ '`/reject` ─ deny a permission request\n' +
319
+ '`/always` ─ approve and remember for the session\n\n' +
262
320
  'These commands respond to OpenCode permission prompts forwarded by tgagentp.\n' +
263
321
  'When OpenCode needs approval (e.g. to run a shell command), tgagentp sends a\n' +
264
322
  '🔐 notification to the chat. Use one of these commands to answer.');
@@ -266,37 +324,59 @@ async function cmdHelp(token, chatId, args) {
266
324
  }
267
325
  if (topic === 'think') {
268
326
  await sendLongMessage(token, chatId,
269
- '`/think` — toggle thinking message forwarding\n' +
270
- '`/think on` — forward model thinking to the chat in real-time\n' +
271
- '`/think off` — stop forwarding thinking messages (default)\n' +
272
- '`/think switch` — toggle current state\n\n' +
327
+ '`/think` ─ toggle thinking message forwarding\n' +
328
+ '`/think on` ─ forward model thinking to the chat in real-time\n' +
329
+ '`/think off` ─ stop forwarding thinking messages (default)\n' +
330
+ '`/think switch` ─ toggle current state\n\n' +
273
331
  'When enabled, the model\'s internal reasoning steps are sent to the chat\n' +
274
332
  'as they are generated, prefixed with 💭.\n' +
275
333
  'Can also be enabled at startup with `--think` flag.');
276
334
  return;
277
335
  }
336
+ if (topic === 'queue') {
337
+ await sendLongMessage(token, chatId,
338
+ '`/queue <message>` ─ queue a message when the server is busy\n\n' +
339
+ 'When the server is processing a previous message, queuing allows you to\n' +
340
+ 'continue working without waiting. Once the current task finishes, the next\n' +
341
+ 'queued message is automatically sent and the answer will reply to the\n' +
342
+ 'original queued message.');
343
+ return;
344
+ }
345
+ if (topic === 'record') {
346
+ await sendLongMessage(token, chatId,
347
+ '`/record` ─ toggle recording of messages for agentp context\n' +
348
+ '`/record stop` ─ stop recording and clear buffer\n\n' +
349
+ 'When recording is active, all user and assistant messages are buffered in memory\n' +
350
+ '(max 100 messages / 100KB). The next `agentp` invocation retrieves the buffered\n' +
351
+ 'conversation and prepends it to its `--qa` stdout output, so OpenCode can\n' +
352
+ 'see the full conversation context.\n\n' +
353
+ 'Buffered messages are NOT sent back to Telegram (they are already there).');
354
+ return;
355
+ }
278
356
  await sendLongMessage(token, chatId,
279
- 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, `/help allow`, `/help think`, or just `/help`.');
357
+ 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, `/help allow`, `/help think`, `/help record`, `/help queue`, or just `/help`.');
280
358
  return;
281
359
  }
282
360
  await sendLongMessage(token, chatId,
283
- '`/help` — show this message\n' +
284
- '`/help <topic>` — detailed help\n' +
285
- '`/status` — show current server and session\n' +
286
- '`/cancel` — cancel the running prompt\n' +
287
- '`/servers` — list running opencode servers\n' +
288
- '`/servers switch <name>` — switch to a different server\n' +
289
- '`/sessions` — list/create/switch/rename sessions\n' +
290
- '`/sessions switch <number-or-name>` — switch to a session\n' +
291
- '`/sessions new [name]` — create a new session\n' +
292
- '`/sessions rename <name>` — rename the active session\n' +
293
- '`/session` — alias for `/sessions`\n' +
294
- '`/agents` — list available agents\n' +
295
- '`/agents switch <name>` — switch active agent\n' +
296
- '`/models` — list providers and models\n' +
297
- '`/think` — toggle thinking message forwarding\n' +
298
- '`/allow` / `/reject` / `/always` — respond to permission requests\n' +
299
- '`/shutdown` — stop tgagentp (only with `--dev` flag)\n\n' +
361
+ '`/help` ─ show this message\n' +
362
+ '`/help <topic>` ─ detailed help\n' +
363
+ '`/status` ─ show current server and session\n' +
364
+ '`/cancel` ─ cancel the running prompt\n' +
365
+ '`/servers` ─ list running opencode servers\n' +
366
+ '`/servers switch <name>` ─ switch to a different server\n' +
367
+ '`/sessions` ─ list/create/switch/rename sessions\n' +
368
+ '`/sessions switch <number-or-name>` ─ switch to a session\n' +
369
+ '`/sessions new [name]` ─ create a new session\n' +
370
+ '`/sessions rename <name>` ─ rename the active session\n' +
371
+ '`/session` ─ alias for `/sessions`\n' +
372
+ '`/agents` ─ list available agents\n' +
373
+ '`/agents switch <name>` ─ switch active agent\n' +
374
+ '`/models` ─ list providers and models\n' +
375
+ '`/think` ─ toggle thinking message forwarding\n' +
376
+ '`/record` ─ record Telegram conversation for agentp context\n' +
377
+ '`/queue <message>` ─ queue a message when server is busy\n' +
378
+ '`/allow` / `/reject` / `/always` ─ respond to permission requests\n' +
379
+ '`/shutdown` ─ stop tgagentp (only with `--dev` flag)\n\n' +
300
380
  'Everything else is forwarded to the active server as `[Telegram] <text>`.');
301
381
  }
302
382
 
@@ -493,10 +573,10 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
493
573
  await sendLongMessage(token, chatId,
494
574
  `Unknown subcommand: /sessions ${sub}\n\n` +
495
575
  'Usage:\n' +
496
- ' `/sessions` — list sessions\n' +
497
- ' `/sessions switch <number-or-name>` — switch to a session\n' +
498
- ' `/sessions new [name]` — create a new session\n' +
499
- ' `/sessions rename <name>` — rename the active session');
576
+ ' `/sessions` ─ list sessions\n' +
577
+ ' `/sessions switch <number-or-name>` ─ switch to a session\n' +
578
+ ' `/sessions new [name]` ─ create a new session\n' +
579
+ ' `/sessions rename <name>` ─ rename the active session');
500
580
  return;
501
581
  }
502
582
 
@@ -596,10 +676,14 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
596
676
  }
597
677
  const target = matches[0];
598
678
  getState(server).activeAgentName = target.name;
599
- const mode = target.mode === 'primary' ? '' : ` (${target.mode})`;
600
- const model = target.model ? ` — \`${target.model.providerID}/${target.model.modelID}\`` : '';
679
+ const st = getState(server);
680
+ if (st.activeSessionId) {
681
+ updateSession(server, st.activeSessionId, undefined, target.name);
682
+ selectSession(server, st.activeSessionId);
683
+ }
684
+ const model = target.model ? ` ─ \`${target.model.providerID}/${target.model.modelID}\`` : '';
601
685
  await sendLongMessage(token, chatId,
602
- `Switched to agent: **${target.name}**${mode}${model}\nSubsequent messages will use this agent.`);
686
+ `Switched to agent: **${target.name}**${model}\nSubsequent messages will use this agent.`);
603
687
  } catch (err) {
604
688
  await sendLongMessage(token, chatId, `Error switching agent: ${err.message}`);
605
689
  }
@@ -612,13 +696,19 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
612
696
  await sendLongMessage(token, chatId, 'No agents available.');
613
697
  return;
614
698
  }
615
- const lines = agents.map(a => {
616
- const mode = a.mode === 'primary' ? '' : ` (${a.mode})`;
617
- const model = a.model ? ` — \`${a.model.providerID}/${a.model.modelID}\`` : '';
699
+ const primary = agents.filter(a => a.mode === 'primary');
700
+ if (primary.length === 0) {
701
+ await sendLongMessage(token, chatId, 'No primary agents available.');
702
+ return;
703
+ }
704
+ const activeAgentName = getState(server).activeAgentName;
705
+ const lines = primary.map(a => {
706
+ const bullet = a.name === activeAgentName ? '▶ ' : ' · ';
707
+ const model = a.model ? ` ─ \`${a.model.providerID}/${a.model.modelID}\`` : '';
618
708
  const desc = a.description ? `. ${a.description}` : '';
619
- return '**' + a.name + '**' + mode + model + desc;
709
+ return bullet + '**' + a.name + '**' + model + desc;
620
710
  });
621
- await sendLongMessage(token, chatId, 'Agents:\n' + lines.join('\n'));
711
+ await sendLongMessage(token, chatId, 'Primary agents:\n' + lines.join('\n'));
622
712
  } catch (err) {
623
713
  await sendLongMessage(token, chatId, `Error listing agents: ${err.message}`);
624
714
  }
@@ -664,6 +754,91 @@ async function cmdModels(token, chatId, args, setServer, setActiveSession, serve
664
754
 
665
755
  const PERM_RESPONSE_HELP = ' No pending permission. Use `/status` to check the current state.';
666
756
 
757
+ // ---------------------------------------------------------------------------
758
+ // Agentp gateway ─ tiny HTTP server for agentp --tg
759
+ // ---------------------------------------------------------------------------
760
+
761
+ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
762
+ return new Promise((resolve) => {
763
+ const server = http.createServer((req, res) => {
764
+ try {
765
+ if (req.method !== 'POST' || req.url !== '/send') {
766
+ res.writeHead(405);
767
+ res.end('Method Not Allowed');
768
+ return;
769
+ }
770
+ if (!validateAgentpAuth(req)) {
771
+ res.writeHead(401);
772
+ res.end('Unauthorized');
773
+ return;
774
+ }
775
+ let body = '';
776
+ req.on('data', (chunk) => { body += chunk; });
777
+ req.on('end', () => {
778
+ try {
779
+ let data;
780
+ try { data = JSON.parse(body); } catch {
781
+ res.writeHead(400);
782
+ res.end('Bad Request');
783
+ return;
784
+ }
785
+ const { text, server } = data;
786
+ if (typeof text !== 'string' || typeof server !== 'string') {
787
+ res.writeHead(400);
788
+ res.end('Bad Request: text and server are required');
789
+ return;
790
+ }
791
+ const chatId = chatIdResolver();
792
+
793
+ // Return recorded conversation buffer before forwarding
794
+ const buffered = flushRecorded();
795
+ res.writeHead(200, { 'Content-Type': 'application/json' });
796
+ res.end(JSON.stringify({ buffered }));
797
+
798
+ if (!chatId) return;
799
+ const activeUrl = normalizeUrl(serverBaseResolver());
800
+ const targetUrl = normalizeUrl(server);
801
+
802
+ if (targetUrl === activeUrl) {
803
+ sendLongMessage(token, chatId, text.trim()).catch(() => {});
804
+ } else {
805
+ if (!agentpQueues[server]) {
806
+ agentpQueues[server] = { messages: [], notifyTimer: null, notified: false };
807
+ }
808
+ const q = agentpQueues[server];
809
+ q.messages.push(text);
810
+ if (!q.notifyTimer) {
811
+ q.notifyTimer = setTimeout(() => {
812
+ q.notifyTimer = null;
813
+ if (q.messages.length > 0 && !q.notified) {
814
+ q.notified = true;
815
+ sendLongMessage(token, chatId,
816
+ `📥 ${q.messages.length} agentp message(s) queued for server \`${server}\`. Switch servers to view them.`)
817
+ .catch(() => {});
818
+ }
819
+ }, TGAGENTP_DEBOUNCE_MS);
820
+ }
821
+ }
822
+ } catch (err) {
823
+ log.error(`Agentp gateway handler error: ${err.message}`);
824
+ }
825
+ });
826
+ } catch (err) {
827
+ log.error(`Agentp gateway handler error: ${err.message}`);
828
+ }
829
+ });
830
+ server.on('error', (err) => {
831
+ log.error(`Agentp gateway server error: ${err.message}`);
832
+ });
833
+ server.listen(TGAGENTP_PORT, '127.0.0.1', () => {
834
+ const port = server.address().port;
835
+ try { fs.writeFileSync(PORT_FILE, String(port)); } catch (_) {}
836
+ log.info(`Agentp gateway listening on port ${port}`);
837
+ resolve(port);
838
+ });
839
+ });
840
+ }
841
+
667
842
  async function cmdAllow(token, chatId, args, setServer, setActiveSession, server) {
668
843
  const st = getState(server);
669
844
  if (!st.pendingPermission) {
@@ -730,6 +905,39 @@ async function cmdThink(token, chatId, args, setServer, setActiveSession, server
730
905
  }
731
906
  }
732
907
 
908
+ async function cmdQueue(token, chatId, args, _, __, serverBase, replyTo) {
909
+ const st = getState(serverBase);
910
+ if (!st.busy) {
911
+ await sendLongMessage(token, chatId, 'Server is not busy. No need to queue — send your message directly.');
912
+ return;
913
+ }
914
+ const text = args.join(' ').trim();
915
+ if (!text) {
916
+ await sendLongMessage(token, chatId, 'Usage: `/queue <message>` — queue a message for when the server is idle.');
917
+ return;
918
+ }
919
+ if (!st.messageQueue) st.messageQueue = [];
920
+ st.messageQueue.push({ text, replyTo });
921
+ await sendLongMessage(token, chatId, `📥 Queued. ${st.messageQueue.length} message(s) in queue. Will be sent when the current task finishes.`);
922
+ }
923
+
924
+ async function cmdRecord(token, chatId, args) {
925
+ if (args.length > 0 && args[0].toLowerCase() === 'stop') {
926
+ recorder.active = false;
927
+ recorder.messages = [];
928
+ recorder.bytes = 0;
929
+ await sendLongMessage(token, chatId, '⏹ Recording stopped. Buffer cleared.');
930
+ return;
931
+ }
932
+ recorder.active = !recorder.active;
933
+ if (!recorder.active) {
934
+ recorder.messages = [];
935
+ recorder.bytes = 0;
936
+ }
937
+ await sendLongMessage(token, chatId,
938
+ recorder.active ? '🔴 Recording started. Messages will be cached for agentp.' : '⏹ Recording stopped. Buffer cleared.');
939
+ }
940
+
733
941
  const COMMANDS = {
734
942
  'help': cmdHelp,
735
943
  'servers': cmdServers,
@@ -738,6 +946,8 @@ const COMMANDS = {
738
946
  'agents': cmdAgents,
739
947
  'models': cmdModels,
740
948
  'think': cmdThink,
949
+ 'record': cmdRecord,
950
+ 'queue': cmdQueue,
741
951
  'allow': cmdAllow,
742
952
  'reject': cmdReject,
743
953
  'always': cmdAlways,
@@ -749,7 +959,7 @@ const COMMANDS = {
749
959
 
750
960
  const serverState = {};
751
961
  function getState(url) {
752
- return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false };
962
+ return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false, messageQueue: [] };
753
963
  }
754
964
 
755
965
  function sessionLabel(st) {
@@ -797,10 +1007,15 @@ async function main() {
797
1007
  console.error('');
798
1008
  console.error('Environment variables:');
799
1009
  console.error(' TELEGRAM_BOT_TOKEN Telegram bot token (required)');
800
- console.error(' OPENCODE_SERVER_PASSWORD OpenCode server password (optional)');
1010
+ console.error(' OPENCODE_SERVER_PASSWORD OpenCode server password (optional, also used for agentp gateway auth)');
801
1011
  console.error(' OPENCODE_SERVER_USERNAME OpenCode server username (optional, default: opencode)');
802
1012
  console.error(' TGAGENTP_ALLOWED_CHAT_IDS Comma-separated Telegram chat IDs (optional)');
803
1013
  console.error(' Example: "123456,-789012"');
1014
+ console.error(' TGAGENTP_PORT Agentp gateway listen port (optional, default: random)');
1015
+ console.error(' TGAGENTP_DEBOUNCE_MS Debounce interval for queued agentp messages (optional, default: 5000)');
1016
+ console.error('');
1017
+ console.error('Also starts an agentp gateway (tiny HTTP server on localhost) that');
1018
+ console.error('accepts POST /send from agentp --tg and forwards answers to Telegram.');
804
1019
  console.error('');
805
1020
  console.error('See also: agentp, ocmux');
806
1021
  console.error('');
@@ -859,10 +1074,19 @@ async function main() {
859
1074
 
860
1075
  log.info('Waiting for Telegram messages... (Ctrl+C to stop)');
861
1076
 
1077
+ // Start agentp gateway (tiny HTTP server for agentp --tg)
1078
+ const chatIdResolver = () => lockedChatId;
1079
+ const serverBaseResolver = () => serverBase;
1080
+ startAgentpGateway(token, chatIdResolver, serverBaseResolver);
1081
+
862
1082
  const setServer = async (url) => {
863
1083
  serverBase = url;
864
1084
  getState(url);
865
1085
  log.info(`Switched to server: ${url}`);
1086
+
1087
+ // Flush agentp queue for the new active server
1088
+ flushAgentpQueue(url, token, lockedChatId);
1089
+
866
1090
  const [discovered, agents] = await Promise.all([
867
1091
  discoverActiveSession(url),
868
1092
  listAgents(url).catch(() => []),
@@ -894,18 +1118,27 @@ async function main() {
894
1118
  const st = getState(url);
895
1119
  const isCancelled = () => getState(url).taskId !== myTaskId;
896
1120
  const cancelRef = { current: null };
1121
+ const sessionCancelRef = { current: null };
897
1122
  st._cancelRef = cancelRef;
1123
+ st._sessionReq = sessionCancelRef;
898
1124
  try {
899
1125
  let answer;
900
1126
  if (useSession) {
901
- // Event-driven flow: start SSE listener first, then send message async
1127
+ // Dual approach:
1128
+ // 1. SSE for permissions/thinking events (connected before sending)
1129
+ // 2. Synchronous sendToSession (POST /session/:id/message) for reliable answer
902
1130
  const notifiedPerms = new Set();
903
1131
  let thinkingBuf = '';
904
- const ssePromise = listenForSessionEvents(url, sessionId, {
1132
+ let sseConnected;
1133
+ const onConnected = Promise.race([
1134
+ new Promise((resolve) => { sseConnected = resolve; }),
1135
+ new Promise((resolve) => setTimeout(resolve, 5000)),
1136
+ ]);
1137
+ listenForSessionEvents(url, sessionId, {
905
1138
  onText: null,
1139
+ onConnected: sseConnected,
906
1140
  onThinking: (chunk) => {
907
1141
  if (!getState(url).thinkingEnabled) { thinkingBuf = ''; return; }
908
- // Forward thinking chunks in real-time
909
1142
  thinkingBuf += chunk;
910
1143
  if (thinkingBuf.length >= 50 || chunk.endsWith('\n')) {
911
1144
  const html = '💭 ' + thinkingBuf.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -942,23 +1175,18 @@ async function main() {
942
1175
  log.debug(` → permission_replied: clearing pendingPermission`);
943
1176
  getState(url).pendingPermission = null;
944
1177
  },
945
- }, cancelRef);
946
- // Send message (SSE is already listening — no race)
947
- try {
948
- await sendToSessionAsync(url, sessionId, `[Telegram] ${text}`, st.activeAgentName);
949
- } catch (err) {
950
- // Abort SSE listener on send failure to avoid resource leak
951
- if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
952
- throw err;
953
- }
954
- // Wait for the SSE listener to collect the full answer
955
- answer = await ssePromise;
1178
+ }, cancelRef).catch(() => {});
1179
+ // Wait for SSE to connect, then send synchronously
1180
+ await onConnected;
1181
+ answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName, sessionCancelRef);
956
1182
  // Flush any remaining thinking buffer
957
1183
  if (thinkingBuf) {
958
1184
  const html = '💭 ' + thinkingBuf.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
959
1185
  sendLongMessage(token, chatId, html, replyTo).catch(() => {});
960
1186
  thinkingBuf = '';
961
1187
  }
1188
+ // Clean up SSE listener
1189
+ if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
962
1190
  } else {
963
1191
  await sendText(url, `[Telegram] ${text}`);
964
1192
  answer = await listenForFinalAnswer(url, null, cancelRef);
@@ -971,13 +1199,18 @@ async function main() {
971
1199
  st.pendingPermission = null;
972
1200
  if (url === serverBase) {
973
1201
  if (answer && answer.trim()) {
974
- await sendLongMessage(token, chatId, answer.trim(), replyTo);
1202
+ const trimmed = answer.trim();
1203
+ await sendLongMessage(token, chatId, trimmed, replyTo);
1204
+ bufferMessage('assistant', trimmed);
975
1205
  }
976
1206
  } else {
977
1207
  getState(url).pendingResponse = answer;
978
1208
  await sendLongMessage(token, chatId, `✅ Server \`${url}\` finished processing. Switch back to see the response.`, replyTo);
1209
+ if (answer && answer.trim()) bufferMessage('assistant', answer.trim());
979
1210
  }
980
1211
  } catch (err) {
1212
+ // Clean up SSE listener if it's still active
1213
+ if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
981
1214
  if (isCancelled()) {
982
1215
  log.debug(` → cancelled, ignoring error from ${url}: ${err.message}`);
983
1216
  st.pendingPermission = null;
@@ -990,8 +1223,24 @@ async function main() {
990
1223
  } finally {
991
1224
  st.pendingPermission = null;
992
1225
  if (st._cancelRef === cancelRef) st._cancelRef = null;
1226
+ if (st._sessionReq === sessionCancelRef) st._sessionReq = null;
993
1227
  if (!isCancelled()) {
994
1228
  getState(url).busy = false;
1229
+ // Drain message queue for this server
1230
+ const q = st.messageQueue || [];
1231
+ st.messageQueue = [];
1232
+ if (q.length > 0) {
1233
+ const next = q[0];
1234
+ // Re-queue remaining items (if any) for the next round
1235
+ st.messageQueue = q.slice(1);
1236
+ const nextText = next.text;
1237
+ const nextReplyTo = next.replyTo;
1238
+ // Mark busy again and process
1239
+ getState(url).busy = true;
1240
+ const nextTaskId = ++st.taskId;
1241
+ log.debug(` → processing queued message (task ${nextTaskId})`);
1242
+ processMessageAsync(chatId, nextText, url, !!st.activeSessionId, st.activeSessionId, nextTaskId, nextReplyTo);
1243
+ }
995
1244
  }
996
1245
  }
997
1246
  }
@@ -1009,6 +1258,11 @@ async function main() {
1009
1258
  st._cancelRef.current.destroy();
1010
1259
  st._cancelRef.current = null;
1011
1260
  }
1261
+ // Abort the synchronous session request if in progress
1262
+ if (st._sessionReq && st._sessionReq.current) {
1263
+ st._sessionReq.current.destroy();
1264
+ st._sessionReq.current = null;
1265
+ }
1012
1266
  await sendLongMessage(token, chatId, '✅ Cancelled.');
1013
1267
  log.debug(` → /cancel on ${serverBase}`);
1014
1268
  }
@@ -1062,6 +1316,7 @@ async function main() {
1062
1316
  lines.push('**Session:** ' + sessionLabel(st));
1063
1317
  lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
1064
1318
  try { await sendLongMessage(token, savedChatId, lines.join('\n')); } catch (_) {}
1319
+ lockedChatId = savedChatId;
1065
1320
  try { fs.unlinkSync(STARTUP_CHAT_FILE); } catch (_) {}
1066
1321
  }
1067
1322
 
@@ -1134,15 +1389,18 @@ async function main() {
1134
1389
  }
1135
1390
  } else {
1136
1391
  await sendLongMessage(token, chatId,
1137
- `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models`);
1392
+ `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /think, /record, /queue`);
1138
1393
  }
1139
1394
  continue;
1140
1395
  }
1141
1396
 
1142
- // Non-command message — process in background
1397
+ // Buffer user messages for /record
1398
+ bufferMessage('user', replyContext + text);
1399
+
1400
+ // Non-command message ─ process in background
1143
1401
  const st = getState(serverBase);
1144
1402
  if (st.busy) {
1145
- await sendLongMessage(token, chatId, '⏳ Server is busy processing a previous message. Please wait for it to finish, or use /cancel to cancel it.');
1403
+ await sendLongMessage(token, chatId, '⏳ Server is busy. Use `/cancel` to cancel or `/queue <message>` to queue your message.');
1146
1404
  continue;
1147
1405
  }
1148
1406
  st.busy = true;