tabminal 1.1.0

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +95 -0
  3. package/build.mjs +156 -0
  4. package/config.sample.json +11 -0
  5. package/icon-gen.html +46 -0
  6. package/package.json +54 -0
  7. package/public/app.js +2245 -0
  8. package/public/apple-touch-icon.png +0 -0
  9. package/public/favicon.svg +23 -0
  10. package/public/fonts/MonaspaceNeon-Bold.woff2 +0 -0
  11. package/public/fonts/MonaspaceNeon-Regular.woff2 +0 -0
  12. package/public/icons/c.svg +1 -0
  13. package/public/icons/certificate.svg +1 -0
  14. package/public/icons/console.svg +1 -0
  15. package/public/icons/cpp.svg +1 -0
  16. package/public/icons/css.svg +1 -0
  17. package/public/icons/docker.svg +1 -0
  18. package/public/icons/document.svg +1 -0
  19. package/public/icons/folder-src-open.svg +1 -0
  20. package/public/icons/folder-src.svg +1 -0
  21. package/public/icons/git.svg +1 -0
  22. package/public/icons/go.svg +1 -0
  23. package/public/icons/html.svg +1 -0
  24. package/public/icons/image.svg +1 -0
  25. package/public/icons/java.svg +1 -0
  26. package/public/icons/javascript.svg +1 -0
  27. package/public/icons/json.svg +1 -0
  28. package/public/icons/lock.svg +1 -0
  29. package/public/icons/map.json +52 -0
  30. package/public/icons/markdown.svg +1 -0
  31. package/public/icons/nodejs.svg +1 -0
  32. package/public/icons/php.svg +1 -0
  33. package/public/icons/python.svg +1 -0
  34. package/public/icons/react.svg +1 -0
  35. package/public/icons/react_ts.svg +1 -0
  36. package/public/icons/readme.svg +1 -0
  37. package/public/icons/ruby.svg +1 -0
  38. package/public/icons/rust.svg +1 -0
  39. package/public/icons/svg.svg +1 -0
  40. package/public/icons/tsconfig.svg +1 -0
  41. package/public/icons/tune.svg +1 -0
  42. package/public/icons/typescript.svg +1 -0
  43. package/public/icons/xml.svg +1 -0
  44. package/public/icons/yaml.svg +1 -0
  45. package/public/index.html +227 -0
  46. package/public/manifest.json +15 -0
  47. package/public/styles.css +1267 -0
  48. package/shell/terminal_ver +12 -0
  49. package/src/auth.mjs +86 -0
  50. package/src/config.mjs +199 -0
  51. package/src/fs-routes.mjs +125 -0
  52. package/src/persistence.mjs +174 -0
  53. package/src/server.mjs +306 -0
  54. package/src/system-monitor.mjs +108 -0
  55. package/src/terminal-manager.mjs +283 -0
  56. package/src/terminal-session.mjs +984 -0
@@ -0,0 +1,12 @@
1
+ #!/bin/bash
2
+ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
3
+ ROOT_DIR="$(dirname "$DIR")"
4
+ PACKAGE_JSON="$ROOT_DIR/package.json"
5
+
6
+ if [ -f "$PACKAGE_JSON" ]; then
7
+ NAME=$(grep -o '"name": *"[^"]*"' "$PACKAGE_JSON" | cut -d'"' -f4)
8
+ VERSION=$(grep -o '"version": *"[^"]*"' "$PACKAGE_JSON" | cut -d'"' -f4)
9
+ echo "$NAME v$VERSION"
10
+ else
11
+ echo "Error: package.json not found at $PACKAGE_JSON"
12
+ fi
package/src/auth.mjs ADDED
@@ -0,0 +1,86 @@
1
+ import { config } from './config.mjs';
2
+
3
+ let failedAttempts = 0;
4
+ let isLocked = false;
5
+ const MAX_ATTEMPTS = 30;
6
+
7
+ export function checkAuth(providedHash) {
8
+ if (isLocked) {
9
+ return { success: false, locked: true };
10
+ }
11
+
12
+ if (!providedHash || providedHash !== config.passwordHash) {
13
+ failedAttempts++;
14
+ if (failedAttempts >= MAX_ATTEMPTS) {
15
+ isLocked = true;
16
+ console.error('[Auth] Maximum failed attempts reached. Service locked.');
17
+ }
18
+ return { success: false, locked: isLocked };
19
+ }
20
+
21
+ // Reset attempts on success?
22
+ // Requirement says "already wrong 30 times, service locked".
23
+ // Usually success resets counter, but strict interpretation might mean cumulative.
24
+ // Assuming standard behavior: success resets counter to avoid accidental lockout over long periods.
25
+ failedAttempts = 0;
26
+ return { success: true, locked: false };
27
+ }
28
+
29
+ export async function authMiddleware(ctx, next) {
30
+ // Allow health check without auth
31
+ if (ctx.path === '/healthz') {
32
+ return next();
33
+ }
34
+
35
+ // Check for Authorization header
36
+ const authHeader = ctx.get('Authorization') || ctx.query.token;
37
+ // Expecting "Authorization: <sha256-hash>"
38
+ // Some clients might send "Bearer <hash>", let's handle raw hash for simplicity as per prompt
39
+ // "header中攜帶這個編碼過的密碼" -> implies the value is the hash.
40
+
41
+ const { success, locked } = checkAuth(authHeader);
42
+
43
+ if (locked) {
44
+ ctx.status = 403;
45
+ ctx.body = { error: 'Service locked due to too many failed attempts. Please restart the service.' };
46
+ return;
47
+ }
48
+
49
+ if (!success) {
50
+ ctx.status = 401;
51
+ ctx.body = { error: 'Unauthorized' };
52
+ return;
53
+ }
54
+
55
+ await next();
56
+ }
57
+
58
+ export function verifyClient(info, cb) {
59
+ const { req } = info;
60
+ // WebSocket headers are in req.headers
61
+ let authHeader = req.headers['authorization'];
62
+
63
+ // If no header, check query parameter
64
+ if (!authHeader && req.url) {
65
+ try {
66
+ const url = new URL(req.url, `http://${req.headers.host}`);
67
+ authHeader = url.searchParams.get('token');
68
+ } catch (e) {
69
+ // ignore invalid url
70
+ }
71
+ }
72
+
73
+ const { success, locked } = checkAuth(authHeader);
74
+
75
+ if (locked) {
76
+ cb(false, 403, 'Service locked');
77
+ return;
78
+ }
79
+
80
+ if (!success) {
81
+ cb(false, 401, 'Unauthorized');
82
+ return;
83
+ }
84
+
85
+ cb(true);
86
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,199 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import { parseArgs } from 'node:util';
6
+
7
+ const DEFAULT_CONFIG = {
8
+ host: '127.0.0.1',
9
+ port: 9846,
10
+ heartbeatInterval: 10000,
11
+ historyLimit: 524288,
12
+ acceptTerms: false,
13
+ password: null,
14
+ model: 'gemini-2.5-flash-preview-09-2025',
15
+ debug: false,
16
+ openrouterKey: null,
17
+ googleKey: null,
18
+ googleCx: null
19
+ };
20
+
21
+ function loadJson(filePath) {
22
+ try {
23
+ if (fs.existsSync(filePath)) {
24
+ const content = fs.readFileSync(filePath, 'utf8');
25
+ return JSON.parse(content);
26
+ }
27
+ } catch (error) {
28
+ console.warn(`[Config] Failed to load config from ${filePath}:`, error.message);
29
+ }
30
+ return {};
31
+ }
32
+
33
+ function generateRandomPassword(length = 32) {
34
+ return crypto.randomBytes(Math.ceil(length / 2)).toString('hex').slice(0, length);
35
+ }
36
+
37
+ function sha256(input) {
38
+ return crypto.createHash('sha256').update(input).digest('hex');
39
+ }
40
+
41
+ function loadConfig() {
42
+ // 1. Load from ~/.tabminal/config.json
43
+ const configDir = path.join(os.homedir(), '.tabminal');
44
+ const homeConfigPath = path.join(configDir, 'config.json');
45
+
46
+ try {
47
+ if (!fs.existsSync(configDir)) {
48
+ fs.mkdirSync(configDir, { recursive: true });
49
+ }
50
+ } catch (e) {
51
+ console.warn('[Config] Failed to create config directory:', e.message);
52
+ }
53
+
54
+ const homeConfig = loadJson(homeConfigPath);
55
+
56
+ // 2. Load from ./config.json
57
+ const localConfigPath = path.join(process.cwd(), 'config.json');
58
+ const localConfig = loadJson(localConfigPath);
59
+
60
+ // 3. Parse CLI arguments
61
+ const { values: args } = parseArgs({
62
+ options: {
63
+ host: {
64
+ type: 'string',
65
+ short: 'h'
66
+ },
67
+ port: {
68
+ type: 'string', // Parse as string first to handle potential non-numeric input safely
69
+ short: 'p'
70
+ },
71
+ password: {
72
+ type: 'string',
73
+ short: 'a'
74
+ },
75
+ 'openrouter-key': {
76
+ type: 'string',
77
+ short: 'k'
78
+ },
79
+ model: {
80
+ type: 'string',
81
+ short: 'm'
82
+ },
83
+ debug: {
84
+ type: 'boolean',
85
+ short: 'd'
86
+ },
87
+ 'google-key': {
88
+ type: 'string',
89
+ short: 'g'
90
+ },
91
+ 'google-cx': {
92
+ type: 'string',
93
+ short: 'c'
94
+ },
95
+ help: {
96
+ type: 'boolean'
97
+ },
98
+ 'accept-terms': {
99
+ type: 'boolean',
100
+ short: 'y'
101
+ }
102
+ },
103
+ strict: false // Allow other args if necessary
104
+ });
105
+
106
+ if (args.help) {
107
+ console.log(`
108
+ Tabminal - A modern web terminal
109
+
110
+ Usage:
111
+ node src/server.mjs [options]
112
+
113
+ Options:
114
+ --host, -h Host to bind to (default: 127.0.0.1)
115
+ --port, -p Port to listen on (default: 9846)
116
+ --password, -a Set access password
117
+ --openrouter-key, -k Set OpenRouter API Key
118
+ --model, -m Set AI Model
119
+ --google-key, -g Set Google Search API Key
120
+ --google-cx, -c Set Google Search Engine ID
121
+ --debug, -d Enable debug mode
122
+ --accept-terms, -y Accept security warning and start server
123
+ --help Show this help message
124
+ `);
125
+ process.exit(0);
126
+ }
127
+
128
+ // Merge configurations: Defaults < Home < Local < CLI
129
+ const finalConfig = {
130
+ ...DEFAULT_CONFIG,
131
+ ...homeConfig,
132
+ ...localConfig
133
+ };
134
+
135
+ // Normalize config keys (support kebab-case in JSON)
136
+ if (finalConfig['accept-terms']) finalConfig.acceptTerms = finalConfig['accept-terms'];
137
+ if (finalConfig['openrouter-key']) finalConfig.openrouterKey = finalConfig['openrouter-key'];
138
+ if (finalConfig['ai-key']) finalConfig.openrouterKey = finalConfig['ai-key']; // Backwards compat
139
+ if (finalConfig['google-key']) finalConfig.googleKey = finalConfig['google-key'];
140
+ if (finalConfig['google-cx']) finalConfig.googleCx = finalConfig['google-cx'];
141
+
142
+ if (args.host) {
143
+ finalConfig.host = args.host;
144
+ }
145
+ if (args.port) {
146
+ const parsedPort = parseInt(args.port, 10);
147
+ if (!isNaN(parsedPort)) {
148
+ finalConfig.port = parsedPort;
149
+ }
150
+ }
151
+ if (args['accept-terms']) {
152
+ finalConfig.acceptTerms = true;
153
+ }
154
+ if (args.password) {
155
+ finalConfig.password = args.password;
156
+ }
157
+ if (args['openrouter-key']) {
158
+ finalConfig.openrouterKey = args['openrouter-key'];
159
+ }
160
+ if (args.model) {
161
+ finalConfig.model = args.model;
162
+ }
163
+ if (args.debug) {
164
+ finalConfig.debug = true;
165
+ }
166
+ if (args['google-key']) {
167
+ finalConfig.googleKey = args['google-key'];
168
+ }
169
+ if (args['google-cx']) {
170
+ finalConfig.googleCx = args['google-cx'];
171
+ }
172
+
173
+ // Environment variables override (for backward compatibility/container usage)
174
+ if (process.env.HOST) finalConfig.host = process.env.HOST;
175
+ if (process.env.PORT) finalConfig.port = parseInt(process.env.PORT, 10);
176
+ if (process.env.TABMINAL_HEARTBEAT) finalConfig.heartbeatInterval = parseInt(process.env.TABMINAL_HEARTBEAT, 10);
177
+ if (process.env.TABMINAL_HISTORY) finalConfig.historyLimit = parseInt(process.env.TABMINAL_HISTORY, 10);
178
+ if (process.env.TABMINAL_PASSWORD) finalConfig.password = process.env.TABMINAL_PASSWORD;
179
+ if (process.env.TABMINAL_OPENROUTER_KEY) finalConfig.openrouterKey = process.env.TABMINAL_OPENROUTER_KEY;
180
+ if (process.env.TABMINAL_MODEL) finalConfig.model = process.env.TABMINAL_MODEL;
181
+ if (process.env.TABMINAL_DEBUG) finalConfig.debug = true;
182
+ if (process.env.TABMINAL_GOOGLE_KEY) finalConfig.googleKey = process.env.TABMINAL_GOOGLE_KEY;
183
+ if (process.env.TABMINAL_GOOGLE_CX) finalConfig.googleCx = process.env.TABMINAL_GOOGLE_CX;
184
+
185
+ // Password Logic
186
+ if (!finalConfig.password) {
187
+ finalConfig.password = generateRandomPassword();
188
+ console.log('\n[SECURITY] No password provided. Generated temporary password:');
189
+ console.log(`\x1b[36m${finalConfig.password}\x1b[0m`);
190
+ console.log('Please save this password or set a custom one using -a/--passwd.\n');
191
+ }
192
+
193
+ // Store SHA256 hash in memory
194
+ finalConfig.passwordHash = sha256(finalConfig.password);
195
+
196
+ return finalConfig;
197
+ }
198
+
199
+ export const config = loadConfig();
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+
6
+ // Helper to safely resolve path
7
+ const resolvePath = (baseDir, targetPath) => {
8
+ return path.resolve(baseDir, targetPath);
9
+ };
10
+
11
+ export const setupFsRoutes = (router) => {
12
+ const baseDir = process.cwd(); // Or config.homeDir if you want to restrict/change it
13
+
14
+ // List directory
15
+ router.get('/api/fs/list', async (ctx) => {
16
+ const dirPath = ctx.query.path || '.';
17
+ try {
18
+ const fullPath = resolvePath(baseDir, dirPath);
19
+ const stats = await fs.stat(fullPath);
20
+
21
+ if (!stats.isDirectory()) {
22
+ ctx.status = 400;
23
+ ctx.body = { error: 'Not a directory' };
24
+ return;
25
+ }
26
+
27
+ const dirents = await fs.readdir(fullPath, { withFileTypes: true });
28
+
29
+ const files = dirents
30
+ .filter(dirent => dirent.name !== '.DS_Store')
31
+ .map(dirent => {
32
+ return {
33
+ name: dirent.name,
34
+ isDirectory: dirent.isDirectory(),
35
+ path: path.join(dirPath, dirent.name),
36
+ // Add basic icon/type hint logic here if needed later
37
+ };
38
+ });
39
+
40
+ // Sort: Directories first, then files
41
+ files.sort((a, b) => {
42
+ if (a.isDirectory === b.isDirectory) {
43
+ return a.name.localeCompare(b.name);
44
+ }
45
+ return a.isDirectory ? -1 : 1;
46
+ });
47
+
48
+ ctx.body = files;
49
+ } catch (err) {
50
+ console.error('FS List Error:', err);
51
+ ctx.status = 500;
52
+ ctx.body = { error: err.message };
53
+ }
54
+ });
55
+
56
+ // Read file
57
+ router.get('/api/fs/read', async (ctx) => {
58
+ const filePath = ctx.query.path;
59
+ if (!filePath) {
60
+ ctx.status = 400;
61
+ ctx.body = { error: 'Path required' };
62
+ return;
63
+ }
64
+
65
+ try {
66
+ const fullPath = resolvePath(baseDir, filePath);
67
+ const stats = await fs.stat(fullPath);
68
+
69
+ if (stats.size > 1024 * 1024 * 5) { // 5MB limit for now
70
+ ctx.status = 400;
71
+ ctx.body = { error: 'File too large' };
72
+ return;
73
+ }
74
+
75
+ const content = await fs.readFile(fullPath, 'utf-8');
76
+
77
+ let readonly = false;
78
+ try {
79
+ const handle = await fs.open(fullPath, 'r+');
80
+ await handle.close();
81
+ } catch (e) {
82
+ readonly = true;
83
+ }
84
+
85
+ ctx.body = { content, readonly };
86
+ } catch (err) {
87
+ console.error('FS Read Error:', err);
88
+ ctx.status = 500;
89
+ ctx.body = { error: err.message };
90
+ }
91
+ });
92
+
93
+ // Raw file access (for images)
94
+ router.get('/api/fs/raw', async (ctx) => {
95
+ const filePath = ctx.query.path;
96
+ if (!filePath) {
97
+ ctx.status = 400;
98
+ return;
99
+ }
100
+
101
+ try {
102
+ const fullPath = resolvePath(baseDir, filePath);
103
+ // Basic mime type handling could be added here
104
+ const ext = path.extname(fullPath).toLowerCase();
105
+ const mimeTypes = {
106
+ '.png': 'image/png',
107
+ '.jpg': 'image/jpeg',
108
+ '.jpeg': 'image/jpeg',
109
+ '.gif': 'image/gif',
110
+ '.svg': 'image/svg+xml',
111
+ '.webp': 'image/webp'
112
+ };
113
+
114
+ if (mimeTypes[ext]) {
115
+ ctx.type = mimeTypes[ext];
116
+ ctx.body = await fs.readFile(fullPath);
117
+ } else {
118
+ ctx.status = 400;
119
+ ctx.body = 'Unsupported file type for raw access';
120
+ }
121
+ } catch (err) {
122
+ ctx.status = 404;
123
+ }
124
+ });
125
+ };
@@ -0,0 +1,174 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ const HOME_DIR = os.homedir();
6
+ const BASE_DIR = path.join(HOME_DIR, '.tabminal');
7
+ const SESSIONS_DIR = path.join(BASE_DIR, 'sessions');
8
+ const MEMORY_FILE = path.join(BASE_DIR, 'memory.json');
9
+
10
+ // Ensure directories exist
11
+ const init = async () => {
12
+ try {
13
+ await fs.mkdir(SESSIONS_DIR, { recursive: true });
14
+ } catch (e) {
15
+ console.error('[Persistence] Failed to create directories:', e);
16
+ }
17
+ };
18
+
19
+ // --- Session Persistence ---
20
+
21
+ export const saveSession = async (id, data) => {
22
+ await init();
23
+ const filePath = path.join(SESSIONS_DIR, `${id}.json`);
24
+ try {
25
+ // We only save serializable data
26
+ const serializable = {
27
+ id: data.id,
28
+ title: data.title,
29
+ cwd: data.cwd,
30
+ env: data.env,
31
+ cols: data.cols,
32
+ rows: data.rows,
33
+ createdAt: data.createdAt,
34
+ // Editor State
35
+ editorState: data.editorState || {},
36
+ executions: data.executions || []
37
+ };
38
+ await fs.writeFile(filePath, JSON.stringify(serializable, null, 2));
39
+ } catch (e) {
40
+ console.error(`[Persistence] Failed to save session ${id}:`, e);
41
+ }
42
+ };
43
+
44
+ export const deleteSession = async (id) => {
45
+ const jsonPath = path.join(SESSIONS_DIR, `${id}.json`);
46
+ const logPath = path.join(SESSIONS_DIR, `${id}.log`);
47
+
48
+ try {
49
+ await fs.unlink(jsonPath);
50
+ } catch (e) {
51
+ if (e.code !== 'ENOENT') console.error(`[Persistence] Failed to delete session config ${id}:`, e);
52
+ }
53
+
54
+ try {
55
+ await fs.unlink(logPath);
56
+ } catch (e) {
57
+ if (e.code !== 'ENOENT') console.error(`[Persistence] Failed to delete session log ${id}:`, e);
58
+ }
59
+ };
60
+
61
+ export const loadSessions = async () => {
62
+ await init();
63
+ try {
64
+ const files = await fs.readdir(SESSIONS_DIR);
65
+ const sessions = [];
66
+ for (const file of files) {
67
+ if (file.endsWith('.json')) {
68
+ try {
69
+ const content = await fs.readFile(path.join(SESSIONS_DIR, file), 'utf-8');
70
+ sessions.push(JSON.parse(content));
71
+ } catch (e) {
72
+ console.warn(`[Persistence] Failed to parse session file ${file}, deleting it:`, e);
73
+ try {
74
+ await fs.unlink(path.join(SESSIONS_DIR, file));
75
+ } catch (delErr) {
76
+ console.error(`[Persistence] Failed to delete corrupted file ${file}:`, delErr);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ // Sort by creation time if possible, or just return
82
+ return sessions.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
83
+ } catch (e) {
84
+ console.error('[Persistence] Failed to load sessions:', e);
85
+ return [];
86
+ }
87
+ };
88
+
89
+ // --- Memory Persistence (Global State) ---
90
+
91
+ const defaultMemory = {
92
+ expandedFolders: [] // Array of { path: string, timestamp: number }
93
+ };
94
+
95
+ export const loadMemory = async () => {
96
+ await init();
97
+ try {
98
+ const content = await fs.readFile(MEMORY_FILE, 'utf-8');
99
+ return { ...defaultMemory, ...JSON.parse(content) };
100
+ } catch (e) {
101
+ return defaultMemory;
102
+ }
103
+ };
104
+
105
+ export const saveMemory = async (memory) => {
106
+ await init();
107
+ try {
108
+ await fs.writeFile(MEMORY_FILE, JSON.stringify(memory, null, 2));
109
+ } catch (e) {
110
+ console.error('[Persistence] Failed to save memory:', e);
111
+ }
112
+ };
113
+
114
+ export const updateExpandedFolder = async (folderPath, isExpanded) => {
115
+ const memory = await loadMemory();
116
+ let list = memory.expandedFolders || [];
117
+
118
+ if (isExpanded) {
119
+ // Remove existing if present (to update timestamp/position)
120
+ list = list.filter(item => item.path !== folderPath);
121
+ // Add to top
122
+ list.unshift({ path: folderPath, timestamp: Date.now() });
123
+ // Limit to 100
124
+ if (list.length > 100) {
125
+ list = list.slice(0, 100);
126
+ }
127
+ } else {
128
+ // Remove
129
+ list = list.filter(item => item.path !== folderPath);
130
+ }
131
+
132
+ memory.expandedFolders = list;
133
+ await saveMemory(memory);
134
+ return list.map(item => item.path); // Return just paths for frontend
135
+ };
136
+
137
+ export const getExpandedFolders = async () => {
138
+ const memory = await loadMemory();
139
+ return (memory.expandedFolders || []).map(item => item.path);
140
+ };
141
+
142
+ // --- Raw Log Persistence ---
143
+
144
+ export const appendSessionLog = async (id, chunk) => {
145
+ await init();
146
+ const filePath = path.join(SESSIONS_DIR, `${id}.log`);
147
+ try {
148
+ await fs.appendFile(filePath, chunk);
149
+ } catch (e) {
150
+ console.error(`[Persistence] Failed to append log for ${id}:`, e);
151
+ }
152
+ };
153
+
154
+ export const loadSessionLog = async (id, limit = 1024 * 1024) => {
155
+ const filePath = path.join(SESSIONS_DIR, `${id}.log`);
156
+ try {
157
+ const stats = await fs.stat(filePath);
158
+ const size = stats.size;
159
+ const start = Math.max(0, size - limit);
160
+ const length = size - start;
161
+
162
+ if (length <= 0) return '';
163
+
164
+ const handle = await fs.open(filePath, 'r');
165
+ const buffer = Buffer.alloc(length);
166
+ await handle.read(buffer, 0, length, start);
167
+ await handle.close();
168
+
169
+ return buffer.toString('utf-8');
170
+ } catch (e) {
171
+ if (e.code !== 'ENOENT') console.error(`[Persistence] Failed to load log for ${id}:`, e);
172
+ return '';
173
+ }
174
+ };