agentp 0.10.0 → 0.11.1
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/CONTRIBUTING.md +139 -0
- package/README.md +23 -6
- package/bin/agentp +93 -33
- package/bin/ocmux +56 -41
- package/bin/tgagentp +921 -251
- package/docs/specification.md +391 -0
- package/lib/ocmux.js +113 -2
- package/lib/opencode.js +60 -21
- package/package.json +9 -2
- package/AGENTS.md +0 -182
- package/CHANGELOG.md +0 -36
- package/devto-article.md +0 -121
package/bin/tgagentp
CHANGED
|
@@ -5,7 +5,7 @@ const https = require('https');
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { version } = require('../package.json');
|
|
8
|
-
const { sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, listenForSessionEvents, listAgents, listProviders } = require('../lib/opencode.js');
|
|
8
|
+
const { getAuthHeaders, sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, listenForSessionEvents, listAgents, listProviders, isServerAlive } = require('../lib/opencode.js');
|
|
9
9
|
|
|
10
10
|
// Logger: info→stdout, error→stderr (always), debug→stderr (--verbose only)
|
|
11
11
|
const log = {
|
|
@@ -15,11 +15,35 @@ const log = {
|
|
|
15
15
|
debug(msg) { if (this.verbose) process.stderr.write(`${msg}\n`); },
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
const STARTUP_CHAT_FILE = '/tmp/tgagentp-startup-chat';
|
|
19
18
|
const TGAGENTP_PORT = parseInt(process.env.TGAGENTP_PORT || '0', 10);
|
|
20
19
|
const PORT_FILE = '/tmp/tgagentp-port';
|
|
21
20
|
const TGAGENTP_DEBOUNCE_MS = parseInt(process.env.TGAGENTP_DEBOUNCE_MS || '5000', 10);
|
|
22
|
-
const
|
|
21
|
+
const CONNECTIONS_FILE = '/tmp/tgagentp-connections.json';
|
|
22
|
+
const { listServers, activateServer, resurrectServer, statefileFor, readState } = require('../lib/ocmux.js');
|
|
23
|
+
|
|
24
|
+
// Persist chat↔server directory mappings across restarts
|
|
25
|
+
function saveConnection(chatId, threadId, dir) {
|
|
26
|
+
let data = { connections: [] };
|
|
27
|
+
try { data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8')); } catch {}
|
|
28
|
+
const key = convKey(chatId, threadId);
|
|
29
|
+
const idx = data.connections.findIndex(c => convKey(c.chatId, c.threadId) === key);
|
|
30
|
+
const entry = { chatId, threadId: threadId || null, dir };
|
|
31
|
+
if (idx !== -1) data.connections[idx] = entry;
|
|
32
|
+
else data.connections.push(entry);
|
|
33
|
+
try { fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2)); } catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function removeConnection(chatId, threadId) {
|
|
37
|
+
let data = { connections: [] };
|
|
38
|
+
try { data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8')); } catch {}
|
|
39
|
+
const key = convKey(chatId, threadId);
|
|
40
|
+
data.connections = data.connections.filter(c => convKey(c.chatId, c.threadId) !== key);
|
|
41
|
+
try { fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2)); } catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function clearConnections() {
|
|
45
|
+
try { fs.unlinkSync(CONNECTIONS_FILE); } catch {}
|
|
46
|
+
}
|
|
23
47
|
|
|
24
48
|
// Agentp gateway message queue, keyed by server URL
|
|
25
49
|
const agentpQueues = {};
|
|
@@ -32,46 +56,86 @@ function validateAgentpAuth(req) {
|
|
|
32
56
|
return req.headers.authorization === expected;
|
|
33
57
|
}
|
|
34
58
|
|
|
35
|
-
function flushAgentpQueue(server, token, chatId) {
|
|
59
|
+
function flushAgentpQueue(server, token, chatId, threadId) {
|
|
36
60
|
if (!chatId) return;
|
|
37
61
|
const q = agentpQueues[server];
|
|
38
62
|
if (!q || q.messages.length === 0) return;
|
|
39
63
|
const msgs = q.messages.splice(0);
|
|
40
64
|
q.notified = false;
|
|
41
65
|
if (q.notifyTimer) { clearTimeout(q.notifyTimer); q.notifyTimer = null; }
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
const cs = { chatId, threadId };
|
|
67
|
+
sendLongMessage(token, cs,
|
|
68
|
+
`📨 Delivering ${msgs.length} queued agentp message(s):`, undefined, threadId)
|
|
44
69
|
.then(() => {
|
|
45
70
|
for (const msg of msgs) {
|
|
46
|
-
sendLongMessage(token,
|
|
71
|
+
sendLongMessage(token, cs, msg.trim(), undefined, threadId).catch(() => {});
|
|
47
72
|
}
|
|
48
73
|
})
|
|
49
74
|
.catch(() => {});
|
|
50
75
|
}
|
|
51
76
|
|
|
77
|
+
// Check if an opencode server is reachable by hitting its /session endpoint.
|
|
78
|
+
// Uses a short 5s timeout; returns true for any HTTP response (even 4xx/5xx)
|
|
79
|
+
// and false only for connection-level failures (dead server, DNS, timeout).
|
|
52
80
|
// ---------------------------------------------------------------------------
|
|
53
|
-
//
|
|
81
|
+
// Per-chat state
|
|
54
82
|
// ---------------------------------------------------------------------------
|
|
55
83
|
|
|
56
84
|
const MAX_RECORDED_MSGS = 100;
|
|
57
85
|
const MAX_RECORDED_BYTES = 100 * 1024;
|
|
58
86
|
|
|
59
|
-
const
|
|
87
|
+
const chatStates = {};
|
|
88
|
+
const serverOwners = {}; // serverUrl → { chatId, threadId } (for agentp gateway routing)
|
|
89
|
+
|
|
90
|
+
// Compound key for isolated chat+thread (forum topic) state
|
|
91
|
+
function convKey(chatId, threadId) {
|
|
92
|
+
return threadId ? `${chatId}:t${threadId}` : chatId;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getChatState(chatId, threadId, defaultServer) {
|
|
96
|
+
const key = convKey(chatId, threadId);
|
|
97
|
+
if (!chatStates[key]) {
|
|
98
|
+
chatStates[key] = {
|
|
99
|
+
chatId,
|
|
100
|
+
threadId: threadId || null,
|
|
101
|
+
serverBase: defaultServer || null,
|
|
102
|
+
recording: { active: false, paused: false, messages: [], bytes: 0 },
|
|
103
|
+
ring: { messages: [], bytes: 0 },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return chatStates[key];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const RING_MAX_MSGS = 50;
|
|
110
|
+
const RING_MAX_BYTES = 50 * 1024;
|
|
60
111
|
|
|
61
|
-
function bufferMessage(role, text) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
112
|
+
function bufferMessage(chatId, threadId, role, text) {
|
|
113
|
+
const cs = getChatState(chatId, threadId);
|
|
114
|
+
|
|
115
|
+
// Always maintain an in-memory ring buffer of recent messages.
|
|
116
|
+
// This enables retroactive /record N: copy last N messages into recording.
|
|
117
|
+
cs.ring.messages.push({ role, text });
|
|
118
|
+
cs.ring.bytes += Buffer.byteLength(text, 'utf8');
|
|
119
|
+
while (cs.ring.messages.length > RING_MAX_MSGS || cs.ring.bytes > RING_MAX_BYTES) {
|
|
120
|
+
const oldest = cs.ring.messages.shift();
|
|
121
|
+
cs.ring.bytes -= Buffer.byteLength(oldest.text, 'utf8');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Recording buffer (only when active AND not paused)
|
|
125
|
+
if (!cs.recording.active || cs.recording.paused) return;
|
|
126
|
+
cs.recording.messages.push({ role, text });
|
|
127
|
+
cs.recording.bytes += Buffer.byteLength(text, 'utf8');
|
|
128
|
+
while (cs.recording.messages.length > MAX_RECORDED_MSGS || cs.recording.bytes > MAX_RECORDED_BYTES) {
|
|
129
|
+
const oldest = cs.recording.messages.shift();
|
|
130
|
+
cs.recording.bytes -= Buffer.byteLength(oldest.text, 'utf8');
|
|
68
131
|
}
|
|
69
132
|
}
|
|
70
133
|
|
|
71
|
-
function flushRecorded() {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
134
|
+
function flushRecorded(chatId, threadId) {
|
|
135
|
+
const cs = getChatState(chatId, threadId);
|
|
136
|
+
if (!cs.recording.active) return [];
|
|
137
|
+
const msgs = cs.recording.messages.splice(0);
|
|
138
|
+
cs.recording.bytes = 0;
|
|
75
139
|
return msgs;
|
|
76
140
|
}
|
|
77
141
|
|
|
@@ -117,10 +181,11 @@ function getUpdates(token, offset, timeout) {
|
|
|
117
181
|
});
|
|
118
182
|
}
|
|
119
183
|
|
|
120
|
-
function sendTelegramMessage(token, chatId, text, parseMode, replyTo) {
|
|
184
|
+
function sendTelegramMessage(token, chatId, text, parseMode, replyTo, threadId) {
|
|
121
185
|
const params = { chat_id: chatId, text };
|
|
122
186
|
if (parseMode) params.parse_mode = parseMode;
|
|
123
187
|
if (replyTo) params.reply_to_message_id = replyTo;
|
|
188
|
+
if (threadId) params.message_thread_id = threadId;
|
|
124
189
|
return telegramRequest(token, 'sendMessage', params);
|
|
125
190
|
}
|
|
126
191
|
|
|
@@ -228,11 +293,15 @@ function splitMessage(text, maxLength = 4096) {
|
|
|
228
293
|
// Converts Markdown to Telegram HTML formatting.
|
|
229
294
|
// Splits the raw text first, then converts each chunk independently
|
|
230
295
|
// to avoid broken HTML tags across messages.
|
|
231
|
-
async function sendLongMessage(token,
|
|
296
|
+
async function sendLongMessage(token, chatIdOrState, text, replyTo, threadId) {
|
|
297
|
+
// Accept either (token, chatState, ...) or (token, rawChatId, ...)
|
|
298
|
+
const chatId = typeof chatIdOrState === 'object' && chatIdOrState.chatId != null
|
|
299
|
+
? chatIdOrState.chatId : chatIdOrState;
|
|
300
|
+
const effectiveThreadId = threadId || (typeof chatIdOrState === 'object' ? chatIdOrState.threadId : undefined);
|
|
232
301
|
const chunks = splitMessage(text);
|
|
233
302
|
for (let i = 0; i < chunks.length; i++) {
|
|
234
303
|
const html = convertMarkdownToHtml(chunks[i]);
|
|
235
|
-
await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined);
|
|
304
|
+
await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined, effectiveThreadId);
|
|
236
305
|
}
|
|
237
306
|
}
|
|
238
307
|
|
|
@@ -260,18 +329,20 @@ function quoteReply(msg) {
|
|
|
260
329
|
// Telegram command handlers
|
|
261
330
|
// ---------------------------------------------------------------------------
|
|
262
331
|
|
|
263
|
-
async function cmdHelp(token, chatId, args) {
|
|
332
|
+
async function cmdHelp(token, chatId, args, chatState) {
|
|
264
333
|
if (args.length > 0) {
|
|
265
334
|
const topic = args[0].toLowerCase();
|
|
266
335
|
if (topic === 'servers') {
|
|
267
|
-
await sendLongMessage(token,
|
|
336
|
+
await sendLongMessage(token, chatState,
|
|
268
337
|
'`/servers` ─ list all opencode servers (ocmux sessions)\n' +
|
|
269
|
-
'`/servers switch <name>` ─ switch target server\n
|
|
338
|
+
'`/servers switch <name>` ─ switch target server\n' +
|
|
339
|
+
'`/force-switch <name>` ─ take over a server from another chat\n\n' +
|
|
340
|
+
'Name matching: case-insensitive first, then case-sensitive. ' +
|
|
270
341
|
'`<name>` can be a full path or basename of the project directory.');
|
|
271
342
|
return;
|
|
272
343
|
}
|
|
273
344
|
if (topic === 'sessions') {
|
|
274
|
-
await sendLongMessage(token,
|
|
345
|
+
await sendLongMessage(token, chatState,
|
|
275
346
|
'`/sessions` ─ list active sessions (numbered, newest first, max 50)\n' +
|
|
276
347
|
'`/sessions switch <number-or-name>` ─ send messages to a specific session\n' +
|
|
277
348
|
'`/sessions new [name]` ─ create a new session\n' +
|
|
@@ -281,7 +352,7 @@ async function cmdHelp(token, chatId, args) {
|
|
|
281
352
|
return;
|
|
282
353
|
}
|
|
283
354
|
if (topic === 'shutdown') {
|
|
284
|
-
await sendLongMessage(token,
|
|
355
|
+
await sendLongMessage(token, chatState,
|
|
285
356
|
'`/shutdown` ─ stop tgagentp process\n' +
|
|
286
357
|
'`/shutdown force` ─ force shutdown even if busy (in-flight work will be lost)\n\n' +
|
|
287
358
|
'Only available when tgagentp is started with `--dev` flag.\n' +
|
|
@@ -290,14 +361,14 @@ async function cmdHelp(token, chatId, args) {
|
|
|
290
361
|
return;
|
|
291
362
|
}
|
|
292
363
|
if (topic === 'agents') {
|
|
293
|
-
await sendLongMessage(token,
|
|
364
|
+
await sendLongMessage(token, chatState,
|
|
294
365
|
'`/agents` ─ list primary agents (with ▶ marker for the active one).\n' +
|
|
295
366
|
'`/agents switch <name>` ─ switch the active agent. Subsequent messages will use this agent.\n\n' +
|
|
296
367
|
'Agent switching requires an active session (`/sessions switch` or `/sessions new`).');
|
|
297
368
|
return;
|
|
298
369
|
}
|
|
299
370
|
if (topic === 'session') {
|
|
300
|
-
await sendLongMessage(token,
|
|
371
|
+
await sendLongMessage(token, chatState,
|
|
301
372
|
'`/session` is an alias for `/sessions`.\n\n' +
|
|
302
373
|
'`/sessions` ─ list active sessions\n' +
|
|
303
374
|
'`/sessions switch <number-or-name>` ─ switch to a session\n' +
|
|
@@ -306,14 +377,14 @@ async function cmdHelp(token, chatId, args) {
|
|
|
306
377
|
return;
|
|
307
378
|
}
|
|
308
379
|
if (topic === 'models') {
|
|
309
|
-
await sendLongMessage(token,
|
|
380
|
+
await sendLongMessage(token, chatState,
|
|
310
381
|
'`/models` ─ list all providers and their models.\n' +
|
|
311
382
|
'Shows provider name, connection status, default model, and up to 5 models per provider\n' +
|
|
312
383
|
'with status, context limit, and input/output costs.');
|
|
313
384
|
return;
|
|
314
385
|
}
|
|
315
386
|
if (topic === 'allow' || topic === 'reject' || topic === 'always') {
|
|
316
|
-
await sendLongMessage(token,
|
|
387
|
+
await sendLongMessage(token, chatState,
|
|
317
388
|
'`/allow` ─ approve a permission request once\n' +
|
|
318
389
|
'`/reject` ─ deny a permission request\n' +
|
|
319
390
|
'`/always` ─ approve and remember for the session\n\n' +
|
|
@@ -323,7 +394,7 @@ async function cmdHelp(token, chatId, args) {
|
|
|
323
394
|
return;
|
|
324
395
|
}
|
|
325
396
|
if (topic === 'think') {
|
|
326
|
-
await sendLongMessage(token,
|
|
397
|
+
await sendLongMessage(token, chatState,
|
|
327
398
|
'`/think` ─ toggle thinking message forwarding\n' +
|
|
328
399
|
'`/think on` ─ forward model thinking to the chat in real-time\n' +
|
|
329
400
|
'`/think off` ─ stop forwarding thinking messages (default)\n' +
|
|
@@ -334,36 +405,87 @@ async function cmdHelp(token, chatId, args) {
|
|
|
334
405
|
return;
|
|
335
406
|
}
|
|
336
407
|
if (topic === 'queue') {
|
|
337
|
-
await sendLongMessage(token,
|
|
408
|
+
await sendLongMessage(token, chatState,
|
|
338
409
|
'`/queue <message>` ─ queue a message when the server is busy\n\n' +
|
|
339
410
|
'When the server is processing a previous message, queuing allows you to\n' +
|
|
340
411
|
'continue working without waiting. Once the current task finishes, the next\n' +
|
|
341
412
|
'queued message is automatically sent and the answer will reply to the\n' +
|
|
342
|
-
'original queued message
|
|
413
|
+
'original queued message.\n\n' +
|
|
414
|
+
'Messages are also automatically queued when the server is unreachable.\n' +
|
|
415
|
+
'Use `/flush` to clear the queue at any time.');
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (topic === 'flush') {
|
|
419
|
+
await sendLongMessage(token, chatState,
|
|
420
|
+
'`/flush` ─ clear all queued messages\n\n' +
|
|
421
|
+
'Flushes both the regular message queue and any queued agentp messages.\n' +
|
|
422
|
+
'Messages are queued automatically when the server is unreachable or busy.');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (topic === 'comment') {
|
|
426
|
+
await sendLongMessage(token, chatState,
|
|
427
|
+
'`/comment <text>` ─ save a comment in the chat\n\n' +
|
|
428
|
+
'Comments are NOT forwarded to the agent. They are stored as a regular message\n' +
|
|
429
|
+
'in the chat. If you reply to a comment with a message that doesn\'t start with\n' +
|
|
430
|
+
'`/comment`, the comment text is included as context (via the reply-to quoting mechanism).');
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (topic === 'note') {
|
|
434
|
+
await sendLongMessage(token, chatState,
|
|
435
|
+
'`/note <message>` ─ send context for the agent to acknowledge\n\n' +
|
|
436
|
+
'The message is forwarded to the agent with an awareness prefix. ' +
|
|
437
|
+
'The agent will reply with "Ack" and take no action, ' +
|
|
438
|
+
'but the information will inform future responses.');
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (topic === 'disconnect') {
|
|
442
|
+
await sendLongMessage(token, chatState,
|
|
443
|
+
'`/disconnect` ─ disconnect this chat from its server\n\n' +
|
|
444
|
+
'Clears the server connection for this chat. Use `/servers switch <name>` to reconnect.');
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (topic === 'status') {
|
|
448
|
+
await sendLongMessage(token, chatState,
|
|
449
|
+
'`/status` ─ show current server, session, agent, and queue info\n\n' +
|
|
450
|
+
'Shows:\n' +
|
|
451
|
+
'• Server URL and directory path\n' +
|
|
452
|
+
'• Status: ✅ idle, ⏳ busy, or ❌ unreachable\n' +
|
|
453
|
+
'• Active session name/ID\n' +
|
|
454
|
+
'• Active agent name\n' +
|
|
455
|
+
'• Number of queued messages');
|
|
343
456
|
return;
|
|
344
457
|
}
|
|
345
458
|
if (topic === 'record') {
|
|
346
|
-
await sendLongMessage(token,
|
|
347
|
-
'`/record` ─ toggle
|
|
348
|
-
'`/record
|
|
349
|
-
'
|
|
350
|
-
'
|
|
351
|
-
'
|
|
352
|
-
'
|
|
353
|
-
'
|
|
459
|
+
await sendLongMessage(token, chatState,
|
|
460
|
+
'`/record` ─ toggle between active/paused (starts fresh if inactive)\n' +
|
|
461
|
+
'`/record continue` ─ resume recording without clearing buffer\n' +
|
|
462
|
+
'`/record pause [N]` ─ pause recording, keep buffer (optionally discard last N)\n' +
|
|
463
|
+
'`/record flush` ─ clear buffer, keep current state (active/paused)\n' +
|
|
464
|
+
'`/record stop` ─ fully stop and clear buffer\n' +
|
|
465
|
+
'`/record N` ─ retroactively buffer the last N messages from the ring\n\n' +
|
|
466
|
+
'**States:**\n' +
|
|
467
|
+
'🔴 *active* — new messages are added to the buffer\n' +
|
|
468
|
+
'⏸ *paused* — buffer preserved, not recording new messages\n' +
|
|
469
|
+
'⏹ *inactive* — no buffer, nothing recorded\n\n' +
|
|
470
|
+
'When active or paused, the next `agentp` invocation retrieves the buffered\n' +
|
|
471
|
+
'conversation and prepends it to its `--qa` stdout output.\n' +
|
|
472
|
+
'`/record N` uses the always-on ring buffer (last 50 messages) to retroactively\n' +
|
|
473
|
+
'fill the recording buffer — as if `/record` had been enabled N messages ago.\n\n' +
|
|
474
|
+
'Max 100 messages / 100KB. Oldest messages are dropped first.');
|
|
354
475
|
return;
|
|
355
476
|
}
|
|
356
|
-
await sendLongMessage(token,
|
|
357
|
-
'Unknown topic. Try `/help servers`, `/help sessions`, `/help
|
|
477
|
+
await sendLongMessage(token, chatState,
|
|
478
|
+
'Unknown topic. Try `/help servers`, `/help sessions`, `/help agents`, `/help models`, `/help allow`, `/help think`, `/help record`, `/help queue`, `/help flush`, `/help note`, `/help comment`, `/help disconnect`, or just `/help`.');
|
|
358
479
|
return;
|
|
359
480
|
}
|
|
360
|
-
await sendLongMessage(token,
|
|
481
|
+
await sendLongMessage(token, chatState,
|
|
361
482
|
'`/help` ─ show this message\n' +
|
|
362
483
|
'`/help <topic>` ─ detailed help\n' +
|
|
363
484
|
'`/status` ─ show current server and session\n' +
|
|
364
485
|
'`/cancel` ─ cancel the running prompt\n' +
|
|
365
486
|
'`/servers` ─ list running opencode servers\n' +
|
|
366
487
|
'`/servers switch <name>` ─ switch to a different server\n' +
|
|
488
|
+
'`/force-switch <name>` ─ take over a server from another chat\n' +
|
|
367
489
|
'`/sessions` ─ list/create/switch/rename sessions\n' +
|
|
368
490
|
'`/sessions switch <number-or-name>` ─ switch to a session\n' +
|
|
369
491
|
'`/sessions new [name]` ─ create a new session\n' +
|
|
@@ -375,51 +497,97 @@ async function cmdHelp(token, chatId, args) {
|
|
|
375
497
|
'`/think` ─ toggle thinking message forwarding\n' +
|
|
376
498
|
'`/record` ─ record Telegram conversation for agentp context\n' +
|
|
377
499
|
'`/queue <message>` ─ queue a message when server is busy\n' +
|
|
500
|
+
'`/flush` ─ clear all queued messages\n' +
|
|
501
|
+
'`/note <message>` ─ send context for the agent to acknowledge\n' +
|
|
502
|
+
'`/comment <text>` ─ save a comment in the chat (not forwarded to the agent)\n' +
|
|
503
|
+
'`/disconnect` ─ disconnect this chat from its server\n' +
|
|
378
504
|
'`/allow` / `/reject` / `/always` ─ respond to permission requests\n' +
|
|
379
505
|
'`/shutdown` ─ stop tgagentp (only with `--dev` flag)\n\n' +
|
|
380
506
|
'Everything else is forwarded to the active server as `[Telegram] <text>`.');
|
|
381
507
|
}
|
|
382
508
|
|
|
383
|
-
async function
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
509
|
+
async function cmdForceSwitch(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
510
|
+
return cmdServers(token, chatId, ['switch', '--force', ...args], chatState, setServerForChat, setActiveSessionForChat);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function cmdServers(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
514
|
+
const isForce = args.length > 0 && args[0].toLowerCase() === 'force-switch';
|
|
515
|
+
if (args.length > 0 && (args[0].toLowerCase() === 'switch' || isForce)) {
|
|
516
|
+
const forceIdx = args.indexOf('--force');
|
|
517
|
+
const force = forceIdx !== -1 || isForce;
|
|
518
|
+
const switchArgs = args.slice(1).filter(a => a !== '--force');
|
|
519
|
+
if (switchArgs.length < 1) {
|
|
520
|
+
await sendLongMessage(token, chatState,
|
|
387
521
|
'Usage: `/servers switch <name>`\n' +
|
|
522
|
+
'To force-switch (take over a server from another chat), use `/force-switch <name>`.\n' +
|
|
388
523
|
'`<name>` can be a full directory path or a basename.');
|
|
389
524
|
return;
|
|
390
525
|
}
|
|
391
|
-
const name =
|
|
526
|
+
const name = switchArgs.join(' ');
|
|
392
527
|
const servers = listServers();
|
|
393
528
|
if (servers.length === 0) {
|
|
394
|
-
await sendLongMessage(token,
|
|
529
|
+
await sendLongMessage(token, chatState, 'No servers running.');
|
|
395
530
|
return;
|
|
396
531
|
}
|
|
397
|
-
|
|
532
|
+
|
|
533
|
+
// Phase 1: case-insensitive exact match on dir or basename
|
|
534
|
+
let matches = servers.filter(s => s.dir.toLowerCase() === name.toLowerCase());
|
|
398
535
|
if (matches.length === 0) {
|
|
399
|
-
matches = servers.filter(s => path.basename(s.dir) === name);
|
|
536
|
+
matches = servers.filter(s => path.basename(s.dir).toLowerCase() === name.toLowerCase());
|
|
400
537
|
}
|
|
538
|
+
// Fallback: case-insensitive substring match
|
|
401
539
|
if (matches.length === 0) {
|
|
402
|
-
|
|
540
|
+
const lowerName = name.toLowerCase();
|
|
541
|
+
matches = servers.filter(s => s.dir.toLowerCase().includes(lowerName) || path.basename(s.dir).toLowerCase().includes(lowerName));
|
|
403
542
|
}
|
|
404
|
-
|
|
405
|
-
|
|
543
|
+
|
|
544
|
+
if (matches.length === 1) {
|
|
545
|
+
// Phase 1 found a single match — use it directly
|
|
546
|
+
} else if (matches.length === 0) {
|
|
547
|
+
await sendLongMessage(token, chatState, `No server found matching "${name}".`);
|
|
406
548
|
return;
|
|
549
|
+
} else {
|
|
550
|
+
// Phase 2: case-sensitive exact match to disambiguate
|
|
551
|
+
let exact = servers.filter(s => s.dir === name);
|
|
552
|
+
if (exact.length === 0) {
|
|
553
|
+
exact = servers.filter(s => path.basename(s.dir) === name);
|
|
554
|
+
}
|
|
555
|
+
if (exact.length === 1) {
|
|
556
|
+
matches = exact;
|
|
557
|
+
} else {
|
|
558
|
+
const list = matches.map(s => ` ${s.url} → ${s.dir}`).join('\n');
|
|
559
|
+
await sendLongMessage(token, chatState,
|
|
560
|
+
`Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
407
563
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
564
|
+
const target = matches[0];
|
|
565
|
+
const existing = serverOwners[target.url];
|
|
566
|
+
if (existing && existing.chatId !== chatState.chatId && !force) {
|
|
567
|
+
await sendLongMessage(token, chatState,
|
|
568
|
+
`⚠️ Server \`${target.url}\` is already connected to another chat.\n\n` +
|
|
569
|
+
`Use \`/servers force-switch ${name}\` to take over (the other chat will be disconnected).`);
|
|
412
570
|
return;
|
|
413
571
|
}
|
|
414
|
-
|
|
415
|
-
|
|
572
|
+
if (existing && existing.chatId === chatState.chatId) {
|
|
573
|
+
// Already connected — re-run setup to refresh state
|
|
574
|
+
}
|
|
575
|
+
if (force && existing && existing.chatId !== chatState.chatId) {
|
|
576
|
+
// Reset the old owner's server and notify them
|
|
577
|
+
const oldState = getChatState(existing.chatId, existing.threadId);
|
|
578
|
+
oldState.serverBase = null;
|
|
579
|
+
sendLongMessage(token, oldState,
|
|
580
|
+
`⚠️ Server \`${target.url}\` has been taken over by another chat.\n\n` +
|
|
581
|
+
`Use \`/servers\` to see available servers and reconnect.`).catch(() => {});
|
|
582
|
+
}
|
|
583
|
+
const pending = await setServerForChat(chatState, target.url);
|
|
416
584
|
activateServer(target.dir, target.index, target.url);
|
|
417
585
|
let msg = `Switched to server: ${target.url} → ${target.dir}`;
|
|
418
586
|
if (pending && pending.trim()) {
|
|
419
|
-
await sendLongMessage(token,
|
|
420
|
-
await sendLongMessage(token,
|
|
587
|
+
await sendLongMessage(token, chatState, msg);
|
|
588
|
+
await sendLongMessage(token, chatState, pending.trim());
|
|
421
589
|
} else {
|
|
422
|
-
await sendLongMessage(token,
|
|
590
|
+
await sendLongMessage(token, chatState, msg);
|
|
423
591
|
}
|
|
424
592
|
return;
|
|
425
593
|
}
|
|
@@ -427,15 +595,16 @@ async function cmdServers(token, chatId, args, setServer, setActiveSession, serv
|
|
|
427
595
|
// Default: list servers
|
|
428
596
|
const servers = listServers();
|
|
429
597
|
if (servers.length === 0) {
|
|
430
|
-
await sendLongMessage(token,
|
|
598
|
+
await sendLongMessage(token, chatState, 'No servers running.');
|
|
431
599
|
return;
|
|
432
600
|
}
|
|
433
|
-
const activeUrl = normalizeUrl(serverBase);
|
|
601
|
+
const activeUrl = chatState.serverBase ? normalizeUrl(chatState.serverBase) : null;
|
|
434
602
|
const lines = servers.map(s => {
|
|
435
|
-
const bullet = normalizeUrl(s.url) === activeUrl ? '▶ ' : ' · ';
|
|
603
|
+
const bullet = activeUrl && normalizeUrl(s.url) === activeUrl ? '▶ ' : ' · ';
|
|
436
604
|
return bullet + `${s.url} → ${s.dir} (${s.status})`;
|
|
437
605
|
});
|
|
438
|
-
|
|
606
|
+
const prefix = chatState.serverBase ? 'Active servers:' : 'Available servers:';
|
|
607
|
+
await sendLongMessage(token, chatState, prefix + '\n' + lines.join('\n'));
|
|
439
608
|
}
|
|
440
609
|
|
|
441
610
|
function sessionName(s) {
|
|
@@ -476,7 +645,8 @@ async function discoverActiveSession(server) {
|
|
|
476
645
|
}
|
|
477
646
|
}
|
|
478
647
|
|
|
479
|
-
async function cmdSessions(token, chatId, args,
|
|
648
|
+
async function cmdSessions(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
649
|
+
const server = chatState.serverBase;
|
|
480
650
|
if (args.length > 0) {
|
|
481
651
|
const sub = args[0].toLowerCase();
|
|
482
652
|
|
|
@@ -485,41 +655,41 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
485
655
|
try {
|
|
486
656
|
const session = await createSession(server, title);
|
|
487
657
|
const name = session.title || session.slug || session.id;
|
|
488
|
-
|
|
658
|
+
setActiveSessionForChat(chatState, session.id);
|
|
489
659
|
selectSession(server, session.id);
|
|
490
|
-
await sendLongMessage(token,
|
|
660
|
+
await sendLongMessage(token, chatState,
|
|
491
661
|
`Created session: \`${name}\`\nIt is now the active session.`);
|
|
492
662
|
} catch (err) {
|
|
493
|
-
await sendLongMessage(token,
|
|
663
|
+
await sendLongMessage(token, chatState, `Error creating session: ${err.message}`);
|
|
494
664
|
}
|
|
495
665
|
return;
|
|
496
666
|
}
|
|
497
667
|
|
|
498
668
|
if (sub === 'rename') {
|
|
499
669
|
if (args.length < 2) {
|
|
500
|
-
await sendLongMessage(token,
|
|
670
|
+
await sendLongMessage(token, chatState, 'Usage: `/sessions rename <name>`');
|
|
501
671
|
return;
|
|
502
672
|
}
|
|
503
673
|
const st = getState(server);
|
|
504
674
|
if (!st.activeSessionId) {
|
|
505
|
-
await sendLongMessage(token,
|
|
675
|
+
await sendLongMessage(token, chatState, 'No active session. Use `/sessions switch` or `/sessions new` first.');
|
|
506
676
|
return;
|
|
507
677
|
}
|
|
508
678
|
const newTitle = args.slice(1).join(' ');
|
|
509
679
|
try {
|
|
510
680
|
const session = await updateSession(server, st.activeSessionId, newTitle);
|
|
511
681
|
const name = session.title || session.slug || session.id;
|
|
512
|
-
await sendLongMessage(token,
|
|
682
|
+
await sendLongMessage(token, chatState,
|
|
513
683
|
`Renamed session to: \`${name}\``);
|
|
514
684
|
} catch (err) {
|
|
515
|
-
await sendLongMessage(token,
|
|
685
|
+
await sendLongMessage(token, chatState, `Error renaming session: ${err.message}`);
|
|
516
686
|
}
|
|
517
687
|
return;
|
|
518
688
|
}
|
|
519
689
|
|
|
520
690
|
if (sub === 'switch') {
|
|
521
691
|
if (args.length < 2) {
|
|
522
|
-
await sendLongMessage(token,
|
|
692
|
+
await sendLongMessage(token, chatState,
|
|
523
693
|
'Usage: `/sessions switch <number-or-name>`\n' +
|
|
524
694
|
'Use `/sessions` to see numbered list. You can switch by number or by name (case-insensitive partial match).');
|
|
525
695
|
return;
|
|
@@ -537,9 +707,9 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
537
707
|
const num = parseInt(query, 10);
|
|
538
708
|
if (/^\d+$/.test(query) && num >= 1 && num <= sorted.length) {
|
|
539
709
|
const target = sorted[num - 1];
|
|
540
|
-
|
|
710
|
+
setActiveSessionForChat(chatId, target.id);
|
|
541
711
|
selectSession(server, target.id);
|
|
542
|
-
await sendLongMessage(token,
|
|
712
|
+
await sendLongMessage(token, chatState,
|
|
543
713
|
`Switched to session #${num}: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
|
|
544
714
|
return;
|
|
545
715
|
}
|
|
@@ -548,29 +718,29 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
548
718
|
const q = query.toLowerCase();
|
|
549
719
|
const matches = sorted.filter(s => sessionName(s).toLowerCase().includes(q));
|
|
550
720
|
if (matches.length === 0) {
|
|
551
|
-
await sendLongMessage(token,
|
|
721
|
+
await sendLongMessage(token, chatState,
|
|
552
722
|
`No session matching "${query}". Use /sessions to list active sessions.`);
|
|
553
723
|
return;
|
|
554
724
|
}
|
|
555
725
|
if (matches.length > 1) {
|
|
556
726
|
const list = matches.map(s => ` \`${sessionName(s)}\``).join('\n');
|
|
557
|
-
await sendLongMessage(token,
|
|
727
|
+
await sendLongMessage(token, chatState,
|
|
558
728
|
`Multiple matches for "${query}":\n${list}\n\nPlease be more specific or use a number.`);
|
|
559
729
|
return;
|
|
560
730
|
}
|
|
561
731
|
const target = matches[0];
|
|
562
|
-
|
|
732
|
+
setActiveSessionForChat(chatId, target.id);
|
|
563
733
|
selectSession(server, target.id);
|
|
564
|
-
await sendLongMessage(token,
|
|
734
|
+
await sendLongMessage(token, chatState,
|
|
565
735
|
`Switched to session: \`${sessionName(target)}\`\nSubsequent messages will be sent to this session.`);
|
|
566
736
|
} catch (err) {
|
|
567
|
-
await sendLongMessage(token,
|
|
737
|
+
await sendLongMessage(token, chatState, `Error switching session: ${err.message}`);
|
|
568
738
|
}
|
|
569
739
|
return;
|
|
570
740
|
}
|
|
571
741
|
|
|
572
742
|
// Unknown subcommand
|
|
573
|
-
await sendLongMessage(token,
|
|
743
|
+
await sendLongMessage(token, chatState,
|
|
574
744
|
`Unknown subcommand: /sessions ${sub}\n\n` +
|
|
575
745
|
'Usage:\n' +
|
|
576
746
|
' `/sessions` ─ list sessions\n' +
|
|
@@ -585,7 +755,7 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
585
755
|
const projectDir = serverDir(server);
|
|
586
756
|
const sessions = await listSessions(server, projectDir);
|
|
587
757
|
if (!sessions || sessions.length === 0) {
|
|
588
|
-
await sendLongMessage(token,
|
|
758
|
+
await sendLongMessage(token, chatState, 'No active sessions.');
|
|
589
759
|
return;
|
|
590
760
|
}
|
|
591
761
|
|
|
@@ -595,7 +765,7 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
595
765
|
(s.agent || s.directory || s.id === activeId) && (!projectDir || s.directory === projectDir));
|
|
596
766
|
const sorted = sortedSessions(filtered, 50);
|
|
597
767
|
if (sorted.length === 0) {
|
|
598
|
-
await sendLongMessage(token,
|
|
768
|
+
await sendLongMessage(token, chatState, 'No active sessions.');
|
|
599
769
|
return;
|
|
600
770
|
}
|
|
601
771
|
|
|
@@ -639,16 +809,17 @@ async function cmdSessions(token, chatId, args, setServer, setActiveSession, ser
|
|
|
639
809
|
lines.push('`' + bullet + pos + ' ' + timeLabel + ' – ' + name.replace(/`/g, "'") + '`');
|
|
640
810
|
});
|
|
641
811
|
|
|
642
|
-
await sendLongMessage(token,
|
|
812
|
+
await sendLongMessage(token, chatState, 'Sessions:\n' + lines.join('\n'));
|
|
643
813
|
} catch (err) {
|
|
644
|
-
await sendLongMessage(token,
|
|
814
|
+
await sendLongMessage(token, chatState, `Error listing sessions: ${err.message}`);
|
|
645
815
|
}
|
|
646
816
|
}
|
|
647
817
|
|
|
648
|
-
async function cmdAgents(token, chatId, args,
|
|
818
|
+
async function cmdAgents(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
819
|
+
const server = chatState.serverBase;
|
|
649
820
|
if (args.length > 0 && args[0].toLowerCase() === 'switch') {
|
|
650
821
|
if (args.length < 2) {
|
|
651
|
-
await sendLongMessage(token,
|
|
822
|
+
await sendLongMessage(token, chatState,
|
|
652
823
|
'Usage: `/agents switch <name>`\n' +
|
|
653
824
|
'Use `/agents` to list available agents.');
|
|
654
825
|
return;
|
|
@@ -657,7 +828,7 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
|
|
|
657
828
|
try {
|
|
658
829
|
const agents = await listAgents(server);
|
|
659
830
|
if (!agents || agents.length === 0) {
|
|
660
|
-
await sendLongMessage(token,
|
|
831
|
+
await sendLongMessage(token, chatState, 'No agents available.');
|
|
661
832
|
return;
|
|
662
833
|
}
|
|
663
834
|
let matches = agents.filter(a => a.name === name);
|
|
@@ -665,12 +836,12 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
|
|
|
665
836
|
matches = agents.filter(a => a.name.toLowerCase().includes(name.toLowerCase()));
|
|
666
837
|
}
|
|
667
838
|
if (matches.length === 0) {
|
|
668
|
-
await sendLongMessage(token,
|
|
839
|
+
await sendLongMessage(token, chatState, `No agent matching "${name}".`);
|
|
669
840
|
return;
|
|
670
841
|
}
|
|
671
842
|
if (matches.length > 1) {
|
|
672
843
|
const list = matches.map(a => ` \`${a.name}\``).join('\n');
|
|
673
|
-
await sendLongMessage(token,
|
|
844
|
+
await sendLongMessage(token, chatState,
|
|
674
845
|
`Multiple matches for "${name}":\n${list}\n\nPlease be more specific.`);
|
|
675
846
|
return;
|
|
676
847
|
}
|
|
@@ -682,10 +853,10 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
|
|
|
682
853
|
selectSession(server, st.activeSessionId);
|
|
683
854
|
}
|
|
684
855
|
const model = target.model ? ` ─ \`${target.model.providerID}/${target.model.modelID}\`` : '';
|
|
685
|
-
await sendLongMessage(token,
|
|
856
|
+
await sendLongMessage(token, chatState,
|
|
686
857
|
`Switched to agent: **${target.name}**${model}\nSubsequent messages will use this agent.`);
|
|
687
858
|
} catch (err) {
|
|
688
|
-
await sendLongMessage(token,
|
|
859
|
+
await sendLongMessage(token, chatState, `Error switching agent: ${err.message}`);
|
|
689
860
|
}
|
|
690
861
|
return;
|
|
691
862
|
}
|
|
@@ -693,12 +864,12 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
|
|
|
693
864
|
try {
|
|
694
865
|
const agents = await listAgents(server);
|
|
695
866
|
if (!agents || agents.length === 0) {
|
|
696
|
-
await sendLongMessage(token,
|
|
867
|
+
await sendLongMessage(token, chatState, 'No agents available.');
|
|
697
868
|
return;
|
|
698
869
|
}
|
|
699
870
|
const primary = agents.filter(a => a.mode === 'primary');
|
|
700
871
|
if (primary.length === 0) {
|
|
701
|
-
await sendLongMessage(token,
|
|
872
|
+
await sendLongMessage(token, chatState, 'No primary agents available.');
|
|
702
873
|
return;
|
|
703
874
|
}
|
|
704
875
|
const activeAgentName = getState(server).activeAgentName;
|
|
@@ -708,13 +879,14 @@ async function cmdAgents(token, chatId, args, setServer, setActiveSession, serve
|
|
|
708
879
|
const desc = a.description ? `. ${a.description}` : '';
|
|
709
880
|
return bullet + '**' + a.name + '**' + model + desc;
|
|
710
881
|
});
|
|
711
|
-
await sendLongMessage(token,
|
|
882
|
+
await sendLongMessage(token, chatState, 'Primary agents:\n' + lines.join('\n'));
|
|
712
883
|
} catch (err) {
|
|
713
|
-
await sendLongMessage(token,
|
|
884
|
+
await sendLongMessage(token, chatState, `Error listing agents: ${err.message}`);
|
|
714
885
|
}
|
|
715
886
|
}
|
|
716
887
|
|
|
717
|
-
async function cmdModels(token, chatId, args,
|
|
888
|
+
async function cmdModels(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
889
|
+
const server = chatState.serverBase;
|
|
718
890
|
try {
|
|
719
891
|
const result = await listProviders(server);
|
|
720
892
|
const all = result.all || [];
|
|
@@ -722,7 +894,7 @@ async function cmdModels(token, chatId, args, setServer, setActiveSession, serve
|
|
|
722
894
|
|
|
723
895
|
const filtered = all.filter(p => connectedIds.includes(p.id));
|
|
724
896
|
if (filtered.length === 0) {
|
|
725
|
-
await sendLongMessage(token,
|
|
897
|
+
await sendLongMessage(token, chatState, 'No connected providers.');
|
|
726
898
|
return;
|
|
727
899
|
}
|
|
728
900
|
|
|
@@ -746,9 +918,9 @@ async function cmdModels(token, chatId, args, setServer, setActiveSession, serve
|
|
|
746
918
|
}
|
|
747
919
|
}
|
|
748
920
|
|
|
749
|
-
await sendLongMessage(token,
|
|
921
|
+
await sendLongMessage(token, chatState, 'Connected providers:\n' + lines.join('\n'));
|
|
750
922
|
} catch (err) {
|
|
751
|
-
await sendLongMessage(token,
|
|
923
|
+
await sendLongMessage(token, chatState, `Error listing models: ${err.message}`);
|
|
752
924
|
}
|
|
753
925
|
}
|
|
754
926
|
|
|
@@ -758,7 +930,7 @@ const PERM_RESPONSE_HELP = ' No pending permission. Use `/status` to check the c
|
|
|
758
930
|
// Agentp gateway ─ tiny HTTP server for agentp --tg
|
|
759
931
|
// ---------------------------------------------------------------------------
|
|
760
932
|
|
|
761
|
-
function startAgentpGateway(token,
|
|
933
|
+
function startAgentpGateway(token, serverBaseResolver) {
|
|
762
934
|
return new Promise((resolve) => {
|
|
763
935
|
const server = http.createServer((req, res) => {
|
|
764
936
|
try {
|
|
@@ -788,19 +960,21 @@ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
|
|
|
788
960
|
res.end('Bad Request: text and server are required');
|
|
789
961
|
return;
|
|
790
962
|
}
|
|
791
|
-
const chatId = chatIdResolver();
|
|
792
963
|
|
|
793
|
-
//
|
|
794
|
-
const
|
|
964
|
+
// Find the chat that owns this server
|
|
965
|
+
const targetUrl = normalizeUrl(server);
|
|
966
|
+
const owningChatId = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === targetUrl)?.[1];
|
|
967
|
+
|
|
968
|
+
// Return recorded conversation buffer for the owning chat
|
|
969
|
+
const buffered = owningChatId ? flushRecorded(owningChatId.chatId, owningChatId.threadId) : [];
|
|
795
970
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
796
971
|
res.end(JSON.stringify({ buffered }));
|
|
797
972
|
|
|
798
|
-
if (!
|
|
973
|
+
if (!owningChatId) return;
|
|
799
974
|
const activeUrl = normalizeUrl(serverBaseResolver());
|
|
800
|
-
const targetUrl = normalizeUrl(server);
|
|
801
975
|
|
|
802
976
|
if (targetUrl === activeUrl) {
|
|
803
|
-
sendLongMessage(token,
|
|
977
|
+
sendLongMessage(token, owningChatId, text.trim()).catch(() => {});
|
|
804
978
|
} else {
|
|
805
979
|
if (!agentpQueues[server]) {
|
|
806
980
|
agentpQueues[server] = { messages: [], notifyTimer: null, notified: false };
|
|
@@ -812,7 +986,7 @@ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
|
|
|
812
986
|
q.notifyTimer = null;
|
|
813
987
|
if (q.messages.length > 0 && !q.notified) {
|
|
814
988
|
q.notified = true;
|
|
815
|
-
sendLongMessage(token,
|
|
989
|
+
sendLongMessage(token, owningChatId,
|
|
816
990
|
`📥 ${q.messages.length} agentp message(s) queued for server \`${server}\`. Switch servers to view them.`)
|
|
817
991
|
.catch(() => {});
|
|
818
992
|
}
|
|
@@ -839,115 +1013,321 @@ function startAgentpGateway(token, chatIdResolver, serverBaseResolver) {
|
|
|
839
1013
|
});
|
|
840
1014
|
}
|
|
841
1015
|
|
|
842
|
-
async function cmdAllow(token, chatId, args,
|
|
843
|
-
const st = getState(
|
|
1016
|
+
async function cmdAllow(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1017
|
+
const st = getState(chatState.serverBase);
|
|
844
1018
|
if (!st.pendingPermission) {
|
|
845
|
-
await sendLongMessage(token,
|
|
1019
|
+
await sendLongMessage(token, chatState, '/allow' + PERM_RESPONSE_HELP);
|
|
846
1020
|
return;
|
|
847
1021
|
}
|
|
848
1022
|
const p = st.pendingPermission;
|
|
849
1023
|
try {
|
|
850
1024
|
await respondToPermission(p.server, p.sessionId, p.permissionId, 'once');
|
|
851
|
-
await sendLongMessage(token,
|
|
1025
|
+
await sendLongMessage(token, chatState, '✅ Permission approved (once).');
|
|
852
1026
|
log.debug(` → /allow: ${p.type} ${p.pattern || ''}`);
|
|
853
1027
|
} catch (err) {
|
|
854
|
-
await sendLongMessage(token,
|
|
1028
|
+
await sendLongMessage(token, chatState, `Error: ${err.message}`);
|
|
855
1029
|
}
|
|
856
1030
|
}
|
|
857
1031
|
|
|
858
|
-
async function cmdReject(token, chatId, args,
|
|
859
|
-
const st = getState(
|
|
1032
|
+
async function cmdReject(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1033
|
+
const st = getState(chatState.serverBase);
|
|
860
1034
|
if (!st.pendingPermission) {
|
|
861
|
-
await sendLongMessage(token,
|
|
1035
|
+
await sendLongMessage(token, chatState, '/reject' + PERM_RESPONSE_HELP);
|
|
862
1036
|
return;
|
|
863
1037
|
}
|
|
864
1038
|
const p = st.pendingPermission;
|
|
865
1039
|
try {
|
|
866
1040
|
await respondToPermission(p.server, p.sessionId, p.permissionId, 'reject');
|
|
867
|
-
await sendLongMessage(token,
|
|
1041
|
+
await sendLongMessage(token, chatState, '⛔ Permission denied.');
|
|
868
1042
|
log.debug(` → /reject: ${p.type} ${p.pattern || ''}`);
|
|
869
1043
|
} catch (err) {
|
|
870
|
-
await sendLongMessage(token,
|
|
1044
|
+
await sendLongMessage(token, chatState, `Error: ${err.message}`);
|
|
871
1045
|
}
|
|
872
1046
|
}
|
|
873
1047
|
|
|
874
|
-
async function cmdAlways(token, chatId, args,
|
|
875
|
-
const st = getState(
|
|
1048
|
+
async function cmdAlways(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1049
|
+
const st = getState(chatState.serverBase);
|
|
876
1050
|
if (!st.pendingPermission) {
|
|
877
|
-
await sendLongMessage(token,
|
|
1051
|
+
await sendLongMessage(token, chatState, '/always' + PERM_RESPONSE_HELP);
|
|
878
1052
|
return;
|
|
879
1053
|
}
|
|
880
1054
|
const p = st.pendingPermission;
|
|
881
1055
|
try {
|
|
882
1056
|
await respondToPermission(p.server, p.sessionId, p.permissionId, 'always');
|
|
883
|
-
await sendLongMessage(token,
|
|
1057
|
+
await sendLongMessage(token, chatState, '✅ Permission approved (always).');
|
|
884
1058
|
log.debug(` → /always: ${p.type} ${p.pattern || ''}`);
|
|
885
1059
|
} catch (err) {
|
|
886
|
-
await sendLongMessage(token,
|
|
1060
|
+
await sendLongMessage(token, chatState, `Error: ${err.message}`);
|
|
887
1061
|
}
|
|
888
1062
|
}
|
|
889
1063
|
|
|
890
|
-
async function cmdThink(token, chatId, args,
|
|
891
|
-
const st = getState(
|
|
1064
|
+
async function cmdThink(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1065
|
+
const st = getState(chatState.serverBase);
|
|
892
1066
|
const sub = args && args.length > 0 ? args[0].toLowerCase() : 'switch';
|
|
893
1067
|
if (sub === 'on') {
|
|
894
1068
|
st.thinkingEnabled = true;
|
|
895
|
-
await sendLongMessage(token,
|
|
1069
|
+
await sendLongMessage(token, chatState, '💭 Thinking messages **on** (forwarded to chat).');
|
|
896
1070
|
} else if (sub === 'off') {
|
|
897
1071
|
st.thinkingEnabled = false;
|
|
898
|
-
await sendLongMessage(token,
|
|
1072
|
+
await sendLongMessage(token, chatState, '💭 Thinking messages **off**.');
|
|
899
1073
|
} else if (sub === 'switch') {
|
|
900
1074
|
st.thinkingEnabled = !st.thinkingEnabled;
|
|
901
|
-
await sendLongMessage(token,
|
|
1075
|
+
await sendLongMessage(token, chatState,
|
|
902
1076
|
`💭 Thinking messages ${st.thinkingEnabled ? '**on**' : '**off**'}.`);
|
|
903
1077
|
} else {
|
|
904
|
-
await sendLongMessage(token,
|
|
1078
|
+
await sendLongMessage(token, chatState, 'Usage: `/think [on|off|switch]` (default: switch).');
|
|
905
1079
|
}
|
|
906
1080
|
}
|
|
907
1081
|
|
|
908
|
-
async function cmdQueue(token, chatId, args,
|
|
909
|
-
const st = getState(serverBase);
|
|
1082
|
+
async function cmdQueue(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat, replyTo) {
|
|
1083
|
+
const st = getState(chatState.serverBase);
|
|
910
1084
|
if (!st.busy) {
|
|
911
|
-
await sendLongMessage(token,
|
|
1085
|
+
await sendLongMessage(token, chatState, 'Server is not busy. No need to queue — send your message directly.');
|
|
912
1086
|
return;
|
|
913
1087
|
}
|
|
914
1088
|
const text = args.join(' ').trim();
|
|
915
1089
|
if (!text) {
|
|
916
|
-
await sendLongMessage(token,
|
|
1090
|
+
await sendLongMessage(token, chatState, 'Usage: `/queue <message>` — queue a message for when the server is idle.');
|
|
917
1091
|
return;
|
|
918
1092
|
}
|
|
919
1093
|
if (!st.messageQueue) st.messageQueue = [];
|
|
920
1094
|
st.messageQueue.push({ text, replyTo });
|
|
921
|
-
await sendLongMessage(token,
|
|
1095
|
+
await sendLongMessage(token, chatState, `📥 Queued. ${st.messageQueue.length} message(s) in queue. Will be sent when the current task finishes.`);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function recordingLabel(rec) {
|
|
1099
|
+
if (rec.paused) return '⏸ paused';
|
|
1100
|
+
return rec.active ? '🔴 active' : '⏹ inactive';
|
|
922
1101
|
}
|
|
923
1102
|
|
|
924
|
-
async function cmdRecord(token, chatId, args) {
|
|
1103
|
+
async function cmdRecord(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
|
|
1104
|
+
const rec = chatState.recording;
|
|
1105
|
+
const ring = chatState.ring;
|
|
1106
|
+
|
|
1107
|
+
// /record N: retroactively buffer last N messages from the always-on ring
|
|
1108
|
+
if (args.length > 0 && /^\d+$/.test(args[0])) {
|
|
1109
|
+
const n = parseInt(args[0], 10);
|
|
1110
|
+
const count = Math.min(n, ring.messages.length);
|
|
1111
|
+
const msgs = ring.messages.slice(-count);
|
|
1112
|
+
for (const m of msgs) {
|
|
1113
|
+
rec.messages.push({ role: m.role, text: m.text });
|
|
1114
|
+
rec.bytes += Buffer.byteLength(m.text, 'utf8');
|
|
1115
|
+
}
|
|
1116
|
+
while (rec.messages.length > MAX_RECORDED_MSGS || rec.bytes > MAX_RECORDED_BYTES) {
|
|
1117
|
+
const oldest = rec.messages.shift();
|
|
1118
|
+
rec.bytes -= Buffer.byteLength(oldest.text, 'utf8');
|
|
1119
|
+
}
|
|
1120
|
+
rec.active = true;
|
|
1121
|
+
rec.paused = false;
|
|
1122
|
+
const label = rec.messages.length > 0 ? `🔴 *active*, ${rec.messages.length} buffered` : '⏹ *inactive*, buffer empty';
|
|
1123
|
+
await sendLongMessage(token, chatState,
|
|
1124
|
+
`${label} (${count} message(s) retroactively loaded from ring).`);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// /record pause [N]: pause recording, keep buffer, optionally discard last N
|
|
1129
|
+
if (args.length > 0 && args[0].toLowerCase() === 'pause') {
|
|
1130
|
+
if (rec.active && !rec.paused && args.length > 1 && /^\d+$/.test(args[1])) {
|
|
1131
|
+
const n = parseInt(args[1], 10);
|
|
1132
|
+
const count = Math.min(n, rec.messages.length);
|
|
1133
|
+
rec.messages.splice(-count);
|
|
1134
|
+
rec.bytes = rec.messages.reduce((b, m) => b + Buffer.byteLength(m.text, 'utf8'), 0);
|
|
1135
|
+
await sendLongMessage(token, chatState,
|
|
1136
|
+
`⏸ Recording paused. Discarded last ${count} message(s). ${rec.messages.length} remaining.`);
|
|
1137
|
+
} else {
|
|
1138
|
+
rec.paused = true;
|
|
1139
|
+
rec.active = true;
|
|
1140
|
+
await sendLongMessage(token, chatState,
|
|
1141
|
+
`⏸ Recording paused. Buffer preserved (${rec.messages.length} messages).`);
|
|
1142
|
+
}
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// /record flush: clear buffer, keep state
|
|
1147
|
+
if (args.length > 0 && args[0].toLowerCase() === 'flush') {
|
|
1148
|
+
rec.messages = [];
|
|
1149
|
+
rec.bytes = 0;
|
|
1150
|
+
await sendLongMessage(token, chatState,
|
|
1151
|
+
`🧹 Buffer cleared. State: ${recordingLabel(rec)}.`);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// /record stop: flush + pause = fully stop and clear
|
|
925
1156
|
if (args.length > 0 && args[0].toLowerCase() === 'stop') {
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1157
|
+
rec.active = false;
|
|
1158
|
+
rec.paused = false;
|
|
1159
|
+
rec.messages = [];
|
|
1160
|
+
rec.bytes = 0;
|
|
1161
|
+
await sendLongMessage(token, chatState, '⏹ Recording stopped. Buffer cleared.');
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// /record (no args) or /record continue: toggle/continue
|
|
1166
|
+
if (args.length > 0 && args[0].toLowerCase() === 'continue') {
|
|
1167
|
+
rec.paused = false;
|
|
1168
|
+
rec.active = true;
|
|
1169
|
+
const empty = rec.messages.length === 0;
|
|
1170
|
+
await sendLongMessage(token, chatState,
|
|
1171
|
+
empty
|
|
1172
|
+
? '🔴 Recording started (buffer was empty).'
|
|
1173
|
+
: `🔴 Recording continued. ${rec.messages.length} buffered message(s) will grow.`);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Bare /record: toggle between active ↔ paused (if active), or activate (if inactive)
|
|
1178
|
+
if (!rec.active) {
|
|
1179
|
+
rec.active = true;
|
|
1180
|
+
rec.paused = false;
|
|
1181
|
+
rec.messages = [];
|
|
1182
|
+
rec.bytes = 0;
|
|
1183
|
+
await sendLongMessage(token, chatState, '🔴 Recording started.');
|
|
1184
|
+
} else if (!rec.paused) {
|
|
1185
|
+
rec.paused = true;
|
|
1186
|
+
await sendLongMessage(token, chatState,
|
|
1187
|
+
`⏸ Recording paused. ${rec.messages.length} message(s) preserved.`);
|
|
1188
|
+
} else {
|
|
1189
|
+
rec.paused = false;
|
|
1190
|
+
const empty = rec.messages.length === 0;
|
|
1191
|
+
await sendLongMessage(token, chatState,
|
|
1192
|
+
empty
|
|
1193
|
+
? '🔴 Recording continued (buffer was empty).'
|
|
1194
|
+
: `🔴 Recording continued. ${rec.messages.length} buffered message(s) will grow.`);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async function cmdFlush(token, chatId, args, chatState) {
|
|
1199
|
+
const st = getState(chatState.serverBase);
|
|
1200
|
+
const server = chatState.serverBase;
|
|
1201
|
+
const q = st.messageQueue || [];
|
|
1202
|
+
const qlen = q.length;
|
|
1203
|
+
|
|
1204
|
+
// Clear the message queue
|
|
1205
|
+
st.messageQueue = [];
|
|
1206
|
+
|
|
1207
|
+
// Clear the agentp queue
|
|
1208
|
+
const aq = agentpQueues[server];
|
|
1209
|
+
let alen = 0;
|
|
1210
|
+
if (aq) {
|
|
1211
|
+
alen = aq.messages.length;
|
|
1212
|
+
aq.messages = [];
|
|
1213
|
+
aq.notified = false;
|
|
1214
|
+
if (aq.notifyTimer) { clearTimeout(aq.notifyTimer); aq.notifyTimer = null; }
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
await sendLongMessage(token, chatState,
|
|
1218
|
+
qlen + alen > 0
|
|
1219
|
+
? `🧹 Flushed ${qlen} queued message(s) and ${alen} agentp message(s).`
|
|
1220
|
+
: '🧹 No queued messages to flush.');
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
async function cmdComment(token, chatId, args, chatState) {
|
|
1224
|
+
// No-op: the message is already in the chat. /comment prevents forwarding to the agent.
|
|
1225
|
+
// Replying to a comment with a non-/comment message includes it via the quote mechanism.
|
|
1226
|
+
log.info(`[COMMENT] chat=${chatId} text: ${(args || []).join(' ')}`);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async function cmdResurrect(token, chatId, args, chatState) {
|
|
1230
|
+
const url = chatState.serverBase;
|
|
1231
|
+
if (!url) {
|
|
1232
|
+
await sendLongMessage(token, chatState, '🔌 Not connected to any server. Nothing to resurrect.');
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
let dir = serverDir(url);
|
|
1237
|
+
if (!dir) {
|
|
1238
|
+
// Fallback: look up directory from saved connections
|
|
1239
|
+
try {
|
|
1240
|
+
const savedData = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8'));
|
|
1241
|
+
if (savedData && Array.isArray(savedData.connections)) {
|
|
1242
|
+
const match = savedData.connections.find(c =>
|
|
1243
|
+
String(c.chatId) === String(chatId) && (c.threadId || null) === (chatState.threadId || null)
|
|
1244
|
+
);
|
|
1245
|
+
if (match && match.dir) dir = match.dir;
|
|
1246
|
+
}
|
|
1247
|
+
} catch {}
|
|
1248
|
+
}
|
|
1249
|
+
if (!dir) {
|
|
1250
|
+
await sendLongMessage(token, chatState,
|
|
1251
|
+
'Could not find the directory for this server.\n' +
|
|
1252
|
+
'The tmux window may have been removed.\n' +
|
|
1253
|
+
'Use `ocmux resurrect` from the terminal to recover it.');
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
await sendLongMessage(token, chatState,
|
|
1258
|
+
`♻️ Resurrecting server \`${dir}\`...\n` +
|
|
1259
|
+
`The old server at \`${url}\` will be replaced with a new one.`);
|
|
1260
|
+
|
|
1261
|
+
try {
|
|
1262
|
+
const { url: newUrl } = resurrectServer(dir, false);
|
|
1263
|
+
|
|
1264
|
+
// Update chat state to new URL
|
|
1265
|
+
chatState.serverBase = newUrl;
|
|
1266
|
+
|
|
1267
|
+
// Transfer relevant state from old URL to new URL
|
|
1268
|
+
const oldSt = getState(url);
|
|
1269
|
+
const newSt = getState(newUrl);
|
|
1270
|
+
newSt.activeSessionId = oldSt.activeSessionId;
|
|
1271
|
+
newSt.activeSessionName = oldSt.activeSessionName;
|
|
1272
|
+
newSt.activeAgentName = oldSt.activeAgentName;
|
|
1273
|
+
newSt.thinkingEnabled = oldSt.thinkingEnabled;
|
|
1274
|
+
|
|
1275
|
+
// Update serverOwners to point to the new URL
|
|
1276
|
+
const normOld = normalizeUrl(url);
|
|
1277
|
+
for (const [sfUrl, owner] of Object.entries(serverOwners)) {
|
|
1278
|
+
if (normalizeUrl(sfUrl) === normOld) {
|
|
1279
|
+
serverOwners[newUrl] = owner;
|
|
1280
|
+
delete serverOwners[sfUrl];
|
|
1281
|
+
break;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
await sendLongMessage(token, chatState,
|
|
1286
|
+
`✅ Server resurrected.\n` +
|
|
1287
|
+
`New URL: \`${newUrl}\`\n\n` +
|
|
1288
|
+
`Use \`/status\` to check the session.`);
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
await sendLongMessage(token, chatState,
|
|
1291
|
+
`❌ Resurrection failed: ${err.message}\n` +
|
|
1292
|
+
`Try \`ocmux resurrect\` from the terminal.`);
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
async function cmdDisconnect(token, chatId, args, chatState) {
|
|
1297
|
+
const url = chatState.serverBase;
|
|
1298
|
+
if (!url) {
|
|
1299
|
+
await sendLongMessage(token, chatState, '🔌 Not connected to any server.');
|
|
930
1300
|
return;
|
|
931
1301
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1302
|
+
// Clear ownership if this chat is the owner
|
|
1303
|
+
const key = convKey(chatId, chatState.threadId);
|
|
1304
|
+
for (const [srvUrl, owner] of Object.entries(serverOwners)) {
|
|
1305
|
+
if (String(owner.chatId) === String(chatId) && owner.threadId === chatState.threadId) {
|
|
1306
|
+
delete serverOwners[srvUrl];
|
|
1307
|
+
break;
|
|
1308
|
+
}
|
|
936
1309
|
}
|
|
937
|
-
|
|
938
|
-
|
|
1310
|
+
chatState.serverBase = null;
|
|
1311
|
+
removeConnection(chatId, chatState.threadId);
|
|
1312
|
+
await sendLongMessage(token, chatState, '👋 Disconnected from `' + url + '`.');
|
|
1313
|
+
log.info(`[chat ${chatId}] Disconnected from server: ${url}`);
|
|
939
1314
|
}
|
|
940
1315
|
|
|
941
1316
|
const COMMANDS = {
|
|
942
1317
|
'help': cmdHelp,
|
|
943
1318
|
'servers': cmdServers,
|
|
1319
|
+
'force-switch': cmdForceSwitch,
|
|
944
1320
|
'sessions': cmdSessions,
|
|
945
1321
|
'session': cmdSessions,
|
|
946
1322
|
'agents': cmdAgents,
|
|
947
1323
|
'models': cmdModels,
|
|
948
1324
|
'think': cmdThink,
|
|
949
1325
|
'record': cmdRecord,
|
|
1326
|
+
'resurrect': cmdResurrect,
|
|
950
1327
|
'queue': cmdQueue,
|
|
1328
|
+
'flush': cmdFlush,
|
|
1329
|
+
'comment': cmdComment,
|
|
1330
|
+
'disconnect': cmdDisconnect,
|
|
951
1331
|
'allow': cmdAllow,
|
|
952
1332
|
'reject': cmdReject,
|
|
953
1333
|
'always': cmdAlways,
|
|
@@ -959,7 +1339,7 @@ const COMMANDS = {
|
|
|
959
1339
|
|
|
960
1340
|
const serverState = {};
|
|
961
1341
|
function getState(url) {
|
|
962
|
-
return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false, messageQueue: [] };
|
|
1342
|
+
return serverState[url] = serverState[url] || { busy: false, serverDead: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false, messageQueue: [] };
|
|
963
1343
|
}
|
|
964
1344
|
|
|
965
1345
|
function sessionLabel(st) {
|
|
@@ -984,6 +1364,7 @@ async function main() {
|
|
|
984
1364
|
process.exit(0);
|
|
985
1365
|
} else if (args[i] === '--dev') {
|
|
986
1366
|
DEV_MODE = true;
|
|
1367
|
+
log.verbose = true;
|
|
987
1368
|
} else if (args[i] === '--verbose') {
|
|
988
1369
|
log.verbose = true;
|
|
989
1370
|
} else if (args[i] === '--think') {
|
|
@@ -1041,6 +1422,26 @@ async function main() {
|
|
|
1041
1422
|
|
|
1042
1423
|
log.info(`tgagentp v${version} started. OpenCode server: ${serverBase}`);
|
|
1043
1424
|
|
|
1425
|
+
// When --dev is active, log all message traffic to stderr
|
|
1426
|
+
let msgLogStream = null;
|
|
1427
|
+
if (DEV_MODE) {
|
|
1428
|
+
try {
|
|
1429
|
+
msgLogStream = fs.createWriteStream('/tmp/tgagentp-msg.log', { flags: 'a' });
|
|
1430
|
+
const hdr = JSON.stringify({ ts: new Date().toISOString(), type: 'startup', version });
|
|
1431
|
+
msgLogStream.write(hdr + '\n');
|
|
1432
|
+
} catch (_) { msgLogStream = null; }
|
|
1433
|
+
}
|
|
1434
|
+
const msgLog = (type, chatId, from, text) => {
|
|
1435
|
+
if (!msgLogStream) return;
|
|
1436
|
+
try {
|
|
1437
|
+
const entry = JSON.stringify({
|
|
1438
|
+
ts: new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ''),
|
|
1439
|
+
type, chatId, from: from || '', text: text || '',
|
|
1440
|
+
});
|
|
1441
|
+
msgLogStream.write(entry + '\n');
|
|
1442
|
+
} catch (_) {}
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1044
1445
|
if (!process.env.OPENCODE_SERVER_PASSWORD) {
|
|
1045
1446
|
log.error('🔓 Server is not password-protected! Set OPENCODE_SERVER_PASSWORD for better security.');
|
|
1046
1447
|
}
|
|
@@ -1052,8 +1453,6 @@ async function main() {
|
|
|
1052
1453
|
log.info('Chat allowlist: not configured (all chats accepted)');
|
|
1053
1454
|
}
|
|
1054
1455
|
|
|
1055
|
-
let lockedChatId = null;
|
|
1056
|
-
|
|
1057
1456
|
getState(serverBase);
|
|
1058
1457
|
|
|
1059
1458
|
log.info('Discovering server state...');
|
|
@@ -1074,32 +1473,61 @@ async function main() {
|
|
|
1074
1473
|
|
|
1075
1474
|
log.info('Waiting for Telegram messages... (Ctrl+C to stop)');
|
|
1076
1475
|
|
|
1077
|
-
//
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
|
|
1476
|
+
// Chat-aware server/session mutation callbacks (defined here for token access)
|
|
1477
|
+
const setServerForChat = async (chatState, url) => {
|
|
1478
|
+
const cs = chatState;
|
|
1479
|
+
|
|
1480
|
+
// Disconnect any other chat still connected to this server (one server → one chat)
|
|
1481
|
+
const normalizedUrl = normalizeUrl(url);
|
|
1482
|
+
for (const key of Object.keys(chatStates)) {
|
|
1483
|
+
const state = chatStates[key];
|
|
1484
|
+
if (state === cs) continue;
|
|
1485
|
+
if (state.serverBase && normalizeUrl(state.serverBase) === normalizedUrl) {
|
|
1486
|
+
state.serverBase = null;
|
|
1487
|
+
removeConnection(state.chatId, state.threadId);
|
|
1488
|
+
log.info(`[chat ${state.chatId}] Disconnected from server: ${url} (taken over by chat ${cs.chatId})`);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1081
1491
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1492
|
+
// Clear old server ownership when switching away from a server
|
|
1493
|
+
const oldUrl = cs.serverBase;
|
|
1494
|
+
cs.serverBase = url;
|
|
1495
|
+
if (oldUrl && oldUrl !== url) {
|
|
1496
|
+
if (serverOwners[oldUrl] &&
|
|
1497
|
+
String(serverOwners[oldUrl].chatId) === String(cs.chatId)) {
|
|
1498
|
+
delete serverOwners[oldUrl];
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
serverOwners[url] = { chatId: cs.chatId, threadId: cs.threadId };
|
|
1084
1502
|
getState(url);
|
|
1085
|
-
|
|
1503
|
+
|
|
1504
|
+
// Auto-activate tmux window for this server
|
|
1505
|
+
const servers = listServers();
|
|
1506
|
+
const normalized = normalizeUrl(url);
|
|
1507
|
+
const match = servers.find(s => normalizeUrl(s.url) === normalized);
|
|
1508
|
+
if (match) {
|
|
1509
|
+
activateServer(match.dir, match.index, match.url);
|
|
1510
|
+
saveConnection(cs.chatId, cs.threadId, match.dir);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
log.info(`[chat ${cs.chatId}] Switched to server: ${url}`);
|
|
1086
1514
|
|
|
1087
1515
|
// Flush agentp queue for the new active server
|
|
1088
|
-
flushAgentpQueue(url, token,
|
|
1516
|
+
flushAgentpQueue(url, token, cs.chatId, cs.threadId);
|
|
1089
1517
|
|
|
1090
|
-
const [
|
|
1518
|
+
const [srvDiscovered, srvAgents] = await Promise.all([
|
|
1091
1519
|
discoverActiveSession(url),
|
|
1092
1520
|
listAgents(url).catch(() => []),
|
|
1093
1521
|
]);
|
|
1094
|
-
if (
|
|
1095
|
-
getState(url).activeSessionId =
|
|
1096
|
-
getState(url).activeSessionName =
|
|
1097
|
-
log.info(` → session: ${
|
|
1522
|
+
if (srvDiscovered) {
|
|
1523
|
+
getState(url).activeSessionId = srvDiscovered.id;
|
|
1524
|
+
getState(url).activeSessionName = srvDiscovered.name;
|
|
1525
|
+
log.info(` → session: ${srvDiscovered.name || srvDiscovered.id}`);
|
|
1098
1526
|
}
|
|
1099
|
-
const
|
|
1100
|
-
if (
|
|
1101
|
-
getState(url).activeAgentName =
|
|
1102
|
-
log.info(` → agent: ${
|
|
1527
|
+
const defaultSrvAgent = srvAgents.find(a => a.mode === 'primary') || srvAgents[0];
|
|
1528
|
+
if (defaultSrvAgent) {
|
|
1529
|
+
getState(url).activeAgentName = defaultSrvAgent.name;
|
|
1530
|
+
log.info(` → agent: ${defaultSrvAgent.name}`);
|
|
1103
1531
|
}
|
|
1104
1532
|
const p = getState(url).pendingResponse;
|
|
1105
1533
|
if (p) {
|
|
@@ -1109,24 +1537,35 @@ async function main() {
|
|
|
1109
1537
|
return null;
|
|
1110
1538
|
};
|
|
1111
1539
|
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1540
|
+
const setActiveSessionForChat = (chatState, id) => {
|
|
1541
|
+
const server = chatState.serverBase;
|
|
1542
|
+
getState(server).activeSessionId = id;
|
|
1543
|
+
log.info(`[chat ${chatState.chatId}] Switched to session: ${id}`);
|
|
1115
1544
|
};
|
|
1116
1545
|
|
|
1117
|
-
|
|
1546
|
+
// Start agentp gateway (tiny HTTP server for agentp --tg)
|
|
1547
|
+
const serverBaseResolver = () => {
|
|
1548
|
+
const entries = Object.entries(serverOwners);
|
|
1549
|
+
if (entries.length === 0) return serverBase;
|
|
1550
|
+
const cs = getChatState(entries[0][1].chatId, entries[0][1].threadId);
|
|
1551
|
+
return cs.serverBase;
|
|
1552
|
+
};
|
|
1553
|
+
startAgentpGateway(token, serverBaseResolver);
|
|
1554
|
+
|
|
1555
|
+
async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId, replyTo, threadId) {
|
|
1118
1556
|
const st = getState(url);
|
|
1119
1557
|
const isCancelled = () => getState(url).taskId !== myTaskId;
|
|
1120
1558
|
const cancelRef = { current: null };
|
|
1559
|
+
const chatState = { chatId, threadId };
|
|
1121
1560
|
const sessionCancelRef = { current: null };
|
|
1561
|
+
// Destroy any previous SSE listener so stale forwarding to old chats stops
|
|
1562
|
+
if (st._cancelRef && st._cancelRef.current) { st._cancelRef.current.destroy(); st._cancelRef.current = null; }
|
|
1563
|
+
if (st._sessionReq && st._sessionReq.current) { st._sessionReq.current.destroy(); st._sessionReq.current = null; }
|
|
1122
1564
|
st._cancelRef = cancelRef;
|
|
1123
1565
|
st._sessionReq = sessionCancelRef;
|
|
1124
1566
|
try {
|
|
1125
1567
|
let answer;
|
|
1126
1568
|
if (useSession) {
|
|
1127
|
-
// Dual approach:
|
|
1128
|
-
// 1. SSE for permissions/thinking events (connected before sending)
|
|
1129
|
-
// 2. Synchronous sendToSession (POST /session/:id/message) for reliable answer
|
|
1130
1569
|
const notifiedPerms = new Set();
|
|
1131
1570
|
let thinkingBuf = '';
|
|
1132
1571
|
let sseConnected;
|
|
@@ -1142,7 +1581,7 @@ async function main() {
|
|
|
1142
1581
|
thinkingBuf += chunk;
|
|
1143
1582
|
if (thinkingBuf.length >= 50 || chunk.endsWith('\n')) {
|
|
1144
1583
|
const html = '💭 ' + thinkingBuf.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1145
|
-
sendLongMessage(token,
|
|
1584
|
+
sendLongMessage(token, chatState, html, replyTo, threadId)
|
|
1146
1585
|
.then(() => {})
|
|
1147
1586
|
.catch(() => {});
|
|
1148
1587
|
thinkingBuf = '';
|
|
@@ -1164,7 +1603,7 @@ async function main() {
|
|
|
1164
1603
|
+ (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
|
|
1165
1604
|
+ 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
|
|
1166
1605
|
log.debug(` → sending permission notification to chat ${chatId}`);
|
|
1167
|
-
sendLongMessage(token,
|
|
1606
|
+
sendLongMessage(token, chatState, msg, replyTo, threadId)
|
|
1168
1607
|
.then(() => log.debug(` → permission notification sent OK`))
|
|
1169
1608
|
.catch((err) => log.debug(` → permission notification failed: ${err.message}`));
|
|
1170
1609
|
} else {
|
|
@@ -1176,16 +1615,13 @@ async function main() {
|
|
|
1176
1615
|
getState(url).pendingPermission = null;
|
|
1177
1616
|
},
|
|
1178
1617
|
}, cancelRef).catch(() => {});
|
|
1179
|
-
// Wait for SSE to connect, then send synchronously
|
|
1180
1618
|
await onConnected;
|
|
1181
1619
|
answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName, sessionCancelRef);
|
|
1182
|
-
// Flush any remaining thinking buffer
|
|
1183
1620
|
if (thinkingBuf) {
|
|
1184
1621
|
const html = '💭 ' + thinkingBuf.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1185
|
-
sendLongMessage(token,
|
|
1622
|
+
sendLongMessage(token, chatState, html, replyTo, threadId).catch(() => {});
|
|
1186
1623
|
thinkingBuf = '';
|
|
1187
1624
|
}
|
|
1188
|
-
// Clean up SSE listener
|
|
1189
1625
|
if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
|
|
1190
1626
|
} else {
|
|
1191
1627
|
await sendText(url, `[Telegram] ${text}`);
|
|
@@ -1197,19 +1633,21 @@ async function main() {
|
|
|
1197
1633
|
return;
|
|
1198
1634
|
}
|
|
1199
1635
|
st.pendingPermission = null;
|
|
1200
|
-
|
|
1636
|
+
const chatServer = getChatState(chatId, threadId).serverBase;
|
|
1637
|
+
if (url === chatServer) {
|
|
1201
1638
|
if (answer && answer.trim()) {
|
|
1202
1639
|
const trimmed = answer.trim();
|
|
1203
|
-
|
|
1204
|
-
|
|
1640
|
+
msgLog('out', chatId, '', trimmed);
|
|
1641
|
+
await sendLongMessage(token, chatState, trimmed, replyTo, threadId);
|
|
1642
|
+
bufferMessage(chatId, threadId, 'assistant', trimmed);
|
|
1205
1643
|
}
|
|
1206
1644
|
} else {
|
|
1207
1645
|
getState(url).pendingResponse = answer;
|
|
1208
|
-
|
|
1209
|
-
|
|
1646
|
+
msgLog('out', chatId, '(switch)', (answer || '').trim());
|
|
1647
|
+
await sendLongMessage(token, chatState, `✅ Server \`${url}\` finished processing. Switch back to see the response.`, replyTo, threadId);
|
|
1648
|
+
if (answer && answer.trim()) bufferMessage(chatId, threadId, 'assistant', answer.trim());
|
|
1210
1649
|
}
|
|
1211
1650
|
} catch (err) {
|
|
1212
|
-
// Clean up SSE listener if it's still active
|
|
1213
1651
|
if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
|
|
1214
1652
|
if (isCancelled()) {
|
|
1215
1653
|
log.debug(` → cancelled, ignoring error from ${url}: ${err.message}`);
|
|
@@ -1217,8 +1655,28 @@ async function main() {
|
|
|
1217
1655
|
return;
|
|
1218
1656
|
}
|
|
1219
1657
|
log.error(`Error processing message (${url}): ${err.message}`);
|
|
1220
|
-
|
|
1221
|
-
|
|
1658
|
+
|
|
1659
|
+
// Detect connection errors (server dead) and requeue the message
|
|
1660
|
+
const isConnError = err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' ||
|
|
1661
|
+
err.code === 'ETIMEDOUT' || err.message.includes('socket hang up') ||
|
|
1662
|
+
err.message.includes('connect ECONNREFUSED');
|
|
1663
|
+
if (isConnError) {
|
|
1664
|
+
st.serverDead = true;
|
|
1665
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
1666
|
+
st.messageQueue.unshift({ text, replyTo });
|
|
1667
|
+
log.info(`Server ${url} appears dead. Re-queued message.`);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
const chatServer = getChatState(chatId, threadId).serverBase;
|
|
1671
|
+
if (url === chatServer) {
|
|
1672
|
+
const msg = isConnError
|
|
1673
|
+
? `⚠️ Server \`${url}\` is unreachable. The message has been requeued and will be sent when the server is available again.\n\n` +
|
|
1674
|
+
`**Options:**\n` +
|
|
1675
|
+
`• Start the server: \`ocmux serve\`\n` +
|
|
1676
|
+
`• Switch to another server: \`/servers switch <name>\`\n` +
|
|
1677
|
+
`• Flush the queue: \`/flush\``
|
|
1678
|
+
: `Error: ${err.message}`;
|
|
1679
|
+
try { await sendLongMessage(token, chatState, msg, replyTo, threadId); } catch (_) {}
|
|
1222
1680
|
}
|
|
1223
1681
|
} finally {
|
|
1224
1682
|
st.pendingPermission = null;
|
|
@@ -1226,98 +1684,250 @@ async function main() {
|
|
|
1226
1684
|
if (st._sessionReq === sessionCancelRef) st._sessionReq = null;
|
|
1227
1685
|
if (!isCancelled()) {
|
|
1228
1686
|
getState(url).busy = false;
|
|
1229
|
-
//
|
|
1687
|
+
// Don't drain the queue if the server is dead — messages stay queued
|
|
1688
|
+
if (st.serverDead) return;
|
|
1230
1689
|
const q = st.messageQueue || [];
|
|
1231
1690
|
st.messageQueue = [];
|
|
1232
1691
|
if (q.length > 0) {
|
|
1233
1692
|
const next = q[0];
|
|
1234
|
-
// Re-queue remaining items (if any) for the next round
|
|
1235
1693
|
st.messageQueue = q.slice(1);
|
|
1236
1694
|
const nextText = next.text;
|
|
1237
1695
|
const nextReplyTo = next.replyTo;
|
|
1238
|
-
// Mark busy again and process
|
|
1239
1696
|
getState(url).busy = true;
|
|
1240
1697
|
const nextTaskId = ++st.taskId;
|
|
1241
1698
|
log.debug(` → processing queued message (task ${nextTaskId})`);
|
|
1242
|
-
processMessageAsync(chatId, nextText, url, !!st.activeSessionId, st.activeSessionId, nextTaskId, nextReplyTo);
|
|
1699
|
+
processMessageAsync(chatId, nextText, url, !!st.activeSessionId, st.activeSessionId, nextTaskId, nextReplyTo, threadId);
|
|
1243
1700
|
}
|
|
1244
1701
|
}
|
|
1245
1702
|
}
|
|
1246
1703
|
}
|
|
1247
1704
|
|
|
1248
|
-
const cmdCancel = async (token, chatId) => {
|
|
1249
|
-
const
|
|
1705
|
+
const cmdCancel = async (token, chatId, cmdArgs, chatState) => {
|
|
1706
|
+
const cs = chatState || getChatState(chatId);
|
|
1707
|
+
if (!cs.serverBase) {
|
|
1708
|
+
await sendLongMessage(token, cs, '🔌 Not connected to any server.');
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const st = getState(cs.serverBase);
|
|
1250
1712
|
if (!st.busy) {
|
|
1251
|
-
await sendLongMessage(token,
|
|
1713
|
+
await sendLongMessage(token, cs, 'Nothing to cancel.');
|
|
1252
1714
|
return;
|
|
1253
1715
|
}
|
|
1254
1716
|
st.taskId++;
|
|
1255
1717
|
st.busy = false;
|
|
1256
|
-
// Abort the SSE stream so the server stops processing
|
|
1257
1718
|
if (st._cancelRef && st._cancelRef.current) {
|
|
1258
1719
|
st._cancelRef.current.destroy();
|
|
1259
1720
|
st._cancelRef.current = null;
|
|
1260
1721
|
}
|
|
1261
|
-
// Abort the synchronous session request if in progress
|
|
1262
1722
|
if (st._sessionReq && st._sessionReq.current) {
|
|
1263
1723
|
st._sessionReq.current.destroy();
|
|
1264
1724
|
st._sessionReq.current = null;
|
|
1265
1725
|
}
|
|
1266
|
-
await sendLongMessage(token,
|
|
1267
|
-
log.debug(`
|
|
1726
|
+
await sendLongMessage(token, cs, '✅ Cancelled.');
|
|
1727
|
+
log.debug(`[chat ${chatId}] /cancel on ${cs.serverBase}`);
|
|
1268
1728
|
}
|
|
1269
1729
|
|
|
1270
|
-
const cmdStatus = async (token, chatId) => {
|
|
1271
|
-
const
|
|
1730
|
+
const cmdStatus = async (token, chatId, cmdArgs, chatState) => {
|
|
1731
|
+
const cs = chatState || getChatState(chatId);
|
|
1732
|
+
if (!cs.serverBase) {
|
|
1733
|
+
await sendLongMessage(token, cs, '🔌 Not connected to any server.');
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
const st = getState(cs.serverBase);
|
|
1737
|
+
const deadInfo = st.serverDead ? ' ❌ unreachable' : '';
|
|
1272
1738
|
const busyInfo = st.busy ? '⏳ busy' : '✅ idle';
|
|
1273
|
-
const dir = serverDir(serverBase);
|
|
1739
|
+
const dir = serverDir(cs.serverBase);
|
|
1274
1740
|
const lines = ['**Server:**'];
|
|
1275
1741
|
if (dir) lines.push(' • **Path:** `' + dir + '`');
|
|
1276
|
-
lines.push(' • **URL:** `' + serverBase + '`');
|
|
1277
|
-
lines.push(' • **Status:** ' + busyInfo);
|
|
1742
|
+
lines.push(' • **URL:** `' + cs.serverBase + '`');
|
|
1743
|
+
lines.push(' • **Status:** ' + busyInfo + deadInfo);
|
|
1278
1744
|
lines.push('**Session:** ' + sessionLabel(st));
|
|
1279
1745
|
lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
|
|
1280
|
-
await sendLongMessage(token,
|
|
1746
|
+
await sendLongMessage(token, cs, lines.join('\n'));
|
|
1281
1747
|
};
|
|
1282
1748
|
|
|
1283
|
-
const cmdShutdown = async (token, chatId, args) => {
|
|
1284
|
-
const
|
|
1749
|
+
const cmdShutdown = async (token, chatId, args, chatState) => {
|
|
1750
|
+
const cs = chatState || getChatState(chatId);
|
|
1751
|
+
const st = getState(cs.serverBase);
|
|
1752
|
+
|
|
1753
|
+
// Handle /shutdown clear — wipe saved connections before exiting
|
|
1754
|
+
if (args && args.length > 0 && args[0].toLowerCase() === 'clear') {
|
|
1755
|
+
clearConnections();
|
|
1756
|
+
await sendLongMessage(token, cs, '🧹 Connection state cleared.');
|
|
1757
|
+
log.debug('/shutdown clear received, exiting...');
|
|
1758
|
+
try { await getUpdates(token, offset, 1); } catch (_) {}
|
|
1759
|
+
process.exit(0);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1285
1762
|
const isForce = args && args.length > 0 && args[0].toLowerCase() === 'force';
|
|
1286
1763
|
if (st.busy && !isForce) {
|
|
1287
|
-
await sendLongMessage(token,
|
|
1764
|
+
await sendLongMessage(token, cs,
|
|
1288
1765
|
'⏳ Server is busy processing a message.\n' +
|
|
1289
1766
|
'Use `/shutdown force` to shut down anyway (in-flight response will be lost).');
|
|
1290
1767
|
return;
|
|
1291
1768
|
}
|
|
1292
1769
|
const status = st.busy ? '⏳ busy (forced)' : '✅ idle';
|
|
1293
|
-
await sendLongMessage(token,
|
|
1294
|
-
try {
|
|
1295
|
-
fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId));
|
|
1296
|
-
} catch (_) {}
|
|
1770
|
+
await sendLongMessage(token, cs, `👋 Shutting down (state: ${status})...`);
|
|
1297
1771
|
try { await getUpdates(token, offset, 1); } catch (_) {}
|
|
1298
1772
|
log.debug('/shutdown received, exiting...');
|
|
1299
1773
|
process.exit(0);
|
|
1300
1774
|
};
|
|
1301
1775
|
|
|
1302
|
-
const
|
|
1776
|
+
const cmdNote = async (token, chatId, cmdArgs, chatState) => {
|
|
1777
|
+
const text = cmdArgs.join(' ');
|
|
1778
|
+
if (!text) {
|
|
1779
|
+
await sendLongMessage(token, chatState, 'Usage: `/note <message>` — sends a note for the agent to acknowledge.');
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
const url = chatState.serverBase;
|
|
1783
|
+
if (!url) {
|
|
1784
|
+
await sendLongMessage(token, chatState, '🔌 Not connected to any server.');
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
const st = getState(url);
|
|
1788
|
+
if (st.serverDead) {
|
|
1789
|
+
const alive = await isServerAlive(url);
|
|
1790
|
+
if (!alive) {
|
|
1791
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
1792
|
+
st.messageQueue.push({ text: '📝 **Note:** ' + text, replyTo: null });
|
|
1793
|
+
await sendLongMessage(token, chatState, `⚠️ Server unreachable. Note queued (#${st.messageQueue.length}).`);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
st.serverDead = false;
|
|
1797
|
+
}
|
|
1798
|
+
if (st.busy) {
|
|
1799
|
+
await sendLongMessage(token, chatState, '⏳ Server is busy. Use `/cancel` to cancel or note again later.');
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// Auto-switch tmux window to this chat's server
|
|
1804
|
+
const srvList = listServers();
|
|
1805
|
+
const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(url));
|
|
1806
|
+
if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
|
|
1807
|
+
|
|
1808
|
+
const intro = 'The following information is for your awareness. Reply with only "Ack" and do not take any action:';
|
|
1809
|
+
st.busy = true;
|
|
1810
|
+
const myTaskId = ++st.taskId;
|
|
1811
|
+
log.debug(` → /note: starting background processing (task ${myTaskId})`);
|
|
1812
|
+
processMessageAsync(chatId, intro + '\n\n' + text, url, !!st.activeSessionId, st.activeSessionId, myTaskId, null, chatState.threadId);
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
const cmds = { ...COMMANDS, status: cmdStatus, cancel: cmdCancel, note: cmdNote };
|
|
1303
1816
|
if (DEV_MODE) cmds.shutdown = cmdShutdown;
|
|
1304
1817
|
|
|
1305
1818
|
let offset = 0;
|
|
1306
1819
|
|
|
1307
|
-
//
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1820
|
+
// Restore chat↔server connections from persistent state
|
|
1821
|
+
let savedData;
|
|
1822
|
+
let connByOldId = {};
|
|
1823
|
+
try { savedData = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8')); } catch {}
|
|
1824
|
+
if (savedData && Array.isArray(savedData.connections)) {
|
|
1825
|
+
// Phase 1: restore chat states and populate serverOwners
|
|
1826
|
+
for (const { chatId, threadId, dir } of savedData.connections) {
|
|
1827
|
+
connByOldId[String(chatId)] = { chatId, threadId, dir };
|
|
1828
|
+
}
|
|
1829
|
+
for (const { chatId, threadId, dir } of savedData.connections) {
|
|
1830
|
+
if (!dir) continue;
|
|
1831
|
+
const cs = getChatState(chatId, threadId);
|
|
1832
|
+
const sf = statefileFor(dir);
|
|
1833
|
+
const state = readState(sf);
|
|
1834
|
+
if (state && state.url) {
|
|
1835
|
+
cs.serverBase = state.url;
|
|
1836
|
+
// Prefer non-thread (private chat) as owner over topic threads
|
|
1837
|
+
const existing = serverOwners[state.url];
|
|
1838
|
+
if (!existing || (!threadId && existing.threadId)) {
|
|
1839
|
+
serverOwners[state.url] = { chatId, threadId };
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
// Phase 2: prune secondary connections per URL (only owner survives)
|
|
1844
|
+
for (const key of Object.keys(chatStates)) {
|
|
1845
|
+
const state = chatStates[key];
|
|
1846
|
+
if (!state.serverBase) continue;
|
|
1847
|
+
const owner = serverOwners[state.serverBase];
|
|
1848
|
+
if (!owner) continue;
|
|
1849
|
+
if (String(owner.chatId) !== String(state.chatId) || owner.threadId !== state.threadId) {
|
|
1850
|
+
const url = state.serverBase;
|
|
1851
|
+
state.serverBase = null;
|
|
1852
|
+
removeConnection(state.chatId, state.threadId);
|
|
1853
|
+
log.info(`[chat ${state.chatId}] Pruned secondary connection to server: ${url}`);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
// Phase 3: send startup notification only to the surviving owner per URL
|
|
1857
|
+
const notifiedUrls = new Set();
|
|
1858
|
+
for (const { chatId, threadId, dir } of savedData.connections) {
|
|
1859
|
+
if (!dir) continue;
|
|
1860
|
+
const cs = getChatState(chatId, threadId);
|
|
1861
|
+
if (!cs.serverBase) continue;
|
|
1862
|
+
const sf = statefileFor(dir);
|
|
1863
|
+
const state = readState(sf);
|
|
1864
|
+
if (state && state.url) {
|
|
1865
|
+
if (notifiedUrls.has(state.url)) {
|
|
1866
|
+
log.info(`[chat ${chatId}] Skipping startup notification for ${dir} (already notified for this URL)`);
|
|
1867
|
+
continue;
|
|
1868
|
+
}
|
|
1869
|
+
notifiedUrls.add(state.url);
|
|
1870
|
+
const alive = await isServerAlive(state.url);
|
|
1871
|
+
if (alive) {
|
|
1872
|
+
const lines = [
|
|
1873
|
+
'✅ Bot started.',
|
|
1874
|
+
'**Server:**',
|
|
1875
|
+
' • **Path:** `' + dir + '`',
|
|
1876
|
+
' • **URL:** `' + state.url + '`',
|
|
1877
|
+
' • **Status:** ✅ restored',
|
|
1878
|
+
];
|
|
1879
|
+
try { await sendLongMessage(token, cs, lines.join('\n')); } catch (_) {}
|
|
1880
|
+
log.info(`Restored connection: chat ${chatId} → ${dir}`);
|
|
1881
|
+
} else {
|
|
1882
|
+
log.info(`Saved connection for chat ${chatId} → ${dir}, but server ${state.url} is unreachable`);
|
|
1883
|
+
try {
|
|
1884
|
+
await sendLongMessage(token, cs,
|
|
1885
|
+
'🔄 Bot started. Previously connected to `' + dir + '`.\n' +
|
|
1886
|
+
'⚠️ The server at `' + state.url + '` is not reachable.\n' +
|
|
1887
|
+
'Options: start the server with `ocmux serve`, or switch with `/servers switch <name>`.');
|
|
1888
|
+
} catch (_) {}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// ── Group migration handler ──────────────────────────────────────
|
|
1895
|
+
// When a regular group is promoted to a supergroup (or topics enabled),
|
|
1896
|
+
// Telegram changes the chat ID. This function updates the stored
|
|
1897
|
+
// connections and allowlist so the bot continues to work transparently.
|
|
1898
|
+
async function handleGroupMigration(oldId, newId) {
|
|
1899
|
+
if (oldId === newId) return;
|
|
1900
|
+
log.info(`Group migration detected: chat ${oldId} → ${newId}`);
|
|
1901
|
+
|
|
1902
|
+
// Update allowlist if the old ID is in it
|
|
1903
|
+
if (allowedChatIds) {
|
|
1904
|
+
const idx = allowedChatIds.indexOf(oldId);
|
|
1905
|
+
if (idx !== -1) {
|
|
1906
|
+
allowedChatIds[idx] = newId;
|
|
1907
|
+
log.info(`Updated allowlist: ${oldId} → ${newId}`);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// Update connections.json: replace all references to oldId with newId
|
|
1912
|
+
try {
|
|
1913
|
+
const data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8'));
|
|
1914
|
+
if (Array.isArray(data.connections)) {
|
|
1915
|
+
let changed = false;
|
|
1916
|
+
for (const conn of data.connections) {
|
|
1917
|
+
if (String(conn.chatId) === oldId) {
|
|
1918
|
+
conn.chatId = newId;
|
|
1919
|
+
changed = true;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
if (changed) {
|
|
1923
|
+
fs.writeFileSync(CONNECTIONS_FILE, JSON.stringify(data, null, 2));
|
|
1924
|
+
log.info(`Updated connection: ${oldId} → ${newId}`);
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
} catch (_) {}
|
|
1928
|
+
|
|
1929
|
+
// Update in-memory chat state key (the existing state remains, just
|
|
1930
|
+
// the key changes — new messages from newId will hit a new state)
|
|
1321
1931
|
}
|
|
1322
1932
|
|
|
1323
1933
|
// Poll for updates and forward each text message to OpenCode, sending the answer back.
|
|
@@ -1341,12 +1951,22 @@ async function main() {
|
|
|
1341
1951
|
offset = update.update_id + 1;
|
|
1342
1952
|
|
|
1343
1953
|
const msg = update.message;
|
|
1344
|
-
if (!msg
|
|
1345
|
-
|
|
1954
|
+
if (!msg) continue;
|
|
1955
|
+
|
|
1956
|
+
// Handle group → supergroup migration (e.g., when topics are enabled)
|
|
1957
|
+
if (msg.migrate_to_chat_id) {
|
|
1958
|
+
await handleGroupMigration(String(msg.chat.id), String(msg.migrate_to_chat_id));
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
if (msg.migrate_from_chat_id) {
|
|
1962
|
+
await handleGroupMigration(String(msg.migrate_from_chat_id), String(msg.chat.id));
|
|
1346
1963
|
continue;
|
|
1347
1964
|
}
|
|
1348
1965
|
|
|
1966
|
+
if (typeof msg.text !== 'string') continue;
|
|
1967
|
+
|
|
1349
1968
|
const chatId = String(msg.chat.id);
|
|
1969
|
+
const threadId = msg.message_thread_id || null;
|
|
1350
1970
|
const replyTo = msg.message_id;
|
|
1351
1971
|
const text = msg.text;
|
|
1352
1972
|
const from = msg.from?.username
|
|
@@ -1355,21 +1975,22 @@ async function main() {
|
|
|
1355
1975
|
|
|
1356
1976
|
// Enforce chat allowlist if configured
|
|
1357
1977
|
if (allowedChatIds && !allowedChatIds.includes(chatId)) {
|
|
1358
|
-
|
|
1978
|
+
const chatLabel = msg.chat?.title ? ` (${msg.chat.title})` : '';
|
|
1979
|
+
log.debug(`[chat ${chatId}]${chatLabel} ${from}: blocked (not in allowlist)`);
|
|
1980
|
+
// Log a hint if the old chat ID is known in connections.json
|
|
1981
|
+
const blockedDebug = `[chat ${chatId}]${chatLabel} ${from}: blocked (not in allowlist)`;
|
|
1982
|
+
const known = Object.keys(connByOldId);
|
|
1983
|
+
if (known.length > 0) {
|
|
1984
|
+
log.debug(` Known connections: ${known.join(', ')}. Chat may have been migrated (enable topics → supergroup changes chat ID).`);
|
|
1985
|
+
}
|
|
1359
1986
|
continue;
|
|
1360
1987
|
}
|
|
1361
1988
|
|
|
1362
|
-
//
|
|
1363
|
-
|
|
1364
|
-
lockedChatId = chatId;
|
|
1365
|
-
log.debug(`[chat ${chatId}] ${from}: locked to this chat`);
|
|
1366
|
-
} else if (lockedChatId !== chatId) {
|
|
1367
|
-
log.debug(`[chat ${chatId}] ${from}: blocked (locked to ${lockedChatId})`);
|
|
1368
|
-
continue;
|
|
1369
|
-
}
|
|
1989
|
+
// Per-chat state: get or create for this chat (thread-aware)
|
|
1990
|
+
const cs = getChatState(chatId, threadId);
|
|
1370
1991
|
|
|
1371
1992
|
log.debug(`[chat ${chatId}] ${from}: ${text}`);
|
|
1372
|
-
|
|
1993
|
+
msgLog('in', chatId, from, text);
|
|
1373
1994
|
|
|
1374
1995
|
// Build reply context if this message is a Telegram reply
|
|
1375
1996
|
const replyContext = quoteReply(msg);
|
|
@@ -1381,32 +2002,81 @@ async function main() {
|
|
|
1381
2002
|
const cmdArgs = parts.slice(1);
|
|
1382
2003
|
const handler = cmds[cmd];
|
|
1383
2004
|
if (handler) {
|
|
2005
|
+
// Disconnected guard: only /help, /servers, /force-switch, /start, /comment, and /shutdown work without a server
|
|
2006
|
+
if (!cs.serverBase && cmd !== 'help' && cmd !== 'servers' && cmd !== 'force-switch' && cmd !== 'start' && cmd !== 'comment' && cmd !== 'shutdown') {
|
|
2007
|
+
await sendLongMessage(token, cs,
|
|
2008
|
+
'🔌 Not connected to any server.\n\n' +
|
|
2009
|
+
'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
|
|
2010
|
+
continue;
|
|
2011
|
+
}
|
|
1384
2012
|
try {
|
|
1385
|
-
await handler(token, chatId, cmdArgs,
|
|
2013
|
+
await handler(token, chatId, cmdArgs, cs, setServerForChat, setActiveSessionForChat);
|
|
1386
2014
|
} catch (err) {
|
|
1387
2015
|
log.error(`Error handling /${cmd}: ${err.message}`);
|
|
1388
|
-
try { await sendLongMessage(token,
|
|
2016
|
+
try { await sendLongMessage(token, cs, `Error: ${err.message}`); } catch (_) {}
|
|
1389
2017
|
}
|
|
1390
2018
|
} else {
|
|
1391
|
-
await sendLongMessage(token,
|
|
1392
|
-
`Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /think, /record, /queue`);
|
|
2019
|
+
await sendLongMessage(token, cs,
|
|
2020
|
+
`Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /think, /record, /queue, /flush, /note, /comment, /disconnect`);
|
|
1393
2021
|
}
|
|
1394
2022
|
continue;
|
|
1395
2023
|
}
|
|
1396
2024
|
|
|
2025
|
+
// Disconnected guard for non-command messages
|
|
2026
|
+
if (!cs.serverBase) {
|
|
2027
|
+
await sendLongMessage(token, cs,
|
|
2028
|
+
'🔌 Not connected to any server.\n\n' +
|
|
2029
|
+
'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
1397
2033
|
// Buffer user messages for /record
|
|
1398
|
-
bufferMessage('user', replyContext + text);
|
|
2034
|
+
bufferMessage(chatId, threadId, 'user', replyContext + text);
|
|
1399
2035
|
|
|
1400
2036
|
// Non-command message ─ process in background
|
|
1401
|
-
const st = getState(serverBase);
|
|
2037
|
+
const st = getState(cs.serverBase);
|
|
2038
|
+
|
|
2039
|
+
// Server health check: detect dead servers and auto-reconnect
|
|
2040
|
+
if (st.serverDead) {
|
|
2041
|
+
const alive = await isServerAlive(cs.serverBase);
|
|
2042
|
+
if (alive) {
|
|
2043
|
+
st.serverDead = false;
|
|
2044
|
+
log.info(`Server ${cs.serverBase} is back online`);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
if (st.serverDead) {
|
|
2048
|
+
// Server is dead ─ auto-queue and notify
|
|
2049
|
+
if (!st.messageQueue) st.messageQueue = [];
|
|
2050
|
+
st.messageQueue.push({ text: replyContext + text, replyTo });
|
|
2051
|
+
const qlen = st.messageQueue.length;
|
|
2052
|
+
const servers = listServers();
|
|
2053
|
+
const suggestions = [];
|
|
2054
|
+
suggestions.push(`• Start the server: \`ocmux serve\``);
|
|
2055
|
+
if (servers.length > 0) {
|
|
2056
|
+
suggestions.push(`• Switch to another server: \`/servers switch <name>\``);
|
|
2057
|
+
}
|
|
2058
|
+
suggestions.push(`• Flush the queue: \`/flush\``);
|
|
2059
|
+
await sendLongMessage(token, cs,
|
|
2060
|
+
`⚠️ Server \`${cs.serverBase}\` is not reachable.\n\n` +
|
|
2061
|
+
`Your message has been queued (#${qlen}). It will be sent when the server becomes available.\n\n` +
|
|
2062
|
+
`**Options:**\n${suggestions.join('\n')}`);
|
|
2063
|
+
continue;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
1402
2066
|
if (st.busy) {
|
|
1403
|
-
await sendLongMessage(token,
|
|
2067
|
+
await sendLongMessage(token, cs, '⏳ Server is busy. Use `/cancel` to cancel or `/queue <message>` to queue your message.');
|
|
1404
2068
|
continue;
|
|
1405
2069
|
}
|
|
2070
|
+
|
|
2071
|
+
// Auto-switch tmux window to this chat's server
|
|
2072
|
+
const srvList = listServers();
|
|
2073
|
+
const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(cs.serverBase));
|
|
2074
|
+
if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
|
|
2075
|
+
|
|
1406
2076
|
st.busy = true;
|
|
1407
2077
|
const myTaskId = ++st.taskId;
|
|
1408
2078
|
log.debug(` → started background processing (task ${myTaskId})`);
|
|
1409
|
-
processMessageAsync(chatId, replyContext + text, serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId, replyTo);
|
|
2079
|
+
processMessageAsync(chatId, replyContext + text, cs.serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId, replyTo, threadId);
|
|
1410
2080
|
}
|
|
1411
2081
|
}
|
|
1412
2082
|
}
|