agentp 0.11.8 → 0.11.10

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
@@ -233,29 +233,20 @@ Non-text Telegram updates (photos, stickers, etc.) are silently ignored.
233
233
  | Command | Action |
234
234
  |---|---|
235
235
  | `/help [topic]` | Show general help or help for a topic (`servers`, `sessions`, `agents`, `models`, `allow`, `think`, `record`, `queue`) |
236
- | `/servers` | List all running ocmux-served projects with URL + status (✅ idle / ⏳ busy) |
236
+ | `/servers` | List all ocmux-served projects (▶ active, 🔌 disconnected, 💀 dead) |
237
237
  | `/servers switch <name> [--force]` | Switch active server; matches by full path, basename, or substring; `--force` takes over from another chat |
238
- | `/serve <path>` | Start a server in an existing directory under `TGAGENTP_ROOT` |
239
- | `/new <path>` | Create a directory, init git, and start a server under `TGAGENTP_ROOT` |
240
- | `/sessions` | List recent sessions for the current server (max 50, with date headings) |
241
- | `/sessions switch <number\|name>` | Switch active session by position or partial name match |
242
- | `/agents` | List primary agents (▶ marker for the active one) |
243
- | `/agents switch <name>` | Switch active agent — persists on session and refreshes TUI |
244
- | `/models` | List connected providers with model counts, context limits, and costs |
245
- | `/status` | Show current server path, URL, busy status, and active session |
246
- | `/cancel` | Cancel the current AI response for the active server |
247
- | `/think [on\|off\|switch]` | Toggle forwarding of model thinking messages to the chat |
248
- | `/record [stop]` | Toggle recording of Telegram conversation for agentp context; `/record stop` clears and stops |
249
- | `/queue <message>` | Queue a message when the server is busy; auto-sent when current task finishes |
250
- | `/flush` | Clear all queued messages (manual and auto-queued) |
251
- | `/resurrect` | Recover a dead server — restart processes in the same directory, reconnect chat |
238
+ | `/resurrect [path]` | Restart a crashed server from its `.ocmux.json`; accepts optional directory path |
252
239
  | `/allow` | Approve a permission request once |
253
240
  | `/reject` | Deny a permission request |
254
241
  | `/always` | Approve and remember for the session |
255
242
  | `/answer <number>` | Respond to a question asked by the AI (structured multiple-choice) |
256
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) |
257
246
  | `/shutdown [force\|clear]` | (requires `--dev`) Stop tgagentp; `clear` also wipes saved connections |
258
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
+
259
250
  #### TUI Command Passthrough
260
251
 
261
252
  Messages starting with `//` are forwarded to the OpenCode TUI as raw keystrokes (not text sent to the AI):
package/bin/ocmux CHANGED
@@ -22,6 +22,7 @@ const {
22
22
  logfileFor,
23
23
  URL_RE,
24
24
  resurrectServer,
25
+ startServer,
25
26
  getActiveServer,
26
27
  } = require('../lib/ocmux');
27
28
 
@@ -308,6 +309,8 @@ function cmdNew(targetDir, longMode, printLogs) {
308
309
  }
309
310
 
310
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 {}
311
314
  ensureSession();
312
315
  const lf = logfileFor(targetDir);
313
316
 
@@ -332,7 +335,7 @@ function doNew(targetDir, longMode, printLogs) {
332
335
  try { fs.truncateSync(lf); } catch { /* may not exist yet */ }
333
336
 
334
337
  // Send the command to the interactive shell (like typing it manually)
335
- const serveFlags = printLogs ? '--port 0 --print-logs' : '--port 0';
338
+ const serveFlags = printLogs ? '--print-logs' : '';
336
339
  tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`,
337
340
  `opencode serve ${serveFlags} 2>&1 | tee '${lf}'`, 'Enter']);
338
341
 
@@ -600,13 +603,26 @@ function main() {
600
603
  console.error("Error: '--print-logs' is only valid with 'serve'.");
601
604
  process.exit(1);
602
605
  }
603
- // Default mode
604
- const sf = findParentStatefile(targetDir);
606
+ // Default mode — find an existing server and switch to it
607
+ let sf = findParentStatefile(targetDir);
605
608
  if (!sf) {
606
609
  console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
607
610
  console.error("Run 'ocmux serve' to create a new server.");
608
611
  process.exit(1);
609
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
+
610
626
  ensureSession();
611
627
  cmdSwitch(sf, longMode);
612
628
  }
package/bin/tgagentp CHANGED
@@ -18,7 +18,7 @@ const TGAGENTP_PORT = parseInt(process.env.TGAGENTP_PORT || '0', 10);
18
18
  const PORT_FILE = '/tmp/tgagentp-port';
19
19
  const TGAGENTP_DEBOUNCE_MS = parseInt(process.env.TGAGENTP_DEBOUNCE_MS || '5000', 10);
20
20
  const CONNECTIONS_FILE = '/tmp/tgagentp-connections.json';
21
- const { listServers, activateServer, resurrectServer, statefileFor, readState, tuiPaneId } = require('../lib/ocmux.js');
21
+ const { listServers, activateServer, resurrectServer, statefileFor, readState, tuiPaneId, serverStatus, listDeadServers } = require('../lib/ocmux.js');
22
22
  const { executeTuiCommand } = require('../lib/tui-cmd.js');
23
23
  const { getUpdates, downloadTelegramFile, sendTelegramDocument } = require('../lib/telegram-api.js');
24
24
  const { sendLongMessage } = require('../lib/telegram-format.js');
@@ -559,14 +559,21 @@ async function cmdServers(token, chatId, args, chatState, setServerForChat, setA
559
559
  return;
560
560
  }
561
561
 
562
- // Default: list servers
562
+ // Default: list servers (alive + dead)
563
563
  const servers = listServers();
564
- if (servers.length === 0) {
565
- await sendLongMessage(token, chatState, 'No servers running.');
564
+ // Also find dead servers from saved connections
565
+ let savedData;
566
+ try { savedData = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8')); } catch {}
567
+ const knownDirs = savedData && Array.isArray(savedData.connections)
568
+ ? savedData.connections.map(c => c.dir).filter(Boolean) : [];
569
+ const dead = listDeadServers(knownDirs);
570
+ const all = [...servers, ...dead.map(d => ({ url: d.url, dir: d.dir, index: d.windowIndex, status: 'dead' }))];
571
+ if (all.length === 0) {
572
+ await sendLongMessage(token, chatState, 'No servers found.');
566
573
  return;
567
574
  }
568
575
  const activeUrl = chatState.serverBase ? normalizeUrl(chatState.serverBase) : null;
569
- const lines = servers.map(s => {
576
+ const lines = all.map(s => {
570
577
  const normalizedUrl = normalizeUrl(s.url);
571
578
  const isActive = activeUrl && normalizedUrl === activeUrl;
572
579
  const isOwned = Object.keys(serverOwners).some(url => normalizeUrl(url) === normalizedUrl);
@@ -575,12 +582,14 @@ async function cmdServers(token, chatId, args, chatState, setServerForChat, setA
575
582
  bullet = '▶ ';
576
583
  } else if (isOwned) {
577
584
  bullet = ' · ';
585
+ } else if (s.status === 'dead') {
586
+ bullet = '💀 ';
578
587
  } else {
579
588
  bullet = '🔌 ';
580
589
  }
581
590
  return bullet + `${s.url} → ${s.dir} (${s.status})`;
582
591
  });
583
- const prefix = chatState.serverBase ? 'Active servers:' : 'Available servers:';
592
+ const prefix = chatState.serverBase ? 'All servers:' : 'Available servers:';
584
593
  await sendLongMessage(token, chatState, prefix + '\n' + lines.join('\n'));
585
594
  }
586
595
 
@@ -1363,31 +1372,53 @@ async function cmdComment(token, chatId, args, chatState) {
1363
1372
  }
1364
1373
 
1365
1374
  async function cmdResurrect(token, chatId, args, chatState) {
1366
- const url = chatState.serverBase;
1367
- if (!url) {
1368
- await sendLongMessage(token, chatState, '🔌 Not connected to any server. Nothing to resurrect.');
1369
- return;
1370
- }
1371
-
1372
- let dir = serverDir(url);
1373
- if (!dir) {
1374
- // Fallback: look up directory from saved connections
1375
- try {
1376
- const savedData = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8'));
1377
- if (savedData && Array.isArray(savedData.connections)) {
1378
- const match = savedData.connections.find(c =>
1379
- String(c.chatId) === String(chatId) && (c.threadId || null) === (chatState.threadId || null)
1380
- );
1381
- if (match && match.dir) dir = match.dir;
1375
+ let dir = null;
1376
+ let url = null;
1377
+ const dirArg = args && args.length > 0 ? args.join(' ') : null;
1378
+ if (dirArg) {
1379
+ // User specified a directory: resolve it
1380
+ if (TGAGENTP_ROOT) {
1381
+ const resolved = path.resolve(TGAGENTP_ROOT, dirArg);
1382
+ if (resolved.startsWith(TGAGENTP_ROOT) && fs.existsSync(resolved)) {
1383
+ dir = resolved;
1382
1384
  }
1383
- } catch {}
1384
- }
1385
- if (!dir) {
1386
- await sendLongMessage(token, chatState,
1387
- 'Could not find the directory for this server.\n' +
1388
- 'The tmux window may have been removed.\n' +
1389
- 'Use `ocmux resurrect` from the terminal to recover it.');
1390
- return;
1385
+ }
1386
+ if (!dir) {
1387
+ // Resolve against home or absolute path
1388
+ const resolved = path.resolve(dirArg);
1389
+ if (fs.existsSync(resolved)) dir = resolved;
1390
+ }
1391
+ if (!dir) {
1392
+ await sendLongMessage(token, chatState, `❌ Directory not found: \`${dirArg}\``);
1393
+ return;
1394
+ }
1395
+ // Read the old URL from the state file
1396
+ const state = readState(statefileFor(dir));
1397
+ url = state ? state.url : null;
1398
+ } else {
1399
+ url = chatState.serverBase;
1400
+ if (!url) {
1401
+ await sendLongMessage(token, chatState, '🔌 Not connected to any server. Specify a directory or switch to a server first.');
1402
+ return;
1403
+ }
1404
+ dir = serverDir(url);
1405
+ if (!dir) {
1406
+ try {
1407
+ const savedData = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8'));
1408
+ if (savedData && Array.isArray(savedData.connections)) {
1409
+ const match = savedData.connections.find(c =>
1410
+ String(c.chatId) === String(chatId) && (c.threadId || null) === (chatState.threadId || null)
1411
+ );
1412
+ if (match && match.dir) dir = match.dir;
1413
+ }
1414
+ } catch {}
1415
+ }
1416
+ if (!dir) {
1417
+ await sendLongMessage(token, chatState,
1418
+ 'Could not find the directory for this server.\n' +
1419
+ 'Use `ocmux resurrect` from the terminal or specify a directory: `/resurrect <path>`.');
1420
+ return;
1421
+ }
1391
1422
  }
1392
1423
 
1393
1424
  await sendLongMessage(token, chatState,
@@ -1486,9 +1517,16 @@ async function cmdServe(token, chatId, args, chatState, setServerForChat, setAct
1486
1517
  }
1487
1518
  const sf = statefileFor(targetDir);
1488
1519
  if (fs.existsSync(sf)) {
1489
- await sendLongMessage(token, chatState,
1490
- `⚠️ Server already exists for \`${relPath}\`.\n` +
1491
- 'Use `/servers switch <name>` to connect to it.');
1520
+ const status = serverStatus(targetDir);
1521
+ if (status && status.status === 'dead') {
1522
+ await sendLongMessage(token, chatState,
1523
+ `💀 A dead server exists at \`${relPath}\` (${status.url}).\n` +
1524
+ `Use \`/resurrect ${relPath}\` to restart it.`);
1525
+ } else {
1526
+ await sendLongMessage(token, chatState,
1527
+ `⚠️ Server already exists for \`${relPath}\`.\n` +
1528
+ 'Use `/servers switch <name>` to connect to it.');
1529
+ }
1492
1530
  return;
1493
1531
  }
1494
1532
  await sendLongMessage(token, chatState,
@@ -2020,7 +2058,7 @@ async function main() {
2020
2058
  const msg = isConnError
2021
2059
  ? `⚠️ Server \`${url}\` is unreachable. The message has been requeued and will be sent when the server is available again.\n\n` +
2022
2060
  `**Options:**\n` +
2023
- `• Start the server: \`ocmux serve\`\n` +
2061
+ `• Restart it: \`/resurrect\`\n` +
2024
2062
  `• Switch to another server: \`/servers switch <name>\`\n` +
2025
2063
  `• Flush the queue: \`/flush\``
2026
2064
  : `Error: ${err.message}`;
@@ -2251,7 +2289,7 @@ async function main() {
2251
2289
  await sendLongMessage(token, cs,
2252
2290
  '🔄 Bot started. Previously connected to `' + dir + '`.\n' +
2253
2291
  '⚠️ The server at `' + state.url + '` is not reachable.\n' +
2254
- 'Options: start the server with `ocmux serve`, or switch with `/servers switch <name>`.');
2292
+ 'Use `/resurrect` to restart it, or `/servers switch <name>` to connect elsewhere.');
2255
2293
  } catch (_) {}
2256
2294
  }
2257
2295
  }
@@ -2387,7 +2425,7 @@ async function main() {
2387
2425
  `⚠️ Server \`${cs.serverBase}\` is not reachable.\n\n` +
2388
2426
  `Your file upload has been queued (#${qlen}). It will be sent when the server becomes available.\n\n` +
2389
2427
  `**Options:**\n` +
2390
- `• Start the server: \`ocmux serve\`\n` +
2428
+ `• Restart it: \`/resurrect\`\n` +
2391
2429
  `• Switch to another server: \`/servers switch <name>\`\n` +
2392
2430
  `• Flush the queue: \`/flush\``);
2393
2431
  continue;
@@ -2538,8 +2576,8 @@ async function main() {
2538
2576
  const cmdArgs = parts.slice(1);
2539
2577
  const handler = cmds[cmd];
2540
2578
  if (handler) {
2541
- // Disconnected guard: only /help, /servers, /force-switch, /start, /comment, /shutdown, /serve, and /new work without a server
2542
- if (!cs.serverBase && cmd !== 'help' && cmd !== 'servers' && cmd !== 'force-switch' && cmd !== 'start' && cmd !== 'comment' && cmd !== 'shutdown' && cmd !== 'serve' && cmd !== 'new') {
2579
+ // Disconnected guard: only /help, /servers, /force-switch, /start, /comment, /shutdown, /serve, /new, and /resurrect work without a server
2580
+ if (!cs.serverBase && cmd !== 'help' && cmd !== 'servers' && cmd !== 'force-switch' && cmd !== 'start' && cmd !== 'comment' && cmd !== 'shutdown' && cmd !== 'serve' && cmd !== 'new' && cmd !== 'resurrect') {
2543
2581
  await sendLongMessage(token, cs,
2544
2582
  '🔌 Not connected to any server.\n\n' +
2545
2583
  'Use `/servers` to see available servers and `/servers switch <name>` to connect.');
@@ -2588,7 +2626,7 @@ async function main() {
2588
2626
  const qlen = st.messageQueue.length;
2589
2627
  const servers = listServers();
2590
2628
  const suggestions = [];
2591
- suggestions.push(`• Start the server: \`ocmux serve\``);
2629
+ suggestions.push(`• Restart it: \`/resurrect\``);
2592
2630
  if (servers.length > 0) {
2593
2631
  suggestions.push(`• Switch to another server: \`/servers switch <name>\``);
2594
2632
  }
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
  }
@@ -130,6 +135,37 @@ function listServers() {
130
135
  return rows;
131
136
  }
132
137
 
138
+ // Check the state of a server in a given directory.
139
+ // Returns null if no .ocmux.json exists, or an object with status.
140
+ function serverStatus(dir) {
141
+ const sf = statefileFor(dir);
142
+ const state = readState(sf);
143
+ if (!state || !state.url) return null;
144
+ const winIdx = windowByDir(dir);
145
+ const tui = winIdx != null ? tuiPaneId(winIdx) : null;
146
+ return {
147
+ dir,
148
+ url: state.url,
149
+ status: winIdx != null ? 'alive' : 'dead',
150
+ windowIndex: winIdx,
151
+ hasTui: tui != null,
152
+ };
153
+ }
154
+
155
+ // List dead servers by scanning known directories for orphaned .ocmux.json files.
156
+ // dirs: array of directories to check (e.g. from connections file).
157
+ // Returns entries with status 'dead' that are not already in listServers().
158
+ function listDeadServers(knownDirs) {
159
+ const alive = new Set(listServers().map(s => s.dir));
160
+ const dead = [];
161
+ for (const dir of knownDirs) {
162
+ if (alive.has(dir)) continue;
163
+ const status = serverStatus(dir);
164
+ if (status) dead.push(status);
165
+ }
166
+ return dead;
167
+ }
168
+
133
169
  // Switch tmux focus to the given server window and ensure the TUI pane is alive and zoomed.
134
170
  function activateServer(dir, index, url) {
135
171
  const winIdx = index;
@@ -182,8 +218,10 @@ function activateServer(dir, index, url) {
182
218
 
183
219
  // Recover a dead/crashed server: kill old window, remove state file,
184
220
  // create fresh server + TUI in the same directory.
221
+ // Delegates to startServer for the actual server creation.
185
222
  // Returns { url, dir } on success, throws on error.
186
223
  function resurrectServer(dir, printLogs) {
224
+ try { dir = fs.realpathSync(dir); } catch {}
187
225
  const sf = path.join(dir, '.ocmux.json');
188
226
  const state = readState(sf);
189
227
 
@@ -209,7 +247,16 @@ function resurrectServer(dir, printLogs) {
209
247
  // Remove old state file so we can create a fresh one
210
248
  try { fs.unlinkSync(sf); } catch {}
211
249
 
212
- // Create fresh server + TUI
250
+ // Start fresh server (same logic as ocmux serve)
251
+ return startServer(dir, printLogs);
252
+ }
253
+
254
+ // Start a new opencode server in the given directory.
255
+ // Creates a tmux window, starts opencode serve, waits for the URL,
256
+ // and writes .ocmux.json. Returns { url, dir } on success.
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 {}
213
260
  ensureSession();
214
261
  const lf = logfileFor(dir);
215
262
 
@@ -223,16 +270,16 @@ function resurrectServer(dir, printLogs) {
223
270
 
224
271
  try { fs.truncateSync(lf); } catch {}
225
272
 
226
- const serveFlags = printLogs ? '--port 0 --print-logs' : '--port 0';
273
+ const serveFlags = printLogs ? '--print-logs' : '';
227
274
  _tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`,
228
- `opencode serve ${serveFlags} 2>&1 | tee '${lf}'`, 'Enter']);
275
+ 'opencode serve' + (serveFlags ? ' ' + serveFlags : '') + ` 2>&1 | tee '${lf}'`, 'Enter']);
229
276
 
230
277
  let url = null;
231
278
  for (let i = 0; i < 50; i++) {
232
279
  sleep(0.2);
233
280
  const log = (() => { try { return fs.readFileSync(lf, 'utf8'); } catch { return ''; } })();
234
281
  const m = log.match(URL_RE);
235
- if (m) url = m[1];
282
+ if (m) { url = m[1]; break; }
236
283
  }
237
284
 
238
285
  if (!url) {
@@ -242,7 +289,7 @@ function resurrectServer(dir, printLogs) {
242
289
  }
243
290
 
244
291
  const newState = { url, logfile: lf, window_index: winIdx };
245
- fs.writeFileSync(sf, JSON.stringify(newState) + '\n');
292
+ fs.writeFileSync(path.join(dir, '.ocmux.json'), JSON.stringify(newState) + '\n');
246
293
 
247
294
  const splitResult = _tmux(['split-window', '-v', '-P', '-F', '#{pane_id}', '-t', `${SESSION}:${winIdx}`, '-c', dir]);
248
295
  const tuiPane = splitResult.stdout.trim();
@@ -278,4 +325,7 @@ module.exports = {
278
325
  ensureSession,
279
326
  pinWindowName,
280
327
  resurrectServer,
328
+ startServer,
329
+ serverStatus,
330
+ listDeadServers,
281
331
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.11.8",
3
+ "version": "0.11.10",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",