agentp 0.7.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.
@@ -95,15 +102,19 @@ These endpoints are consumed but not documented elsewhere in the repo:
95
102
 
96
103
  ### Pending
97
104
 
98
- - TUI navigation on session switch (open code API limitation — likely not possible)
99
105
  - `--print-logs` flag for `ocmux serve`
100
106
 
101
107
  ### In Progress
102
108
 
103
- - Event-driven permission handling via Telegram: `POST /session/:id/prompt_async` + `GET /event` SSE listener for `permission.updated` events + `/allow`, `/reject`, `/always` commands — implemented, pending user testing
109
+ *(none)*
104
110
 
105
111
  ### Done
106
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)
107
118
  - /agents switch <name> — switch active agent on the active session
108
119
  - /sessions new [name] — create a new session, TUI follows via /session/:id/select
109
120
  - /sessions rename <name> — rename the current active session
@@ -111,7 +122,7 @@ These endpoints are consumed but not documented elsewhere in the repo:
111
122
  - Case-insensitive matching for all switch subcommands (/servers, /sessions, /agents)
112
123
  - Fix: /sessions filter includes sessions with directory (TUI sessions) + active session exception
113
124
  - Fix: /sessions now shows the active session even without agent set (exception in filter)
114
- - 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)
115
126
  - Fix: active session highlighted with ▶ bullet in session list
116
127
  - Fix: discover TUI session on startup and server switch via `discoverActiveSession()`
117
128
  - Message splitting (>4096 chars, breaks at newlines)
@@ -121,10 +132,6 @@ These endpoints are consumed but not documented elsewhere in the repo:
121
132
  - /cancel with true server-side SSE abort via cancelRef
122
133
  - /shutdown (--dev mode) with Telegram offset acknowledgment
123
134
 
124
- ### With Issues
125
-
126
- - `/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.
127
-
128
135
  ### Verified
129
136
 
130
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
@@ -6,17 +6,13 @@ const path = require('path');
6
6
  const { version } = require('../package.json');
7
7
  const { sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, listenForSessionEvents, listAgents, listProviders } = require('../lib/opencode.js');
8
8
 
9
- // Monkey-patch stderr to prefix all output with timestamps
10
- {
11
- const origWrite = process.stderr.write.bind(process.stderr);
12
- process.stderr.write = function (chunk, encoding, cb) {
13
- const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
14
- const str = typeof chunk === 'string' ? chunk : chunk.toString(encoding || 'utf8');
15
- const lines = str.split('\n');
16
- const prefixed = lines.map(l => l ? `[${ts}] ${l}` : l).join('\n');
17
- return origWrite(prefixed, encoding, cb);
18
- };
19
- }
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
+ };
20
16
 
21
17
  const STARTUP_CHAT_FILE = '/tmp/tgagentp-startup-chat';
22
18
  const { listServers, activateServer } = require('../lib/ocmux.js');
@@ -63,9 +59,10 @@ function getUpdates(token, offset, timeout) {
63
59
  });
64
60
  }
65
61
 
66
- function sendTelegramMessage(token, chatId, text, parseMode) {
62
+ function sendTelegramMessage(token, chatId, text, parseMode, replyTo) {
67
63
  const params = { chat_id: chatId, text };
68
64
  if (parseMode) params.parse_mode = parseMode;
65
+ if (replyTo) params.reply_to_message_id = replyTo;
69
66
  return telegramRequest(token, 'sendMessage', params);
70
67
  }
71
68
 
@@ -173,12 +170,32 @@ function splitMessage(text, maxLength = 4096) {
173
170
  // Converts Markdown to Telegram HTML formatting.
174
171
  // Splits the raw text first, then converts each chunk independently
175
172
  // to avoid broken HTML tags across messages.
176
- async function sendLongMessage(token, chatId, text) {
173
+ async function sendLongMessage(token, chatId, text, replyTo) {
177
174
  const chunks = splitMessage(text);
178
- for (const chunk of chunks) {
179
- const html = convertMarkdownToHtml(chunk);
180
- 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;
181
196
  }
197
+ const fence = '`'.repeat(maxBackticks + 1);
198
+ return `In response to ${replyFrom} (${senderType}):\n\n${fence}\n${rm.text}\n${fence}\n\n`;
182
199
  }
183
200
 
184
201
  // ---------------------------------------------------------------------------
@@ -207,7 +224,8 @@ async function cmdHelp(token, chatId, args) {
207
224
  }
208
225
  if (topic === 'shutdown') {
209
226
  await sendLongMessage(token, chatId,
210
- '`/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' +
211
229
  'Only available when tgagentp is started with `--dev` flag.\n' +
212
230
  'Useful when running tgagentp in a restart loop:\n' +
213
231
  '`while true; do tgagentp --dev; done`');
@@ -246,8 +264,19 @@ async function cmdHelp(token, chatId, args) {
246
264
  '🔐 notification to the chat. Use one of these commands to answer.');
247
265
  return;
248
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
+ }
249
278
  await sendLongMessage(token, chatId,
250
- 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, `/help allow`, or just `/help`.');
279
+ 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, `/help allow`, `/help think`, or just `/help`.');
251
280
  return;
252
281
  }
253
282
  await sendLongMessage(token, chatId,
@@ -265,12 +294,13 @@ async function cmdHelp(token, chatId, args) {
265
294
  '`/agents` — list available agents\n' +
266
295
  '`/agents switch <name>` — switch active agent\n' +
267
296
  '`/models` — list providers and models\n' +
297
+ '`/think` — toggle thinking message forwarding\n' +
268
298
  '`/allow` / `/reject` / `/always` — respond to permission requests\n' +
269
299
  '`/shutdown` — stop tgagentp (only with `--dev` flag)\n\n' +
270
300
  'Everything else is forwarded to the active server as `[Telegram] <text>`.');
271
301
  }
272
302
 
273
- async function cmdServers(token, chatId, args, setServer) {
303
+ async function cmdServers(token, chatId, args, setServer, setActiveSession, serverBase) {
274
304
  if (args.length > 0 && args[0].toLowerCase() === 'switch') {
275
305
  if (args.length < 2) {
276
306
  await sendLongMessage(token, chatId,
@@ -644,7 +674,7 @@ async function cmdAllow(token, chatId, args, setServer, setActiveSession, server
644
674
  try {
645
675
  await respondToPermission(p.server, p.sessionId, p.permissionId, 'once');
646
676
  await sendLongMessage(token, chatId, '✅ Permission approved (once).');
647
- process.stderr.write(` → /allow: ${p.type} ${p.pattern || ''}\n`);
677
+ log.debug(` → /allow: ${p.type} ${p.pattern || ''}`);
648
678
  } catch (err) {
649
679
  await sendLongMessage(token, chatId, `Error: ${err.message}`);
650
680
  }
@@ -660,7 +690,7 @@ async function cmdReject(token, chatId, args, setServer, setActiveSession, serve
660
690
  try {
661
691
  await respondToPermission(p.server, p.sessionId, p.permissionId, 'reject');
662
692
  await sendLongMessage(token, chatId, '⛔ Permission denied.');
663
- process.stderr.write(` → /reject: ${p.type} ${p.pattern || ''}\n`);
693
+ log.debug(` → /reject: ${p.type} ${p.pattern || ''}`);
664
694
  } catch (err) {
665
695
  await sendLongMessage(token, chatId, `Error: ${err.message}`);
666
696
  }
@@ -676,12 +706,30 @@ async function cmdAlways(token, chatId, args, setServer, setActiveSession, serve
676
706
  try {
677
707
  await respondToPermission(p.server, p.sessionId, p.permissionId, 'always');
678
708
  await sendLongMessage(token, chatId, '✅ Permission approved (always).');
679
- process.stderr.write(` → /always: ${p.type} ${p.pattern || ''}\n`);
709
+ log.debug(` → /always: ${p.type} ${p.pattern || ''}`);
680
710
  } catch (err) {
681
711
  await sendLongMessage(token, chatId, `Error: ${err.message}`);
682
712
  }
683
713
  }
684
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
+
685
733
  const COMMANDS = {
686
734
  'help': cmdHelp,
687
735
  'servers': cmdServers,
@@ -689,6 +737,7 @@ const COMMANDS = {
689
737
  'session': cmdSessions,
690
738
  'agents': cmdAgents,
691
739
  'models': cmdModels,
740
+ 'think': cmdThink,
692
741
  'allow': cmdAllow,
693
742
  'reject': cmdReject,
694
743
  'always': cmdAlways,
@@ -700,7 +749,7 @@ const COMMANDS = {
700
749
 
701
750
  const serverState = {};
702
751
  function getState(url) {
703
- return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null };
752
+ return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false };
704
753
  }
705
754
 
706
755
  function sessionLabel(st) {
@@ -725,6 +774,10 @@ async function main() {
725
774
  process.exit(0);
726
775
  } else if (args[i] === '--dev') {
727
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;
728
781
  } else if (args[i] === '--help') {
729
782
  console.error('Usage: tgagentp [options] [url]');
730
783
  console.error('');
@@ -732,9 +785,11 @@ async function main() {
732
785
  console.error('Incoming Telegram messages are forwarded to OpenCode; answers are sent back to the chat.');
733
786
  console.error('');
734
787
  console.error('Options:');
735
- console.error(' --dev Enable development mode (adds /shutdown command)');
736
- console.error(' --version Show version');
737
- console.error(' --help Show this help message');
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');
738
793
  console.error('');
739
794
  console.error('Arguments:');
740
795
  console.error(' url OpenCode TUI server URL or port number (default: 4096)');
@@ -769,24 +824,24 @@ async function main() {
769
824
  process.exit(1);
770
825
  }
771
826
 
772
- process.stderr.write(`tgagentp v${version} started. OpenCode server: ${serverBase}\n`);
827
+ log.info(`tgagentp v${version} started. OpenCode server: ${serverBase}`);
773
828
 
774
829
  if (!process.env.OPENCODE_SERVER_PASSWORD) {
775
- 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.');
776
831
  }
777
832
 
778
833
  const allowedChatIds = parseAllowlist(process.env.TGAGENTP_ALLOWED_CHAT_IDS);
779
834
  if (allowedChatIds) {
780
- process.stderr.write(`Chat allowlist: ${allowedChatIds.length} chat(s) configured\n`);
835
+ log.info(`Chat allowlist: ${allowedChatIds.length} chat(s) configured`);
781
836
  } else {
782
- process.stderr.write('Chat allowlist: not configured (all chats accepted)\n');
837
+ log.info('Chat allowlist: not configured (all chats accepted)');
783
838
  }
784
839
 
785
840
  let lockedChatId = null;
786
841
 
787
842
  getState(serverBase);
788
843
 
789
- process.stderr.write('Discovering server state...\n');
844
+ log.info('Discovering server state...');
790
845
  const [discovered, agents] = await Promise.all([
791
846
  discoverActiveSession(serverBase),
792
847
  listAgents(serverBase).catch(() => []),
@@ -794,20 +849,20 @@ async function main() {
794
849
  if (discovered) {
795
850
  getState(serverBase).activeSessionId = discovered.id;
796
851
  getState(serverBase).activeSessionName = discovered.name;
797
- process.stderr.write(` → session: ${discovered.name || discovered.id}\n`);
852
+ log.info(` → session: ${discovered.name || discovered.id}`);
798
853
  }
799
854
  const defaultAgent = agents.find(a => a.mode === 'primary') || agents[0];
800
855
  if (defaultAgent) {
801
856
  getState(serverBase).activeAgentName = defaultAgent.name;
802
- process.stderr.write(` → agent: ${defaultAgent.name}\n`);
857
+ log.info(` → agent: ${defaultAgent.name}`);
803
858
  }
804
859
 
805
- process.stderr.write('Waiting for Telegram messages... (Ctrl+C to stop)\n');
860
+ log.info('Waiting for Telegram messages... (Ctrl+C to stop)');
806
861
 
807
862
  const setServer = async (url) => {
808
863
  serverBase = url;
809
864
  getState(url);
810
- process.stderr.write(`Switched to server: ${url}\n`);
865
+ log.info(`Switched to server: ${url}`);
811
866
  const [discovered, agents] = await Promise.all([
812
867
  discoverActiveSession(url),
813
868
  listAgents(url).catch(() => []),
@@ -815,12 +870,12 @@ async function main() {
815
870
  if (discovered) {
816
871
  getState(url).activeSessionId = discovered.id;
817
872
  getState(url).activeSessionName = discovered.name;
818
- process.stderr.write(` → session: ${discovered.name || discovered.id}\n`);
873
+ log.info(` → session: ${discovered.name || discovered.id}`);
819
874
  }
820
875
  const defaultAgent = agents.find(a => a.mode === 'primary') || agents[0];
821
876
  if (defaultAgent) {
822
877
  getState(url).activeAgentName = defaultAgent.name;
823
- process.stderr.write(` → agent: ${defaultAgent.name}\n`);
878
+ log.info(` → agent: ${defaultAgent.name}`);
824
879
  }
825
880
  const p = getState(url).pendingResponse;
826
881
  if (p) {
@@ -832,10 +887,10 @@ async function main() {
832
887
 
833
888
  const setActiveSession = (id) => {
834
889
  getState(serverBase).activeSessionId = id;
835
- process.stderr.write(`Switched to session: ${id}\n`);
890
+ log.info(`Switched to session: ${id}`);
836
891
  };
837
892
 
838
- async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId) {
893
+ async function processMessageAsync(chatId, text, url, useSession, sessionId, myTaskId, replyTo) {
839
894
  const st = getState(url);
840
895
  const isCancelled = () => getState(url).taskId !== myTaskId;
841
896
  const cancelRef = { current: null };
@@ -845,11 +900,25 @@ async function main() {
845
900
  if (useSession) {
846
901
  // Event-driven flow: start SSE listener first, then send message async
847
902
  const notifiedPerms = new Set();
903
+ let thinkingBuf = '';
848
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
+ },
849
918
  onPermission: (perm) => {
850
919
  const st2 = getState(url);
851
920
  const permDump = JSON.stringify(perm);
852
- process.stderr.write(` → permission_asked callback: ${permDump}\n`);
921
+ log.debug(` → permission_asked callback: ${permDump}`);
853
922
  st2.pendingPermission = { server: url, sessionId, permissionId: perm.id, type: perm.permission || 'unspecified', pattern: perm.patterns || perm.pattern, title: perm.title };
854
923
  if (!notifiedPerms.has(perm.id)) {
855
924
  notifiedPerms.add(perm.id);
@@ -861,16 +930,16 @@ async function main() {
861
930
  + (patternStr ? '**Target:** `' + patternStr + '`\n' : '')
862
931
  + (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
863
932
  + 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
864
- process.stderr.write(` → sending permission notification to chat ${chatId}\n`);
865
- sendLongMessage(token, chatId, msg)
866
- .then(() => process.stderr.write(` → permission notification sent OK\n`))
867
- .catch((err) => process.stderr.write(` → permission notification failed: ${err.message}\n`));
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}`));
868
937
  } else {
869
- process.stderr.write(` → permission ${perm.id} already notified, updating pendingPermission only\n`);
938
+ log.debug(` → permission ${perm.id} already notified, updating pendingPermission only`);
870
939
  }
871
940
  },
872
941
  onPermissionReplied: () => {
873
- process.stderr.write(` → permission_replied: clearing pendingPermission\n`);
942
+ log.debug(` → permission_replied: clearing pendingPermission`);
874
943
  getState(url).pendingPermission = null;
875
944
  },
876
945
  }, cancelRef);
@@ -884,33 +953,39 @@ async function main() {
884
953
  }
885
954
  // Wait for the SSE listener to collect the full answer
886
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
+ }
887
962
  } else {
888
963
  await sendText(url, `[Telegram] ${text}`);
889
964
  answer = await listenForFinalAnswer(url, null, cancelRef);
890
965
  }
891
966
  if (isCancelled()) {
892
- process.stderr.write(` → cancelled, discarding response from ${url}\n`);
967
+ log.debug(` → cancelled, discarding response from ${url}`);
893
968
  st.pendingPermission = null;
894
969
  return;
895
970
  }
896
971
  st.pendingPermission = null;
897
972
  if (url === serverBase) {
898
973
  if (answer && answer.trim()) {
899
- await sendLongMessage(token, chatId, answer.trim());
974
+ await sendLongMessage(token, chatId, answer.trim(), replyTo);
900
975
  }
901
976
  } else {
902
977
  getState(url).pendingResponse = answer;
903
- 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);
904
979
  }
905
980
  } catch (err) {
906
981
  if (isCancelled()) {
907
- process.stderr.write(` → cancelled, ignoring error from ${url}: ${err.message}\n`);
982
+ log.debug(` → cancelled, ignoring error from ${url}: ${err.message}`);
908
983
  st.pendingPermission = null;
909
984
  return;
910
985
  }
911
- process.stderr.write(`Error processing message (${url}): ${err.message}\n`);
986
+ log.error(`Error processing message (${url}): ${err.message}`);
912
987
  if (url === serverBase) {
913
- try { await sendLongMessage(token, chatId, `Error: ${err.message}`); } catch (_) {}
988
+ try { await sendLongMessage(token, chatId, `Error: ${err.message}`, replyTo); } catch (_) {}
914
989
  }
915
990
  } finally {
916
991
  st.pendingPermission = null;
@@ -935,7 +1010,7 @@ async function main() {
935
1010
  st._cancelRef.current = null;
936
1011
  }
937
1012
  await sendLongMessage(token, chatId, '✅ Cancelled.');
938
- process.stderr.write(` → /cancel on ${serverBase}\n`);
1013
+ log.debug(` → /cancel on ${serverBase}`);
939
1014
  }
940
1015
 
941
1016
  const cmdStatus = async (token, chatId) => {
@@ -951,13 +1026,22 @@ async function main() {
951
1026
  await sendLongMessage(token, chatId, lines.join('\n'));
952
1027
  };
953
1028
 
954
- const cmdShutdown = async (token, chatId) => {
955
- 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})...`);
956
1040
  try {
957
1041
  fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId));
958
1042
  } catch (_) {}
959
1043
  try { await getUpdates(token, offset, 1); } catch (_) {}
960
- process.stderr.write('/shutdown received, exiting...\n');
1044
+ log.debug('/shutdown received, exiting...');
961
1045
  process.exit(0);
962
1046
  };
963
1047
 
@@ -987,13 +1071,13 @@ async function main() {
987
1071
  try {
988
1072
  result = await getUpdates(token, offset, 30);
989
1073
  } catch (err) {
990
- process.stderr.write(`Polling error: ${err.message}\n`);
1074
+ log.error(`Polling error: ${err.message}`);
991
1075
  await new Promise((r) => setTimeout(r, 5000));
992
1076
  continue;
993
1077
  }
994
1078
 
995
1079
  if (!result.ok) {
996
- process.stderr.write(`Telegram API error: ${result.description || JSON.stringify(result)}\n`);
1080
+ log.error(`Telegram API error: ${result.description || JSON.stringify(result)}`);
997
1081
  await new Promise((r) => setTimeout(r, 5000));
998
1082
  continue;
999
1083
  }
@@ -1008,6 +1092,7 @@ async function main() {
1008
1092
  }
1009
1093
 
1010
1094
  const chatId = String(msg.chat.id);
1095
+ const replyTo = msg.message_id;
1011
1096
  const text = msg.text;
1012
1097
  const from = msg.from?.username
1013
1098
  ? `@${msg.from.username}`
@@ -1015,22 +1100,25 @@ async function main() {
1015
1100
 
1016
1101
  // Enforce chat allowlist if configured
1017
1102
  if (allowedChatIds && !allowedChatIds.includes(chatId)) {
1018
- process.stderr.write(`[chat ${chatId}] ${from}: blocked (not in allowlist)\n`);
1103
+ log.debug(`[chat ${chatId}] ${from}: blocked (not in allowlist)`);
1019
1104
  continue;
1020
1105
  }
1021
1106
 
1022
1107
  // Lock to the first chat that connects (single-user guard)
1023
1108
  if (lockedChatId === null) {
1024
1109
  lockedChatId = chatId;
1025
- process.stderr.write(`[chat ${chatId}] ${from}: locked to this chat\n`);
1110
+ log.debug(`[chat ${chatId}] ${from}: locked to this chat`);
1026
1111
  } else if (lockedChatId !== chatId) {
1027
- process.stderr.write(`[chat ${chatId}] ${from}: blocked (locked to ${lockedChatId})\n`);
1112
+ log.debug(`[chat ${chatId}] ${from}: blocked (locked to ${lockedChatId})`);
1028
1113
  continue;
1029
1114
  }
1030
1115
 
1031
- process.stderr.write(`[chat ${chatId}] ${from}: ${text}\n`);
1116
+ log.debug(`[chat ${chatId}] ${from}: ${text}`);
1032
1117
  try { fs.writeFileSync(STARTUP_CHAT_FILE, String(chatId)); } catch (_) {}
1033
1118
 
1119
+ // Build reply context if this message is a Telegram reply
1120
+ const replyContext = quoteReply(msg);
1121
+
1034
1122
  // Intercept /commands
1035
1123
  if (text.startsWith('/')) {
1036
1124
  const parts = text.slice(1).split(' ');
@@ -1041,7 +1129,7 @@ async function main() {
1041
1129
  try {
1042
1130
  await handler(token, chatId, cmdArgs, setServer, setActiveSession, serverBase);
1043
1131
  } catch (err) {
1044
- process.stderr.write(`Error handling /${cmd}: ${err.message}\n`);
1132
+ log.error(`Error handling /${cmd}: ${err.message}`);
1045
1133
  try { await sendLongMessage(token, chatId, `Error: ${err.message}`); } catch (_) {}
1046
1134
  }
1047
1135
  } else {
@@ -1059,14 +1147,14 @@ async function main() {
1059
1147
  }
1060
1148
  st.busy = true;
1061
1149
  const myTaskId = ++st.taskId;
1062
- process.stderr.write(` → started background processing (task ${myTaskId})\n`);
1063
- 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);
1064
1152
  }
1065
1153
  }
1066
1154
  }
1067
1155
 
1068
1156
  process.on('SIGINT', () => {
1069
- process.stderr.write('\ntgagentp stopped.\n');
1157
+ log.info('tgagentp stopped.');
1070
1158
  process.exit(0);
1071
1159
  });
1072
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
  }
@@ -376,6 +379,10 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
376
379
  if (callbacks.onText) callbacks.onText(part.text);
377
380
  collected += part.text;
378
381
  }
382
+
383
+ if ((part.type === 'reasoning' || part.type === 'thinking') && part.text != null && part.text !== '') {
384
+ if (callbacks.onThinking) callbacks.onThinking(part.text);
385
+ }
379
386
  } catch (e) {
380
387
  // Malformed SSE event — skip
381
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.7.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",
package/status_report.md DELETED
@@ -1,85 +0,0 @@
1
- # Project Status Report — June 2026
2
-
3
- ## Environment
4
-
5
- - **Node.js:** v22.12.0
6
- - **opencode:** v1.15.13 (compiled ELF binary, bun-based)
7
- - **Package:** agentp v0.6.0 (just published)
8
- - **Branch structure (local):**
9
- - `main` — v0.6.0, published, contains all ocmux fixes
10
- - `pr-2` — worktree at `wt/pr-2/`, same as main + minor divergence
11
- - `feature/tgagentp-session-init` — branch with WIP session discovery work (based on old v0.5.0, needs rebase onto main)
12
-
13
- ## What's Fixed (v0.6.0)
14
-
15
- - **tuiPaneId** rewritten to use pane index (server=0, TUI=1+) instead of process name — works with any runtime (node, bun, compiled binary)
16
- - **URL polling** in `doNew()` takes the last match instead of the first — more robust with bun startup
17
- - **agentp freeze** confirmed resolved — the 401/event stream issue was caused by stale opencode 0.14.39 session, not a code bug
18
-
19
- ## Known Issues (unsolved)
20
-
21
- ### 1. Session initialization — startup / server switch
22
- - **Branch:** `feature/tgagentp-session-init` (1 commit, needs rebase)
23
- - **Problem:** On fresh tgagentp start or `/servers switch`, `activeSessionId` is `null`. The user must manually do `/sessions switch` or `/sessions new` before session-aware messaging works.
24
- - **WIP fix:** `discoverActiveSession()` helper — calls `listSessions`, sorts by `time.updated`, picks most recent. Called at startup (async `.then()`) and in `setServer()` (awaited). But startup greeting may fire before discovery completes, showing "Session: *none (TUI+SSE)*".
25
- - **Next step:** Rebase branch onto main, tune timing (await discovery before greeting, or delay greeting).
26
-
27
- ### 2. /status shows session ID instead of name
28
- - `/status` displays raw `ses_xxxxx` ID. Would be user-friendly to show the session title/slug (available from `listSessions`).
29
- - **Fix approach:** Either fetch session name on `/status` (adds latency), or cache discovered session names.
30
-
31
- ### 3. /status shows "Agent: *default*" instead of actual agent name
32
- - `activeAgentName` is never initialized on startup. The TUI has an active agent but tgagentp doesn't discover it.
33
- - **Fix:** Could extend `discoverActiveSession()` to also fetch the session's agent, or query `GET /agent` for the default agent on startup.
34
-
35
- ### 4. /servers list — no active server marker
36
- - Unlike `/sessions` (which marks active session with ▶), `/servers` doesn't highlight the currently selected server.
37
- - **Fix:** Simple — compare each server URL against `serverBase` and prepend a bullet.
38
-
39
- ### 5. TUI navigation on session switch
40
- - `/sessions switch` and `/sessions new` call `selectSession()` which tries `POST /session/:id/select` and `POST /tui/select-session`. Neither navigates the TUI pane. Message routing works correctly in background.
41
- - **Status:** Known limitation — documented in AGENTS.md "With Issues." The underlying opencode API may lack the needed TUI navigation endpoints. Revisit after opencode 1.15.x API audit.
42
-
43
- ### 6. AGENTS.md docs need updating
44
- - `tuiPaneId` doc still says "returns pane ID if TUI process (`node`) is running" — now uses pane index, not process name.
45
- - Session discovery feature (once merged) needs documenting.
46
-
47
- ### 7. --print-logs request (low priority)
48
- - User requested a `--print-logs` option for the server startup command in ocmux to aid debugging.
49
- - The log file is at `/tmp/opencode-serve-<hashDir(dir)>.log` and is already captured via `tee`. Could add a flag to tail it.
50
-
51
- ## Session discovery (feature branch details)
52
-
53
- Branch `feature/tgagentp-session-init` contains 1 commit (`6393a8f`):
54
-
55
- ```
56
- WIP: session discovery, initialization improvements, known issues
57
- - Added discoverActiveSession() helper
58
- - setServer() made async
59
- - Startup greeting uses st.activeSessionId
60
-
61
- Known issues (at time of writing, with opencode v1.14.39):
62
- 1. Startup greeting shows "Session: *none*" due to async race
63
- 2. /status shows session ID not name
64
- 3. /status shows "Agent: *default*"
65
- 4. /servers list no active marker
66
- 5. /servers switch doesn't switch tmux window (now partially fixed in v0.6.0)
67
- 6. /sessions switch doesn't navigate TUI
68
- 7. ocmux misbehaved with opencode >= 1.15.x (now fixed in v0.6.0)
69
- ```
70
-
71
- **Files changed:** `bin/tgagentp`, `AGENTS.md` (+34/-5 lines)
72
-
73
- The branch needs to be rebased onto main (v0.6.0) before further work.
74
-
75
- ## Next Steps
76
-
77
- 1. Rebase `feature/tgagentp-session-init` onto main
78
- 2. Continue on that branch:
79
- a. Fix startup greeting race (await discovery before sending)
80
- b. Add session name resolution for /status
81
- c. Add agent discovery for /status
82
- d. Add active server marker in /servers
83
- e. Revisit TUI navigation after opencode API audit
84
- 3. Merge back into main when ready
85
- 4. Clean up: remove `pr-2` worktree and branch