agentp 0.11.9 → 0.11.11

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
@@ -241,8 +241,12 @@ Non-text Telegram updates (photos, stickers, etc.) are silently ignored.
241
241
  | `/always` | Approve and remember for the session |
242
242
  | `/answer <number>` | Respond to a question asked by the AI (structured multiple-choice) |
243
243
  | `/markdown` | Send the original markdown of the last response as a `.md` file; reply to a message to get that specific response |
244
+ | `/agents list` | List available agents/models |
245
+ | `/agents switch <name>` | Switch the active agent for subsequent messages (synced to server via API) |
244
246
  | `/shutdown [force\|clear]` | (requires `--dev`) Stop tgagentp; `clear` also wipes saved connections |
245
247
 
248
+ > **Note on model/agent switching:** Changing the model in the OpenCode TUI's prompt dropdown is a **local UI action** — it only takes effect server-side after a message is sent *through the TUI*. If you change the model in the TUI and then send a prompt via `agentp` or Telegram, the old model will still be used. Use `/agents switch <name>` from Telegram (or `tgagentp`) to change models — this explicitly calls the API and syncs correctly.
249
+
246
250
  #### TUI Command Passthrough
247
251
 
248
252
  Messages starting with `//` are forwarded to the OpenCode TUI as raw keystrokes (not text sent to the AI):
package/bin/ocmux CHANGED
@@ -309,6 +309,8 @@ function cmdNew(targetDir, longMode, printLogs) {
309
309
  }
310
310
 
311
311
  function doNew(targetDir, longMode, printLogs) {
312
+ // Normalize to real path so tmux window names are consistent regardless of symlinks
313
+ try { targetDir = fs.realpathSync(targetDir); } catch {}
312
314
  ensureSession();
313
315
  const lf = logfileFor(targetDir);
314
316
 
@@ -601,13 +603,26 @@ function main() {
601
603
  console.error("Error: '--print-logs' is only valid with 'serve'.");
602
604
  process.exit(1);
603
605
  }
604
- // Default mode
605
- const sf = findParentStatefile(targetDir);
606
+ // Default mode — find an existing server and switch to it
607
+ let sf = findParentStatefile(targetDir);
606
608
  if (!sf) {
607
609
  console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
608
610
  console.error("Run 'ocmux serve' to create a new server.");
609
611
  process.exit(1);
610
612
  }
613
+
614
+ // Skip stale state files whose tmux window no longer exists,
615
+ // and search from the parent directory instead.
616
+ const state = readState(sf);
617
+ if (state && state.window_index != null) {
618
+ const serverDir = path.dirname(sf);
619
+ const actualName = windowNameByIndex(state.window_index);
620
+ if (actualName !== serverDir && windowByDir(serverDir) == null) {
621
+ const parentSf = findParentStatefile(path.dirname(serverDir));
622
+ if (parentSf) sf = parentSf;
623
+ }
624
+ }
625
+
611
626
  ensureSession();
612
627
  cmdSwitch(sf, longMode);
613
628
  }
package/bin/tgagentp CHANGED
@@ -4,7 +4,7 @@ const http = require('http');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { version } = require('../package.json');
7
- const { getAuthHeaders, sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, respondToQuestion, listenForSessionEvents, listAgents, listProviders, isServerAlive } = require('../lib/opencode.js');
7
+ const { getAuthHeaders, makeRequest, buildJsonRequest, sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, respondToQuestion, listenForSessionEvents, listAgents, listProviders, isServerAlive } = require('../lib/opencode.js');
8
8
 
9
9
  // Logger: info→stdout, error→stderr (always), debug→stderr (--verbose only)
10
10
  const log = {
@@ -308,9 +308,25 @@ async function cmdHelp(token, chatId, args, chatState) {
308
308
  }
309
309
  if (topic === 'models') {
310
310
  await sendLongMessage(token, chatState,
311
- '`/models` ─ list all providers and their models.\n' +
312
- 'Shows provider name, connection status, default model, and up to 5 models per provider\n' +
313
- 'with status, context limit, and input/output costs.');
311
+ '`/models` ─ list connected providers.\n' +
312
+ ' • Shows provider names with model count: `**OpenAI** (*5 models*)`\n' +
313
+ ' • The current provider is marked with ▶\n' +
314
+ ' • Includes hint to drill down: `/models <provider>`\n\n' +
315
+ '`/models <provider>` ─ list all models for a matching provider.\n' +
316
+ ' • Partial name/ID matching supported\n' +
317
+ ' • Current model marked with ▶\n' +
318
+ ' • Shows context limit and input/output costs in parentheses\n\n' +
319
+ 'Use `/model <providerID/modelID>` or `/model <modelID>` to switch the active session model.');
320
+ return;
321
+ }
322
+ if (topic === 'model') {
323
+ await sendLongMessage(token, chatState,
324
+ '`/model <providerID/modelID>` ─ switch the active session to a specific model.\n' +
325
+ '`/model <modelID>` ─ search all connected providers for a model by name.\n\n' +
326
+ 'Examples:\n' +
327
+ ' `/model opencode/big-pickle` ─ explicit provider/model\n' +
328
+ ' `/model gpt-4` ─ search across all providers\n\n' +
329
+ 'Use `/models` to see available providers and models.');
314
330
  return;
315
331
  }
316
332
  if (topic === 'allow' || topic === 'reject' || topic === 'always' || topic === 'answer') {
@@ -888,27 +904,124 @@ async function cmdModels(token, chatId, args, chatState, setServerForChat, setAc
888
904
  return;
889
905
  }
890
906
 
891
- const lines = [];
892
-
893
- for (const provider of filtered) {
894
- lines.push('');
895
- lines.push('**' + provider.name + '** ✅');
907
+ // Determine current model: session model first (the real in-use model,
908
+ // including manual /model TUI switches), then fall back to the agent's
909
+ // configured default model.
910
+ let currentProviderID = null;
911
+ let currentModelID = null;
912
+ const st = getState(server);
913
+ let activeSid = st.activeSessionId;
914
+ log.info(`[cmdModels] activeSessionId=${activeSid} activeAgentName=${st.activeAgentName}`);
915
+ // Auto-discover active session if not set
916
+ if (!activeSid) {
917
+ try {
918
+ const discovered = await discoverActiveSession(server);
919
+ if (discovered) {
920
+ st.activeSessionId = discovered.id;
921
+ st.activeSessionName = discovered.name;
922
+ activeSid = discovered.id;
923
+ log.info(`[cmdModels] auto-discovered session: ${discovered.name || discovered.id}`);
924
+ }
925
+ } catch (err) {
926
+ log.info(`[cmdModels] auto-discover error: ${err.message}`);
927
+ }
928
+ }
929
+ if (activeSid) {
930
+ try {
931
+ const sessions = await listSessions(server);
932
+ const session = sessions.find(s => String(s.id) === String(activeSid));
933
+ if (session && session.model) {
934
+ const m = session.model;
935
+ log.info(`[cmdModels] session model: ${JSON.stringify(m)}`);
936
+ if (typeof m === 'object' && m.providerID) {
937
+ currentProviderID = m.providerID;
938
+ currentModelID = m.modelID || m.id;
939
+ } else if (typeof m === 'string') {
940
+ const parts = m.split('/');
941
+ if (parts.length === 2) {
942
+ currentProviderID = parts[0];
943
+ currentModelID = parts[1];
944
+ }
945
+ }
946
+ } else {
947
+ log.info(`[cmdModels] session found=${!!session}, has model=${session && !!session.model}`);
948
+ }
949
+ } catch (err) {
950
+ log.info(`[cmdModels] listSessions error: ${err.message}`);
951
+ }
952
+ }
953
+ // Fallback: active agent's configured model
954
+ if (!currentModelID) {
955
+ const activeAgentName = st.activeAgentName;
956
+ if (activeAgentName) {
957
+ try {
958
+ const agents = await listAgents(server);
959
+ const activeAgent = agents.find(a => a.name === activeAgentName);
960
+ if (activeAgent && activeAgent.model) {
961
+ const am = activeAgent.model;
962
+ log.info(`[cmdModels] agent model: ${JSON.stringify(am)}`);
963
+ currentProviderID = am.providerID;
964
+ currentModelID = am.modelID || am.id;
965
+ } else {
966
+ log.info(`[cmdModels] agent found=${!!activeAgent}, has model=${activeAgent && !!activeAgent.model}`);
967
+ }
968
+ } catch (err) {
969
+ log.info(`[cmdModels] listAgents error: ${err.message}`);
970
+ }
971
+ }
972
+ }
973
+ log.info(`[cmdModels] result: provider=${currentProviderID} model=${currentModelID}`);
896
974
 
975
+ // /models <provider> — list all models for a matching provider
976
+ if (args.length > 0) {
977
+ const query = args.join(' ');
978
+ const matches = filtered.filter(p =>
979
+ p.name.toLowerCase().includes(query.toLowerCase()) ||
980
+ p.id.toLowerCase().includes(query.toLowerCase())
981
+ );
982
+ if (matches.length === 0) {
983
+ await sendLongMessage(token, chatState, `No provider matching "${query}".`);
984
+ return;
985
+ }
986
+ if (matches.length > 1) {
987
+ const list = matches.map(p => ' `' + p.name + '`').join('\n');
988
+ await sendLongMessage(token, chatState,
989
+ `Multiple matches for "${query}":\n${list}\n\nPlease be more specific.`);
990
+ return;
991
+ }
992
+ const provider = matches[0];
897
993
  const modelEntries = Object.entries(provider.models || {});
898
- if (modelEntries.length === 0) continue;
899
-
900
- const shown = modelEntries.slice(0, 5);
901
- for (const [id, model] of shown) {
902
- const ctx = model.limit ? Math.round(model.limit.context / 1000) + 'K' : '';
903
- const cost = model.cost ? ` $${model.cost.input}/${model.cost.output}` : '';
904
- lines.push(' `' + id + '`' + (ctx ? ' ' + ctx : '') + cost);
994
+ if (modelEntries.length === 0) {
995
+ await sendLongMessage(token, chatState,
996
+ '**' + provider.name + '** has no models configured.');
997
+ return;
905
998
  }
906
- if (modelEntries.length > 5) {
907
- lines.push(' *…and ' + (modelEntries.length - 5) + ' more*');
999
+ const lines = ['**' + provider.name + '** models:'];
1000
+ for (const [id, model] of modelEntries) {
1001
+ const isCurrent = currentProviderID === provider.id && currentModelID === id;
1002
+ const bullet = isCurrent ? '\u25b6 ' : '\u00b7 ';
1003
+ const extras = [];
1004
+ if (model.limit) extras.push(Math.round(model.limit.context / 1000) + 'K ctx');
1005
+ if (model.cost) extras.push(`$${model.cost.input}/$${model.cost.output}`);
1006
+ const suffix = extras.length > 0 ? ` (*${extras.join(', ')}*)` : '';
1007
+ lines.push(bullet + '`' + id + '`' + suffix);
908
1008
  }
1009
+ await sendLongMessage(token, chatState, lines.join('\n'));
1010
+ return;
909
1011
  }
910
1012
 
911
- await sendLongMessage(token, chatState, 'Connected providers:\n' + lines.join('\n'));
1013
+ // Default: list providers with model count per provider
1014
+ const lines = ['**Connected providers:**'];
1015
+ for (const provider of filtered) {
1016
+ const modelCount = Object.keys(provider.models || {}).length;
1017
+ const isCurrentProvider = currentProviderID === provider.id;
1018
+ const bullet = isCurrentProvider ? '\u25b6 ' : '\u00b7 ';
1019
+ lines.push(bullet + '**' + provider.name + '**' +
1020
+ (modelCount > 0 ? ` (*${modelCount} models*)` : ' *No models*'));
1021
+ }
1022
+ lines.push('');
1023
+ lines.push('\u{1f4a1} Use `/models <provider>` to list the models it provides.');
1024
+ await sendLongMessage(token, chatState, lines.join('\n'));
912
1025
  } catch (err) {
913
1026
  await sendLongMessage(token, chatState, `Error listing models: ${err.message}`);
914
1027
  }
@@ -1614,6 +1727,115 @@ async function cmdNew(token, chatId, args, chatState, setServerForChat, setActiv
1614
1727
  log.info(`[chat ${chatId}] Created ${targetDir} → ${url}`);
1615
1728
  }
1616
1729
 
1730
+ async function cmdModel(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat) {
1731
+ const server = chatState.serverBase;
1732
+ const st = getState(server);
1733
+ // Auto-discover active session if not set
1734
+ if (!st.activeSessionId) {
1735
+ try {
1736
+ const discovered = await discoverActiveSession(server);
1737
+ if (discovered) {
1738
+ st.activeSessionId = discovered.id;
1739
+ st.activeSessionName = discovered.name;
1740
+ }
1741
+ } catch (err) {
1742
+ log.info(`[cmdModel] auto-discover error: ${err.message}`);
1743
+ }
1744
+ }
1745
+ if (args.length === 0) {
1746
+ await sendLongMessage(token, chatState,
1747
+ 'Usage: `/model <providerID/modelID>` or `/model <modelID>` to search.\n' +
1748
+ 'Example: `/model opencode/big-pickle` or `/model gpt-4`');
1749
+ return;
1750
+ }
1751
+ if (!st.activeSessionId) {
1752
+ await sendLongMessage(token, chatState, 'No active session. Use `/sessions` to select one.');
1753
+ return;
1754
+ }
1755
+ const query = args[0];
1756
+ let providerID, modelID;
1757
+
1758
+ // Try "providerID/modelID" format first
1759
+ if (query.includes('/')) {
1760
+ const parts = query.split('/');
1761
+ const rawProviderID = parts[0];
1762
+ modelID = parts[1];
1763
+ providerID = rawProviderID;
1764
+
1765
+ // Validate provider ID exists, or try partial match (e.g. "go" → "opencode-go")
1766
+ try {
1767
+ const result = await listProviders(server);
1768
+ const all = result.all || [];
1769
+ const connectedIds = result.connected || [];
1770
+ const connected = all.filter(p => connectedIds.includes(p.id));
1771
+ const exists = connected.some(p => p.id === rawProviderID);
1772
+ if (!exists) {
1773
+ const match = connected.find(p =>
1774
+ (p.name && p.name.toLowerCase().includes(rawProviderID.toLowerCase())) ||
1775
+ (p.id && p.id.toLowerCase().includes(rawProviderID.toLowerCase()))
1776
+ );
1777
+ if (match) {
1778
+ providerID = match.id;
1779
+ log.info(`[cmdModel] resolved "${rawProviderID}" → "${match.id}"`);
1780
+ } else {
1781
+ await sendLongMessage(token, chatState,
1782
+ `Provider "${rawProviderID}" not found. Use \`/models\` to list available providers.`);
1783
+ return;
1784
+ }
1785
+ }
1786
+ // Verify model exists under this provider
1787
+ const provider = all.find(p => p.id === providerID);
1788
+ if (!provider || !provider.models || !provider.models[modelID]) {
1789
+ const availableModels = provider && provider.models ? Object.keys(provider.models).join(', ') : 'unknown';
1790
+ log.info(`[cmdModel] model "${modelID}" not found in ${providerID}, available: ${availableModels}`);
1791
+ await sendLongMessage(token, chatState,
1792
+ `Model "${modelID}" not found under provider \`${providerID}\`. Use \`/models\` to list available models.`);
1793
+ return;
1794
+ }
1795
+ } catch (err) {
1796
+ log.info(`[cmdModel] provider validation error: ${err.message}`);
1797
+ // Continue with what we have — may still work
1798
+ }
1799
+ } else {
1800
+ // Search across all connected providers to resolve to providerID/modelID
1801
+ try {
1802
+ const result = await listProviders(server);
1803
+ const all = result.all || [];
1804
+ const connectedIds = result.connected || [];
1805
+ const connected = all.filter(p => connectedIds.includes(p.id));
1806
+ for (const provider of connected) {
1807
+ const models = Object.keys(provider.models || {});
1808
+ const match = models.find(m => m.toLowerCase() === query.toLowerCase());
1809
+ if (match) {
1810
+ providerID = provider.id;
1811
+ modelID = match;
1812
+ break;
1813
+ }
1814
+ }
1815
+ } catch (err) {
1816
+ log.info(`[cmdModel] listProviders error: ${err.message}`);
1817
+ }
1818
+ if (!modelID) {
1819
+ await sendLongMessage(token, chatState,
1820
+ `No model matching "${query}" found in any connected provider. Use \`/models\` to list available models.`);
1821
+ return;
1822
+ }
1823
+ }
1824
+
1825
+ // Send an async no-reply message with the desired model.
1826
+ // This triggers createUserMessage which fires ModelSwitched when the model differs,
1827
+ // persisting it as the session's current model.
1828
+ try {
1829
+ await sendToSessionAsync(server, st.activeSessionId, '.', null, { providerID, modelID }, true);
1830
+ const label = providerID ? `\`${providerID}/${modelID}\`` : `\`${modelID}\``;
1831
+ await sendLongMessage(token, chatState,
1832
+ `✅ Switched to model ${label}.`);
1833
+ log.info(`[chat ${chatId}] Switched to model ${providerID || '?'}/${modelID}`);
1834
+ } catch (err) {
1835
+ await sendLongMessage(token, chatState, `Error switching model: ${err.message}`);
1836
+ }
1837
+ }
1838
+
1617
1839
  async function cmdMarkdown(token, chatId, args, chatState, setServerForChat, setActiveSessionForChat, replyTo, replyToMsgId) {
1618
1840
  let entry = null;
1619
1841
  if (replyToMsgId) {
@@ -1642,6 +1864,7 @@ const COMMANDS = {
1642
1864
  'session': cmdSessions,
1643
1865
  'agents': cmdAgents,
1644
1866
  'models': cmdModels,
1867
+ 'model': cmdModel,
1645
1868
  'think': cmdThink,
1646
1869
  'record': cmdRecord,
1647
1870
  'resurrect': cmdResurrect,
@@ -1851,6 +2074,7 @@ async function main() {
1851
2074
  if (srvDiscovered) {
1852
2075
  getState(url).activeSessionId = srvDiscovered.id;
1853
2076
  getState(url).activeSessionName = srvDiscovered.name;
2077
+ startSessionMonitor(url, srvDiscovered.id);
1854
2078
  log.info(` → session: ${srvDiscovered.name || srvDiscovered.id}`);
1855
2079
  }
1856
2080
  const defaultSrvAgent = srvAgents.find(a => a.mode === 'primary') || srvAgents[0];
@@ -1872,7 +2096,10 @@ async function main() {
1872
2096
  getState(server).activeSessionId = id;
1873
2097
  if (name) getState(server).activeSessionName = name;
1874
2098
  log.info(`[chat ${chatState.chatId}] Switched to session: ${name || id}`);
1875
- if (id !== prevId) sendGreeting(chatState);
2099
+ if (id !== prevId) {
2100
+ sendGreeting(chatState);
2101
+ startSessionMonitor(server, id);
2102
+ }
1876
2103
  };
1877
2104
 
1878
2105
  const TELEGRAM_GREETING_BODY = (sessionName) =>
@@ -1891,6 +2118,90 @@ async function main() {
1891
2118
  'For more details about a command:\n' +
1892
2119
  '[Telegram]{"command":"help","topic":"<upload|download|help>"}';
1893
2120
 
2121
+ /**
2122
+ * Start a persistent SSE session monitor that catches question.asked,
2123
+ * permission_asked, and permission_replied events in the background,
2124
+ * even when the AI was triggered by agentp (--tg) rather than by tgagentp.
2125
+ * Automatically restarted when the session or server changes.
2126
+ */
2127
+ function startSessionMonitor(server, sessionId) {
2128
+ const st = getState(server);
2129
+ if (st._monitorRef && st._monitorRef.current) {
2130
+ st._monitorRef.current.destroy();
2131
+ st._monitorRef.current = null;
2132
+ }
2133
+ const monRef = { current: null };
2134
+ st._monitorRef = monRef;
2135
+ st._monitorStarted = true;
2136
+
2137
+ const notifiedPerms = new Set();
2138
+
2139
+ listenForSessionEvents(server, sessionId, {
2140
+ onText: null,
2141
+ onConnected: null,
2142
+ onThinking: null,
2143
+ onPermission: (perm) => {
2144
+ const st2 = getState(server);
2145
+ const permDump = JSON.stringify(perm);
2146
+ log.debug(` [monitor] -> permission_asked callback: ${permDump}`);
2147
+ st2.pendingPermission = { server, sessionId, permissionId: perm.id, type: perm.permission || 'unspecified', pattern: perm.patterns || perm.pattern, title: perm.title };
2148
+ const ownerChat = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === normalizeUrl(server))?.[1];
2149
+ if (ownerChat && !notifiedPerms.has(perm.id)) {
2150
+ notifiedPerms.add(perm.id);
2151
+ const toolStr = perm.permission || 'unspecified';
2152
+ const patternArr = perm.patterns || (perm.pattern ? [perm.pattern] : []);
2153
+ const patternStr = patternArr.join(', ');
2154
+ const msg = '\u{1f510}**Permission needed**\n'
2155
+ + '**Tool:** `' + toolStr + '`\n'
2156
+ + (patternStr ? '**Target:** `' + patternStr + '`\n' : '')
2157
+ + (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
2158
+ + 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
2159
+ log.debug(` [monitor] -> sending permission notification to chat ${ownerChat.chatId}`);
2160
+ sendLongMessage(token, { chatId: ownerChat.chatId, threadId: ownerChat.threadId }, msg)
2161
+ .then(() => log.debug(` [monitor] -> permission notification sent OK`))
2162
+ .catch((err) => log.debug(` [monitor] -> permission notification failed: ${err.message}`));
2163
+ } else if (ownerChat) {
2164
+ log.debug(` [monitor] -> permission ${perm.id} already notified, updating pendingPermission only`);
2165
+ } else {
2166
+ log.debug(` [monitor] -> no owner chat found for server ${server}, skipping notification`);
2167
+ }
2168
+ },
2169
+ onPermissionReplied: () => {
2170
+ log.debug(` [monitor] -> permission_replied: clearing pendingPermission`);
2171
+ getState(server).pendingPermission = null;
2172
+ },
2173
+ onQuestion: (q) => {
2174
+ const st2 = getState(server);
2175
+ const qDump = JSON.stringify(q);
2176
+ log.debug(` [monitor] -> question_asked callback: ${qDump}`);
2177
+ st2.pendingQuestion = { server, sessionId, questionId: q.id, question: q.question || 'Unknown question', options: q.options || [] };
2178
+ const ownerChat = Object.entries(serverOwners).find(([url]) => normalizeUrl(url) === normalizeUrl(server))?.[1];
2179
+ if (ownerChat) {
2180
+ const options = (q.options || []).map((opt, i) => `${i + 1}. ${opt.text || opt}`).join('\n');
2181
+ const msg = '\u2753**Question from the AI**\n\n'
2182
+ + (q.question || 'Choose an option:') + '\n\n'
2183
+ + (options ? options + '\n\n' : '')
2184
+ + 'Use `/answer <number>` to respond.';
2185
+ log.debug(` [monitor] -> sending question notification to chat ${ownerChat.chatId}`);
2186
+ sendLongMessage(token, { chatId: ownerChat.chatId, threadId: ownerChat.threadId }, msg)
2187
+ .then(() => log.debug(` [monitor] -> question notification sent OK`))
2188
+ .catch((err) => log.debug(` [monitor] -> question notification failed: ${err.message}`));
2189
+ } else {
2190
+ log.debug(` [monitor] -> no owner chat found for server ${server}, skipping question notification`);
2191
+ }
2192
+ },
2193
+ }, monRef).then(() => {
2194
+ // Normal completion (90s safety timeout or stream ended)
2195
+ }, (err) => {
2196
+ log.debug(` [monitor] SSE listener for session ${sessionId} ended: ${err.message}`);
2197
+ }).finally(() => {
2198
+ if (getState(server)._monitorRef === monRef) {
2199
+ getState(server)._monitorRef = null;
2200
+ getState(server)._monitorStarted = false;
2201
+ }
2202
+ });
2203
+ }
2204
+
1894
2205
  async function sendGreeting(chatState) {
1895
2206
  const st = getState(chatState.serverBase);
1896
2207
  if (!st.activeSessionId) {
@@ -1928,10 +2239,17 @@ async function main() {
1928
2239
  if (st._sessionReq && st._sessionReq.current) { st._sessionReq.current.destroy(); st._sessionReq.current = null; }
1929
2240
  st._cancelRef = cancelRef;
1930
2241
  st._sessionReq = sessionCancelRef;
2242
+
2243
+ // Ensure the persistent session monitor is running so question/permission
2244
+ // events from any source (agentp, tgagentp, TUI) are caught, not just
2245
+ // those triggered by this message's per-request SSE listener.
2246
+ if (useSession && sessionId && !st._monitorStarted) {
2247
+ startSessionMonitor(url, sessionId);
2248
+ }
2249
+
1931
2250
  try {
1932
2251
  let answer;
1933
2252
  if (useSession) {
1934
- const notifiedPerms = new Set();
1935
2253
  let sseConnected;
1936
2254
  const onConnected = Promise.race([
1937
2255
  new Promise((resolve) => { sseConnected = resolve; }),
@@ -1953,48 +2271,9 @@ async function main() {
1953
2271
  .catch(() => {});
1954
2272
  }
1955
2273
  },
1956
- onPermission: (perm) => {
1957
- const st2 = getState(url);
1958
- const permDump = JSON.stringify(perm);
1959
- log.debug(` → permission_asked callback: ${permDump}`);
1960
- st2.pendingPermission = { server: url, sessionId, permissionId: perm.id, type: perm.permission || 'unspecified', pattern: perm.patterns || perm.pattern, title: perm.title };
1961
- if (!notifiedPerms.has(perm.id)) {
1962
- notifiedPerms.add(perm.id);
1963
- const toolStr = perm.permission || 'unspecified';
1964
- const patternArr = perm.patterns || (perm.pattern ? [perm.pattern] : []);
1965
- const patternStr = patternArr.join(', ');
1966
- const msg = '🔐 **Permission needed**\n'
1967
- + '**Tool:** `' + toolStr + '`\n'
1968
- + (patternStr ? '**Target:** `' + patternStr + '`\n' : '')
1969
- + (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
1970
- + 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
1971
- log.debug(` → sending permission notification to chat ${chatId}`);
1972
- sendLongMessage(token, chatState, msg, replyTo, threadId)
1973
- .then(() => log.debug(` → permission notification sent OK`))
1974
- .catch((err) => log.debug(` → permission notification failed: ${err.message}`));
1975
- } else {
1976
- log.debug(` → permission ${perm.id} already notified, updating pendingPermission only`);
1977
- }
1978
- },
1979
- onPermissionReplied: () => {
1980
- log.debug(` → permission_replied: clearing pendingPermission`);
1981
- getState(url).pendingPermission = null;
1982
- },
1983
- onQuestion: (q) => {
1984
- const st2 = getState(url);
1985
- const qDump = JSON.stringify(q);
1986
- log.debug(` → question_asked callback: ${qDump}`);
1987
- st2.pendingQuestion = { server: url, sessionId, questionId: q.id, question: q.question || 'Unknown question', options: q.options || [] };
1988
- const options = (q.options || []).map((opt, i) => `${i + 1}. ${opt.text || opt}`).join('\n');
1989
- const msg = '❓ **Question from the AI**\n\n'
1990
- + (q.question || 'Choose an option:') + '\n\n'
1991
- + (options ? options + '\n\n' : '')
1992
- + 'Use `/answer <number>` to respond.';
1993
- log.debug(` → sending question notification to chat ${chatId}`);
1994
- sendLongMessage(token, chatState, msg, replyTo, threadId)
1995
- .then(() => log.debug(` → question notification sent OK`))
1996
- .catch((err) => log.debug(` → question notification failed: ${err.message}`));
1997
- },
2274
+ // onPermission, onPermissionReplied, and onQuestion are handled by
2275
+ // the persistent session monitor (startSessionMonitor) so they are
2276
+ // caught even when agentp (--tg) triggers the AI response.
1998
2277
  }, cancelRef).catch(() => {});
1999
2278
  await onConnected;
2000
2279
  answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName, sessionCancelRef);
@@ -2003,8 +2282,8 @@ async function main() {
2003
2282
  sendLongMessage(token, chatState, html, replyTo, threadId).catch(() => {});
2004
2283
  st._thinkingBuf = '';
2005
2284
  }
2006
- // Keep the SSE listener alive so subsequent permission/question events
2007
- // that arrive after sendToSession returns are still forwarded to Telegram.
2285
+ // Keep the per-request SSE listener alive for streaming text events.
2286
+ // Questions and permissions are handled by the persistent monitor.
2008
2287
  } else {
2009
2288
  await sendText(url, `[Telegram] ${text}`);
2010
2289
  answer = await listenForFinalAnswer(url, null, cancelRef);
@@ -2143,24 +2422,32 @@ async function main() {
2143
2422
  // Handle /shutdown clear — wipe saved connections before exiting
2144
2423
  if (args && args.length > 0 && args[0].toLowerCase() === 'clear') {
2145
2424
  clearConnections();
2146
- await sendLongMessage(token, cs, '🧹 Connection state cleared.');
2147
- log.debug('/shutdown clear received, exiting...');
2148
- try { await getUpdates(token, offset, 1); } catch (_) {}
2425
+ await sendLongMessage(token, cs, '\u{1f9f9} Connection state cleared.').catch(() => {});
2426
+ log.info('/shutdown clear: exiting');
2149
2427
  process.exit(0);
2150
2428
  }
2151
2429
 
2152
- const isForce = args && args.length > 0 && args[0].toLowerCase() === 'force';
2430
+ // Parse numeric exit code from args (default 0). Uses regex /^\d+$/
2431
+ // to robustly match only pure-digit arguments.
2432
+ let exitCode = 0;
2433
+ for (const arg of (args || [])) {
2434
+ if (/^\d+$/.test(arg)) {
2435
+ exitCode = parseInt(arg, 10);
2436
+ break;
2437
+ }
2438
+ }
2439
+
2440
+ const isForce = args && args.some(a => a.toLowerCase() === 'force');
2153
2441
  if (st.busy && !isForce) {
2154
2442
  await sendLongMessage(token, cs,
2155
- '⏳ Server is busy processing a message.\n' +
2156
- 'Use `/shutdown force` to shut down anyway (in-flight response will be lost).');
2443
+ '\u23f3 Server is busy processing a message.\n' +
2444
+ 'Use `/shutdown force` to shut down anyway (in-flight response will be lost).').catch(() => {});
2157
2445
  return;
2158
2446
  }
2159
- const status = st.busy ? '⏳ busy (forced)' : '✅ idle';
2160
- await sendLongMessage(token, cs, `👋 Shutting down (state: ${status})...`);
2161
- try { await getUpdates(token, offset, 1); } catch (_) {}
2162
- log.debug('/shutdown received, exiting...');
2163
- process.exit(0);
2447
+ const status = st.busy ? '\u23f3 busy (forced)' : '\u2705 idle';
2448
+ await sendLongMessage(token, cs, `\u{1f44b} Shutting down (state: ${status}, exit ${exitCode})...`).catch(() => {});
2449
+ log.info(`/shutdown: exiting with code ${exitCode}`);
2450
+ process.exit(exitCode);
2164
2451
  };
2165
2452
 
2166
2453
  const cmdNote = async (token, chatId, cmdArgs, chatState) => {
@@ -2335,6 +2622,18 @@ async function main() {
2335
2622
  // the key changes — new messages from newId will hit a new state)
2336
2623
  }
2337
2624
 
2625
+ // Drain stale updates from previous sessions before entering the poll loop.
2626
+ // Using timeout=1 to return immediately without long-polling.
2627
+ try {
2628
+ const drainResult = await getUpdates(token, 0, 1);
2629
+ if (drainResult && drainResult.ok && drainResult.result) {
2630
+ for (const u of drainResult.result) {
2631
+ offset = Math.max(offset, u.update_id + 1);
2632
+ }
2633
+ log.info(`Drained ${drainResult.result.length} stale update(s), offset ${offset}`);
2634
+ }
2635
+ } catch (_) {}
2636
+
2338
2637
  // Poll for updates and forward each text message to OpenCode, sending the answer back.
2339
2638
  while (true) {
2340
2639
  let result;
@@ -2592,7 +2891,7 @@ async function main() {
2592
2891
  }
2593
2892
  } else {
2594
2893
  await sendLongMessage(token, cs,
2595
- `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /think, /record, /queue, /flush, /note, /comment, /disconnect, /serve, /new, /markdown`);
2894
+ `Unknown command: /${cmd}\n\nAvailable commands: /help, /status, /servers, /sessions, /agents, /models, /model, /think, /record, /queue, /flush, /note, /comment, /disconnect, /serve, /new, /markdown`);
2596
2895
  }
2597
2896
  continue;
2598
2897
  }
package/lib/ocmux.js CHANGED
@@ -63,11 +63,16 @@ function tuiPaneId(windowIndex) {
63
63
  }
64
64
 
65
65
  function windowByDir(dir) {
66
+ let realDir;
67
+ try { realDir = fs.realpathSync(dir); } catch { realDir = dir; }
66
68
  const r = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
67
69
  if (r.status !== 0) return null;
68
70
  for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
69
71
  const [idx, name] = line.split('\t');
70
72
  if (name === dir) return parseInt(idx, 10);
73
+ try {
74
+ if (fs.realpathSync(name) === realDir) return parseInt(idx, 10);
75
+ } catch {}
71
76
  }
72
77
  return null;
73
78
  }
@@ -216,6 +221,7 @@ function activateServer(dir, index, url) {
216
221
  // Delegates to startServer for the actual server creation.
217
222
  // Returns { url, dir } on success, throws on error.
218
223
  function resurrectServer(dir, printLogs) {
224
+ try { dir = fs.realpathSync(dir); } catch {}
219
225
  const sf = path.join(dir, '.ocmux.json');
220
226
  const state = readState(sf);
221
227
 
@@ -249,6 +255,8 @@ function resurrectServer(dir, printLogs) {
249
255
  // Creates a tmux window, starts opencode serve, waits for the URL,
250
256
  // and writes .ocmux.json. Returns { url, dir } on success.
251
257
  function startServer(dir, printLogs) {
258
+ // Normalize to real path so tmux window names are consistent regardless of symlinks
259
+ try { dir = fs.realpathSync(dir); } catch {}
252
260
  ensureSession();
253
261
  const lf = logfileFor(dir);
254
262
 
package/lib/opencode.js CHANGED
@@ -217,11 +217,17 @@ async function createSession(server, title) {
217
217
  return JSON.parse(responseBody);
218
218
  }
219
219
 
220
- // Update a session's properties (e.g. title).
221
- async function updateSession(server, sessionId, title, agent) {
220
+ // Update a session's properties (e.g. title, agent, model).
221
+ // model can be a string "providerID/modelID" or an object { providerID, modelID }.
222
+ async function updateSession(server, sessionId, title, agent, model) {
222
223
  const bodyObj = {};
223
224
  if (title != null) bodyObj.title = title;
224
225
  if (agent != null) bodyObj.agent = agent;
226
+ if (model != null) {
227
+ bodyObj.model = typeof model === 'string'
228
+ ? model
229
+ : { providerID: model.providerID, id: model.modelID || model.id };
230
+ }
225
231
  const url = `${server}/session/${encodeURIComponent(sessionId)}`;
226
232
  const body = JSON.stringify(bodyObj);
227
233
  const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'PATCH', body), body);
@@ -297,10 +303,13 @@ async function listProviders(server) {
297
303
  }
298
304
 
299
305
  // Send a message asynchronously (non-blocking). Returns 204 No Content.
300
- async function sendToSessionAsync(server, sessionId, text, agent) {
306
+ // Optionally specify a model and/or noReply (injects context without AI response).
307
+ async function sendToSessionAsync(server, sessionId, text, agent, model, noReply) {
301
308
  const url = `${server}/session/${encodeURIComponent(sessionId)}/prompt_async`;
302
309
  const bodyObj = { parts: [{ type: 'text', text }] };
303
310
  if (agent) bodyObj.agent = agent;
311
+ if (model) bodyObj.model = typeof model === 'string' ? model : { providerID: model.providerID, modelID: model.modelID };
312
+ if (noReply) bodyObj.noReply = true;
304
313
  const body = JSON.stringify(bodyObj);
305
314
  const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
306
315
  if (status !== 204) throw new Error(`Failed to send async message: ${status}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.11.9",
3
+ "version": "0.11.11",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",