@pixelbyte-software/pixcode 1.53.17 → 1.53.19

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
@@ -8,6 +8,7 @@ import os from 'os';
8
8
  import http from 'http';
9
9
  import net from 'node:net';
10
10
  import crypto from 'node:crypto';
11
+ import readline from 'node:readline';
11
12
  import { createRequire } from 'node:module';
12
13
  import { spawn } from 'child_process';
13
14
 
@@ -41,6 +42,7 @@ const DAEMON_COMMAND_CONTEXT = {
41
42
  cliEntry: path.join(APP_ROOT, 'server', 'cli.js'),
42
43
  nodeExecPath: process.execPath,
43
44
  };
45
+ const JSONL_STREAM_LINE_MAX_CHARS = 1024 * 1024;
44
46
 
45
47
  function resolveMonacoAssetsPath() {
46
48
  const appParent = path.dirname(APP_ROOT);
@@ -625,9 +627,25 @@ const WATCHER_IGNORED_PATTERNS = [
625
627
  '**/node_modules/**',
626
628
  '**/.git/**',
627
629
  '**/dist/**',
630
+ '**/dist-server/**',
628
631
  '**/build/**',
632
+ '**/out/**',
633
+ '**/target/**',
634
+ '**/vendor/**',
635
+ '**/prebuilts/**',
636
+ '**/.repo/**',
637
+ '**/.gradle/**',
638
+ '**/.next/**',
639
+ '**/.nuxt/**',
640
+ '**/.svelte-kit/**',
641
+ '**/.turbo/**',
642
+ '**/.cache/**',
643
+ '**/.venv/**',
644
+ '**/venv/**',
645
+ '**/coverage/**',
629
646
  '**/*.tmp',
630
647
  '**/*.swp',
648
+ '**/*.log',
631
649
  '**/.DS_Store'
632
650
  ];
633
651
  // Debounce chokidar events before rescanning all provider project trees.
@@ -638,8 +656,10 @@ const WATCHER_IGNORED_PATTERNS = [
638
656
  // a full chat reply into ~1 scan while still feeling responsive when
639
657
  // the user flips to the projects list.
640
658
  const WATCHER_DEBOUNCE_MS = Number.parseInt(process.env.PIXCODE_PROJECT_WATCH_DEBOUNCE_MS || '', 10) || 10000;
659
+ const PROVIDER_WATCHER_DEPTH = Number.parseInt(process.env.PIXCODE_PROVIDER_WATCH_DEPTH || '', 10) || 8;
641
660
  let projectsWatchers = [];
642
661
  let projectsWatcherDebounceTimer = null;
662
+ let pendingProjectsWatcherRefresh = null;
643
663
  const connectedClients = new Set();
644
664
  let isGetProjectsRunning = false; // Flag to prevent reentrant calls
645
665
  const STARTUP_INPUT_READY_TIMEOUT_MS = 10 * 60 * 1000;
@@ -678,7 +698,20 @@ async function setupProjectsWatcher() {
678
698
  );
679
699
  projectsWatchers = [];
680
700
 
701
+ const sendToOpenClient = (client, payload) => {
702
+ if (client.readyState !== WebSocket.OPEN) {
703
+ return false;
704
+ }
705
+ if (client.bufferedAmount > SHELL_WS_BACKPRESSURE_LIMIT) {
706
+ return false;
707
+ }
708
+ client.send(JSON.stringify(payload));
709
+ return true;
710
+ };
711
+
681
712
  const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
713
+ pendingProjectsWatcherRefresh = { eventType, filePath, provider, rootPath };
714
+
682
715
  if (projectsWatcherDebounceTimer) {
683
716
  clearTimeout(projectsWatcherDebounceTimer);
684
717
  }
@@ -686,35 +719,37 @@ async function setupProjectsWatcher() {
686
719
  projectsWatcherDebounceTimer = setTimeout(async () => {
687
720
  // Prevent reentrant calls
688
721
  if (isGetProjectsRunning) {
722
+ projectsWatcherDebounceTimer = setTimeout(() => {
723
+ const pending = pendingProjectsWatcherRefresh;
724
+ if (pending) {
725
+ debouncedUpdate(pending.eventType, pending.filePath, pending.provider, pending.rootPath);
726
+ }
727
+ }, WATCHER_DEBOUNCE_MS);
689
728
  return;
690
729
  }
691
730
 
692
731
  try {
693
732
  isGetProjectsRunning = true;
733
+ const pending = pendingProjectsWatcherRefresh || { eventType, filePath, provider, rootPath };
734
+ pendingProjectsWatcherRefresh = null;
694
735
 
695
- if (eventType === 'addDir' || eventType === 'unlinkDir') {
736
+ if (pending.eventType === 'addDir' || pending.eventType === 'unlinkDir') {
696
737
  clearProjectDirectoryCache();
697
738
  }
698
739
 
699
- // Get updated projects list
700
- const updatedProjects = await getProjects(broadcastProgress);
701
-
702
740
  const updatePayload = {
703
741
  type: 'projects_updated',
704
742
  timestamp: new Date().toISOString(),
705
- changeType: eventType,
706
- changedFile: path.relative(rootPath, filePath),
707
- watchProvider: provider
743
+ changeType: pending.eventType,
744
+ changedFile: path.relative(pending.rootPath, pending.filePath),
745
+ watchProvider: pending.provider,
746
+ invalidated: true,
708
747
  };
709
748
 
710
- // Notify all connected clients about project changes, scoped to their access.
749
+ // Notify clients that provider metadata changed. Avoid broadcasting the full
750
+ // project/session tree from watcher events; clients can pull when needed.
711
751
  connectedClients.forEach(client => {
712
- if (client.readyState === WebSocket.OPEN) {
713
- client.send(JSON.stringify({
714
- ...updatePayload,
715
- projects: filterProjectsForUser(updatedProjects, client.user),
716
- }));
717
- }
752
+ sendToOpenClient(client, updatePayload);
718
753
  });
719
754
 
720
755
  } catch (error) {
@@ -737,7 +772,7 @@ async function setupProjectsWatcher() {
737
772
  persistent: true,
738
773
  ignoreInitial: true, // Don't fire events for existing files on startup
739
774
  followSymlinks: false,
740
- depth: 10, // Reasonable depth limit
775
+ depth: PROVIDER_WATCHER_DEPTH,
741
776
  awaitWriteFinish: {
742
777
  // Raised from (100, 50) to (500, 250). The old settings
743
778
  // had chokidar polling every 50ms per in-flight file; an
@@ -782,6 +817,8 @@ async function setupProjectsWatcher() {
782
817
  // `project_files_updated` events to subscribed clients only, letting the
783
818
  // explorer refresh automatically without HTTP polling.
784
819
  const WORKSPACE_WATCHER_DEBOUNCE_MS = 800;
820
+ const WORKSPACE_WATCHER_DEPTH = Number.parseInt(process.env.PIXCODE_WORKSPACE_WATCH_DEPTH || '', 10) || 5;
821
+ const WORKSPACE_WS_BACKPRESSURE_LIMIT = 2 * 1024 * 1024;
785
822
  const WORKSPACE_DIFF_SNAPSHOT_MAX_BYTES = 128 * 1024;
786
823
  const WORKSPACE_DIFF_SNAPSHOT_MAX_FILES = 80;
787
824
  const WORKSPACE_DIFF_SNAPSHOT_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
@@ -1014,7 +1051,7 @@ async function subscribeToWorkspace(ws, projectName) {
1014
1051
  timestamp: new Date().toISOString(),
1015
1052
  });
1016
1053
  entry.subscribers.forEach((client) => {
1017
- if (client.readyState === WebSocket.OPEN) {
1054
+ if (client.readyState === WebSocket.OPEN && client.bufferedAmount <= WORKSPACE_WS_BACKPRESSURE_LIMIT) {
1018
1055
  client.send(message);
1019
1056
  }
1020
1057
  });
@@ -1028,7 +1065,7 @@ async function subscribeToWorkspace(ws, projectName) {
1028
1065
  persistent: true,
1029
1066
  ignoreInitial: true,
1030
1067
  followSymlinks: false,
1031
- depth: 10,
1068
+ depth: WORKSPACE_WATCHER_DEPTH,
1032
1069
  awaitWriteFinish: {
1033
1070
  stabilityThreshold: 500,
1034
1071
  pollInterval: 250
@@ -1085,6 +1122,36 @@ const PTY_SESSION_BUFFER_MAX_BYTES = Number.parseInt(process.env.PIXCODE_PTY_BUF
1085
1122
  const SHELL_INPUT_CHUNK_CHARS = 4096;
1086
1123
  const SHELL_PENDING_INPUT_MAX_BYTES = Number.parseInt(process.env.PIXCODE_SHELL_PENDING_INPUT_MAX_BYTES || '', 10) || (16 * 1024 * 1024);
1087
1124
  const SHELL_CLI_PROVIDERS = new Set(['claude', 'codex', 'cursor', 'gemini', 'qwen', 'opencode']);
1125
+ const FILE_TREE_MAX_ITEMS = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_ITEMS || '', 10) || 5000;
1126
+ const FILE_TREE_MAX_DIRECTORIES = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_DIRECTORIES || '', 10) || 1200;
1127
+ const FILE_TREE_SCAN_MAX_MS = Number.parseInt(process.env.PIXCODE_FILE_TREE_SCAN_MAX_MS || '', 10) || 4000;
1128
+ const FILE_TREE_MAX_ENTRIES_PER_DIRECTORY = Number.parseInt(process.env.PIXCODE_FILE_TREE_MAX_ENTRIES_PER_DIRECTORY || '', 10) || 1500;
1129
+ const FILE_TREE_EXCLUDED_ENTRY_NAMES = new Set([
1130
+ '.cache',
1131
+ '.git',
1132
+ '.gradle',
1133
+ '.hg',
1134
+ '.next',
1135
+ '.nuxt',
1136
+ '.pnpm-store',
1137
+ '.repo',
1138
+ '.svn',
1139
+ '.svelte-kit',
1140
+ '.turbo',
1141
+ '.venv',
1142
+ 'build',
1143
+ 'coverage',
1144
+ 'DerivedData',
1145
+ 'dist',
1146
+ 'dist-server',
1147
+ 'node_modules',
1148
+ 'out',
1149
+ 'Pods',
1150
+ 'prebuilts',
1151
+ 'target',
1152
+ 'vendor',
1153
+ 'venv',
1154
+ ]);
1088
1155
  import { stripAnsiSequences, normalizeDetectedUrl, extractUrlsFromText, shouldAutoOpenUrlFromOutput } from './utils/url-detection.js';
1089
1156
 
1090
1157
  function terminatePtySession(sessionKey, session, reason) {
@@ -4018,11 +4085,22 @@ function handleShellConnection(ws, request) {
4018
4085
  if (existingSession.buffer && existingSession.buffer.length > 0) {
4019
4086
  console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);
4020
4087
  existingSession.buffer.forEach(bufferedData => {
4088
+ if (ws.bufferedAmount > SHELL_WS_BACKPRESSURE_LIMIT) {
4089
+ existingSession.droppedOutputBytes = (existingSession.droppedOutputBytes || 0) + Buffer.byteLength(String(bufferedData || ''), 'utf8');
4090
+ return;
4091
+ }
4021
4092
  ws.send(JSON.stringify({
4022
4093
  type: 'output',
4023
4094
  data: bufferedData
4024
4095
  }));
4025
4096
  });
4097
+ if ((existingSession.droppedOutputBytes || 0) > 0 && ws.bufferedAmount <= SHELL_WS_BACKPRESSURE_LIMIT) {
4098
+ ws.send(JSON.stringify({
4099
+ type: 'output',
4100
+ data: `\r\n\x1b[33m[Pixcode] ${existingSession.droppedOutputBytes} bytes of buffered terminal output were skipped because the browser connection was backpressured.\x1b[0m\r\n`,
4101
+ }));
4102
+ existingSession.droppedOutputBytes = 0;
4103
+ }
4026
4104
  }
4027
4105
 
4028
4106
  existingSession.ws = ws;
@@ -4531,22 +4609,46 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
4531
4609
  if (provider === 'codex') {
4532
4610
  const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
4533
4611
 
4534
- // Find the session file by searching for the session ID
4612
+ // Find the session file by searching for the session ID without
4613
+ // materializing every directory entry in memory.
4535
4614
  const findSessionFile = async (dir) => {
4536
- try {
4537
- const entries = await fsPromises.readdir(dir, { withFileTypes: true });
4538
- for (const entry of entries) {
4539
- const fullPath = path.join(dir, entry.name);
4540
- if (entry.isDirectory()) {
4541
- const found = await findSessionFile(fullPath);
4542
- if (found) return found;
4543
- } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
4544
- return fullPath;
4615
+ const pendingDirs = [dir];
4616
+ let visitedDirs = 0;
4617
+ let visitedFiles = 0;
4618
+ const maxDirs = 2500;
4619
+ const maxFiles = 10000;
4620
+
4621
+ while (pendingDirs.length > 0 && visitedDirs < maxDirs && visitedFiles < maxFiles) {
4622
+ const currentDir = pendingDirs.pop();
4623
+ visitedDirs += 1;
4624
+
4625
+ try {
4626
+ const dirHandle = await fsPromises.opendir(currentDir);
4627
+ try {
4628
+ for await (const entry of dirHandle) {
4629
+ const fullPath = path.join(currentDir, entry.name);
4630
+ if (entry.isDirectory()) {
4631
+ if (visitedDirs + pendingDirs.length < maxDirs) {
4632
+ pendingDirs.push(fullPath);
4633
+ }
4634
+ } else {
4635
+ visitedFiles += 1;
4636
+ if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
4637
+ return fullPath;
4638
+ }
4639
+ if (visitedFiles >= maxFiles) {
4640
+ break;
4641
+ }
4642
+ }
4643
+ }
4644
+ } finally {
4645
+ await dirHandle.close().catch(() => undefined);
4545
4646
  }
4647
+ } catch (error) {
4648
+ // Skip directories we can't read
4546
4649
  }
4547
- } catch (error) {
4548
- // Skip directories we can't read
4549
4650
  }
4651
+
4550
4652
  return null;
4551
4653
  };
4552
4654
 
@@ -4556,24 +4658,31 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
4556
4658
  return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
4557
4659
  }
4558
4660
 
4559
- // Read and parse the Codex JSONL file
4560
- let fileContent;
4661
+ // Stream and parse the Codex JSONL file. Keeping only the latest
4662
+ // token_count event avoids loading very large session logs.
4561
4663
  try {
4562
- fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
4664
+ await fsPromises.access(sessionFilePath);
4563
4665
  } catch (error) {
4564
4666
  if (error.code === 'ENOENT') {
4565
4667
  return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
4566
4668
  }
4567
4669
  throw error;
4568
4670
  }
4569
- const lines = fileContent.trim().split('\n');
4671
+ const fileStream = fs.createReadStream(sessionFilePath, { encoding: 'utf8' });
4672
+ const rl = readline.createInterface({
4673
+ input: fileStream,
4674
+ crlfDelay: Infinity,
4675
+ });
4570
4676
  let totalTokens = 0;
4571
4677
  let contextWindow = 200000; // Default for Codex/OpenAI
4572
4678
 
4573
- // Find the latest token_count event with info (scan from end)
4574
- for (let i = lines.length - 1; i >= 0; i--) {
4679
+ for await (const line of rl) {
4680
+ if (!line.trim() || line.length > JSONL_STREAM_LINE_MAX_CHARS) {
4681
+ continue;
4682
+ }
4683
+
4575
4684
  try {
4576
- const entry = JSON.parse(lines[i]);
4685
+ const entry = JSON.parse(line);
4577
4686
 
4578
4687
  // Codex stores token info in event_msg with type: "token_count"
4579
4688
  if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
@@ -4584,7 +4693,6 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
4584
4693
  if (tokenInfo.model_context_window) {
4585
4694
  contextWindow = tokenInfo.model_context_window;
4586
4695
  }
4587
- break; // Stop after finding the latest token count
4588
4696
  }
4589
4697
  } catch (parseError) {
4590
4698
  // Skip lines that can't be parsed
@@ -4622,17 +4730,21 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
4622
4730
  return res.status(400).json({ error: 'Invalid path' });
4623
4731
  }
4624
4732
 
4625
- // Read and parse the JSONL file
4626
- let fileContent;
4733
+ // Stream and parse the JSONL file. Keeping only the latest assistant
4734
+ // usage object avoids loading very large session logs.
4627
4735
  try {
4628
- fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
4736
+ await fsPromises.access(jsonlPath);
4629
4737
  } catch (error) {
4630
4738
  if (error.code === 'ENOENT') {
4631
4739
  return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
4632
4740
  }
4633
4741
  throw error; // Re-throw other errors to be caught by outer try-catch
4634
4742
  }
4635
- const lines = fileContent.trim().split('\n');
4743
+ const fileStream = fs.createReadStream(jsonlPath, { encoding: 'utf8' });
4744
+ const rl = readline.createInterface({
4745
+ input: fileStream,
4746
+ crlfDelay: Infinity,
4747
+ });
4636
4748
 
4637
4749
  const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
4638
4750
  const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
@@ -4640,10 +4752,13 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
4640
4752
  let cacheCreationTokens = 0;
4641
4753
  let cacheReadTokens = 0;
4642
4754
 
4643
- // Find the latest assistant message with usage data (scan from end)
4644
- for (let i = lines.length - 1; i >= 0; i--) {
4755
+ for await (const line of rl) {
4756
+ if (!line.trim() || line.length > JSONL_STREAM_LINE_MAX_CHARS) {
4757
+ continue;
4758
+ }
4759
+
4645
4760
  try {
4646
- const entry = JSON.parse(lines[i]);
4761
+ const entry = JSON.parse(line);
4647
4762
 
4648
4763
  // Only count assistant messages which have usage data
4649
4764
  if (entry.type === 'assistant' && entry.message?.usage) {
@@ -4653,8 +4768,6 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
4653
4768
  inputTokens = usage.input_tokens || 0;
4654
4769
  cacheCreationTokens = usage.cache_creation_input_tokens || 0;
4655
4770
  cacheReadTokens = usage.cache_read_input_tokens || 0;
4656
-
4657
- break; // Stop after finding the latest assistant message
4658
4771
  }
4659
4772
  } catch (parseError) {
4660
4773
  // Skip lines that can't be parsed
@@ -4772,74 +4885,124 @@ function permToRwx(perm) {
4772
4885
  return r + w + x;
4773
4886
  }
4774
4887
 
4775
- async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
4776
- // Using fsPromises from import
4777
- const items = [];
4888
+ function createFileTreeScanContext() {
4889
+ return {
4890
+ startedAt: Date.now(),
4891
+ itemCount: 0,
4892
+ directoryCount: 0,
4893
+ limitReached: false,
4894
+ };
4895
+ }
4778
4896
 
4779
- try {
4780
- const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
4897
+ function hasFileTreeBudget(context) {
4898
+ if (!context || context.limitReached) {
4899
+ return false;
4900
+ }
4781
4901
 
4782
- for (const entry of entries) {
4783
- // Debug: log all entries including hidden files
4902
+ if (context.itemCount >= FILE_TREE_MAX_ITEMS || context.directoryCount >= FILE_TREE_MAX_DIRECTORIES) {
4903
+ context.limitReached = true;
4904
+ return false;
4905
+ }
4784
4906
 
4907
+ if (Date.now() - context.startedAt > FILE_TREE_SCAN_MAX_MS) {
4908
+ context.limitReached = true;
4909
+ return false;
4910
+ }
4785
4911
 
4786
- // Skip heavy build directories and VCS directories
4787
- if (entry.name === 'node_modules' ||
4788
- entry.name === 'dist' ||
4789
- entry.name === 'build' ||
4790
- entry.name === '.git' ||
4791
- entry.name === '.svn' ||
4792
- entry.name === '.hg') continue;
4912
+ return true;
4913
+ }
4793
4914
 
4794
- const itemPath = path.join(dirPath, entry.name);
4795
- const item = {
4796
- name: entry.name,
4797
- path: itemPath,
4798
- type: entry.isDirectory() ? 'directory' : 'file'
4799
- };
4915
+ function shouldSkipFileTreeEntry(entryName, showHidden) {
4916
+ if (!showHidden && entryName.startsWith('.')) {
4917
+ return true;
4918
+ }
4800
4919
 
4801
- // Get file stats for additional metadata
4802
- try {
4803
- const stats = await fsPromises.stat(itemPath);
4804
- item.size = stats.size;
4805
- item.modified = stats.mtime.toISOString();
4806
-
4807
- // Convert permissions to rwx format
4808
- const mode = stats.mode;
4809
- const ownerPerm = (mode >> 6) & 7;
4810
- const groupPerm = (mode >> 3) & 7;
4811
- const otherPerm = mode & 7;
4812
- item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
4813
- item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
4814
- } catch (statError) {
4815
- // If stat fails, provide default values
4816
- item.size = 0;
4817
- item.modified = null;
4818
- item.permissions = '000';
4819
- item.permissionsRwx = '---------';
4820
- }
4920
+ return FILE_TREE_EXCLUDED_ENTRY_NAMES.has(entryName)
4921
+ || entryName.endsWith('.log')
4922
+ || entryName === '.DS_Store';
4923
+ }
4821
4924
 
4822
- if (entry.isDirectory() && currentDepth < maxDepth) {
4823
- // Recursively get subdirectories but limit depth
4824
- try {
4825
- // Check if we can access the directory before trying to read it
4826
- await fsPromises.access(item.path, fs.constants.R_OK);
4827
- item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
4828
- } catch (e) {
4829
- // Silently skip directories we can't access (permission denied, etc.)
4830
- item.children = [];
4925
+ async function readDirectoryEntriesBounded(dirPath, context) {
4926
+ const entries = [];
4927
+
4928
+ try {
4929
+ const dir = await fsPromises.opendir(dirPath);
4930
+ try {
4931
+ for await (const entry of dir) {
4932
+ if (!hasFileTreeBudget(context) || entries.length >= FILE_TREE_MAX_ENTRIES_PER_DIRECTORY) {
4933
+ context.limitReached = true;
4934
+ break;
4831
4935
  }
4832
- }
4833
4936
 
4834
- items.push(item);
4937
+ entries.push(entry);
4938
+ }
4939
+ } finally {
4940
+ await dir.close().catch(() => undefined);
4835
4941
  }
4836
4942
  } catch (error) {
4837
- // Only log non-permission errors to avoid spam
4838
- if (error.code !== 'EACCES' && error.code !== 'EPERM') {
4943
+ if (error.code !== 'EACCES' && error.code !== 'EPERM' && error.code !== 'ENOENT') {
4839
4944
  console.error('Error reading directory:', error);
4840
4945
  }
4841
4946
  }
4842
4947
 
4948
+ return entries;
4949
+ }
4950
+
4951
+ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true, scanContext = null) {
4952
+ const context = scanContext || createFileTreeScanContext();
4953
+ const items = [];
4954
+
4955
+ if (!hasFileTreeBudget(context)) {
4956
+ return items;
4957
+ }
4958
+
4959
+ context.directoryCount += 1;
4960
+ const entries = await readDirectoryEntriesBounded(dirPath, context);
4961
+
4962
+ for (const entry of entries) {
4963
+ if (!hasFileTreeBudget(context) || shouldSkipFileTreeEntry(entry.name, showHidden)) {
4964
+ continue;
4965
+ }
4966
+
4967
+ const itemPath = path.join(dirPath, entry.name);
4968
+ const item = {
4969
+ name: entry.name,
4970
+ path: itemPath,
4971
+ type: entry.isDirectory() ? 'directory' : 'file'
4972
+ };
4973
+
4974
+ try {
4975
+ const stats = await fsPromises.stat(itemPath);
4976
+ item.size = stats.size;
4977
+ item.modified = stats.mtime.toISOString();
4978
+
4979
+ const mode = stats.mode;
4980
+ const ownerPerm = (mode >> 6) & 7;
4981
+ const groupPerm = (mode >> 3) & 7;
4982
+ const otherPerm = mode & 7;
4983
+ item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
4984
+ item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
4985
+ } catch {
4986
+ item.size = 0;
4987
+ item.modified = null;
4988
+ item.permissions = '000';
4989
+ item.permissionsRwx = '---------';
4990
+ }
4991
+
4992
+ context.itemCount += 1;
4993
+
4994
+ if (entry.isDirectory() && currentDepth < maxDepth && hasFileTreeBudget(context)) {
4995
+ try {
4996
+ await fsPromises.access(item.path, fs.constants.R_OK);
4997
+ item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden, context);
4998
+ } catch {
4999
+ item.children = [];
5000
+ }
5001
+ }
5002
+
5003
+ items.push(item);
5004
+ }
5005
+
4843
5006
  return items.sort((a, b) => {
4844
5007
  if (a.type !== b.type) {
4845
5008
  return a.type === 'directory' ? -1 : 1;