agentp 2.0.1 → 2.1.1

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/bin/tgagentp CHANGED
@@ -18,12 +18,56 @@ 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, serverStatus, listDeadServers } = require('../lib/ocmux.js');
21
+ const { switchTui } = require('../lib/ocmux.js');
22
+ const projectState = require('../lib/project-state.js');
23
+ const tuiRegistry = require('../lib/tui-registry.js');
22
24
  const { executeTuiCommand } = require('../lib/tui-cmd.js');
23
25
  const { getUpdates, downloadTelegramFile, sendTelegramDocument } = require('../lib/telegram-api.js');
24
26
  const { sendLongMessage } = require('../lib/telegram-format.js');
25
27
  const { spawnSync } = require('child_process');
26
28
 
29
+ function readProject(dir) {
30
+ return projectState.readProjectState(projectState.statefileFor(dir));
31
+ }
32
+
33
+ // Telegram's historical "servers" are project routes. With user-placed TUIs,
34
+ // the runtime registry is the only global list available without scanning the
35
+ // filesystem. Saved chat connections supplement it where a project is headless.
36
+ function listServers() {
37
+ const rows = [];
38
+ const seen = new Set();
39
+ for (const instance of tuiRegistry.list()) {
40
+ if (!instance.directory || !instance.server) continue;
41
+ const key = `${instance.directory}\0${instance.server}`;
42
+ if (seen.has(key)) continue;
43
+ seen.add(key);
44
+ rows.push({
45
+ url: instance.server,
46
+ dir: instance.directory,
47
+ status: instance.shared ? 'shared' : 'project',
48
+ instance,
49
+ });
50
+ }
51
+ try {
52
+ const saved = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8'));
53
+ for (const connection of saved.connections || []) {
54
+ const state = connection.dir ? readProject(connection.dir) : null;
55
+ if (!state || !state.server) continue;
56
+ const key = `${state.directory}\0${state.server}`;
57
+ if (seen.has(key)) continue;
58
+ seen.add(key);
59
+ rows.push({ url: state.server, dir: state.directory, status: 'headless', instance: null });
60
+ }
61
+ } catch {}
62
+ return rows;
63
+ }
64
+
65
+ function activateServer(dir, _unused, server) {
66
+ const state = readProject(dir);
67
+ if (!state || !state.session) return null;
68
+ return switchTui(dir, server || state.server, state.session);
69
+ }
70
+
27
71
  const TGAGENTP_ROOT = process.env.TGAGENTP_ROOT;
28
72
  let tgagentpRootValid = false;
29
73
  if (TGAGENTP_ROOT) {
@@ -461,7 +505,7 @@ async function cmdHelp(token, chatId, args, chatState) {
461
505
  const hint = TGAGENTP_ROOT ? '' :
462
506
  '\nℹ️ Currently disabled. Set the `TGAGENTP_ROOT` environment variable to a writable directory to enable `/serve` and `/new`.';
463
507
  await sendLongMessage(token, chatState,
464
- '`/serve <path>` ─ start a server in an existing directory\n\n' +
508
+ '`/serve <path>` ─ initialize an existing project directory\n\n' +
465
509
  'The path is relative to `TGAGENTP_ROOT`.\n' +
466
510
  'Example: `/serve projects/my-app`\n\n' +
467
511
  'Requires the `TGAGENTP_ROOT` environment variable to be set to a writable directory.' +
@@ -472,7 +516,7 @@ async function cmdHelp(token, chatId, args, chatState) {
472
516
  const hint = TGAGENTP_ROOT ? '' :
473
517
  '\nℹ️ Currently disabled. Set the `TGAGENTP_ROOT` environment variable to a writable directory to enable `/serve` and `/new`.';
474
518
  await sendLongMessage(token, chatState,
475
- '`/new <path>` ─ create a directory, initialize git, and start a server\n\n' +
519
+ '`/new <path>` ─ create a directory, initialize git, and configure it\n\n' +
476
520
  'The path is relative to `TGAGENTP_ROOT`.\n' +
477
521
  'Example: `/new projects/my-app`\n\n' +
478
522
  'Requires the `TGAGENTP_ROOT` environment variable to be set to a writable directory.' +
@@ -517,8 +561,8 @@ async function cmdHelp(token, chatId, args, chatState) {
517
561
  '`/markdown` ─ send the original markdown of the last response as a `.md` file\n' +
518
562
  '`/shutdown` ─ stop tgagentp (only with `--dev` flag)\n' +
519
563
  (tgagentpRootValid
520
- ? '`/serve <path>` ─ start a server in an existing directory\n' +
521
- '`/new <path>` ─ create a directory, init git, and start a server\n\n'
564
+ ? '`/serve <path>` ─ initialize an existing project directory\n' +
565
+ '`/new <path>` ─ create a directory, init git, and configure it\n\n'
522
566
  : 'ℹ️ Set `TGAGENTP_ROOT` to a writable directory to enable `/serve` and `/new`.\n\n') +
523
567
  'Everything else is forwarded to the active server as `[Telegram] <text>`.');
524
568
  }
@@ -537,21 +581,15 @@ async function cmdServers(token, chatId, args, chatState, setServerForChat, setA
537
581
  }
538
582
  }
539
583
 
540
- // Default: list servers (alive + dead)
584
+ // Default: list configured project routes known to the runtime registry or
585
+ // saved Telegram connections.
541
586
  const servers = listServers();
542
- // Also find dead servers from saved connections
543
- let savedData;
544
- try { savedData = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf8')); } catch {}
545
- const knownDirs = savedData && Array.isArray(savedData.connections)
546
- ? savedData.connections.map(c => c.dir).filter(Boolean) : [];
547
- const dead = listDeadServers(knownDirs);
548
- const all = [...servers, ...dead.map(d => ({ url: d.url, dir: d.dir, index: d.windowIndex, status: 'dead' }))];
549
- if (all.length === 0) {
550
- await sendLongMessage(token, chatState, 'No servers found.');
587
+ if (servers.length === 0) {
588
+ await sendLongMessage(token, chatState, 'No configured project routes found.');
551
589
  return;
552
590
  }
553
591
  const activeUrl = chatState.serverBase ? normalizeUrl(chatState.serverBase) : null;
554
- const lines = all.map(s => {
592
+ const lines = servers.map(s => {
555
593
  const normalizedUrl = normalizeUrl(s.url);
556
594
  const isActive = activeUrl && normalizedUrl === activeUrl;
557
595
  const isOwned = Object.keys(serverOwners).some(url => normalizeUrl(url) === normalizedUrl);
@@ -560,8 +598,8 @@ async function cmdServers(token, chatId, args, chatState, setServerForChat, setA
560
598
  bullet = '▶ ';
561
599
  } else if (isOwned) {
562
600
  bullet = ' · ';
563
- } else if (s.status === 'dead') {
564
- bullet = '💀 ';
601
+ } else if (s.status === 'headless') {
602
+ bullet = '○ ';
565
603
  } else {
566
604
  bullet = '🔌 ';
567
605
  }
@@ -1359,8 +1397,8 @@ async function cmdResurrect(token, chatId, args, chatState) {
1359
1397
  return;
1360
1398
  }
1361
1399
  // Read the old URL from the state file
1362
- const state = readState(statefileFor(dir));
1363
- url = state ? state.url : null;
1400
+ const state = readProject(dir);
1401
+ url = state ? state.server : null;
1364
1402
  } else {
1365
1403
  url = chatState.serverBase;
1366
1404
  if (!url) {
@@ -1382,48 +1420,19 @@ async function cmdResurrect(token, chatId, args, chatState) {
1382
1420
  if (!dir) {
1383
1421
  await sendLongMessage(token, chatState,
1384
1422
  'Could not find the directory for this server.\n' +
1385
- 'Use `ocmux resurrect` from the terminal or specify a directory: `/resurrect <path>`.');
1423
+ 'Specify a configured directory: `/resurrect <path>`.');
1386
1424
  return;
1387
1425
  }
1388
1426
  }
1389
1427
 
1390
- await sendLongMessage(token, chatState,
1391
- `♻️ Resurrecting server \`${dir}\`...\n` +
1392
- `The old server at \`${url}\` will be replaced with a new one.`);
1393
-
1394
- try {
1395
- const { url: newUrl } = resurrectServer(dir, false);
1396
-
1397
- // Update chat state to new URL
1398
- chatState.serverBase = newUrl;
1399
-
1400
- // Transfer relevant state from old URL to new URL
1401
- const oldSt = getState(url);
1402
- const newSt = getState(newUrl);
1403
- newSt.activeSessionId = oldSt.activeSessionId;
1404
- newSt.activeSessionName = oldSt.activeSessionName;
1405
- newSt.activeAgentName = oldSt.activeAgentName;
1406
- newSt.thinkingEnabled = oldSt.thinkingEnabled;
1407
-
1408
- // Update serverOwners to point to the new URL
1409
- const normOld = normalizeUrl(url);
1410
- for (const [sfUrl, owner] of Object.entries(serverOwners)) {
1411
- if (normalizeUrl(sfUrl) === normOld) {
1412
- serverOwners[newUrl] = owner;
1413
- delete serverOwners[sfUrl];
1414
- break;
1415
- }
1416
- }
1417
-
1418
- await sendLongMessage(token, chatState,
1419
- `✅ Server resurrected.\n` +
1420
- `New URL: \`${newUrl}\`\n\n` +
1421
- `Use \`/status\` to check the session.`);
1422
- } catch (err) {
1423
- await sendLongMessage(token, chatState,
1424
- `❌ Resurrection failed: ${err.message}\n` +
1425
- `Try \`ocmux resurrect\` from the terminal.`);
1428
+ const alive = url ? await isServerAlive(url) : false;
1429
+ if (alive) {
1430
+ await sendLongMessage(token, chatState, `✅ Server is already reachable at \`${url}\`.`);
1431
+ return;
1426
1432
  }
1433
+ await sendLongMessage(token, chatState,
1434
+ `❌ Server at \`${url || 'unknown URL'}\` is not reachable.\n` +
1435
+ 'OpenCode servers are user-managed; start `opencode serve` externally, then use `/server <name>` again.');
1427
1436
  }
1428
1437
 
1429
1438
  async function cmdDisconnect(token, chatId, args, chatState) {
@@ -1481,22 +1490,15 @@ async function cmdServe(token, chatId, args, chatState, setServerForChat, setAct
1481
1490
  `❌ Directory does not exist: \`${relPath}\``);
1482
1491
  return;
1483
1492
  }
1484
- const sf = statefileFor(targetDir);
1493
+ const sf = projectState.statefileFor(targetDir);
1485
1494
  if (fs.existsSync(sf)) {
1486
- const status = serverStatus(targetDir);
1487
- if (status && status.status === 'dead') {
1488
- await sendLongMessage(token, chatState,
1489
- `💀 A dead server exists at \`${relPath}\` (${status.url}).\n` +
1490
- `Use \`/resurrect ${relPath}\` to restart it.`);
1491
- } else {
1492
- await sendLongMessage(token, chatState,
1493
- `⚠️ Server already exists for \`${relPath}\`.\n` +
1494
- 'Use `/server <name>` to connect to it.');
1495
- }
1495
+ await sendLongMessage(token, chatState,
1496
+ `⚠️ Project state already exists for \`${relPath}\`.\n` +
1497
+ 'Use `/server <name>` to connect to it.');
1496
1498
  return;
1497
1499
  }
1498
1500
  await sendLongMessage(token, chatState,
1499
- `🚀 Starting server for \`${relPath}\`...`);
1501
+ `🚀 Initializing project \`${relPath}\`...`);
1500
1502
  const result = spawnSync('ocmux', ['serve', targetDir], { encoding: 'utf8', timeout: 15000 });
1501
1503
  if (result.status !== 0) {
1502
1504
  const err = result.stderr || result.stdout || 'unknown error';
@@ -1504,15 +1506,16 @@ async function cmdServe(token, chatId, args, chatState, setServerForChat, setAct
1504
1506
  `❌ Failed to start server:\n\`\`\`\n${err}\n\`\`\``);
1505
1507
  return;
1506
1508
  }
1507
- const url = (result.stdout || '').trim();
1509
+ const state = readProject(targetDir);
1510
+ const url = state && state.server;
1508
1511
  if (!url) {
1509
1512
  await sendLongMessage(token, chatState,
1510
- '❌ Server started but did not return a URL.');
1513
+ '❌ Project initialized but its server URL was not recorded.');
1511
1514
  return;
1512
1515
  }
1513
- await setServerForChat(chatState, url);
1516
+ await setServerForChat(chatState, url, targetDir);
1514
1517
  await sendLongMessage(token, chatState,
1515
- `✅ Server started.\nURL: \`${url}\`\nDir: \`${targetDir}\``);
1518
+ `✅ Project initialized.\nURL: \`${url}\`\nDir: \`${targetDir}\``);
1516
1519
  log.info(`[chat ${chatId}] Served ${targetDir} → ${url}`);
1517
1520
  }
1518
1521
 
@@ -1528,7 +1531,7 @@ async function cmdNew(token, chatId, args, chatState, setServerForChat, setActiv
1528
1531
  if (!relPath) {
1529
1532
  await sendLongMessage(token, chatState,
1530
1533
  'Usage: `/new <path>`\n\n' +
1531
- 'Create a new directory under `TGAGENTP_ROOT`, initialize git, and start a server.\n' +
1534
+ 'Create a new directory under `TGAGENTP_ROOT`, initialize git, and configure it.\n' +
1532
1535
  'The path is relative to the root directory.');
1533
1536
  return;
1534
1537
  }
@@ -1542,7 +1545,7 @@ async function cmdNew(token, chatId, args, chatState, setServerForChat, setActiv
1542
1545
  if (fs.existsSync(targetDir)) {
1543
1546
  await sendLongMessage(token, chatState,
1544
1547
  `❌ Directory already exists: \`${relPath}\`.\n` +
1545
- 'Use `/serve` to start a server in an existing directory.');
1548
+ 'Use `/serve` to initialize an existing directory.');
1546
1549
  return;
1547
1550
  }
1548
1551
  try {
@@ -1560,7 +1563,7 @@ async function cmdNew(token, chatId, args, chatState, setServerForChat, setActiv
1560
1563
  return;
1561
1564
  }
1562
1565
  await sendLongMessage(token, chatState,
1563
- `🚀 Starting server for \`${relPath}\`...`);
1566
+ `🚀 Creating project \`${relPath}\`...`);
1564
1567
  const result = spawnSync('ocmux', ['serve', targetDir], { encoding: 'utf8', timeout: 15000 });
1565
1568
  if (result.status !== 0) {
1566
1569
  const err = result.stderr || result.stdout || 'unknown error';
@@ -1568,15 +1571,16 @@ async function cmdNew(token, chatId, args, chatState, setServerForChat, setActiv
1568
1571
  `❌ Failed to start server:\n\`\`\`\n${err}\n\`\`\``);
1569
1572
  return;
1570
1573
  }
1571
- const url = (result.stdout || '').trim();
1574
+ const state = readProject(targetDir);
1575
+ const url = state && state.server;
1572
1576
  if (!url) {
1573
1577
  await sendLongMessage(token, chatState,
1574
- '❌ Server started but did not return a URL.');
1578
+ '❌ Project initialized but its server URL was not recorded.');
1575
1579
  return;
1576
1580
  }
1577
- await setServerForChat(chatState, url);
1581
+ await setServerForChat(chatState, url, targetDir);
1578
1582
  await sendLongMessage(token, chatState,
1579
- `✅ Directory created and server started.\nURL: \`${url}\`\nDir: \`${targetDir}\``);
1583
+ `✅ Directory created and project initialized.\nURL: \`${url}\`\nDir: \`${targetDir}\``);
1580
1584
  log.info(`[chat ${chatId}] Created ${targetDir} → ${url}`);
1581
1585
  }
1582
1586
 
@@ -1752,8 +1756,7 @@ async function cmdServer(token, chatId, args, chatState, setServerForChat, setAc
1752
1756
  `⚠️ Server \`${target.url}\` has been taken over by another chat.\n\n` +
1753
1757
  `Use \`/servers\` to see available servers and reconnect.`).catch(() => {});
1754
1758
  }
1755
- const pending = await setServerForChat(chatState, target.url);
1756
- activateServer(target.dir, target.index, target.url);
1759
+ const pending = await setServerForChat(chatState, target.url, target.dir);
1757
1760
  let msg = `Switched to server: ${target.url} → ${target.dir}`;
1758
1761
  if (pending && pending.trim()) {
1759
1762
  await sendLongMessage(token, chatState, msg);
@@ -2081,7 +2084,7 @@ async function main() {
2081
2084
  log.info('Waiting for Telegram messages... (Ctrl+C to stop)');
2082
2085
 
2083
2086
  // Chat-aware server/session mutation callbacks (defined here for token access)
2084
- const setServerForChat = async (chatState, url) => {
2087
+ const setServerForChat = async (chatState, url, projectDir) => {
2085
2088
  const cs = chatState;
2086
2089
 
2087
2090
  // Disconnect any other chat still connected to this server (one server → one chat)
@@ -2111,10 +2114,12 @@ async function main() {
2111
2114
  // Send Telegram bridge awareness note to the new session
2112
2115
  sendGreeting(cs).catch(() => {});
2113
2116
 
2114
- // Auto-activate tmux window for this server
2117
+ // Refresh the registered TUI route for this project.
2115
2118
  const servers = listServers();
2116
2119
  const normalized = normalizeUrl(url);
2117
- const match = servers.find(s => normalizeUrl(s.url) === normalized);
2120
+ const match = projectDir
2121
+ ? { dir: projectDir, url }
2122
+ : servers.find(s => normalizeUrl(s.url) === normalized);
2118
2123
  if (match) {
2119
2124
  activateServer(match.dir, match.index, match.url);
2120
2125
  saveConnection(cs.chatId, cs.threadId, match.dir);
@@ -2153,6 +2158,15 @@ async function main() {
2153
2158
  const prevId = getState(server).activeSessionId;
2154
2159
  getState(server).activeSessionId = id;
2155
2160
  if (name) getState(server).activeSessionName = name;
2161
+ const dir = serverDir(server);
2162
+ if (dir) {
2163
+ const state = readProject(dir);
2164
+ if (state) projectState.writeProjectState(state.file, { session: id });
2165
+ const result = switchTui(dir, server, id);
2166
+ if (!result.ok && result.reason === 'failed') {
2167
+ log.error(`Warning: session selected, but its ${result.kind} TUI could not be refreshed: ${result.error}`);
2168
+ }
2169
+ }
2156
2170
  log.info(`[chat ${chatState.chatId}] Switched to session: ${name || id}`);
2157
2171
  if (id !== prevId) {
2158
2172
  sendGreeting(chatState);
@@ -2417,7 +2431,7 @@ async function main() {
2417
2431
  const msg = isConnError
2418
2432
  ? `⚠️ Server \`${url}\` is unreachable. The message has been requeued and will be sent when the server is available again.\n\n` +
2419
2433
  `**Options:**\n` +
2420
- `• Restart it: \`/resurrect\`\n` +
2434
+ '• Start the user-managed OpenCode server externally\n' +
2421
2435
  `• Switch to another server: \`/server <name>\`\n` +
2422
2436
  `• Flush the queue: \`/flush\``
2423
2437
  : `Error: ${err.message}`;
@@ -2573,11 +2587,6 @@ async function main() {
2573
2587
  return;
2574
2588
  }
2575
2589
 
2576
- // Auto-switch tmux window to this chat's server
2577
- const srvList = listServers();
2578
- const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(url));
2579
- if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
2580
-
2581
2590
  st.busy = true;
2582
2591
  const myTaskId = ++st.taskId;
2583
2592
  log.debug(` → /note: starting background processing (task ${myTaskId})`);
@@ -2601,14 +2610,13 @@ async function main() {
2601
2610
  for (const { chatId, threadId, dir } of savedData.connections) {
2602
2611
  if (!dir) continue;
2603
2612
  const cs = getChatState(chatId, threadId);
2604
- const sf = statefileFor(dir);
2605
- const state = readState(sf);
2606
- if (state && state.url) {
2607
- cs.serverBase = state.url;
2613
+ const state = readProject(dir);
2614
+ if (state && state.server) {
2615
+ cs.serverBase = state.server;
2608
2616
  // Prefer non-thread (private chat) as owner over topic threads
2609
- const existing = serverOwners[state.url];
2617
+ const existing = serverOwners[state.server];
2610
2618
  if (!existing || (!threadId && existing.threadId)) {
2611
- serverOwners[state.url] = { chatId, threadId };
2619
+ serverOwners[state.server] = { chatId, threadId };
2612
2620
  }
2613
2621
  }
2614
2622
  }
@@ -2631,32 +2639,31 @@ async function main() {
2631
2639
  if (!dir) continue;
2632
2640
  const cs = getChatState(chatId, threadId);
2633
2641
  if (!cs.serverBase) continue;
2634
- const sf = statefileFor(dir);
2635
- const state = readState(sf);
2636
- if (state && state.url) {
2637
- if (notifiedUrls.has(state.url)) {
2642
+ const state = readProject(dir);
2643
+ if (state && state.server) {
2644
+ if (notifiedUrls.has(state.server)) {
2638
2645
  log.info(`[chat ${chatId}] Skipping startup notification for ${dir} (already notified for this URL)`);
2639
2646
  continue;
2640
2647
  }
2641
- notifiedUrls.add(state.url);
2642
- const alive = await isServerAlive(state.url);
2648
+ notifiedUrls.add(state.server);
2649
+ const alive = await isServerAlive(state.server);
2643
2650
  if (alive) {
2644
2651
  const lines = [
2645
2652
  '✅ Bot started.',
2646
2653
  '**Server:**',
2647
2654
  ' • **Path:** `' + dir + '`',
2648
- ' • **URL:** `' + state.url + '`',
2655
+ ' • **URL:** `' + state.server + '`',
2649
2656
  ' • **Status:** ✅ restored',
2650
2657
  ];
2651
2658
  try { await sendLongMessage(token, cs, lines.join('\n')); } catch (_) {}
2652
2659
  log.info(`Restored connection: chat ${chatId} → ${dir}`);
2653
2660
  } else {
2654
- log.info(`Saved connection for chat ${chatId} → ${dir}, but server ${state.url} is unreachable`);
2661
+ log.info(`Saved connection for chat ${chatId} → ${dir}, but server ${state.server} is unreachable`);
2655
2662
  try {
2656
2663
  await sendLongMessage(token, cs,
2657
2664
  '🔄 Bot started. Previously connected to `' + dir + '`.\n' +
2658
- '⚠️ The server at `' + state.url + '` is not reachable.\n' +
2659
- 'Use `/resurrect` to restart it, or `/server <name>` to connect elsewhere.');
2665
+ '⚠️ The server at `' + state.server + '` is not reachable.\n' +
2666
+ 'Start the user-managed OpenCode server externally, or use `/server <name>` to connect elsewhere.');
2660
2667
  } catch (_) {}
2661
2668
  }
2662
2669
  }
@@ -2804,7 +2811,7 @@ async function main() {
2804
2811
  `⚠️ Server \`${cs.serverBase}\` is not reachable.\n\n` +
2805
2812
  `Your file upload has been queued (#${qlen}). It will be sent when the server becomes available.\n\n` +
2806
2813
  `**Options:**\n` +
2807
- `• Restart it: \`/resurrect\`\n` +
2814
+ '• Start the user-managed OpenCode server externally\n' +
2808
2815
  `• Switch to another server: \`/server <name>\`\n` +
2809
2816
  `• Flush the queue: \`/flush\``);
2810
2817
  continue;
@@ -2817,11 +2824,6 @@ async function main() {
2817
2824
  continue;
2818
2825
  }
2819
2826
 
2820
- // Auto-switch tmux window
2821
- const srvList = listServers();
2822
- const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(cs.serverBase));
2823
- if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
2824
-
2825
2827
  st.busy = true;
2826
2828
  const myTaskId = ++st.taskId;
2827
2829
  log.debug(` → processing file upload notification (task ${myTaskId})`);
@@ -2914,21 +2916,15 @@ async function main() {
2914
2916
  await sendLongMessage(token, cs, '⏳ Server is busy. Use `/cancel` to cancel first.');
2915
2917
  continue;
2916
2918
  }
2917
- const srvList = listServers();
2918
- const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(cs.serverBase));
2919
- if (!srvMatch) {
2920
- await sendLongMessage(token, cs, '❌ Server not found in tmux.');
2921
- continue;
2922
- }
2923
- activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
2924
- const paneId = tuiPaneId(srvMatch.index);
2925
- if (!paneId) {
2926
- await sendLongMessage(token, cs, '❌ TUI pane not found.');
2919
+ const projectDir = serverDir(cs.serverBase);
2920
+ const target = projectDir ? tuiRegistry.resolve(projectDir) : null;
2921
+ if (!target) {
2922
+ await sendLongMessage(token, cs, '❌ No registered TUI is available for this project.');
2927
2923
  continue;
2928
2924
  }
2929
2925
  st.busy = true;
2930
2926
  try {
2931
- const answer = await executeTuiCommand(paneId, cs.serverBase, tuiCmd);
2927
+ const answer = await executeTuiCommand(target.instance, cs.serverBase, tuiCmd);
2932
2928
  if (answer && answer.trim()) {
2933
2929
  const cleaned = await processTelegramCommands(answer.trim(), cs.serverBase, token, cs);
2934
2930
  if (cleaned) {
@@ -3005,7 +3001,7 @@ async function main() {
3005
3001
  const qlen = st.messageQueue.length;
3006
3002
  const servers = listServers();
3007
3003
  const suggestions = [];
3008
- suggestions.push(`• Restart it: \`/resurrect\``);
3004
+ suggestions.push('• Start the user-managed OpenCode server externally');
3009
3005
  if (servers.length > 0) {
3010
3006
  suggestions.push(`• Switch to another server: \`/server <name>\``);
3011
3007
  }
@@ -3022,11 +3018,6 @@ async function main() {
3022
3018
  continue;
3023
3019
  }
3024
3020
 
3025
- // Auto-switch tmux window to this chat's server
3026
- const srvList = listServers();
3027
- const srvMatch = srvList.find(s => normalizeUrl(s.url) === normalizeUrl(cs.serverBase));
3028
- if (srvMatch) activateServer(srvMatch.dir, srvMatch.index, srvMatch.url);
3029
-
3030
3021
  st.busy = true;
3031
3022
  const myTaskId = ++st.taskId;
3032
3023
  log.debug(` → started background processing (task ${myTaskId})`);
@@ -9,7 +9,8 @@ See **[specification_v2.md](./specification_v2.md)** for the current design:
9
9
  - a single, **user-managed** OpenCode v2 server (no per-project servers);
10
10
  - the **project = directory / target = session** model with a v2 `.ocmux.json`
11
11
  (`directory`, `session`, `server`, `annotations`);
12
- - `ocmux` project/TUI-window management and its interactive pickers;
12
+ - `ocmux` project/session routing, registered dedicated/shared TUIs, and its
13
+ interactive pickers;
13
14
  - `agentp` resolution, deferred tickets (incl. `cancelled`) and `--qa` output;
14
15
  - `lib/opencode.js` as the v2-only HTTP/SSE client.
15
16