agentp 0.11.0 → 0.11.2

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/README.md CHANGED
@@ -230,17 +230,7 @@ Non-text Telegram updates (photos, stickers, etc.) are silently ignored.
230
230
  |---|---|
231
231
  | `/help [topic]` | Show general help or help for a topic (`servers`, `sessions`, `agents`, `models`, `allow`, `think`, `record`, `queue`) |
232
232
  | `/servers` | List all running ocmux-served projects with URL + status (✅ idle / ⏳ busy) |
233
- | `/servers switch <name>` | Switch active server; matches by full path, basename, or substring |
234
- | `/sessions` | List recent sessions for the current server (max 50, with date headings) |
235
- | `/sessions switch <number\|name>` | Switch active session by position or partial name match |
236
- | `/agents` | List primary agents (▶ marker for the active one) |
237
- | `/agents switch <name>` | Switch active agent — persists on session and refreshes TUI |
238
- | `/models` | List connected providers with model counts, context limits, and costs |
239
- | `/status` | Show current server path, URL, busy status, and active session |
240
- | `/cancel` | Cancel the current AI response for the active server |
241
- | `/think [on\|off\|switch]` | Toggle forwarding of model thinking messages to the chat |
242
- | `/record [stop]` | Toggle recording of Telegram conversation for agentp context; `/record stop` clears and stops |
243
- | `/servers switch <name> [--force]` | Switch to a server; `--force` takes over from another chat |
233
+ | `/servers switch <name> [--force]` | Switch active server; matches by full path, basename, or substring; `--force` takes over from another chat |
244
234
  | `/sessions` | List recent sessions for the current server (max 50, with date headings) |
245
235
  | `/sessions switch <number\|name>` | Switch active session by position or partial name match |
246
236
  | `/agents` | List primary agents (▶ marker for the active one) |
@@ -256,8 +246,15 @@ Non-text Telegram updates (photos, stickers, etc.) are silently ignored.
256
246
  | `/allow` | Approve a permission request once |
257
247
  | `/reject` | Deny a permission request |
258
248
  | `/always` | Approve and remember for the session |
249
+ | `/answer <number>` | Respond to a question asked by the AI (structured multiple-choice) |
259
250
  | `/shutdown [force\|clear]` | (requires `--dev`) Stop tgagentp; `clear` also wipes saved connections |
260
251
 
252
+ #### TUI Command Passthrough
253
+
254
+ Messages starting with `//` are forwarded to the OpenCode TUI as raw keystrokes (not text sent to the AI):
255
+
256
+ - **`//<command>`** — Sends `/command` directly to the TUI prompt. Useful for TUI-level commands like `/init`, `/clear`, `/history`, etc. The AI response is captured and forwarded to Telegram (or a confirmation is sent if the command is quick).
257
+
261
258
  ### Chat-server ownership
262
259
 
263
260
  Each server can be owned by at most one chat at a time. New chats start disconnected. Use `/servers switch <name>` to connect; `--force` takes over and notifies the previous owner. Connections are persisted to `/tmp/tgagentp-connections.json` and restored automatically on restart (server URL is re-discovered from `.ocmux.json`).
package/bin/tgagentp CHANGED
@@ -5,7 +5,7 @@ const https = require('https');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { version } = require('../package.json');
8
- const { getAuthHeaders, sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, listenForSessionEvents, listAgents, listProviders, isServerAlive } = require('../lib/opencode.js');
8
+ const { getAuthHeaders, sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, respondToQuestion, listenForSessionEvents, listAgents, listProviders, isServerAlive } = require('../lib/opencode.js');
9
9
 
10
10
  // Logger: info→stdout, error→stderr (always), debug→stderr (--verbose only)
11
11
  const log = {
@@ -19,7 +19,8 @@ const TGAGENTP_PORT = parseInt(process.env.TGAGENTP_PORT || '0', 10);
19
19
  const PORT_FILE = '/tmp/tgagentp-port';
20
20
  const TGAGENTP_DEBOUNCE_MS = parseInt(process.env.TGAGENTP_DEBOUNCE_MS || '5000', 10);
21
21
  const CONNECTIONS_FILE = '/tmp/tgagentp-connections.json';
22
- const { listServers, activateServer, resurrectServer, statefileFor, readState } = require('../lib/ocmux.js');
22
+ const { listServers, activateServer, resurrectServer, statefileFor, readState, tuiPaneId } = require('../lib/ocmux.js');
23
+ const { executeTuiCommand } = require('../lib/tui-cmd.js');
23
24
 
24
25
  // Persist chat↔server directory mappings across restarts
25
26
  function saveConnection(chatId, threadId, dir) {
@@ -383,14 +384,17 @@ async function cmdHelp(token, chatId, args, chatState) {
383
384
  'with status, context limit, and input/output costs.');
384
385
  return;
385
386
  }
386
- if (topic === 'allow' || topic === 'reject' || topic === 'always') {
387
+ if (topic === 'allow' || topic === 'reject' || topic === 'always' || topic === 'answer') {
387
388
  await sendLongMessage(token, chatState,
388
389
  '`/allow` ─ approve a permission request once\n' +
389
390
  '`/reject` ─ deny a permission request\n' +
390
- '`/always` ─ approve and remember for the session\n\n' +
391
- 'These commands respond to OpenCode permission prompts forwarded by tgagentp.\n' +
391
+ '`/always` ─ approve and remember for the session\n' +
392
+ '`/answer <number>` ─ respond to a question from the AI\n\n' +
393
+ 'These commands respond to OpenCode prompts forwarded by tgagentp.\n' +
392
394
  'When OpenCode needs approval (e.g. to run a shell command), tgagentp sends a\n' +
393
- '🔐 notification to the chat. Use one of these commands to answer.');
395
+ '🔐 notification to the chat. Use one of these commands to answer.\n' +
396
+ 'When the AI asks a multiple-choice question, tgagentp sends a ❓ notification\n' +
397
+ 'with numbered options. Use `/answer <number>` to respond.');
394
398
  return;
395
399
  }
396
400
  if (topic === 'think') {
@@ -1061,6 +1065,24 @@ async function cmdAlways(token, chatId, args, chatState, setServerForChat, setAc
1061
1065
  }
1062
1066
  }
1063
1067
 
1068
+ async function cmdAnswer(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1069
+ const st = getState(chatState.serverBase);
1070
+ if (!st.pendingQuestion) {
1071
+ await sendLongMessage(token, chatState, 'No pending question. Use `/status` to check the current state.');
1072
+ return;
1073
+ }
1074
+ const q = st.pendingQuestion;
1075
+ const n = parseInt(args[0], 10);
1076
+ if (isNaN(n) || n < 1 || n > q.options.length) {
1077
+ await sendLongMessage(token, chatState, `Invalid answer. Use a number between 1 and ${q.options.length}.`);
1078
+ return;
1079
+ }
1080
+ const answer = q.options[n - 1].text || q.options[n - 1];
1081
+ await respondToQuestion(q.server, q.sessionId, q.questionId, answer);
1082
+ await sendLongMessage(token, chatState, `✅ Answered: ${answer}`);
1083
+ st.pendingQuestion = null;
1084
+ }
1085
+
1064
1086
  async function cmdThink(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1065
1087
  const st = getState(chatState.serverBase);
1066
1088
  const sub = args && args.length > 0 ? args[0].toLowerCase() : 'switch';
@@ -1331,6 +1353,7 @@ const COMMANDS = {
1331
1353
  'allow': cmdAllow,
1332
1354
  'reject': cmdReject,
1333
1355
  'always': cmdAlways,
1356
+ 'answer': cmdAnswer,
1334
1357
  };
1335
1358
 
1336
1359
  // --------------------------------------------------------------------------
@@ -1339,7 +1362,7 @@ const COMMANDS = {
1339
1362
 
1340
1363
  const serverState = {};
1341
1364
  function getState(url) {
1342
- return serverState[url] = serverState[url] || { busy: false, serverDead: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, thinkingEnabled: false, messageQueue: [] };
1365
+ return serverState[url] = serverState[url] || { busy: false, serverDead: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null, pendingQuestion: null, thinkingEnabled: false, messageQueue: [] };
1343
1366
  }
1344
1367
 
1345
1368
  function sessionLabel(st) {
@@ -1614,6 +1637,21 @@ async function main() {
1614
1637
  log.debug(` → permission_replied: clearing pendingPermission`);
1615
1638
  getState(url).pendingPermission = null;
1616
1639
  },
1640
+ onQuestion: (q) => {
1641
+ const st2 = getState(url);
1642
+ const qDump = JSON.stringify(q);
1643
+ log.debug(` → question_asked callback: ${qDump}`);
1644
+ st2.pendingQuestion = { server: url, sessionId, questionId: q.id, question: q.question || 'Unknown question', options: q.options || [] };
1645
+ const options = (q.options || []).map((opt, i) => `${i + 1}. ${opt.text || opt}`).join('\n');
1646
+ const msg = '❓ **Question from the AI**\n\n'
1647
+ + (q.question || 'Choose an option:') + '\n\n'
1648
+ + (options ? options + '\n\n' : '')
1649
+ + 'Use `/answer <number>` to respond.';
1650
+ log.debug(` → sending question notification to chat ${chatId}`);
1651
+ sendLongMessage(token, chatState, msg, replyTo, threadId)
1652
+ .then(() => log.debug(` → question notification sent OK`))
1653
+ .catch((err) => log.debug(` → question notification failed: ${err.message}`));
1654
+ },
1617
1655
  }, cancelRef).catch(() => {});
1618
1656
  await onConnected;
1619
1657
  answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName, sessionCancelRef);
@@ -1799,6 +1837,12 @@ async function main() {
1799
1837
  await sendLongMessage(token, chatState, '⏳ Server is busy. Use `/cancel` to cancel or note again later.');
1800
1838
  return;
1801
1839
  }
1840
+
1841
+ // Auto-switch tmux window to this chat's server
1842
+ const srvList = listServers();
1843
+ const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(url));
1844
+ if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
1845
+
1802
1846
  const intro = 'The following information is for your awareness. Reply with only "Ack" and do not take any action:';
1803
1847
  st.busy = true;
1804
1848
  const myTaskId = ++st.taskId;
@@ -1989,6 +2033,47 @@ async function main() {
1989
2033
  // Build reply context if this message is a Telegram reply
1990
2034
  const replyContext = quoteReply(msg);
1991
2035
 
2036
+ // Intercept //commands (raw TUI passthrough via tmux send-keys)
2037
+ if (text.startsWith('//')) {
2038
+ const tuiCmd = text.slice(1);
2039
+ if (!cs.serverBase) {
2040
+ await sendLongMessage(token, cs, '🔌 Not connected to any server.');
2041
+ continue;
2042
+ }
2043
+ const st = getState(cs.serverBase);
2044
+ if (st.busy) {
2045
+ await sendLongMessage(token, cs, '⏳ Server is busy. Use `/cancel` to cancel first.');
2046
+ continue;
2047
+ }
2048
+ const srvList = listServers();
2049
+ const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(cs.serverBase));
2050
+ if (!srvMatch) {
2051
+ await sendLongMessage(token, cs, '❌ Server not found in tmux.');
2052
+ continue;
2053
+ }
2054
+ activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
2055
+ const paneId = tuiPaneId(srvMatch.index);
2056
+ if (!paneId) {
2057
+ await sendLongMessage(token, cs, '❌ TUI pane not found.');
2058
+ continue;
2059
+ }
2060
+ st.busy = true;
2061
+ try {
2062
+ const answer = await executeTuiCommand(paneId, cs.serverBase, tuiCmd);
2063
+ if (answer && answer.trim()) {
2064
+ await sendLongMessage(token, cs, answer.trim());
2065
+ } else {
2066
+ await sendLongMessage(token, cs, `✅ \`${tuiCmd}\` submitted.`);
2067
+ }
2068
+ } catch (err) {
2069
+ log.error(`Error executing TUI command ${tuiCmd}: ${err.message}`);
2070
+ try { await sendLongMessage(token, cs, `Error executing \`${tuiCmd}\`: ${err.message}`); } catch (_) {}
2071
+ } finally {
2072
+ st.busy = false;
2073
+ }
2074
+ continue;
2075
+ }
2076
+
1992
2077
  // Intercept /commands
1993
2078
  if (text.startsWith('/')) {
1994
2079
  const parts = text.slice(1).split(' ');
@@ -2061,6 +2146,12 @@ async function main() {
2061
2146
  await sendLongMessage(token, cs, '⏳ Server is busy. Use `/cancel` to cancel or `/queue <message>` to queue your message.');
2062
2147
  continue;
2063
2148
  }
2149
+
2150
+ // Auto-switch tmux window to this chat's server
2151
+ const srvList = listServers();
2152
+ const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(cs.serverBase));
2153
+ if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
2154
+
2064
2155
  st.busy = true;
2065
2156
  const myTaskId = ++st.taskId;
2066
2157
  log.debug(` → started background processing (task ${myTaskId})`);
@@ -16,7 +16,7 @@ All tools share `lib/opencode.js` (HTTP session API client) and `lib/ocmux.js` (
16
16
 
17
17
  ### `package.json`
18
18
 
19
- **Version:** 0.13.0
19
+ **Version:** 0.11.2
20
20
 
21
21
  Fields:
22
22
  - `"bin"` — registers `agentp`, `ocmux`, `tgagentp`
@@ -117,6 +117,7 @@ Shared by `bin/agentp` and `bin/tgagentp`. Functions:
117
117
  | `sendToSession(server, id, text, agent?, cancelRef?)` | POST /session/:id/message | Synchronous message; optional abort |
118
118
  | `sendToSessionAsync(server, id, text, agent?)` | POST /session/:id/prompt_async | Non-blocking (204); answer via SSE |
119
119
  | `respondToPermission(server, id, permissionId, response)` | POST /session/:id/permissions/:id | Permission response |
120
+ | `respondToQuestion(server, id, questionId, answer)` | POST /session/:id/questions/:id | Question response |
120
121
  | `selectSession(server, id)` | POST /session/:id/select | TUI navigation |
121
122
  | `listAgents(server)` | GET /agent | Returns parsed array |
122
123
  | `listProviders(server)` | GET /provider | Returns parsed array |
@@ -342,6 +343,50 @@ Both command and non-command paths in the message loop check `cs.serverBase`:
342
343
 
343
344
  ---
344
345
 
346
+ ## `//command` TUI Passthrough
347
+
348
+ Messages starting with `//` are forwarded to the TUI as raw keystrokes (not AI text). The `//` prefix is stripped, a trailing space is appended to activate the TUI as-you-type menu, and `tmux send-keys` sends the keys to the TUI pane. An SSE listener connects before the Enter key to catch the AI response.
349
+
350
+ **Flow:**
351
+ 1. Chat message: `//init`
352
+ 2. Strips `//` → `init`
353
+ 3. Prepends `/` → `/init`
354
+ 4. Appends space → `/init ` (selects TUI menu item)
355
+ 5. `tmux send-keys` to TUI pane: `C-u` (clear line), type `/init `, Enter
356
+ 6. SSE listener starts before Enter (15s timeout)
357
+ 7. If AI responds: forwards full answer to Telegram
358
+ 8. If timeout: sends `✅ /init submitted.` confirmation
359
+
360
+ **Busy guard:** `//command` is blocked when `st.busy` is true (same "use /cancel" message as regular messages).
361
+
362
+ **Commands that trigger AI responses:** `//init`, `//clear`, `//history` (and any other TUI command that produces an AI answer). Quick commands like `/exit` timeout without an answer.
363
+
364
+ ---
365
+
366
+ ## Question Handling
367
+
368
+ When the AI asks a structured multiple-choice question (via `question.asked` SSE event), tgagentp forwards the question to Telegram with numbered options.
369
+
370
+ **Notification format:**
371
+ ```
372
+ ❓ **Question from the AI**
373
+
374
+ What would you like to test?
375
+
376
+ 1. Option A
377
+ 2. Option B
378
+ 3. Option C
379
+
380
+ Use /answer <number> to respond.
381
+ ```
382
+
383
+ **Response:**
384
+ - `/answer <number>` → `POST /session/:id/questions/:id` with the selected option's value
385
+ - Only one question can be pending at a time per server
386
+ - The question is stored per-server in `chatStates.pendingQuestion`
387
+
388
+ ---
389
+
345
390
  ## Logging Conventions (tgagentp)
346
391
 
347
392
  - **stdout** — informational messages (startup, discovery, session switches)
package/lib/opencode.js CHANGED
@@ -318,7 +318,7 @@ async function respondToPermission(server, sessionId, permissionId, response, re
318
318
 
319
319
  // Listen for session events via SSE, collecting assistant text parts and detecting
320
320
  // permission requests. Resolves with the full collected text when the session goes idle.
321
- // callbacks: { onText(chunk), onPermission(permission), onThinking(chunk), onConnected() }
321
+ // callbacks: { onText(chunk), onPermission(permission), onThinking(chunk), onConnected(), onQuestion(question) }
322
322
  // cancelRef — allows aborting the SSE stream via req.destroy()
323
323
  // logFn — optional logger; if provided, used instead of process.stderr.write
324
324
  function listenForSessionEvents(server, sessionId, callbacks, cancelRef, logFn) {
@@ -413,6 +413,18 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef, logFn)
413
413
  continue;
414
414
  }
415
415
 
416
+ if (event.type === 'question.asked') {
417
+ const qStr = JSON.stringify(props);
418
+ sseLog(` [SSE] QUESTION ASKED: ${qStr}\n`);
419
+ if (callbacks.onQuestion) {
420
+ sseLog(` [SSE] calling onQuestion callback\n`);
421
+ callbacks.onQuestion(props);
422
+ } else {
423
+ sseLog(` [SSE] WARNING: no onQuestion callback registered\n`);
424
+ }
425
+ continue;
426
+ }
427
+
416
428
  // Completion signals
417
429
  if (event.type === 'session.idle') {
418
430
  clearTimeout(safetyTimer);
@@ -510,6 +522,14 @@ function isServerAlive(url) {
510
522
  });
511
523
  }
512
524
 
525
+ // Respond to a question (choice) asked by the AI.
526
+ async function respondToQuestion(server, sessionId, questionId, answer) {
527
+ const url = `${server}/session/${encodeURIComponent(sessionId)}/questions/${encodeURIComponent(questionId)}`;
528
+ const body = JSON.stringify({ answer });
529
+ const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
530
+ if (status !== 200) throw new Error(`Failed to respond to question: ${status}`);
531
+ }
532
+
513
533
  module.exports = {
514
534
  getAuthHeaders,
515
535
  makeRequest,
@@ -519,6 +539,7 @@ module.exports = {
519
539
  submitPrompt,
520
540
  sendText,
521
541
  listenForFinalAnswer,
542
+ respondToQuestion,
522
543
  listSessions,
523
544
  getSession,
524
545
  createSession,
package/lib/tui-cmd.js ADDED
@@ -0,0 +1,30 @@
1
+ const child_process = require('child_process')
2
+ const { listenForFinalAnswer } = require('./opencode.js')
3
+
4
+ async function executeTuiCommand(paneId, server, command) {
5
+ const commandWithSpace = command + ' '
6
+ const cancelRef = { current: null }
7
+
8
+ const answerPromise = listenForFinalAnswer(server, null, cancelRef)
9
+
10
+ child_process.spawnSync('tmux', ['send-keys', '-t', paneId, 'C-u'])
11
+ child_process.spawnSync('tmux', ['send-keys', '-t', paneId, '-l', commandWithSpace])
12
+ child_process.spawnSync('tmux', ['send-keys', '-t', paneId, 'Enter'])
13
+
14
+ const timeoutMs = 15000
15
+ const timeoutPromise = new Promise((_, reject) => {
16
+ setTimeout(() => reject(new Error('timeout')), timeoutMs)
17
+ })
18
+
19
+ try {
20
+ return await Promise.race([answerPromise, timeoutPromise])
21
+ } catch {
22
+ if (cancelRef.current) {
23
+ cancelRef.current.destroy()
24
+ cancelRef.current = null
25
+ }
26
+ return null
27
+ }
28
+ }
29
+
30
+ module.exports = { executeTuiCommand }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",