@pixelbyte-software/pixcode 1.53.10 → 1.53.12

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/server/index.js CHANGED
@@ -45,23 +45,48 @@ const DAEMON_COMMAND_CONTEXT = {
45
45
  function resolveMonacoAssetsPath() {
46
46
  const appParent = path.dirname(APP_ROOT);
47
47
  const appGrandparent = path.dirname(appParent);
48
+ const nodePathRoots = String(process.env.NODE_PATH || '')
49
+ .split(path.delimiter)
50
+ .map((entry) => entry.trim())
51
+ .filter(Boolean);
48
52
  const candidates = [
49
53
  path.join(APP_ROOT, 'node_modules', 'monaco-editor', 'min', 'vs'),
50
54
  path.join(appParent, 'node_modules', 'monaco-editor', 'min', 'vs'),
51
55
  path.join(appParent, 'monaco-editor', 'min', 'vs'),
52
56
  path.join(appGrandparent, 'node_modules', 'monaco-editor', 'min', 'vs'),
53
57
  path.join(appGrandparent, 'monaco-editor', 'min', 'vs'),
58
+ ...nodePathRoots.flatMap((nodePathRoot) => [
59
+ path.join(nodePathRoot, 'monaco-editor', 'min', 'vs'),
60
+ path.join(nodePathRoot, 'node_modules', 'monaco-editor', 'min', 'vs'),
61
+ ]),
62
+ ];
63
+ const resolutionPaths = [
64
+ APP_ROOT,
65
+ appParent,
66
+ appGrandparent,
67
+ __dirname,
68
+ process.cwd(),
69
+ ...nodePathRoots,
54
70
  ];
55
71
 
56
72
  try {
57
73
  const monacoPackagePath = require.resolve('monaco-editor/package.json', {
58
- paths: [APP_ROOT, appParent, appGrandparent, __dirname, process.cwd()],
74
+ paths: resolutionPaths,
59
75
  });
60
76
  candidates.push(path.join(path.dirname(monacoPackagePath), 'min', 'vs'));
61
77
  } catch {
62
78
  // The editor will show its normal load failure if the dependency is unavailable.
63
79
  }
64
80
 
81
+ try {
82
+ const monacoLoaderPath = require.resolve('monaco-editor/min/vs/loader.js', {
83
+ paths: resolutionPaths,
84
+ });
85
+ candidates.push(path.dirname(monacoLoaderPath));
86
+ } catch {
87
+ // Some package managers only expose the package root; candidates above cover that case.
88
+ }
89
+
65
90
  return candidates.find((candidate) => fs.existsSync(path.join(candidate, 'loader.js'))) || null;
66
91
  }
67
92
 
@@ -132,7 +157,7 @@ import {
132
157
  } from './services/provider-credentials.js';
133
158
  import { primeCliBinPath } from './services/install-jobs.js';
134
159
  import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
135
- import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, appConfigDb } from './database/db.js';
160
+ import { initializeDatabase, sessionNamesDb, applyCustomSessionNames, appConfigDb, telegramLinksDb } from './database/db.js';
136
161
  import { setNotificationWebSocketServer } from './services/notification-orchestrator.js';
137
162
  import { configureWebPush } from './services/vapid-keys.js';
138
163
  import { validateApiKey, authenticateToken, authenticateWebSocket, requireAdmin, requireApiScope } from './middleware/auth.js';
@@ -612,7 +637,7 @@ const WATCHER_IGNORED_PATTERNS = [
612
637
  // every open tab, which shows up as mouse/UI stutter. 1500ms collapses
613
638
  // a full chat reply into ~1 scan while still feeling responsive when
614
639
  // the user flips to the projects list.
615
- const WATCHER_DEBOUNCE_MS = 1500;
640
+ const WATCHER_DEBOUNCE_MS = Number.parseInt(process.env.PIXCODE_PROJECT_WATCH_DEBOUNCE_MS || '', 10) || 10000;
616
641
  let projectsWatchers = [];
617
642
  let projectsWatcherDebounceTimer = null;
618
643
  const connectedClients = new Set();
@@ -667,8 +692,9 @@ async function setupProjectsWatcher() {
667
692
  try {
668
693
  isGetProjectsRunning = true;
669
694
 
670
- // Clear project directory cache when files change
671
- clearProjectDirectoryCache();
695
+ if (eventType === 'addDir' || eventType === 'unlinkDir') {
696
+ clearProjectDirectoryCache();
697
+ }
672
698
 
673
699
  // Get updated projects list
674
700
  const updatedProjects = await getProjects(broadcastProgress);
@@ -756,10 +782,44 @@ async function setupProjectsWatcher() {
756
782
  // `project_files_updated` events to subscribed clients only, letting the
757
783
  // explorer refresh automatically without HTTP polling.
758
784
  const WORKSPACE_WATCHER_DEBOUNCE_MS = 800;
759
- const WORKSPACE_DIFF_SNAPSHOT_MAX_BYTES = 512 * 1024;
760
- const WORKSPACE_DIFF_SNAPSHOT_MAX_FILES = 800;
785
+ const WORKSPACE_DIFF_SNAPSHOT_MAX_BYTES = 128 * 1024;
786
+ const WORKSPACE_DIFF_SNAPSHOT_MAX_FILES = 80;
787
+ const WORKSPACE_DIFF_SNAPSHOT_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
761
788
  const workspaceWatchers = new Map(); // projectName -> { watcher, subscribers, debounceTimer, pendingEvent, rootPath, fileSnapshots }
762
789
 
790
+ function getWorkspaceSnapshotBytes(content) {
791
+ return Buffer.byteLength(String(content || ''), 'utf8');
792
+ }
793
+
794
+ function deleteWorkspaceSnapshot(entry, relativePath) {
795
+ if (!entry?.fileSnapshots?.has(relativePath)) return;
796
+ const previous = entry.fileSnapshots.get(relativePath);
797
+ entry.snapshotBytes = Math.max(0, (entry.snapshotBytes || 0) - getWorkspaceSnapshotBytes(previous));
798
+ entry.fileSnapshots.delete(relativePath);
799
+ }
800
+
801
+ function setWorkspaceSnapshot(entry, relativePath, content) {
802
+ if (!entry || !relativePath || typeof content !== 'string') return;
803
+ const nextBytes = getWorkspaceSnapshotBytes(content);
804
+ if (nextBytes > WORKSPACE_DIFF_SNAPSHOT_MAX_BYTES) {
805
+ deleteWorkspaceSnapshot(entry, relativePath);
806
+ return;
807
+ }
808
+
809
+ deleteWorkspaceSnapshot(entry, relativePath);
810
+ entry.fileSnapshots.set(relativePath, content);
811
+ entry.snapshotBytes = (entry.snapshotBytes || 0) + nextBytes;
812
+
813
+ while (
814
+ entry.fileSnapshots.size > WORKSPACE_DIFF_SNAPSHOT_MAX_FILES ||
815
+ (entry.snapshotBytes || 0) > WORKSPACE_DIFF_SNAPSHOT_MAX_TOTAL_BYTES
816
+ ) {
817
+ const oldestKey = entry.fileSnapshots.keys().next().value;
818
+ if (!oldestKey) break;
819
+ deleteWorkspaceSnapshot(entry, oldestKey);
820
+ }
821
+ }
822
+
763
823
  function runWorkspaceCommand(command, args, cwd) {
764
824
  return new Promise((resolve, reject) => {
765
825
  const child = spawn(command, args, {
@@ -843,7 +903,7 @@ async function readWorkspaceTextSnapshot(filePath) {
843
903
  }
844
904
  }
845
905
 
846
- async function initializeWorkspaceDirtySnapshots(rootPath, fileSnapshots) {
906
+ async function initializeWorkspaceDirtySnapshots(rootPath, entry) {
847
907
  try {
848
908
  const { stdout } = await runWorkspaceCommand('git', ['status', '--porcelain', '-z'], rootPath);
849
909
  const changedPaths = parseGitPorcelainZ(stdout).slice(0, WORKSPACE_DIFF_SNAPSHOT_MAX_FILES);
@@ -851,7 +911,7 @@ async function initializeWorkspaceDirtySnapshots(rootPath, fileSnapshots) {
851
911
  await Promise.all(changedPaths.map(async (relativePath) => {
852
912
  const content = await readWorkspaceTextSnapshot(path.join(rootPath, relativePath));
853
913
  if (typeof content === 'string') {
854
- fileSnapshots.set(relativePath, content);
914
+ setWorkspaceSnapshot(entry, relativePath, content);
855
915
  }
856
916
  }));
857
917
  } catch {
@@ -885,9 +945,10 @@ async function subscribeToWorkspace(ws, projectName) {
885
945
  pendingEvent: null,
886
946
  rootPath,
887
947
  fileSnapshots: new Map(),
948
+ snapshotBytes: 0,
888
949
  };
889
950
  workspaceWatchers.set(projectName, entry);
890
- await initializeWorkspaceDirtySnapshots(rootPath, entry.fileSnapshots);
951
+ await initializeWorkspaceDirtySnapshots(rootPath, entry);
891
952
 
892
953
  const broadcastFileUpdate = async (eventType, filePath) => {
893
954
  const relativePath = filePath ? normalizeWorkspaceRelativePath(rootPath, filePath) : null;
@@ -897,12 +958,12 @@ async function subscribeToWorkspace(ws, projectName) {
897
958
  if (relativePath && !eventType.endsWith('Dir')) {
898
959
  if (eventType === 'unlink') {
899
960
  currentContent = '';
900
- entry.fileSnapshots.delete(relativePath);
961
+ deleteWorkspaceSnapshot(entry, relativePath);
901
962
  } else {
902
963
  const snapshot = await readWorkspaceTextSnapshot(filePath);
903
964
  if (typeof snapshot === 'string') {
904
965
  currentContent = snapshot;
905
- entry.fileSnapshots.set(relativePath, snapshot);
966
+ setWorkspaceSnapshot(entry, relativePath, snapshot);
906
967
  }
907
968
  }
908
969
  }
@@ -1016,13 +1077,13 @@ const server = http.createServer(app);
1016
1077
 
1017
1078
  const ptySessionsMap = new Map();
1018
1079
  const PTY_SESSION_TIMEOUT = 30 * 60 * 1000;
1019
- const COMPLETED_PTY_SESSION_TTL = 5 * 60 * 1000;
1080
+ const COMPLETED_PTY_SESSION_TTL = Number.parseInt(process.env.PIXCODE_COMPLETED_PTY_TTL_MS || '', 10) || 60 * 1000;
1020
1081
  const SHELL_URL_PARSE_BUFFER_LIMIT = 32768;
1021
1082
  const SHELL_OUTPUT_FLUSH_MAX_CHARS = 128 * 1024;
1022
1083
  const SHELL_WS_BACKPRESSURE_LIMIT = 8 * 1024 * 1024;
1023
- const PTY_SESSION_BUFFER_MAX_BYTES = Number.parseInt(process.env.PIXCODE_PTY_BUFFER_MAX_BYTES || '', 10) || (2 * 1024 * 1024);
1084
+ const PTY_SESSION_BUFFER_MAX_BYTES = Number.parseInt(process.env.PIXCODE_PTY_BUFFER_MAX_BYTES || '', 10) || (512 * 1024);
1024
1085
  const SHELL_INPUT_CHUNK_CHARS = 4096;
1025
- const SHELL_PENDING_INPUT_MAX_BYTES = 2 * 1024 * 1024;
1086
+ const SHELL_PENDING_INPUT_MAX_BYTES = Number.parseInt(process.env.PIXCODE_SHELL_PENDING_INPUT_MAX_BYTES || '', 10) || (16 * 1024 * 1024);
1026
1087
  const SHELL_CLI_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
1027
1088
  import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
1028
1089
 
@@ -1300,6 +1361,76 @@ function writeTerminalInputChunks(ptyProcess, data) {
1300
1361
  return true;
1301
1362
  }
1302
1363
 
1364
+ function readPtyTarget(value) {
1365
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
1366
+ }
1367
+
1368
+ function findProviderPtySession({
1369
+ provider,
1370
+ projectPath,
1371
+ user,
1372
+ tabId = null,
1373
+ sessionId = null,
1374
+ requireRunning = false,
1375
+ requirePty = false,
1376
+ }) {
1377
+ const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
1378
+ const requestUserId = user?.id ?? user?.userId ?? null;
1379
+ const canUseAnyShellSession = ['admin', 'owner'].includes(user?.role);
1380
+ const requestedTabId = readPtyTarget(tabId);
1381
+ const requestedSessionId = readPtyTarget(sessionId);
1382
+ const candidates = [];
1383
+
1384
+ for (const session of ptySessionsMap.values()) {
1385
+ if (session?.provider !== provider || session?.isPlainShell) continue;
1386
+ if (requirePty && !session?.pty) continue;
1387
+ if (requireRunning && session?.lifecycleState !== 'running') continue;
1388
+ if (!canUseAnyShellSession && session?.userId !== requestUserId) continue;
1389
+ if (requestedProjectPath && path.resolve(session.projectPath || os.homedir()) !== requestedProjectPath) continue;
1390
+ if (requestedTabId && session.tabId !== requestedTabId) continue;
1391
+ if (requestedSessionId && session.sessionId !== requestedSessionId) continue;
1392
+ candidates.push(session);
1393
+ }
1394
+
1395
+ if (candidates.length === 0) {
1396
+ return {
1397
+ status: 'missing',
1398
+ session: null,
1399
+ candidates,
1400
+ requestedProjectPath,
1401
+ };
1402
+ }
1403
+
1404
+ if ((requestedTabId || requestedSessionId) && candidates.length > 1) {
1405
+ return {
1406
+ status: 'ambiguous',
1407
+ session: null,
1408
+ candidates,
1409
+ requestedProjectPath,
1410
+ };
1411
+ }
1412
+
1413
+ const session = candidates.reduce((latest, candidate) => (
1414
+ !latest || (candidate.updatedAt || 0) > (latest.updatedAt || 0) ? candidate : latest
1415
+ ), null);
1416
+
1417
+ if (!requestedTabId && !requestedSessionId && candidates.length > 1) {
1418
+ return {
1419
+ status: 'legacy-latest',
1420
+ session,
1421
+ candidates,
1422
+ requestedProjectPath,
1423
+ };
1424
+ }
1425
+
1426
+ return {
1427
+ status: 'matched',
1428
+ session,
1429
+ candidates,
1430
+ requestedProjectPath,
1431
+ };
1432
+ }
1433
+
1303
1434
  function normalizeShellPermissionMode(value) {
1304
1435
  return typeof value === 'string' ? value.trim() : '';
1305
1436
  }
@@ -1490,6 +1621,8 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
1490
1621
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.trim()
1491
1622
  ? req.query.projectPath.trim()
1492
1623
  : null;
1624
+ const tabId = readPtyTarget(req.query.tabId);
1625
+ const sessionId = readPtyTarget(req.query.sessionId);
1493
1626
  const maxChars = Math.min(
1494
1627
  20000,
1495
1628
  Math.max(1000, Number.parseInt(String(req.query.maxChars || '12000'), 10) || 12000)
@@ -1499,33 +1632,32 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
1499
1632
  return res.status(400).json({ error: 'Unsupported provider' });
1500
1633
  }
1501
1634
 
1502
- const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
1503
- const requestUserId = req.user?.id ?? req.user?.userId ?? null;
1504
- const canReadAnyShellSession = ['admin', 'owner'].includes(req.user?.role);
1505
- let matchedSession = null;
1506
- for (const session of ptySessionsMap.values()) {
1507
- if (
1508
- session?.provider === provider &&
1509
- !session?.isPlainShell &&
1510
- (canReadAnyShellSession || session?.userId === requestUserId) &&
1511
- (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath)
1512
- ) {
1513
- if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
1514
- matchedSession = session;
1515
- }
1635
+ const match = findProviderPtySession({ provider, projectPath, user: req.user, tabId, sessionId });
1636
+
1637
+ if (!match.session) {
1638
+ if (match.status === 'ambiguous') {
1639
+ return res.status(409).json({
1640
+ active: false,
1641
+ provider,
1642
+ projectPath: match.requestedProjectPath,
1643
+ tabId,
1644
+ sessionId,
1645
+ output: '',
1646
+ message: 'Multiple provider terminal sessions match this target. Pick a specific tab.',
1647
+ });
1516
1648
  }
1517
- }
1518
-
1519
- if (!matchedSession) {
1520
1649
  return res.json({
1521
1650
  active: false,
1522
1651
  provider,
1523
- projectPath: requestedProjectPath,
1652
+ projectPath: match.requestedProjectPath,
1653
+ tabId,
1654
+ sessionId,
1524
1655
  output: '',
1525
1656
  message: 'No active provider terminal session found for this project.',
1526
1657
  });
1527
1658
  }
1528
1659
 
1660
+ const matchedSession = match.session;
1529
1661
  const rawOutput = matchedSession.buffer.join('').slice(-maxChars);
1530
1662
  const output = stripAnsiSequences(rawOutput);
1531
1663
  const terminalState = resolveProviderTerminalState(matchedSession, provider, output);
@@ -1534,7 +1666,9 @@ app.get('/api/shell/sessions/provider-output', authenticateToken, requireProject
1534
1666
  provider,
1535
1667
  projectPath: path.resolve(matchedSession.projectPath || os.homedir()),
1536
1668
  sessionId: matchedSession.sessionId || null,
1669
+ tabId: matchedSession.tabId || null,
1537
1670
  updatedAt: matchedSession.updatedAt || null,
1671
+ matchStatus: match.status,
1538
1672
  ...terminalState,
1539
1673
  output,
1540
1674
  });
@@ -1545,6 +1679,8 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
1545
1679
  const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.trim()
1546
1680
  ? req.body.projectPath.trim()
1547
1681
  : null;
1682
+ const tabId = readPtyTarget(req.body?.tabId);
1683
+ const sessionId = readPtyTarget(req.body?.sessionId);
1548
1684
  const input = typeof req.body?.input === 'string' ? req.body.input : '';
1549
1685
  const submit = req.body?.submit !== false;
1550
1686
 
@@ -1552,35 +1688,40 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
1552
1688
  return res.status(400).json({ error: 'Unsupported provider' });
1553
1689
  }
1554
1690
 
1555
- const requestedProjectPath = projectPath ? path.resolve(projectPath) : null;
1556
- const requestUserId = req.user?.id ?? req.user?.userId ?? null;
1557
- const canWriteAnyShellSession = ['admin', 'owner'].includes(req.user?.role);
1558
- let matchedSession = null;
1559
- for (const session of ptySessionsMap.values()) {
1560
- if (
1561
- session?.provider === provider &&
1562
- !session?.isPlainShell &&
1563
- session?.pty &&
1564
- session.lifecycleState === 'running' &&
1565
- (canWriteAnyShellSession || session?.userId === requestUserId) &&
1566
- (!requestedProjectPath || path.resolve(session.projectPath || os.homedir()) === requestedProjectPath)
1567
- ) {
1568
- if (!matchedSession || (session.updatedAt || 0) > (matchedSession.updatedAt || 0)) {
1569
- matchedSession = session;
1570
- }
1571
- }
1572
- }
1691
+ const match = findProviderPtySession({
1692
+ provider,
1693
+ projectPath,
1694
+ user: req.user,
1695
+ tabId,
1696
+ sessionId,
1697
+ requireRunning: true,
1698
+ requirePty: true,
1699
+ });
1573
1700
 
1574
- if (!matchedSession?.pty) {
1701
+ if (!match.session?.pty) {
1702
+ if (match.status === 'ambiguous') {
1703
+ return res.status(409).json({
1704
+ ok: false,
1705
+ provider,
1706
+ projectPath: match.requestedProjectPath,
1707
+ tabId,
1708
+ sessionId,
1709
+ wrote: false,
1710
+ message: 'Multiple running provider terminal sessions match this target. Pick a specific tab.',
1711
+ });
1712
+ }
1575
1713
  return res.status(404).json({
1576
1714
  ok: false,
1577
1715
  provider,
1578
- projectPath: requestedProjectPath,
1716
+ projectPath: match.requestedProjectPath,
1717
+ tabId,
1718
+ sessionId,
1579
1719
  wrote: false,
1580
1720
  message: 'No running provider terminal session found for this project.',
1581
1721
  });
1582
1722
  }
1583
1723
 
1724
+ const matchedSession = match.session;
1584
1725
  const data = submit ? normalizeTerminalStartupInput(input) : input;
1585
1726
  try {
1586
1727
  writeTerminalInputChunks(matchedSession.pty, data);
@@ -1590,9 +1731,11 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
1590
1731
  provider,
1591
1732
  projectPath: path.resolve(matchedSession.projectPath || os.homedir()),
1592
1733
  sessionId: matchedSession.sessionId || null,
1734
+ tabId: matchedSession.tabId || null,
1593
1735
  wrote: true,
1594
1736
  submitted: submit,
1595
1737
  bytes: Buffer.byteLength(data),
1738
+ matchStatus: match.status,
1596
1739
  });
1597
1740
  } catch (error) {
1598
1741
  res.status(500).json({
@@ -1604,6 +1747,104 @@ app.post('/api/shell/sessions/provider-input', authenticateToken, requireProject
1604
1747
  }
1605
1748
  });
1606
1749
 
1750
+ const sanitizeTelegramActiveString = (value, maxLength = 240) => {
1751
+ if (typeof value !== 'string') return null;
1752
+ const trimmed = value.trim();
1753
+ return trimmed ? trimmed.slice(0, maxLength) : null;
1754
+ };
1755
+
1756
+ function requireVerifiedTelegramLink(req, res) {
1757
+ const link = telegramLinksDb.getByUserId(req.user.id);
1758
+ if (!link?.chat_id || !link?.verified_at) {
1759
+ res.status(409).json({ error: 'Telegram is not paired for this user.' });
1760
+ return null;
1761
+ }
1762
+ return link;
1763
+ }
1764
+
1765
+ // Bind the paired Telegram chat to an already running provider terminal tab.
1766
+ // This route lives next to the PTY registry so it can verify the target is live.
1767
+ app.post('/api/telegram/active-terminal', authenticateToken, requireProjectPathAccess('useShell'), (req, res) => {
1768
+ try {
1769
+ const link = requireVerifiedTelegramLink(req, res);
1770
+ if (!link) return;
1771
+
1772
+ const provider = sanitizeTelegramActiveString(req.body?.provider, 40);
1773
+ const projectPathRaw = sanitizeTelegramActiveString(req.body?.projectPath, 2000);
1774
+ if (!provider || !SHELL_CLI_PROVIDERS.has(provider) || !projectPathRaw) {
1775
+ return res.status(400).json({ error: 'A supported provider and projectPath are required.' });
1776
+ }
1777
+
1778
+ const projectPath = path.resolve(projectPathRaw);
1779
+ const projectName = sanitizeTelegramActiveString(req.body?.projectName) || path.basename(projectPath);
1780
+ const tabId = sanitizeTelegramActiveString(req.body?.tabId, 160);
1781
+ const sessionId = sanitizeTelegramActiveString(req.body?.sessionId, 240);
1782
+
1783
+ const match = findProviderPtySession({
1784
+ provider,
1785
+ projectPath,
1786
+ user: req.user,
1787
+ tabId,
1788
+ sessionId,
1789
+ requireRunning: true,
1790
+ requirePty: true,
1791
+ });
1792
+
1793
+ if (!match.session?.pty) {
1794
+ if (match.status === 'ambiguous') {
1795
+ return res.status(409).json({
1796
+ error: 'Multiple running provider terminal sessions match this target. Pick a specific tab.',
1797
+ candidates: match.candidates.map((session) => ({
1798
+ sessionId: session.sessionId || null,
1799
+ tabId: session.tabId || null,
1800
+ projectPath: path.resolve(session.projectPath || os.homedir()),
1801
+ updatedAt: session.updatedAt || null,
1802
+ })),
1803
+ });
1804
+ }
1805
+ return res.status(404).json({
1806
+ error: 'No running provider terminal session found for this project.',
1807
+ });
1808
+ }
1809
+
1810
+ const matchedSession = match.session;
1811
+ const resolvedMatchedProjectPath = path.resolve(matchedSession.projectPath || projectPath);
1812
+ const activeTerminal = {
1813
+ provider,
1814
+ projectPath: resolvedMatchedProjectPath,
1815
+ projectName,
1816
+ projectLabel: sanitizeTelegramActiveString(req.body?.projectLabel) || projectName,
1817
+ sessionId: matchedSession.sessionId || sessionId || null,
1818
+ tabId: matchedSession.tabId || tabId || null,
1819
+ attachedAt: new Date().toISOString(),
1820
+ };
1821
+ const control = telegramLinksDb.updateControlState(req.user.id, {
1822
+ activeTerminal,
1823
+ selectedProvider: provider,
1824
+ selectedProjectName: projectName,
1825
+ selectedProjectPath: resolvedMatchedProjectPath,
1826
+ });
1827
+
1828
+ res.json({ success: true, activeTerminal: control.activeTerminal, control, matchStatus: match.status });
1829
+ } catch (error) {
1830
+ console.error('telegram/active-terminal failed:', error);
1831
+ res.status(500).json({ error: 'Failed to attach Telegram to this terminal.' });
1832
+ }
1833
+ });
1834
+
1835
+ // Return Telegram text input to the normal AI router.
1836
+ app.delete('/api/telegram/active-terminal', authenticateToken, (req, res) => {
1837
+ try {
1838
+ const link = requireVerifiedTelegramLink(req, res);
1839
+ if (!link) return;
1840
+ const control = telegramLinksDb.updateControlState(req.user.id, { activeTerminal: null });
1841
+ res.json({ success: true, control });
1842
+ } catch (error) {
1843
+ console.error('telegram/active-terminal delete failed:', error);
1844
+ res.status(500).json({ error: 'Failed to detach Telegram terminal.' });
1845
+ }
1846
+ });
1847
+
1607
1848
  // Authentication routes (public)
1608
1849
  app.use('/api/auth', authRoutes);
1609
1850
 
@@ -3491,16 +3732,23 @@ function handleShellConnection(ws, request) {
3491
3732
  const chunks = splitTerminalInput(data);
3492
3733
  if (chunks.length === 0) return;
3493
3734
 
3735
+ let droppedBytes = 0;
3494
3736
  for (const chunk of chunks) {
3737
+ const chunkBytes = Buffer.byteLength(chunk, 'utf8');
3738
+ if (pendingInputBytes + chunkBytes > SHELL_PENDING_INPUT_MAX_BYTES) {
3739
+ droppedBytes += chunkBytes;
3740
+ continue;
3741
+ }
3495
3742
  pendingInputQueue.push(chunk);
3496
- pendingInputBytes += Buffer.byteLength(chunk, 'utf8');
3743
+ pendingInputBytes += chunkBytes;
3497
3744
  }
3498
3745
 
3499
- while (pendingInputQueue.length > 0 && pendingInputBytes > SHELL_PENDING_INPUT_MAX_BYTES) {
3500
- const dropped = pendingInputQueue.shift();
3501
- pendingInputBytes -= Buffer.byteLength(String(dropped || ''), 'utf8');
3746
+ if (droppedBytes > 0 && ws.readyState === WebSocket.OPEN) {
3747
+ ws.send(JSON.stringify({
3748
+ type: 'output',
3749
+ data: `\r\n\x1b[33m[Pixcode] ${droppedBytes} bytes of pasted terminal input were not sent because the input queue limit was reached. Split very large pastes into smaller chunks.\x1b[0m\r\n`,
3750
+ }));
3502
3751
  }
3503
- if (pendingInputBytes < 0) pendingInputBytes = 0;
3504
3752
 
3505
3753
  scheduleInputFlush();
3506
3754
  }
@@ -3937,7 +4185,9 @@ function handleShellConnection(ws, request) {
3937
4185
 
3938
4186
  // Handle process exit
3939
4187
  shellProcess.onExit((exitCode) => {
3940
- console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);
4188
+ const cleanShellExit = exitCode.exitCode === 0;
4189
+ const normalizedExitSignal = cleanShellExit ? null : (exitCode.signal || null);
4190
+ console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', normalizedExitSignal);
3941
4191
  if (outputFlushTimer) {
3942
4192
  clearTimeout(outputFlushTimer);
3943
4193
  outputFlushTimer = null;
@@ -3949,11 +4199,11 @@ function handleShellConnection(ws, request) {
3949
4199
  return;
3950
4200
  }
3951
4201
 
3952
- const exitMessage = `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\x1b[0m\r\n`;
4202
+ const exitMessage = `\r\n\x1b[33mProcess exited with code ${exitCode.exitCode}${normalizedExitSignal ? ` (${normalizedExitSignal})` : ''}\x1b[0m\r\n`;
3953
4203
  if (session) {
3954
- session.lifecycleState = exitCode.exitCode === 0 && !exitCode.signal ? 'completed' : 'failed';
4204
+ session.lifecycleState = cleanShellExit ? 'completed' : 'failed';
3955
4205
  session.exitCode = typeof exitCode.exitCode === 'number' ? exitCode.exitCode : null;
3956
- session.exitSignal = exitCode.signal || null;
4206
+ session.exitSignal = normalizedExitSignal;
3957
4207
  session.completedAt = new Date().toISOString();
3958
4208
  session.updatedAt = Date.now();
3959
4209
  if (session.startupInputTimerId) {