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 +19 -6
- package/CHANGELOG.md +10 -0
- package/README.md +21 -1
- package/bin/tgagentp +313 -48
- package/lib/opencode.js +164 -3
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
| `/
|
|
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 (
|
|
167
|
-
const html = convertMarkdownToHtml(
|
|
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
|
|
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
|
|
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
|
|
635
|
-
console.error(' --version
|
|
636
|
-
console.error(' --
|
|
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
|
-
|
|
827
|
+
log.info(`tgagentp v${version} started. OpenCode server: ${serverBase}`);
|
|
672
828
|
|
|
673
829
|
if (!process.env.OPENCODE_SERVER_PASSWORD) {
|
|
674
|
-
|
|
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
|
-
|
|
835
|
+
log.info(`Chat allowlist: ${allowedChatIds.length} chat(s) configured`);
|
|
680
836
|
} else {
|
|
681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
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
|
-
|
|
982
|
+
log.debug(` → cancelled, ignoring error from ${url}: ${err.message}`);
|
|
983
|
+
st.pendingPermission = null;
|
|
735
984
|
return;
|
|
736
985
|
}
|
|
737
|
-
|
|
986
|
+
log.error(`Error processing message (${url}): ${err.message}`);
|
|
738
987
|
if (url === serverBase) {
|
|
739
|
-
try { await sendLongMessage(token, chatId, `Error: ${err.message}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1110
|
+
log.debug(`[chat ${chatId}] ${from}: locked to this chat`);
|
|
849
1111
|
} else if (lockedChatId !== chatId) {
|
|
850
|
-
|
|
1112
|
+
log.debug(`[chat ${chatId}] ${from}: blocked (locked to ${lockedChatId})`);
|
|
851
1113
|
continue;
|
|
852
1114
|
}
|
|
853
1115
|
|
|
854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|