agentp 0.6.0 → 0.8.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 CHANGED
@@ -14,6 +14,13 @@
14
14
  - **`"type": "commonjs"`**, requires Node >= 18.
15
15
  - **Linux and macOS compatible.** All tools use portable Node.js APIs only.
16
16
 
17
+ ## Logging conventions (tgagentp)
18
+
19
+ - **stdout** — informational messages (startup, discovery, session switches)
20
+ - **stderr** — errors (always shown) + trace/debug (only with `--verbose` flag)
21
+ - Default usage for clean console: `tgagentp 2>/dev/null`
22
+ - The `log` helper object at the top of `bin/tgagentp` provides `log.info()`, `log.error()`, and `log.debug()`.
23
+
17
24
  ## `.ocmux.json`
18
25
 
19
26
  Runtime state file created by `bin/ocmux` (contains URL, log path, and window index). **Not in `.gitignore`** — avoid committing it.
@@ -42,7 +49,7 @@ Shared ocmux session discovery; used by both `bin/ocmux` and `bin/tgagentp`:
42
49
 
43
50
  - `readState(file)` — parses `.ocmux.json`, returns `null` on error
44
51
  - `statefileFor(dir)` — `path.join(dir, '.ocmux.json')`
45
- - `tuiPaneId(windowIndex)` — returns pane ID if TUI process (`node`) is running, `null` if dead
52
+ - `tuiPaneId(windowIndex)` — returns pane ID of the TUI pane (pane index != 0), `null` if dead
46
53
  - `windowByDir(dir)` — returns window index by name match
47
54
  - `windowNameByIndex(idx)` — reverse: name from index
48
55
  - `activeWindowIndex()` — returns index of currently selected tmux window
@@ -95,10 +102,19 @@ These endpoints are consumed but not documented elsewhere in the repo:
95
102
 
96
103
  ### Pending
97
104
 
105
+ - `--print-logs` flag for `ocmux serve`
106
+
107
+ ### In Progress
108
+
98
109
  *(none)*
99
110
 
100
111
  ### Done
101
112
 
113
+ - Logging rebalance: stdout for info, stderr for errors/debug/trace, `--verbose` flag, `2>/dev/null` default usage
114
+ - `/shutdown force` — refuses shutdown when busy unless `force` flag is given; goodbye message includes state
115
+ - `/think [on|off|switch]` — toggle real-time forwarding of model thinking messages to the chat; `--think` CLI flag to start enabled
116
+ - Event-driven permission handling (`POST /session/:id/prompt_async` + SSE listener + `/allow`, `/reject`, `/always` commands) — included in v0.7.0
117
+ - TUI navigation on `/sessions switch` and `/sessions new` — `POST /tui/select-session` now works in opencode 1.15.13 (was: tried second, never reached)
102
118
  - /agents switch <name> — switch active agent on the active session
103
119
  - /sessions new [name] — create a new session, TUI follows via /session/:id/select
104
120
  - /sessions rename <name> — rename the current active session
@@ -106,8 +122,9 @@ These endpoints are consumed but not documented elsewhere in the repo:
106
122
  - Case-insensitive matching for all switch subcommands (/servers, /sessions, /agents)
107
123
  - Fix: /sessions filter includes sessions with directory (TUI sessions) + active session exception
108
124
  - Fix: /sessions now shows the active session even without agent set (exception in filter)
109
- - Fix: /sessions switch and /sessions new call selectSession (TUI navigation doesn't work — see With Issues)
125
+ - Fix: /sessions switch and /sessions new call selectSession (TUI navigation didn't work in opencode < 1.15.x)
110
126
  - Fix: active session highlighted with ▶ bullet in session list
127
+ - Fix: discover TUI session on startup and server switch via `discoverActiveSession()`
111
128
  - Message splitting (>4096 chars, breaks at newlines)
112
129
  - Fix: tuiPaneId detects 'bun' (opencode 1.15.x) in addition to 'node'
113
130
  - Fix: URL polling in ocmux doNew takes last match, not first (more robust with bun startup)
@@ -115,10 +132,6 @@ These endpoints are consumed but not documented elsewhere in the repo:
115
132
  - /cancel with true server-side SSE abort via cancelRef
116
133
  - /shutdown (--dev mode) with Telegram offset acknowledgment
117
134
 
118
- ### With Issues
119
-
120
- - `/sessions switch` and `/sessions new` — `selectSession` tries `POST /session/:id/select` and `POST /tui/select-session` but the TUI pane doesn't navigate to the session. Message routing (via `activeSessionId` + `sendToSession`) works correctly in the background. This is a known limitation — the TUI must be navigated manually to see the conversation in the UI. The underlying opencode API may lack the needed TUI navigation endpoints.
121
-
122
135
  ### Verified
123
136
 
124
137
  - Password auth support (OPENCODE_SERVER_PASSWORD) for agentp, ocmux, tgagentp
package/CHANGELOG.md CHANGED
@@ -8,6 +8,16 @@ All notable changes to this project will be documented in this file.
8
8
  - Extract shared OpenCode client logic into `lib/opencode.js`.
9
9
  - Add `tgagentp` — bridge a Telegram bot chat with an OpenCode TUI session.
10
10
 
11
+ ## [0.8.0] - 2026-06-03
12
+
13
+ - Fix `/servers` crash: `serverBase is not defined` error.
14
+ - Logging rebalance: stdout for info, stderr for errors/debug with `--verbose` flag (`2>/dev/null` for clean console).
15
+ - `/shutdown force`: refuse shutdown when busy unless `force` flag given.
16
+ - `/think [on|off|switch]`: toggle real-time forwarding of model thinking messages; `--think` CLI flag.
17
+ - Reply quoting: when replying to a Telegram message, prepend quoted text with safe backtick fencing.
18
+ - Reply chaining: answers use `reply_to_message_id` to appear as replies.
19
+ - TUI navigation: `POST /tui/select-session` tried first (works in opencode 1.15.13).
20
+
11
21
  ## [0.1.0] - 2026-05-08
12
22
 
13
23
  - Initial release.
package/README.md CHANGED
@@ -192,7 +192,8 @@ Non-text Telegram updates (photos, stickers, etc.) are silently ignored.
192
192
  | `/models` | List connected providers with model counts, context limits, and costs |
193
193
  | `/status` | Show current server path, URL, busy status, and active session |
194
194
  | `/cancel` | Cancel the current AI response for the active server |
195
- | `/shutdown` | (requires `--dev`) Stop tgagentp gracefully; acknowledge offset and exit |
195
+ | `/think [on\|off\|switch]` | Toggle forwarding of model thinking messages to the chat |
196
+ | `/shutdown [force]` | (requires `--dev`) Stop tgagentp; refuses if busy unless `force` is given |
196
197
 
197
198
  ### Per-server state
198
199
 
@@ -212,8 +213,27 @@ tgagentp
212
213
 
213
214
  - `--version`: show version
214
215
  - `--help`: show help message
216
+ - `--verbose`: show detailed logs (errors, trace, debug) on stderr
217
+ - `--think`: start with thinking messages enabled (default: off)
215
218
  - `--dev`: enable `/shutdown` command for remote restart (run via `while true; do tgagentp --dev; done`)
216
219
 
220
+ ### Logging
221
+
222
+ Informational messages (startup, discovery, session switches) go to **stdout**. Errors, warnings, and trace/debug messages go to **stderr**. With `--verbose`, detailed
223
+ trace/debug messages are also printed to stderr.
224
+
225
+ For a clean console with only essential info:
226
+
227
+ ```bash
228
+ tgagentp 2>/dev/null
229
+ ```
230
+
231
+ To capture everything (info + errors) to a log file:
232
+
233
+ ```bash
234
+ tgagentp 2>/var/log/tgagentp.log
235
+ ```
236
+
217
237
  ### Telemetry
218
238
 
219
239
  A startup greeting is sent to the last known chat on boot — includes /status-style server info. The chat ID is persisted at `/tmp/tgagentp-startup-chat`.
package/bin/tgagentp CHANGED
@@ -4,7 +4,15 @@ const https = require('https');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { version } = require('../package.json');
7
- const { sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, listAgents, listProviders } = require('../lib/opencode.js');
7
+ const { sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, listenForSessionEvents, listAgents, listProviders } = require('../lib/opencode.js');
8
+
9
+ // Logger: info→stdout, error→stderr (always), debug→stderr (--verbose only)
10
+ const log = {
11
+ verbose: false,
12
+ info(msg) { process.stdout.write(`${new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '')} ${msg}\n`); },
13
+ error(msg) { process.stderr.write(`${msg}\n`); },
14
+ debug(msg) { if (this.verbose) process.stderr.write(`${msg}\n`); },
15
+ };
8
16
 
9
17
  const STARTUP_CHAT_FILE = '/tmp/tgagentp-startup-chat';
10
18
  const { listServers, activateServer } = require('../lib/ocmux.js');
@@ -51,9 +59,10 @@ function getUpdates(token, offset, timeout) {
51
59
  });
52
60
  }
53
61
 
54
- function sendTelegramMessage(token, chatId, text, parseMode) {
62
+ function sendTelegramMessage(token, chatId, text, parseMode, replyTo) {
55
63
  const params = { chat_id: chatId, text };
56
64
  if (parseMode) params.parse_mode = parseMode;
65
+ if (replyTo) params.reply_to_message_id = replyTo;
57
66
  return telegramRequest(token, 'sendMessage', params);
58
67
  }
59
68
 
@@ -161,12 +170,32 @@ function splitMessage(text, maxLength = 4096) {
161
170
  // Converts Markdown to Telegram HTML formatting.
162
171
  // Splits the raw text first, then converts each chunk independently
163
172
  // to avoid broken HTML tags across messages.
164
- async function sendLongMessage(token, chatId, text) {
173
+ async function sendLongMessage(token, chatId, text, replyTo) {
165
174
  const chunks = splitMessage(text);
166
- for (const chunk of chunks) {
167
- const html = convertMarkdownToHtml(chunk);
168
- await sendTelegramMessage(token, chatId, html, 'HTML');
175
+ for (let i = 0; i < chunks.length; i++) {
176
+ const html = convertMarkdownToHtml(chunks[i]);
177
+ await sendTelegramMessage(token, chatId, html, 'HTML', i === 0 ? replyTo : undefined);
178
+ }
179
+ }
180
+
181
+ // Build a quoted reply context from a Telegram reply_to_message.
182
+ // Returns empty string if the message isn't a reply or has no text.
183
+ // Uses a dynamic code-fence length (max consecutive backticks + 1)
184
+ // so nested backticks never break the structure.
185
+ function quoteReply(msg) {
186
+ const rm = msg.reply_to_message;
187
+ if (!rm || !rm.text) return '';
188
+ const replyFrom = rm.from?.username
189
+ ? `@${rm.from.username}`
190
+ : [rm.from?.first_name, rm.from?.last_name].filter(Boolean).join(' ') || 'unknown';
191
+ const senderType = rm.from?.is_bot ? 'assistant' : 'user';
192
+ let maxBackticks = 0;
193
+ for (let i = 0, cur = 0; i < rm.text.length; i++) {
194
+ if (rm.text[i] === '`') { cur++; maxBackticks = Math.max(maxBackticks, cur); }
195
+ else cur = 0;
169
196
  }
197
+ const fence = '`'.repeat(maxBackticks + 1);
198
+ return `In response to ${replyFrom} (${senderType}):\n\n${fence}\n${rm.text}\n${fence}\n\n`;
170
199
  }
171
200
 
172
201
  // ---------------------------------------------------------------------------
@@ -195,7 +224,8 @@ async function cmdHelp(token, chatId, args) {
195
224
  }
196
225
  if (topic === 'shutdown') {
197
226
  await sendLongMessage(token, chatId,
198
- '`/shutdown` — stop tgagentp process\n\n' +
227
+ '`/shutdown` — stop tgagentp process\n' +
228
+ '`/shutdown force` — force shutdown even if busy (in-flight work will be lost)\n\n' +
199
229
  'Only available when tgagentp is started with `--dev` flag.\n' +
200
230
  'Useful when running tgagentp in a restart loop:\n' +
201
231
  '`while true; do tgagentp --dev; done`');
@@ -224,8 +254,29 @@ async function cmdHelp(token, chatId, args) {
224
254
  'with status, context limit, and input/output costs.');
225
255
  return;
226
256
  }
257
+ if (topic === 'allow' || topic === 'reject' || topic === 'always') {
258
+ await sendLongMessage(token, chatId,
259
+ '`/allow` — approve a permission request once\n' +
260
+ '`/reject` — deny a permission request\n' +
261
+ '`/always` — approve and remember for the session\n\n' +
262
+ 'These commands respond to OpenCode permission prompts forwarded by tgagentp.\n' +
263
+ 'When OpenCode needs approval (e.g. to run a shell command), tgagentp sends a\n' +
264
+ '🔐 notification to the chat. Use one of these commands to answer.');
265
+ return;
266
+ }
267
+ if (topic === 'think') {
268
+ await sendLongMessage(token, chatId,
269
+ '`/think` — toggle thinking message forwarding\n' +
270
+ '`/think on` — forward model thinking to the chat in real-time\n' +
271
+ '`/think off` — stop forwarding thinking messages (default)\n' +
272
+ '`/think switch` — toggle current state\n\n' +
273
+ 'When enabled, the model\'s internal reasoning steps are sent to the chat\n' +
274
+ 'as they are generated, prefixed with 💭.\n' +
275
+ 'Can also be enabled at startup with `--think` flag.');
276
+ return;
277
+ }
227
278
  await sendLongMessage(token, chatId,
228
- 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, or just `/help`.');
279
+ 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, `/help allow`, `/help think`, or just `/help`.');
229
280
  return;
230
281
  }
231
282
  await sendLongMessage(token, chatId,
@@ -243,11 +294,13 @@ async function cmdHelp(token, chatId, args) {
243
294
  '`/agents` — list available agents\n' +
244
295
  '`/agents switch <name>` — switch active agent\n' +
245
296
  '`/models` — list providers and models\n' +
297
+ '`/think` — toggle thinking message forwarding\n' +
298
+ '`/allow` / `/reject` / `/always` — respond to permission requests\n' +
246
299
  '`/shutdown` — stop tgagentp (only with `--dev` flag)\n\n' +
247
300
  'Everything else is forwarded to the active server as `[Telegram] <text>`.');
248
301
  }
249
302
 
250
- async function cmdServers(token, chatId, args, setServer) {
303
+ async function cmdServers(token, chatId, args, setServer, setActiveSession, serverBase) {
251
304
  if (args.length > 0 && args[0].toLowerCase() === 'switch') {
252
305
  if (args.length < 2) {
253
306
  await sendLongMessage(token, chatId,
@@ -279,7 +332,7 @@ async function cmdServers(token, chatId, args, setServer) {
279
332
  return;
280
333
  }
281
334
  const target = matches[0];
282
- const pending = setServer(target.url);
335
+ const pending = await setServer(target.url);
283
336
  activateServer(target.dir, target.index, target.url);
284
337
  let msg = `Switched to server: ${target.url} → ${target.dir}`;
285
338
  if (pending && pending.trim()) {
@@ -297,7 +350,11 @@ async function cmdServers(token, chatId, args, setServer) {
297
350
  await sendLongMessage(token, chatId, 'No servers running.');
298
351
  return;
299
352
  }
300
- const lines = servers.map(s => ` ${s.url} → ${s.dir} (${s.status})`);
353
+ const activeUrl = normalizeUrl(serverBase);
354
+ const lines = servers.map(s => {
355
+ const bullet = normalizeUrl(s.url) === activeUrl ? '▶ ' : ' · ';
356
+ return bullet + `${s.url} → ${s.dir} (${s.status})`;
357
+ });
301
358
  await sendLongMessage(token, chatId, 'Active servers:\n' + lines.join('\n'));
302
359
  }
303
360
 
@@ -324,6 +381,21 @@ function sortedSessions(sessions, limit) {
324
381
  .slice(0, limit || 50);
325
382
  }
326
383
 
384
+ // Find the most recently updated session and return its ID and name.
385
+ // Returns null if no sessions exist or the server is unreachable.
386
+ async function discoverActiveSession(server) {
387
+ try {
388
+ const sessions = await listSessions(server);
389
+ if (!sessions || sessions.length === 0) return null;
390
+ const sorted = sortedSessions(sessions, 1);
391
+ if (sorted.length === 0) return null;
392
+ const s = sorted[0];
393
+ return { id: s.id, name: sessionName(s) };
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
398
+
327
399
  async function cmdSessions(token, chatId, args, setServer, setActiveSession, server) {
328
400
  if (args.length > 0) {
329
401
  const sub = args[0].toLowerCase();
@@ -590,6 +662,74 @@ async function cmdModels(token, chatId, args, setServer, setActiveSession, serve
590
662
  }
591
663
  }
592
664
 
665
+ const PERM_RESPONSE_HELP = ' No pending permission. Use `/status` to check the current state.';
666
+
667
+ async function cmdAllow(token, chatId, args, setServer, setActiveSession, server) {
668
+ const st = getState(server);
669
+ if (!st.pendingPermission) {
670
+ await sendLongMessage(token, chatId, '/allow' + PERM_RESPONSE_HELP);
671
+ return;
672
+ }
673
+ const p = st.pendingPermission;
674
+ try {
675
+ await respondToPermission(p.server, p.sessionId, p.permissionId, 'once');
676
+ await sendLongMessage(token, chatId, '✅ Permission approved (once).');
677
+ log.debug(` → /allow: ${p.type} ${p.pattern || ''}`);
678
+ } catch (err) {
679
+ await sendLongMessage(token, chatId, `Error: ${err.message}`);
680
+ }
681
+ }
682
+
683
+ async function cmdReject(token, chatId, args, setServer, setActiveSession, server) {
684
+ const st = getState(server);
685
+ if (!st.pendingPermission) {
686
+ await sendLongMessage(token, chatId, '/reject' + PERM_RESPONSE_HELP);
687
+ return;
688
+ }
689
+ const p = st.pendingPermission;
690
+ try {
691
+ await respondToPermission(p.server, p.sessionId, p.permissionId, 'reject');
692
+ await sendLongMessage(token, chatId, '⛔ Permission denied.');
693
+ log.debug(` → /reject: ${p.type} ${p.pattern || ''}`);
694
+ } catch (err) {
695
+ await sendLongMessage(token, chatId, `Error: ${err.message}`);
696
+ }
697
+ }
698
+
699
+ async function cmdAlways(token, chatId, args, setServer, setActiveSession, server) {
700
+ const st = getState(server);
701
+ if (!st.pendingPermission) {
702
+ await sendLongMessage(token, chatId, '/always' + PERM_RESPONSE_HELP);
703
+ return;
704
+ }
705
+ const p = st.pendingPermission;
706
+ try {
707
+ await respondToPermission(p.server, p.sessionId, p.permissionId, 'always');
708
+ await sendLongMessage(token, chatId, '✅ Permission approved (always).');
709
+ log.debug(` → /always: ${p.type} ${p.pattern || ''}`);
710
+ } catch (err) {
711
+ await sendLongMessage(token, chatId, `Error: ${err.message}`);
712
+ }
713
+ }
714
+
715
+ async function cmdThink(token, chatId, args, setServer, setActiveSession, server) {
716
+ const st = getState(server);
717
+ const sub = args && args.length > 0 ? args[0].toLowerCase() : 'switch';
718
+ if (sub === 'on') {
719
+ st.thinkingEnabled = true;
720
+ await sendLongMessage(token, chatId, '💭 Thinking messages **on** (forwarded to chat).');
721
+ } else if (sub === 'off') {
722
+ st.thinkingEnabled = false;
723
+ await sendLongMessage(token, chatId, '💭 Thinking messages **off**.');
724
+ } else if (sub === 'switch') {
725
+ st.thinkingEnabled = !st.thinkingEnabled;
726
+ await sendLongMessage(token, chatId,
727
+ `💭 Thinking messages ${st.thinkingEnabled ? '**on**' : '**off**'}.`);
728
+ } else {
729
+ await sendLongMessage(token, chatId, 'Usage: `/think [on|off|switch]` (default: switch).');
730
+ }
731
+ }
732
+
593
733
  const COMMANDS = {
594
734
  'help': cmdHelp,
595
735
  'servers': cmdServers,
@@ -597,6 +737,10 @@ const COMMANDS = {
597
737
  'session': cmdSessions,
598
738
  'agents': cmdAgents,
599
739
  'models': cmdModels,
740
+ 'think': cmdThink,
741
+ 'allow': cmdAllow,
742
+ 'reject': cmdReject,
743
+ 'always': cmdAlways,
600
744
  };
601
745
 
602
746
  // --------------------------------------------------------------------------
@@ -605,7 +749,13 @@ const COMMANDS = {
605
749
 
606
750
  const serverState = {};
607
751
  function getState(url) {
608
- return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeAgentName: null, taskId: 0 };
752
+ return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false };
753
+ }
754
+
755
+ function sessionLabel(st) {
756
+ if (st.activeSessionName) return '`' + st.activeSessionName + '`';
757
+ if (st.activeSessionId) return '`' + st.activeSessionId + '`';
758
+ return '*none (TUI+SSE)*';
609
759
  }
610
760
 
611
761
  // --------------------------------------------------------------------------
@@ -624,6 +774,10 @@ async function main() {
624
774
  process.exit(0);
625
775
  } else if (args[i] === '--dev') {
626
776
  DEV_MODE = true;
777
+ } else if (args[i] === '--verbose') {
778
+ log.verbose = true;
779
+ } else if (args[i] === '--think') {
780
+ getState(serverBase).thinkingEnabled = true;
627
781
  } else if (args[i] === '--help') {
628
782
  console.error('Usage: tgagentp [options] [url]');
629
783
  console.error('');
@@ -631,9 +785,11 @@ async function main() {
631
785
  console.error('Incoming Telegram messages are forwarded to OpenCode; answers are sent back to the chat.');
632
786
  console.error('');
633
787
  console.error('Options:');
634
- console.error(' --dev Enable development mode (adds /shutdown command)');
635
- console.error(' --version Show version');
636
- console.error(' --help Show this help message');
788
+ console.error(' --dev Enable development mode (adds /shutdown command)');
789
+ console.error(' --version Show version');
790
+ console.error(' --verbose Show detailed logs (errors, trace, debug) on stderr');
791
+ console.error(' --think Start with thinking messages enabled (default: off)');
792
+ console.error(' --help Show this help message');
637
793
  console.error('');
638
794
  console.error('Arguments:');
639
795
  console.error(' url OpenCode TUI server URL or port number (default: 4096)');
@@ -668,29 +824,59 @@ async function main() {
668
824
  process.exit(1);
669
825
  }
670
826
 
671
- process.stderr.write(`tgagentp v${version} started. OpenCode server: ${serverBase}\n`);
827
+ log.info(`tgagentp v${version} started. OpenCode server: ${serverBase}`);
672
828
 
673
829
  if (!process.env.OPENCODE_SERVER_PASSWORD) {
674
- process.stderr.write('🔓 ⚠️ Server is not password-protected! Set OPENCODE_SERVER_PASSWORD for better security.\n');
830
+ log.error('🔓 Server is not password-protected! Set OPENCODE_SERVER_PASSWORD for better security.');
675
831
  }
676
832
 
677
833
  const allowedChatIds = parseAllowlist(process.env.TGAGENTP_ALLOWED_CHAT_IDS);
678
834
  if (allowedChatIds) {
679
- process.stderr.write(`Chat allowlist: ${allowedChatIds.length} chat(s) configured\n`);
835
+ log.info(`Chat allowlist: ${allowedChatIds.length} chat(s) configured`);
680
836
  } else {
681
- process.stderr.write('Chat allowlist: not configured (all chats accepted)\n');
837
+ log.info('Chat allowlist: not configured (all chats accepted)');
682
838
  }
683
839
 
684
840
  let lockedChatId = null;
685
841
 
686
- process.stderr.write('Waiting for Telegram messages... (Ctrl+C to stop)\n');
687
-
688
842
  getState(serverBase);
689
843
 
690
- const setServer = (url) => {
844
+ log.info('Discovering server state...');
845
+ const [discovered, agents] = await Promise.all([
846
+ discoverActiveSession(serverBase),
847
+ listAgents(serverBase).catch(() => []),
848
+ ]);
849
+ if (discovered) {
850
+ getState(serverBase).activeSessionId = discovered.id;
851
+ getState(serverBase).activeSessionName = discovered.name;
852
+ log.info(` → session: ${discovered.name || discovered.id}`);
853
+ }
854
+ const defaultAgent = agents.find(a => a.mode === 'primary') || agents[0];
855
+ if (defaultAgent) {
856
+ getState(serverBase).activeAgentName = defaultAgent.name;
857
+ log.info(` → agent: ${defaultAgent.name}`);
858
+ }
859
+
860
+ log.info('Waiting for Telegram messages... (Ctrl+C to stop)');
861
+
862
+ const setServer = async (url) => {
691
863
  serverBase = url;
692
864
  getState(url);
693
- process.stderr.write(`Switched to server: ${url}\n`);
865
+ log.info(`Switched to server: ${url}`);
866
+ const [discovered, agents] = await Promise.all([
867
+ discoverActiveSession(url),
868
+ listAgents(url).catch(() => []),
869
+ ]);
870
+ if (discovered) {
871
+ getState(url).activeSessionId = discovered.id;
872
+ getState(url).activeSessionName = discovered.name;
873
+ log.info(` → session: ${discovered.name || discovered.id}`);
874
+ }
875
+ const defaultAgent = agents.find(a => a.mode === 'primary') || agents[0];
876
+ if (defaultAgent) {
877
+ getState(url).activeAgentName = defaultAgent.name;
878
+ log.info(` → agent: ${defaultAgent.name}`);
879
+ }
694
880
  const p = getState(url).pendingResponse;
695
881
  if (p) {
696
882
  getState(url).pendingResponse = null;
@@ -701,10 +887,10 @@ async function main() {
701
887
 
702
888
  const setActiveSession = (id) => {
703
889
  getState(serverBase).activeSessionId = id;
704
- process.stderr.write(`Switched to session: ${id}\n`);
890
+ log.info(`Switched to session: ${id}`);
705
891
  };
706
892
 
707
- async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId) {
893
+ async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId, replyTo) {
708
894
  const st = getState(url);
709
895
  const isCancelled = () => getState(url).taskId !== myTaskId;
710
896
  const cancelRef = { current: null };
@@ -712,33 +898,97 @@ async function main() {
712
898
  try {
713
899
  let answer;
714
900
  if (useSession) {
715
- answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName);
901
+ // Event-driven flow: start SSE listener first, then send message async
902
+ const notifiedPerms = new Set();
903
+ let thinkingBuf = '';
904
+ const ssePromise = listenForSessionEvents(url, sessionId, {
905
+ onText: null,
906
+ onThinking: (chunk) => {
907
+ if (!getState(url).thinkingEnabled) { thinkingBuf = ''; return; }
908
+ // Forward thinking chunks in real-time
909
+ thinkingBuf += chunk;
910
+ if (thinkingBuf.length >= 50 || chunk.endsWith('\n')) {
911
+ const html = '💭 ' + thinkingBuf.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
912
+ sendLongMessage(token, chatId, html, replyTo)
913
+ .then(() => {})
914
+ .catch(() => {});
915
+ thinkingBuf = '';
916
+ }
917
+ },
918
+ onPermission: (perm) => {
919
+ const st2 = getState(url);
920
+ const permDump = JSON.stringify(perm);
921
+ log.debug(` → permission_asked callback: ${permDump}`);
922
+ st2.pendingPermission = { server: url, sessionId, permissionId: perm.id, type: perm.permission || 'unspecified', pattern: perm.patterns || perm.pattern, title: perm.title };
923
+ if (!notifiedPerms.has(perm.id)) {
924
+ notifiedPerms.add(perm.id);
925
+ const toolStr = perm.permission || 'unspecified';
926
+ const patternArr = perm.patterns || (perm.pattern ? [perm.pattern] : []);
927
+ const patternStr = patternArr.join(', ');
928
+ const msg = '🔐 **Permission needed**\n'
929
+ + '**Tool:** `' + toolStr + '`\n'
930
+ + (patternStr ? '**Target:** `' + patternStr + '`\n' : '')
931
+ + (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
932
+ + 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
933
+ log.debug(` → sending permission notification to chat ${chatId}`);
934
+ sendLongMessage(token, chatId, msg, replyTo)
935
+ .then(() => log.debug(` → permission notification sent OK`))
936
+ .catch((err) => log.debug(` → permission notification failed: ${err.message}`));
937
+ } else {
938
+ log.debug(` → permission ${perm.id} already notified, updating pendingPermission only`);
939
+ }
940
+ },
941
+ onPermissionReplied: () => {
942
+ log.debug(` → permission_replied: clearing pendingPermission`);
943
+ getState(url).pendingPermission = null;
944
+ },
945
+ }, cancelRef);
946
+ // Send message (SSE is already listening — no race)
947
+ try {
948
+ await sendToSessionAsync(url, sessionId, `[Telegram] ${text}`, st.activeAgentName);
949
+ } catch (err) {
950
+ // Abort SSE listener on send failure to avoid resource leak
951
+ if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
952
+ throw err;
953
+ }
954
+ // Wait for the SSE listener to collect the full answer
955
+ answer = await ssePromise;
956
+ // Flush any remaining thinking buffer
957
+ if (thinkingBuf) {
958
+ const html = '💭 ' + thinkingBuf.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
959
+ sendLongMessage(token, chatId, html, replyTo).catch(() => {});
960
+ thinkingBuf = '';
961
+ }
716
962
  } else {
717
963
  await sendText(url, `[Telegram] ${text}`);
718
964
  answer = await listenForFinalAnswer(url, null, cancelRef);
719
965
  }
720
966
  if (isCancelled()) {
721
- process.stderr.write(` → cancelled, discarding response from ${url}\n`);
967
+ log.debug(` → cancelled, discarding response from ${url}`);
968
+ st.pendingPermission = null;
722
969
  return;
723
970
  }
971
+ st.pendingPermission = null;
724
972
  if (url === serverBase) {
725
973
  if (answer && answer.trim()) {
726
- await sendLongMessage(token, chatId, answer.trim());
974
+ await sendLongMessage(token, chatId, answer.trim(), replyTo);
727
975
  }
728
976
  } else {
729
977
  getState(url).pendingResponse = answer;
730
- await sendLongMessage(token, chatId, `✅ Server \`${url}\` finished processing. Switch back to see the response.`);
978
+ await sendLongMessage(token, chatId, `✅ Server \`${url}\` finished processing. Switch back to see the response.`, replyTo);
731
979
  }
732
980
  } catch (err) {
733
981
  if (isCancelled()) {
734
- process.stderr.write(` → cancelled, ignoring error from ${url}: ${err.message}\n`);
982
+ log.debug(` → cancelled, ignoring error from ${url}: ${err.message}`);
983
+ st.pendingPermission = null;
735
984
  return;
736
985
  }
737
- process.stderr.write(`Error processing message (${url}): ${err.message}\n`);
986
+ log.error(`Error processing message (${url}): ${err.message}`);
738
987
  if (url === serverBase) {
739
- try { await sendLongMessage(token, chatId, `Error: ${err.message}`); } catch (_) {}
988
+ try { await sendLongMessage(token, chatId, `Error: ${err.message}`, replyTo); } catch (_) {}
740
989
  }
741
990
  } finally {
991
+ st.pendingPermission = null;
742
992
  if (st._cancelRef === cancelRef) st._cancelRef = null;
743
993
  if (!isCancelled()) {
744
994
  getState(url).busy = false;
@@ -760,7 +1010,7 @@ async function main() {
760
1010
  st._cancelRef.current = null;
761
1011
  }
762
1012
  await sendLongMessage(token, chatId, '✅ Cancelled.');
763
- process.stderr.write(` → /cancel on ${serverBase}\n`);
1013
+ log.debug(` → /cancel on ${serverBase}`);
764
1014
  }
765
1015
 
766
1016
  const cmdStatus = async (token, chatId) => {
@@ -771,18 +1021,27 @@ async function main() {
771
1021
  if (dir) lines.push(' • **Path:** `' + dir + '`');
772
1022
  lines.push(' • **URL:** `' + serverBase + '`');
773
1023
  lines.push(' • **Status:** ' + busyInfo);
774
- lines.push('**Session:** ' + (st.activeSessionId ? '`' + st.activeSessionId + '`' : '*none (TUI+SSE)*'));
1024
+ lines.push('**Session:** ' + sessionLabel(st));
775
1025
  lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
776
1026
  await sendLongMessage(token, chatId, lines.join('\n'));
777
1027
  };
778
1028
 
779
- const cmdShutdown = async (token, chatId) => {
780
- await sendLongMessage(token, chatId, '👋 Shutting down...');
1029
+ const cmdShutdown = async (token, chatId, args) => {
1030
+ const st = getState(serverBase);
1031
+ const isForce = args && args.length > 0 && args[0].toLowerCase() === 'force';
1032
+ if (st.busy && !isForce) {
1033
+ await sendLongMessage(token, chatId,
1034
+ '⏳ Server is busy processing a message.\n' +
1035
+ 'Use `/shutdown force` to shut down anyway (in-flight response will be lost).');
1036
+ return;
1037
+ }
1038
+ const status = st.busy ? '⏳ busy (forced)' : '✅ idle';
1039
+ await sendLongMessage(token, chatId, `👋 Shutting down (state: ${status})...`);
781
1040
  try {
782
1041
  fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId));
783
1042
  } catch (_) {}
784
1043
  try { await getUpdates(token, offset, 1); } catch (_) {}
785
- process.stderr.write('/shutdown received, exiting...\n');
1044
+ log.debug('/shutdown received, exiting...');
786
1045
  process.exit(0);
787
1046
  };
788
1047
 
@@ -794,12 +1053,14 @@ async function main() {
794
1053
  // Startup greeting to last-known chat
795
1054
  const savedChatId = (() => { try { return fs.readFileSync(STARTUP_CHAT_FILE, 'utf8').trim(); } catch { return null; } })();
796
1055
  if (savedChatId) {
1056
+ const st = getState(serverBase);
797
1057
  const dir = serverDir(serverBase);
798
1058
  const lines = ['✅ Bot started.', '**Server:**'];
799
1059
  if (dir) lines.push(' • **Path:** `' + dir + '`');
800
1060
  lines.push(' • **URL:** `' + serverBase + '`');
801
1061
  lines.push(' • **Status:** ✅ idle');
802
- lines.push('**Session:** *none (TUI+SSE)*');
1062
+ lines.push('**Session:** ' + sessionLabel(st));
1063
+ lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
803
1064
  try { await sendLongMessage(token, savedChatId, lines.join('\n')); } catch (_) {}
804
1065
  try { fs.unlinkSync(STARTUP_CHAT_FILE); } catch (_) {}
805
1066
  }
@@ -810,13 +1071,13 @@ async function main() {
810
1071
  try {
811
1072
  result = await getUpdates(token, offset, 30);
812
1073
  } catch (err) {
813
- process.stderr.write(`Polling error: ${err.message}\n`);
1074
+ log.error(`Polling error: ${err.message}`);
814
1075
  await new Promise((r) => setTimeout(r, 5000));
815
1076
  continue;
816
1077
  }
817
1078
 
818
1079
  if (!result.ok) {
819
- process.stderr.write(`Telegram API error: ${result.description || JSON.stringify(result)}\n`);
1080
+ log.error(`Telegram API error: ${result.description || JSON.stringify(result)}`);
820
1081
  await new Promise((r) => setTimeout(r, 5000));
821
1082
  continue;
822
1083
  }
@@ -831,6 +1092,7 @@ async function main() {
831
1092
  }
832
1093
 
833
1094
  const chatId = String(msg.chat.id);
1095
+ const replyTo = msg.message_id;
834
1096
  const text = msg.text;
835
1097
  const from = msg.from?.username
836
1098
  ? `@${msg.from.username}`
@@ -838,22 +1100,25 @@ async function main() {
838
1100
 
839
1101
  // Enforce chat allowlist if configured
840
1102
  if (allowedChatIds && !allowedChatIds.includes(chatId)) {
841
- process.stderr.write(`[chat ${chatId}] ${from}: blocked (not in allowlist)\n`);
1103
+ log.debug(`[chat ${chatId}] ${from}: blocked (not in allowlist)`);
842
1104
  continue;
843
1105
  }
844
1106
 
845
1107
  // Lock to the first chat that connects (single-user guard)
846
1108
  if (lockedChatId === null) {
847
1109
  lockedChatId = chatId;
848
- process.stderr.write(`[chat ${chatId}] ${from}: locked to this chat\n`);
1110
+ log.debug(`[chat ${chatId}] ${from}: locked to this chat`);
849
1111
  } else if (lockedChatId !== chatId) {
850
- process.stderr.write(`[chat ${chatId}] ${from}: blocked (locked to ${lockedChatId})\n`);
1112
+ log.debug(`[chat ${chatId}] ${from}: blocked (locked to ${lockedChatId})`);
851
1113
  continue;
852
1114
  }
853
1115
 
854
- process.stderr.write(`[chat ${chatId}] ${from}: ${text}\n`);
1116
+ log.debug(`[chat ${chatId}] ${from}: ${text}`);
855
1117
  try { fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId)); } catch (_) {}
856
1118
 
1119
+ // Build reply context if this message is a Telegram reply
1120
+ const replyContext = quoteReply(msg);
1121
+
857
1122
  // Intercept /commands
858
1123
  if (text.startsWith('/')) {
859
1124
  const parts = text.slice(1).split(' ');
@@ -864,7 +1129,7 @@ async function main() {
864
1129
  try {
865
1130
  await handler(token, chatId, cmdArgs, setServer, setActiveSession, serverBase);
866
1131
  } catch (err) {
867
- process.stderr.write(`Error handling /${cmd}: ${err.message}\n`);
1132
+ log.error(`Error handling /${cmd}: ${err.message}`);
868
1133
  try { await sendLongMessage(token, chatId, `Error: ${err.message}`); } catch (_) {}
869
1134
  }
870
1135
  } else {
@@ -882,14 +1147,14 @@ async function main() {
882
1147
  }
883
1148
  st.busy = true;
884
1149
  const myTaskId = ++st.taskId;
885
- process.stderr.write(` → started background processing (task ${myTaskId})\n`);
886
- processMessageAsync(chatId, text, serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId);
1150
+ log.debug(` → started background processing (task ${myTaskId})`);
1151
+ processMessageAsync(chatId, replyContext + text, serverBase, !!st.activeSessionId, st.activeSessionId, myTaskId, replyTo);
887
1152
  }
888
1153
  }
889
1154
  }
890
1155
 
891
1156
  process.on('SIGINT', () => {
892
- process.stderr.write('\ntgagentp stopped.\n');
1157
+ log.info('tgagentp stopped.');
893
1158
  process.exit(0);
894
1159
  });
895
1160
 
package/lib/opencode.js CHANGED
@@ -222,13 +222,16 @@ async function updateSession(server, sessionId, title) {
222
222
  async function selectSession(server, sessionId) {
223
223
  const encoded = encodeURIComponent(sessionId);
224
224
  const attempts = [
225
- { url: `${server}/session/${encoded}/select`, body: '{}' },
226
225
  { url: `${server}/tui/select-session`, body: JSON.stringify({ sessionID: sessionId }) },
226
+ { url: `${server}/session/${encoded}/select`, body: '{}' },
227
227
  ];
228
228
  for (const { url, body } of attempts) {
229
229
  try {
230
- await makeRequest(buildJsonRequest(url, 'POST', body), body);
231
- return;
230
+ const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
231
+ // /session/:id/select returns 200 with HTML even for bad IDs (TUI page),
232
+ // but doesn't actually navigate. Only consider it a success if the
233
+ // response looks like JSON (i.e. not HTML).
234
+ if (status === 200) return;
232
235
  } catch {
233
236
  // try next approach
234
237
  }
@@ -251,6 +254,161 @@ async function listProviders(server) {
251
254
  return JSON.parse(body);
252
255
  }
253
256
 
257
+ // Send a message asynchronously (non-blocking). Returns 204 No Content.
258
+ async function sendToSessionAsync(server, sessionId, text, agent) {
259
+ const url = `${server}/session/${encodeURIComponent(sessionId)}/prompt_async`;
260
+ const bodyObj = { parts: [{ type: 'text', text }] };
261
+ if (agent) bodyObj.agent = agent;
262
+ const body = JSON.stringify(bodyObj);
263
+ const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
264
+ if (status !== 204) throw new Error(`Failed to send async message: ${status}`);
265
+ }
266
+
267
+ // Respond to a permission request.
268
+ async function respondToPermission(server, sessionId, permissionId, response, remember) {
269
+ const url = `${server}/session/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(permissionId)}`;
270
+ const bodyObj = { response };
271
+ if (remember) bodyObj.remember = true;
272
+ const body = JSON.stringify(bodyObj);
273
+ const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
274
+ if (status !== 200) throw new Error(`Failed to respond to permission: ${status}`);
275
+ }
276
+
277
+ // Listen for session events via SSE, collecting assistant text parts and detecting
278
+ // permission requests. Resolves with the full collected text when the session goes idle.
279
+ // callbacks: { onText(chunk), onPermission(permission) }
280
+ // cancelRef — allows aborting the SSE stream via req.destroy()
281
+ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
282
+ process.stderr.write(` [SSE] connecting to ${server}/event for session ${sessionId}\n`);
283
+ return new Promise((resolve, reject) => {
284
+ const url = `${server}/event`;
285
+ const parsed = new URL(url);
286
+
287
+ const req = http.request({
288
+ hostname: parsed.hostname,
289
+ port: parsed.port,
290
+ path: parsed.pathname,
291
+ method: 'GET',
292
+ headers: { 'Accept': 'text/event-stream', ...getAuthHeaders() }
293
+ }, (res) => {
294
+ if (res.statusCode === 401) {
295
+ res.resume();
296
+ reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
297
+ return;
298
+ }
299
+ process.stderr.write(` [SSE] connected for session ${sessionId}\n`);
300
+ let buffer = '';
301
+ const userMessageIDs = new Set();
302
+ let collected = '';
303
+
304
+ res.on('data', (chunk) => {
305
+ buffer += chunk.toString();
306
+ while (buffer.includes('\n\n')) {
307
+ const eventEnd = buffer.indexOf('\n\n');
308
+ const eventData = buffer.slice(0, eventEnd);
309
+ buffer = buffer.slice(eventEnd + 2);
310
+
311
+ const lines = eventData.split('\n');
312
+ let jsonStr = '';
313
+ for (const line of lines) {
314
+ if (line.startsWith('data: ')) jsonStr += line.slice(6);
315
+ }
316
+ if (!jsonStr.trim()) continue;
317
+
318
+ try {
319
+ const event = JSON.parse(jsonStr);
320
+ const props = event.properties || {};
321
+
322
+ if (event.type === 'session.status') {
323
+ process.stderr.write(` [SSE] session.status: type=${props.status?.type} status=${JSON.stringify(props.status)}\n`);
324
+ } else {
325
+ process.stderr.write(` [SSE] event type=${event.type} sessionID=${props.sessionID} waitSession=${sessionId}\n`);
326
+ }
327
+
328
+ // Filter by sessionID when the event carries one
329
+ if (props.sessionID && props.sessionID !== sessionId) continue;
330
+
331
+ if (event.type === 'message.updated' && props.info?.role === 'user') {
332
+ userMessageIDs.add(props.info.id);
333
+ continue;
334
+ }
335
+
336
+ if (event.type === 'permission.asked') {
337
+ const permStr = JSON.stringify(props);
338
+ process.stderr.write(` [SSE] PERMISSION ASKED: ${permStr}\n`);
339
+ if (callbacks.onPermission) {
340
+ process.stderr.write(` [SSE] calling onPermission callback\n`);
341
+ callbacks.onPermission(props);
342
+ } else {
343
+ process.stderr.write(` [SSE] WARNING: no onPermission callback registered\n`);
344
+ }
345
+ continue;
346
+ }
347
+
348
+ if (event.type === 'permission.replied') {
349
+ process.stderr.write(` [SSE] PERMISSION REPLIED: type=${props.type}\n`);
350
+ if (callbacks.onPermissionReplied) {
351
+ callbacks.onPermissionReplied(props);
352
+ }
353
+ continue;
354
+ }
355
+
356
+ // Completion signals
357
+ if (event.type === 'session.idle') {
358
+ cleanup();
359
+ req.destroy();
360
+ res.destroy();
361
+ resolve(collected);
362
+ return;
363
+ }
364
+ if (event.type === 'session.status' && props.status?.type === 'idle') {
365
+ cleanup();
366
+ req.destroy();
367
+ res.destroy();
368
+ resolve(collected);
369
+ return;
370
+ }
371
+
372
+ const part = props.part;
373
+ if (!part) continue;
374
+
375
+ // Skip echoed user input
376
+ if (part.messageID && userMessageIDs.has(part.messageID)) continue;
377
+
378
+ if (part.type === 'text' && part.text != null && part.text !== '') {
379
+ if (callbacks.onText) callbacks.onText(part.text);
380
+ collected += part.text;
381
+ }
382
+
383
+ if ((part.type === 'reasoning' || part.type === 'thinking') && part.text != null && part.text !== '') {
384
+ if (callbacks.onThinking) callbacks.onThinking(part.text);
385
+ }
386
+ } catch (e) {
387
+ // Malformed SSE event — skip
388
+ }
389
+ }
390
+ });
391
+
392
+ res.on('end', () => {
393
+ cleanup();
394
+ resolve(collected);
395
+ });
396
+ });
397
+
398
+ const cleanup = () => {
399
+ if (cancelRef) cancelRef.current = null;
400
+ };
401
+
402
+ req.on('error', (err) => {
403
+ cleanup();
404
+ reject(err);
405
+ });
406
+
407
+ if (cancelRef) cancelRef.current = req;
408
+ req.end();
409
+ });
410
+ }
411
+
254
412
  module.exports = {
255
413
  getAuthHeaders,
256
414
  makeRequest,
@@ -265,6 +423,9 @@ module.exports = {
265
423
  updateSession,
266
424
  selectSession,
267
425
  sendToSession,
426
+ sendToSessionAsync,
427
+ respondToPermission,
428
+ listenForSessionEvents,
268
429
  listAgents,
269
430
  listProviders,
270
431
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",