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/AGENTS.md +36 -9
- package/CHANGELOG.md +16 -4
- package/README.md +73 -9
- package/bin/agentp +220 -27
- package/bin/ocmux +24 -6
- package/bin/tgagentp +332 -74
- package/devto-article.md +121 -0
- package/lib/opencode.js +73 -9
- package/package.json +1 -1
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
|
|
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`
|
|
211
|
-
'`/servers switch <name>`
|
|
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`
|
|
218
|
-
'`/sessions switch <number-or-name>`
|
|
219
|
-
'`/sessions new [name]`
|
|
220
|
-
'`/sessions rename <name>`
|
|
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`
|
|
228
|
-
'`/shutdown force`
|
|
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`
|
|
237
|
-
'`/agents switch <name>`
|
|
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`
|
|
245
|
-
'`/sessions switch <number-or-name>`
|
|
246
|
-
'`/sessions new [name]`
|
|
247
|
-
'`/sessions rename <name>`
|
|
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`
|
|
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`
|
|
260
|
-
'`/reject`
|
|
261
|
-
'`/always`
|
|
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`
|
|
270
|
-
'`/think on`
|
|
271
|
-
'`/think off`
|
|
272
|
-
'`/think switch`
|
|
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`
|
|
284
|
-
'`/help <topic>`
|
|
285
|
-
'`/status`
|
|
286
|
-
'`/cancel`
|
|
287
|
-
'`/servers`
|
|
288
|
-
'`/servers switch <name>`
|
|
289
|
-
'`/sessions`
|
|
290
|
-
'`/sessions switch <number-or-name>`
|
|
291
|
-
'`/sessions new [name]`
|
|
292
|
-
'`/sessions rename <name>`
|
|
293
|
-
'`/session`
|
|
294
|
-
'`/agents`
|
|
295
|
-
'`/agents switch <name>`
|
|
296
|
-
'`/models`
|
|
297
|
-
'`/think`
|
|
298
|
-
'`/
|
|
299
|
-
'`/
|
|
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`
|
|
497
|
-
' `/sessions switch <number-or-name>`
|
|
498
|
-
' `/sessions new [name]`
|
|
499
|
-
' `/sessions rename <name>`
|
|
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
|
|
600
|
-
|
|
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}**${
|
|
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
|
|
616
|
-
|
|
617
|
-
|
|
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 + '**' +
|
|
709
|
+
return bullet + '**' + a.name + '**' + model + desc;
|
|
620
710
|
});
|
|
621
|
-
await sendLongMessage(token, chatId, '
|
|
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
|
-
//
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
@@ -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
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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;
|