ai-cli-online 2.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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/README.zh-CN.md +170 -0
- package/bin/ai-cli-online.mjs +89 -0
- package/install-service.sh +319 -0
- package/package.json +57 -0
- package/server/.env.example +18 -0
- package/server/dist/auth.d.ts +3 -0
- package/server/dist/auth.js +9 -0
- package/server/dist/claude.d.ts +16 -0
- package/server/dist/claude.js +141 -0
- package/server/dist/db.d.ts +7 -0
- package/server/dist/db.js +73 -0
- package/server/dist/files.d.ts +15 -0
- package/server/dist/files.js +56 -0
- package/server/dist/index.d.ts +1 -0
- package/server/dist/index.js +466 -0
- package/server/dist/plans.d.ts +7 -0
- package/server/dist/plans.js +120 -0
- package/server/dist/pty.d.ts +15 -0
- package/server/dist/pty.js +75 -0
- package/server/dist/storage.d.ts +22 -0
- package/server/dist/storage.js +149 -0
- package/server/dist/tmux.d.ts +40 -0
- package/server/dist/tmux.js +191 -0
- package/server/dist/types.d.ts +1 -0
- package/server/dist/types.js +1 -0
- package/server/dist/websocket.d.ts +4 -0
- package/server/dist/websocket.js +304 -0
- package/server/package.json +32 -0
- package/shared/dist/types.d.ts +40 -0
- package/shared/dist/types.js +1 -0
- package/shared/package.json +20 -0
- package/start.sh +39 -0
- package/web/dist/assets/index-79TY7o1G.css +32 -0
- package/web/dist/assets/index-mcWZLwbP.js +235 -0
- package/web/dist/assets/pdf-Tk4_4Bu3.js +12 -0
- package/web/dist/assets/pdf.worker-BA9kU3Pw.mjs +61080 -0
- package/web/dist/favicon.svg +5 -0
- package/web/dist/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/web/dist/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/web/dist/fonts/MapleMono-CN-Bold.woff2 +0 -0
- package/web/dist/fonts/MapleMono-CN-Regular.woff2 +0 -0
- package/web/dist/index.html +17 -0
- package/web/package.json +32 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as pty from 'node-pty';
|
|
2
|
+
/**
|
|
3
|
+
* Wraps node-pty to attach to a tmux session.
|
|
4
|
+
* When the WebSocket disconnects, we kill only the PTY (detach from tmux),
|
|
5
|
+
* leaving the tmux session alive for later reconnection.
|
|
6
|
+
*/
|
|
7
|
+
/** Keys to strip from the environment passed to PTY subprocesses */
|
|
8
|
+
const SENSITIVE_ENV_KEYS = ['AUTH_TOKEN', 'SECRET', 'PASSWORD', 'API_KEY', 'PRIVATE_KEY', 'ACCESS_TOKEN'];
|
|
9
|
+
function sanitizedEnv() {
|
|
10
|
+
const env = { ...process.env };
|
|
11
|
+
for (const key of Object.keys(env)) {
|
|
12
|
+
if (SENSITIVE_ENV_KEYS.some((s) => key.toUpperCase().includes(s))) {
|
|
13
|
+
delete env[key];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return env;
|
|
17
|
+
}
|
|
18
|
+
export class PtySession {
|
|
19
|
+
proc;
|
|
20
|
+
dataListeners = [];
|
|
21
|
+
exitListeners = [];
|
|
22
|
+
alive = true;
|
|
23
|
+
constructor(sessionName, cols, rows) {
|
|
24
|
+
this.proc = pty.spawn('tmux', ['attach-session', '-t', sessionName], {
|
|
25
|
+
name: 'xterm-256color',
|
|
26
|
+
cols,
|
|
27
|
+
rows,
|
|
28
|
+
env: sanitizedEnv(),
|
|
29
|
+
});
|
|
30
|
+
this.proc.onData((data) => {
|
|
31
|
+
for (const cb of this.dataListeners) {
|
|
32
|
+
cb(data);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
this.proc.onExit(({ exitCode, signal }) => {
|
|
36
|
+
this.alive = false;
|
|
37
|
+
for (const cb of this.exitListeners) {
|
|
38
|
+
cb(exitCode ?? 0, signal ?? 0);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
onData(cb) {
|
|
43
|
+
this.dataListeners.push(cb);
|
|
44
|
+
}
|
|
45
|
+
onExit(cb) {
|
|
46
|
+
this.exitListeners.push(cb);
|
|
47
|
+
}
|
|
48
|
+
write(data) {
|
|
49
|
+
if (this.alive) {
|
|
50
|
+
this.proc.write(data);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
resize(cols, rows) {
|
|
54
|
+
if (this.alive) {
|
|
55
|
+
this.proc.resize(cols, rows);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
kill() {
|
|
59
|
+
if (this.alive) {
|
|
60
|
+
this.alive = false;
|
|
61
|
+
try {
|
|
62
|
+
this.proc.kill();
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
console.error('[PTY] kill() error (process may have already exited):', err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Clear listener arrays to release closure references for GC
|
|
69
|
+
this.dataListeners = [];
|
|
70
|
+
this.exitListeners = [];
|
|
71
|
+
}
|
|
72
|
+
isAlive() {
|
|
73
|
+
return this.alive;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Conversation, Message } from './types.js';
|
|
2
|
+
export declare class Storage {
|
|
3
|
+
private data;
|
|
4
|
+
private config;
|
|
5
|
+
constructor();
|
|
6
|
+
getWorkingDir(): string;
|
|
7
|
+
setWorkingDir(dir: string): void;
|
|
8
|
+
getCurrentConversationId(): string | null;
|
|
9
|
+
setCurrentConversationId(id: string | null): void;
|
|
10
|
+
createConversation(id: string): Conversation;
|
|
11
|
+
getConversation(id: string): Conversation | null;
|
|
12
|
+
getCurrentConversation(): Conversation | null;
|
|
13
|
+
getAllConversations(): Conversation[];
|
|
14
|
+
addMessage(conversationId: string, message: Message): void;
|
|
15
|
+
updateMessage(conversationId: string, messageId: string, updates: Partial<Message>): void;
|
|
16
|
+
updateConversationWorkingDir(conversationId: string, workingDir: string): void;
|
|
17
|
+
getConversationWorkingDir(conversationId: string): string;
|
|
18
|
+
setClaudeSessionId(conversationId: string, sessionId: string): void;
|
|
19
|
+
getClaudeSessionId(conversationId: string): string | undefined;
|
|
20
|
+
deleteConversation(id: string): void;
|
|
21
|
+
}
|
|
22
|
+
export declare const storage: Storage;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
const DATA_DIR = process.env.DATA_DIR || join(process.cwd(), 'data');
|
|
4
|
+
// Ensure data directory exists
|
|
5
|
+
if (!existsSync(DATA_DIR)) {
|
|
6
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
const CONVERSATIONS_FILE = join(DATA_DIR, 'conversations.json');
|
|
9
|
+
const CONFIG_FILE = join(DATA_DIR, 'config.json');
|
|
10
|
+
function loadData() {
|
|
11
|
+
if (!existsSync(CONVERSATIONS_FILE)) {
|
|
12
|
+
return { conversations: {} };
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const data = readFileSync(CONVERSATIONS_FILE, 'utf-8');
|
|
16
|
+
return JSON.parse(data);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { conversations: {} };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function saveData(data) {
|
|
23
|
+
writeFileSync(CONVERSATIONS_FILE, JSON.stringify(data, null, 2));
|
|
24
|
+
}
|
|
25
|
+
function loadConfig() {
|
|
26
|
+
const defaultConfig = {
|
|
27
|
+
currentConversationId: null,
|
|
28
|
+
workingDir: process.env.DEFAULT_WORKING_DIR || process.env.HOME || '/home/ubuntu',
|
|
29
|
+
};
|
|
30
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
31
|
+
return defaultConfig;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const data = readFileSync(CONFIG_FILE, 'utf-8');
|
|
35
|
+
return { ...defaultConfig, ...JSON.parse(data) };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return defaultConfig;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function saveConfig(config) {
|
|
42
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
43
|
+
}
|
|
44
|
+
export class Storage {
|
|
45
|
+
data;
|
|
46
|
+
config;
|
|
47
|
+
constructor() {
|
|
48
|
+
this.data = loadData();
|
|
49
|
+
this.config = loadConfig();
|
|
50
|
+
}
|
|
51
|
+
// Config methods
|
|
52
|
+
getWorkingDir() {
|
|
53
|
+
return this.config.workingDir;
|
|
54
|
+
}
|
|
55
|
+
setWorkingDir(dir) {
|
|
56
|
+
this.config.workingDir = dir;
|
|
57
|
+
saveConfig(this.config);
|
|
58
|
+
}
|
|
59
|
+
getCurrentConversationId() {
|
|
60
|
+
return this.config.currentConversationId;
|
|
61
|
+
}
|
|
62
|
+
setCurrentConversationId(id) {
|
|
63
|
+
this.config.currentConversationId = id;
|
|
64
|
+
saveConfig(this.config);
|
|
65
|
+
}
|
|
66
|
+
// Conversation methods
|
|
67
|
+
createConversation(id) {
|
|
68
|
+
const conversation = {
|
|
69
|
+
id,
|
|
70
|
+
workingDir: this.config.workingDir,
|
|
71
|
+
messages: [],
|
|
72
|
+
createdAt: Date.now(),
|
|
73
|
+
updatedAt: Date.now(),
|
|
74
|
+
};
|
|
75
|
+
this.data.conversations[id] = conversation;
|
|
76
|
+
this.config.currentConversationId = id;
|
|
77
|
+
saveData(this.data);
|
|
78
|
+
saveConfig(this.config);
|
|
79
|
+
return conversation;
|
|
80
|
+
}
|
|
81
|
+
getConversation(id) {
|
|
82
|
+
return this.data.conversations[id] || null;
|
|
83
|
+
}
|
|
84
|
+
getCurrentConversation() {
|
|
85
|
+
if (!this.config.currentConversationId) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return this.getConversation(this.config.currentConversationId);
|
|
89
|
+
}
|
|
90
|
+
getAllConversations() {
|
|
91
|
+
return Object.values(this.data.conversations).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
92
|
+
}
|
|
93
|
+
addMessage(conversationId, message) {
|
|
94
|
+
const conversation = this.data.conversations[conversationId];
|
|
95
|
+
if (conversation) {
|
|
96
|
+
conversation.messages.push(message);
|
|
97
|
+
conversation.updatedAt = Date.now();
|
|
98
|
+
saveData(this.data);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
updateMessage(conversationId, messageId, updates) {
|
|
102
|
+
const conversation = this.data.conversations[conversationId];
|
|
103
|
+
if (conversation) {
|
|
104
|
+
const messageIndex = conversation.messages.findIndex((m) => m.id === messageId);
|
|
105
|
+
if (messageIndex !== -1) {
|
|
106
|
+
conversation.messages[messageIndex] = {
|
|
107
|
+
...conversation.messages[messageIndex],
|
|
108
|
+
...updates,
|
|
109
|
+
};
|
|
110
|
+
conversation.updatedAt = Date.now();
|
|
111
|
+
saveData(this.data);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
updateConversationWorkingDir(conversationId, workingDir) {
|
|
116
|
+
const conversation = this.data.conversations[conversationId];
|
|
117
|
+
if (conversation) {
|
|
118
|
+
conversation.workingDir = workingDir;
|
|
119
|
+
conversation.updatedAt = Date.now();
|
|
120
|
+
saveData(this.data);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
getConversationWorkingDir(conversationId) {
|
|
124
|
+
const conversation = this.data.conversations[conversationId];
|
|
125
|
+
return conversation?.workingDir || this.config.workingDir;
|
|
126
|
+
}
|
|
127
|
+
setClaudeSessionId(conversationId, sessionId) {
|
|
128
|
+
const conversation = this.data.conversations[conversationId];
|
|
129
|
+
if (conversation) {
|
|
130
|
+
conversation.claudeSessionId = sessionId;
|
|
131
|
+
conversation.updatedAt = Date.now();
|
|
132
|
+
saveData(this.data);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
getClaudeSessionId(conversationId) {
|
|
136
|
+
const conversation = this.data.conversations[conversationId];
|
|
137
|
+
return conversation?.claudeSessionId;
|
|
138
|
+
}
|
|
139
|
+
deleteConversation(id) {
|
|
140
|
+
delete this.data.conversations[id];
|
|
141
|
+
if (this.config.currentConversationId === id) {
|
|
142
|
+
this.config.currentConversationId = null;
|
|
143
|
+
}
|
|
144
|
+
saveData(this.data);
|
|
145
|
+
saveConfig(this.config);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Singleton instance
|
|
149
|
+
export const storage = new Storage();
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface TmuxSessionInfo {
|
|
2
|
+
sessionName: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
createdAt: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Generate a tmux session name from an auth token.
|
|
8
|
+
* Uses SHA256 prefix to avoid leaking the token.
|
|
9
|
+
*/
|
|
10
|
+
export declare function tokenToSessionName(token: string): string;
|
|
11
|
+
/** Validate sessionId: only allow alphanumeric, underscore, hyphen, max 32 chars */
|
|
12
|
+
export declare function isValidSessionId(sessionId: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Build a tmux session name from token + optional sessionId.
|
|
15
|
+
* Without sessionId, behaves identically to tokenToSessionName (backward compat).
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildSessionName(token: string, sessionId?: string): string;
|
|
18
|
+
/** Check if a tmux session exists */
|
|
19
|
+
export declare function hasSession(name: string): Promise<boolean>;
|
|
20
|
+
/** Create a new tmux session (detached) */
|
|
21
|
+
export declare function createSession(name: string, cols: number, rows: number, cwd: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Capture scrollback buffer with ANSI escape sequences preserved.
|
|
24
|
+
* Returns the last 10000 lines of the pane.
|
|
25
|
+
*/
|
|
26
|
+
export declare function captureScrollback(name: string): Promise<string>;
|
|
27
|
+
/** Resize tmux window to match terminal dimensions */
|
|
28
|
+
export declare function resizeSession(name: string, cols: number, rows: number): Promise<void>;
|
|
29
|
+
/** Kill a tmux session */
|
|
30
|
+
export declare function killSession(name: string): Promise<void>;
|
|
31
|
+
/** List all tmux sessions belonging to a given token */
|
|
32
|
+
export declare function listSessions(token: string): Promise<TmuxSessionInfo[]>;
|
|
33
|
+
/** Clean up idle tmux sessions older than the given TTL (hours) */
|
|
34
|
+
export declare function cleanupStaleSessions(ttlHours: number): Promise<void>;
|
|
35
|
+
/** 获取 tmux session 当前活动 pane 的工作目录 */
|
|
36
|
+
export declare function getCwd(sessionName: string): Promise<string>;
|
|
37
|
+
/** 获取 tmux pane 当前运行的命令名称 */
|
|
38
|
+
export declare function getPaneCommand(sessionName: string): Promise<string>;
|
|
39
|
+
/** Check if tmux is available on the system (sync — startup only) */
|
|
40
|
+
export declare function isTmuxAvailable(): boolean;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { execFile as execFileCb, execFileSync } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
const execFile = promisify(execFileCb);
|
|
5
|
+
/**
|
|
6
|
+
* Generate a tmux session name from an auth token.
|
|
7
|
+
* Uses SHA256 prefix to avoid leaking the token.
|
|
8
|
+
*/
|
|
9
|
+
export function tokenToSessionName(token) {
|
|
10
|
+
const hash = createHash('sha256').update(token).digest('hex');
|
|
11
|
+
return `ai-cli-online-${hash.slice(0, 8)}`;
|
|
12
|
+
}
|
|
13
|
+
/** Validate sessionId: only allow alphanumeric, underscore, hyphen, max 32 chars */
|
|
14
|
+
export function isValidSessionId(sessionId) {
|
|
15
|
+
return /^[a-zA-Z0-9_-]{1,32}$/.test(sessionId);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build a tmux session name from token + optional sessionId.
|
|
19
|
+
* Without sessionId, behaves identically to tokenToSessionName (backward compat).
|
|
20
|
+
*/
|
|
21
|
+
export function buildSessionName(token, sessionId) {
|
|
22
|
+
const base = tokenToSessionName(token);
|
|
23
|
+
return sessionId ? `${base}-${sessionId}` : base;
|
|
24
|
+
}
|
|
25
|
+
/** Check if a tmux session exists */
|
|
26
|
+
export async function hasSession(name) {
|
|
27
|
+
try {
|
|
28
|
+
await execFile('tmux', ['has-session', '-t', name]);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Create a new tmux session (detached) */
|
|
36
|
+
export async function createSession(name, cols, rows, cwd) {
|
|
37
|
+
await execFile('tmux', [
|
|
38
|
+
'new-session',
|
|
39
|
+
'-d',
|
|
40
|
+
'-s', name,
|
|
41
|
+
'-x', String(cols),
|
|
42
|
+
'-y', String(rows),
|
|
43
|
+
], { cwd });
|
|
44
|
+
// Configure tmux for web terminal usage (parallel for faster session creation)
|
|
45
|
+
await Promise.all([
|
|
46
|
+
execFile('tmux', ['set-option', '-t', name, 'history-limit', '50000']).catch(() => { }),
|
|
47
|
+
execFile('tmux', ['set-option', '-t', name, 'status', 'off']).catch(() => { }),
|
|
48
|
+
execFile('tmux', ['set-option', '-t', name, 'mouse', 'off']).catch(() => { }),
|
|
49
|
+
]);
|
|
50
|
+
console.log(`[tmux] Created session: ${name} (${cols}x${rows}) in ${cwd}`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Capture scrollback buffer with ANSI escape sequences preserved.
|
|
54
|
+
* Returns the last 10000 lines of the pane.
|
|
55
|
+
*/
|
|
56
|
+
export async function captureScrollback(name) {
|
|
57
|
+
try {
|
|
58
|
+
const { stdout } = await execFile('tmux', [
|
|
59
|
+
'capture-pane',
|
|
60
|
+
'-t', name,
|
|
61
|
+
'-p',
|
|
62
|
+
'-e',
|
|
63
|
+
'-S', '-10000',
|
|
64
|
+
], { encoding: 'utf-8', maxBuffer: 5 * 1024 * 1024 });
|
|
65
|
+
return stdout;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.error(`[tmux] Failed to capture scrollback for ${name}:`, err);
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Resize tmux window to match terminal dimensions */
|
|
73
|
+
export async function resizeSession(name, cols, rows) {
|
|
74
|
+
try {
|
|
75
|
+
await execFile('tmux', [
|
|
76
|
+
'resize-window',
|
|
77
|
+
'-t', name,
|
|
78
|
+
'-x', String(cols),
|
|
79
|
+
'-y', String(rows),
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Resize can fail if dimensions haven't changed, ignore
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Kill a tmux session */
|
|
87
|
+
export async function killSession(name) {
|
|
88
|
+
try {
|
|
89
|
+
await execFile('tmux', ['kill-session', '-t', name]);
|
|
90
|
+
console.log(`[tmux] Killed session: ${name}`);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Session may already be gone
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** List all tmux sessions belonging to a given token */
|
|
97
|
+
export async function listSessions(token) {
|
|
98
|
+
const prefix = tokenToSessionName(token) + '-';
|
|
99
|
+
try {
|
|
100
|
+
const { stdout } = await execFile('tmux', [
|
|
101
|
+
'list-sessions',
|
|
102
|
+
'-F',
|
|
103
|
+
'#{session_name}:#{session_created}',
|
|
104
|
+
], { encoding: 'utf-8' });
|
|
105
|
+
const results = [];
|
|
106
|
+
for (const line of stdout.trim().split('\n')) {
|
|
107
|
+
if (!line)
|
|
108
|
+
continue;
|
|
109
|
+
const lastColon = line.lastIndexOf(':');
|
|
110
|
+
if (lastColon === -1)
|
|
111
|
+
continue;
|
|
112
|
+
const sessionName = line.slice(0, lastColon);
|
|
113
|
+
const createdAt = parseInt(line.slice(lastColon + 1), 10);
|
|
114
|
+
if (!sessionName.startsWith(prefix))
|
|
115
|
+
continue;
|
|
116
|
+
const sessionId = sessionName.slice(prefix.length);
|
|
117
|
+
results.push({ sessionName, sessionId, createdAt });
|
|
118
|
+
}
|
|
119
|
+
return results;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// tmux server not running or no sessions
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Clean up idle tmux sessions older than the given TTL (hours) */
|
|
127
|
+
export async function cleanupStaleSessions(ttlHours) {
|
|
128
|
+
const cutoff = Math.floor(Date.now() / 1000) - ttlHours * 3600;
|
|
129
|
+
try {
|
|
130
|
+
const { stdout } = await execFile('tmux', [
|
|
131
|
+
'list-sessions',
|
|
132
|
+
'-F',
|
|
133
|
+
'#{session_name}:#{session_created}:#{session_attached}',
|
|
134
|
+
], { encoding: 'utf-8' });
|
|
135
|
+
for (const line of stdout.trim().split('\n')) {
|
|
136
|
+
if (!line)
|
|
137
|
+
continue;
|
|
138
|
+
// Use lastIndexOf to safely parse (consistent with listSessions)
|
|
139
|
+
const lastColon = line.lastIndexOf(':');
|
|
140
|
+
if (lastColon === -1)
|
|
141
|
+
continue;
|
|
142
|
+
const attached = parseInt(line.slice(lastColon + 1), 10);
|
|
143
|
+
const rest = line.slice(0, lastColon);
|
|
144
|
+
const secondLastColon = rest.lastIndexOf(':');
|
|
145
|
+
if (secondLastColon === -1)
|
|
146
|
+
continue;
|
|
147
|
+
const created = parseInt(rest.slice(secondLastColon + 1), 10);
|
|
148
|
+
const name = rest.slice(0, secondLastColon);
|
|
149
|
+
if (!name.startsWith('ai-cli-online-'))
|
|
150
|
+
continue;
|
|
151
|
+
if (attached > 0)
|
|
152
|
+
continue;
|
|
153
|
+
if (created < cutoff) {
|
|
154
|
+
console.log(`[tmux] Cleaning up stale session: ${name} (created ${new Date(created * 1000).toISOString()})`);
|
|
155
|
+
await killSession(name);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// No tmux server or no sessions
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** 获取 tmux session 当前活动 pane 的工作目录 */
|
|
164
|
+
export async function getCwd(sessionName) {
|
|
165
|
+
const { stdout } = await execFile('tmux', [
|
|
166
|
+
'display-message', '-p', '-t', sessionName, '#{pane_current_path}',
|
|
167
|
+
], { encoding: 'utf-8' });
|
|
168
|
+
return stdout.trim();
|
|
169
|
+
}
|
|
170
|
+
/** 获取 tmux pane 当前运行的命令名称 */
|
|
171
|
+
export async function getPaneCommand(sessionName) {
|
|
172
|
+
try {
|
|
173
|
+
const { stdout } = await execFile('tmux', [
|
|
174
|
+
'display-message', '-p', '-t', sessionName, '#{pane_current_command}',
|
|
175
|
+
], { encoding: 'utf-8' });
|
|
176
|
+
return stdout.trim();
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return '';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Check if tmux is available on the system (sync — startup only) */
|
|
183
|
+
export function isTmuxAvailable() {
|
|
184
|
+
try {
|
|
185
|
+
execFileSync('tmux', ['-V'], { stdio: 'ignore' });
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { ClientMessage, ServerMessage, FileEntry } from 'ai-cli-online-shared';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
/** Get the set of session names with active open WebSocket connections */
|
|
3
|
+
export declare function getActiveSessionNames(): Set<string>;
|
|
4
|
+
export declare function setupWebSocket(wss: WebSocketServer, authToken: string, defaultCwd: string, tokenCompare: (a: string, b: string) => boolean, maxConnections?: number): void;
|