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 +4 -0
- package/bin/ocmux +17 -2
- package/bin/tgagentp +376 -77
- package/lib/ocmux.js +8 -0
- package/lib/opencode.js +12 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
|
312
|
-
'Shows provider
|
|
313
|
-
'
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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)
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
1957
|
-
|
|
1958
|
-
|
|
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
|
|
2007
|
-
//
|
|
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, '
|
|
2147
|
-
log.
|
|
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
|
-
|
|
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
|
-
'
|
|
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 ? '
|
|
2160
|
-
await sendLongMessage(token, cs,
|
|
2161
|
-
|
|
2162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|