@burdenoff/vibe-agent 1.0.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/.env.example +8 -0
- package/LICENSE +22 -0
- package/README.md +290 -0
- package/dist/app.d.ts +15 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +445 -0
- package/dist/app.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1043 -0
- package/dist/cli.js.map +1 -0
- package/dist/db/schema.d.ts +145 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +536 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/ModuleAuth.d.ts +61 -0
- package/dist/middleware/ModuleAuth.d.ts.map +1 -0
- package/dist/middleware/ModuleAuth.js +220 -0
- package/dist/middleware/ModuleAuth.js.map +1 -0
- package/dist/middleware/auth.d.ts +3 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +11 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/migrations/remove-notes-prompts.d.ts +13 -0
- package/dist/migrations/remove-notes-prompts.d.ts.map +1 -0
- package/dist/migrations/remove-notes-prompts.js +148 -0
- package/dist/migrations/remove-notes-prompts.js.map +1 -0
- package/dist/routes/bookmarks.d.ts +3 -0
- package/dist/routes/bookmarks.d.ts.map +1 -0
- package/dist/routes/bookmarks.js +186 -0
- package/dist/routes/bookmarks.js.map +1 -0
- package/dist/routes/config.d.ts +3 -0
- package/dist/routes/config.d.ts.map +1 -0
- package/dist/routes/config.js +108 -0
- package/dist/routes/config.js.map +1 -0
- package/dist/routes/files.d.ts +3 -0
- package/dist/routes/files.d.ts.map +1 -0
- package/dist/routes/files.js +471 -0
- package/dist/routes/files.js.map +1 -0
- package/dist/routes/git.d.ts +3 -0
- package/dist/routes/git.d.ts.map +1 -0
- package/dist/routes/git.js +498 -0
- package/dist/routes/git.js.map +1 -0
- package/dist/routes/moduleRegistry.d.ts +41 -0
- package/dist/routes/moduleRegistry.d.ts.map +1 -0
- package/dist/routes/moduleRegistry.js +356 -0
- package/dist/routes/moduleRegistry.js.map +1 -0
- package/dist/routes/notifications.d.ts +3 -0
- package/dist/routes/notifications.d.ts.map +1 -0
- package/dist/routes/notifications.js +250 -0
- package/dist/routes/notifications.js.map +1 -0
- package/dist/routes/port-forward.d.ts +3 -0
- package/dist/routes/port-forward.d.ts.map +1 -0
- package/dist/routes/port-forward.js +205 -0
- package/dist/routes/port-forward.js.map +1 -0
- package/dist/routes/projects.d.ts +3 -0
- package/dist/routes/projects.d.ts.map +1 -0
- package/dist/routes/projects.js +442 -0
- package/dist/routes/projects.js.map +1 -0
- package/dist/routes/ssh.d.ts +3 -0
- package/dist/routes/ssh.d.ts.map +1 -0
- package/dist/routes/ssh.js +192 -0
- package/dist/routes/ssh.js.map +1 -0
- package/dist/routes/tasks.d.ts +3 -0
- package/dist/routes/tasks.d.ts.map +1 -0
- package/dist/routes/tasks.js +183 -0
- package/dist/routes/tasks.js.map +1 -0
- package/dist/routes/tmux.d.ts +3 -0
- package/dist/routes/tmux.d.ts.map +1 -0
- package/dist/routes/tmux.js +1191 -0
- package/dist/routes/tmux.js.map +1 -0
- package/dist/routes/tunnel.d.ts +25 -0
- package/dist/routes/tunnel.d.ts.map +1 -0
- package/dist/routes/tunnel.js +449 -0
- package/dist/routes/tunnel.js.map +1 -0
- package/dist/services/ModulePermissions.d.ts +100 -0
- package/dist/services/ModulePermissions.d.ts.map +1 -0
- package/dist/services/ModulePermissions.js +312 -0
- package/dist/services/ModulePermissions.js.map +1 -0
- package/dist/services/ModuleRegistryService.d.ts +152 -0
- package/dist/services/ModuleRegistryService.d.ts.map +1 -0
- package/dist/services/ModuleRegistryService.js +522 -0
- package/dist/services/ModuleRegistryService.js.map +1 -0
- package/dist/services/agent.service.d.ts +19 -0
- package/dist/services/agent.service.d.ts.map +1 -0
- package/dist/services/agent.service.js +88 -0
- package/dist/services/agent.service.js.map +1 -0
- package/dist/services/bootstrap.d.ts +22 -0
- package/dist/services/bootstrap.d.ts.map +1 -0
- package/dist/services/bootstrap.js +206 -0
- package/dist/services/bootstrap.js.map +1 -0
- package/dist/services/service-manager.d.ts +50 -0
- package/dist/services/service-manager.d.ts.map +1 -0
- package/dist/services/service-manager.js +382 -0
- package/dist/services/service-manager.js.map +1 -0
- package/package.json +107 -0
|
@@ -0,0 +1,1191 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { getAgentTunnelUrl } from './tunnel.js';
|
|
3
|
+
export const tmuxRoutes = async (fastify) => {
|
|
4
|
+
// Get all tmux sessions
|
|
5
|
+
fastify.get('/', async (_request, _reply) => {
|
|
6
|
+
const sessions = fastify.db.getAllTmuxSessions();
|
|
7
|
+
return { sessions };
|
|
8
|
+
});
|
|
9
|
+
// Get all system tmux sessions (including unmanaged ones)
|
|
10
|
+
fastify.get('/system', async (_request, reply) => {
|
|
11
|
+
try {
|
|
12
|
+
const systemSessions = await getSystemTmuxSessions();
|
|
13
|
+
const managedSessions = fastify.db.getAllTmuxSessions();
|
|
14
|
+
// Combine system sessions with managed session metadata
|
|
15
|
+
const enrichedSessions = systemSessions.map(systemSession => {
|
|
16
|
+
const managedSession = managedSessions.find(ms => ms.sessionName === systemSession.name);
|
|
17
|
+
return {
|
|
18
|
+
...systemSession,
|
|
19
|
+
isManaged: !!managedSession,
|
|
20
|
+
managedSessionId: managedSession?.id,
|
|
21
|
+
projectId: managedSession?.projectId,
|
|
22
|
+
ttydPort: managedSession?.ttydPort,
|
|
23
|
+
ttydPid: managedSession?.ttydPid,
|
|
24
|
+
createdAt: managedSession?.createdAt || systemSession.created,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
return { sessions: enrichedSessions };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
return reply.code(500).send({
|
|
31
|
+
error: 'Failed to get system tmux sessions',
|
|
32
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
// Get all system ttyd processes
|
|
37
|
+
fastify.get('/system/ttyd', async (_request, reply) => {
|
|
38
|
+
try {
|
|
39
|
+
const ttydProcesses = await getSystemTtydProcesses();
|
|
40
|
+
return { processes: ttydProcesses };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return reply.code(500).send({
|
|
44
|
+
error: 'Failed to get system ttyd processes',
|
|
45
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
// Kill system tmux sessions (bulk)
|
|
50
|
+
fastify.post('/system/kill', async (request, reply) => {
|
|
51
|
+
const { sessionNames, force } = request.body;
|
|
52
|
+
if (!sessionNames || !Array.isArray(sessionNames)) {
|
|
53
|
+
return reply.code(400).send({ error: 'sessionNames array is required' });
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const results = await killSystemTmuxSessions(sessionNames, force);
|
|
57
|
+
return { results };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return reply.code(500).send({
|
|
61
|
+
error: 'Failed to kill system tmux sessions',
|
|
62
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
// Kill system ttyd processes (bulk)
|
|
67
|
+
fastify.post('/system/ttyd/kill', async (request, reply) => {
|
|
68
|
+
const { pids, force } = request.body;
|
|
69
|
+
if (!pids || !Array.isArray(pids)) {
|
|
70
|
+
return reply.code(400).send({ error: 'pids array is required' });
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const results = await killSystemTtydProcesses(pids, force);
|
|
74
|
+
return { results };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return reply.code(500).send({
|
|
78
|
+
error: 'Failed to kill system ttyd processes',
|
|
79
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
// Get session by ID
|
|
84
|
+
fastify.get('/:id', async (request, reply) => {
|
|
85
|
+
const { id } = request.params;
|
|
86
|
+
const session = fastify.db.getTmuxSession(id);
|
|
87
|
+
if (!session) {
|
|
88
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
89
|
+
}
|
|
90
|
+
return { session };
|
|
91
|
+
});
|
|
92
|
+
// ── Bulk session health check ──────────────────────────────────────
|
|
93
|
+
// POST /health-check — accepts an array of session IDs and returns their
|
|
94
|
+
// actual status by checking if the tmux session and ttyd process are alive.
|
|
95
|
+
// Called periodically by the backend to reconcile DB state.
|
|
96
|
+
fastify.post('/health-check', async (request, _reply) => {
|
|
97
|
+
const { sessionIds } = request.body;
|
|
98
|
+
if (!sessionIds || !Array.isArray(sessionIds)) {
|
|
99
|
+
return { results: [] };
|
|
100
|
+
}
|
|
101
|
+
const results = [];
|
|
102
|
+
for (const sessionId of sessionIds) {
|
|
103
|
+
const dbSession = fastify.db.getTmuxSession(sessionId);
|
|
104
|
+
if (!dbSession) {
|
|
105
|
+
results.push({ sessionId, tmuxAlive: false, ttydAlive: false, ttydPort: null, status: 'unknown' });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Check if tmux session is alive
|
|
109
|
+
let tmuxAlive = false;
|
|
110
|
+
try {
|
|
111
|
+
const checkProc = spawn('tmux', ['has-session', '-t', dbSession.sessionName]);
|
|
112
|
+
tmuxAlive = await new Promise((resolve) => {
|
|
113
|
+
checkProc.on('close', (code) => resolve(code === 0));
|
|
114
|
+
checkProc.on('error', () => resolve(false));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
tmuxAlive = false;
|
|
119
|
+
}
|
|
120
|
+
// Check if ttyd process is alive
|
|
121
|
+
let ttydAlive = false;
|
|
122
|
+
if (dbSession.ttydPid) {
|
|
123
|
+
try {
|
|
124
|
+
process.kill(dbSession.ttydPid, 0);
|
|
125
|
+
ttydAlive = true;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
ttydAlive = false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const status = tmuxAlive ? 'running' : 'dead';
|
|
132
|
+
results.push({
|
|
133
|
+
sessionId,
|
|
134
|
+
tmuxAlive,
|
|
135
|
+
ttydAlive,
|
|
136
|
+
ttydPort: dbSession.ttydPort ?? null,
|
|
137
|
+
status,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return { results };
|
|
141
|
+
});
|
|
142
|
+
// Create new tmux session (idempotent — re-adopts existing tmux sessions)
|
|
143
|
+
fastify.post('/create', async (request, reply) => {
|
|
144
|
+
const { sessionId, projectId, sessionName, windowName, command, startDirectory } = request.body;
|
|
145
|
+
let finalSessionName = sessionName || `vibecontrols-${Date.now()}`;
|
|
146
|
+
const finalWindowName = windowName || 'main';
|
|
147
|
+
try {
|
|
148
|
+
// Check if we already have this sessionId in our DB (idempotent re-call)
|
|
149
|
+
const existingDbSession = fastify.db.getTmuxSession(sessionId);
|
|
150
|
+
if (existingDbSession) {
|
|
151
|
+
// Already tracked — verify the tmux session still exists
|
|
152
|
+
const checkProc = spawn('tmux', ['has-session', '-t', existingDbSession.sessionName]);
|
|
153
|
+
const checkOk = await new Promise((resolve) => {
|
|
154
|
+
checkProc.on('close', (code) => resolve(code === 0));
|
|
155
|
+
checkProc.on('error', () => resolve(false));
|
|
156
|
+
});
|
|
157
|
+
if (checkOk) {
|
|
158
|
+
return { session: existingDbSession, reused: true };
|
|
159
|
+
}
|
|
160
|
+
// tmux session is gone — we'll re-create it below
|
|
161
|
+
finalSessionName = existingDbSession.sessionName;
|
|
162
|
+
}
|
|
163
|
+
// Check if tmux session with this name already exists in the system
|
|
164
|
+
// (e.g. from a previous agent run before DB was wiped)
|
|
165
|
+
const hasProc = spawn('tmux', ['has-session', '-t', finalSessionName]);
|
|
166
|
+
const tmuxExists = await new Promise((resolve) => {
|
|
167
|
+
hasProc.on('close', (code) => resolve(code === 0));
|
|
168
|
+
hasProc.on('error', () => resolve(false));
|
|
169
|
+
});
|
|
170
|
+
if (tmuxExists) {
|
|
171
|
+
// tmux session exists but NOT in our DB — adopt it
|
|
172
|
+
console.log(`[tmux/create] Adopting existing tmux session "${finalSessionName}" for sessionId ${sessionId}`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
// Check if the name conflicts with another DB entry, make unique
|
|
176
|
+
const existingSessions = fastify.db.getAllTmuxSessions();
|
|
177
|
+
const existingNames = existingSessions.map(s => s.sessionName);
|
|
178
|
+
if (existingNames.includes(finalSessionName)) {
|
|
179
|
+
finalSessionName = `${finalSessionName}-${Date.now()}`;
|
|
180
|
+
}
|
|
181
|
+
// Create new tmux session
|
|
182
|
+
const tmuxCmd = ['tmux', 'new-session', '-d', '-s', finalSessionName];
|
|
183
|
+
if (startDirectory)
|
|
184
|
+
tmuxCmd.push('-c', startDirectory);
|
|
185
|
+
if (command)
|
|
186
|
+
tmuxCmd.push(command);
|
|
187
|
+
const tmuxProcess = spawn(tmuxCmd[0], tmuxCmd.slice(1));
|
|
188
|
+
await new Promise((resolve, reject) => {
|
|
189
|
+
tmuxProcess.on('close', (code) => {
|
|
190
|
+
if (code === 0)
|
|
191
|
+
resolve(undefined);
|
|
192
|
+
else
|
|
193
|
+
reject(new Error(`Tmux exited with code ${code}`));
|
|
194
|
+
});
|
|
195
|
+
tmuxProcess.on('error', reject);
|
|
196
|
+
});
|
|
197
|
+
// Wait for tmux to be ready
|
|
198
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
199
|
+
// Verify
|
|
200
|
+
const verifyProcess = spawn('tmux', ['has-session', '-t', finalSessionName]);
|
|
201
|
+
await new Promise((resolve, reject) => {
|
|
202
|
+
verifyProcess.on('close', (code) => {
|
|
203
|
+
if (code === 0)
|
|
204
|
+
resolve(undefined);
|
|
205
|
+
else
|
|
206
|
+
reject(new Error(`Failed to verify tmux session ${finalSessionName}`));
|
|
207
|
+
});
|
|
208
|
+
verifyProcess.on('error', reject);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Upsert database entry (handles both new creation and adoption)
|
|
212
|
+
let session;
|
|
213
|
+
if (existingDbSession) {
|
|
214
|
+
// Update existing record
|
|
215
|
+
fastify.db.updateTmuxSession(sessionId, {
|
|
216
|
+
sessionName: finalSessionName,
|
|
217
|
+
status: 'active',
|
|
218
|
+
});
|
|
219
|
+
session = fastify.db.getTmuxSession(sessionId);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
session = fastify.db.createTmuxSession({
|
|
223
|
+
id: sessionId,
|
|
224
|
+
sessionName: finalSessionName,
|
|
225
|
+
projectId,
|
|
226
|
+
windowName: finalWindowName,
|
|
227
|
+
command,
|
|
228
|
+
status: 'active',
|
|
229
|
+
ttydPort: undefined,
|
|
230
|
+
ttydPid: undefined,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// Emit session created event
|
|
234
|
+
fastify.io.emit('session:created', session);
|
|
235
|
+
return { session, reused: tmuxExists };
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
return reply.code(500).send({
|
|
239
|
+
error: 'Failed to create tmux session',
|
|
240
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
// Execute command in session
|
|
245
|
+
fastify.post('/:sessionId/command', async (request, reply) => {
|
|
246
|
+
const { sessionId } = request.params;
|
|
247
|
+
const { command } = request.body;
|
|
248
|
+
try {
|
|
249
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
250
|
+
if (!session) {
|
|
251
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
252
|
+
}
|
|
253
|
+
// Send command to tmux session
|
|
254
|
+
const tmuxProcess = spawn('tmux', ['send-keys', '-t', session.sessionName, command, 'Enter']);
|
|
255
|
+
await new Promise((resolve, reject) => {
|
|
256
|
+
tmuxProcess.on('close', (code) => {
|
|
257
|
+
if (code === 0)
|
|
258
|
+
resolve(undefined);
|
|
259
|
+
else
|
|
260
|
+
reject(new Error(`Tmux exited with code ${code}`));
|
|
261
|
+
});
|
|
262
|
+
tmuxProcess.on('error', reject);
|
|
263
|
+
});
|
|
264
|
+
// Emit command executed event
|
|
265
|
+
fastify.io.emit('session:command', { sessionId, command });
|
|
266
|
+
return { success: true };
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
return reply.code(500).send({
|
|
270
|
+
error: 'Failed to execute command',
|
|
271
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
// Send keys to session
|
|
276
|
+
fastify.post('/:sessionId/keys', async (request, reply) => {
|
|
277
|
+
const { sessionId } = request.params;
|
|
278
|
+
const { keys } = request.body;
|
|
279
|
+
try {
|
|
280
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
281
|
+
if (!session) {
|
|
282
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
283
|
+
}
|
|
284
|
+
// Send keys to tmux session
|
|
285
|
+
const tmuxProcess = spawn('tmux', ['send-keys', '-t', session.sessionName, keys]);
|
|
286
|
+
await new Promise((resolve, reject) => {
|
|
287
|
+
tmuxProcess.on('close', (code) => {
|
|
288
|
+
if (code === 0)
|
|
289
|
+
resolve(undefined);
|
|
290
|
+
else
|
|
291
|
+
reject(new Error(`Tmux exited with code ${code}`));
|
|
292
|
+
});
|
|
293
|
+
tmuxProcess.on('error', reject);
|
|
294
|
+
});
|
|
295
|
+
return { success: true };
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
return reply.code(500).send({
|
|
299
|
+
error: 'Failed to send keys',
|
|
300
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
// Send interrupt (Ctrl+C) to session
|
|
305
|
+
fastify.post('/:sessionId/interrupt', async (request, reply) => {
|
|
306
|
+
const { sessionId } = request.params;
|
|
307
|
+
try {
|
|
308
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
309
|
+
if (!session) {
|
|
310
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
311
|
+
}
|
|
312
|
+
// Send Ctrl+C to tmux session
|
|
313
|
+
const tmuxProcess = spawn('tmux', ['send-keys', '-t', session.sessionName, 'C-c']);
|
|
314
|
+
await new Promise((resolve, reject) => {
|
|
315
|
+
tmuxProcess.on('close', (code) => {
|
|
316
|
+
if (code === 0)
|
|
317
|
+
resolve(undefined);
|
|
318
|
+
else
|
|
319
|
+
reject(new Error(`Tmux exited with code ${code}`));
|
|
320
|
+
});
|
|
321
|
+
tmuxProcess.on('error', reject);
|
|
322
|
+
});
|
|
323
|
+
return { success: true };
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
return reply.code(500).send({
|
|
327
|
+
error: 'Failed to send interrupt',
|
|
328
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
// Capture session output
|
|
333
|
+
fastify.get('/:sessionId/capture', async (request, reply) => {
|
|
334
|
+
const { sessionId } = request.params;
|
|
335
|
+
try {
|
|
336
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
337
|
+
if (!session) {
|
|
338
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
339
|
+
}
|
|
340
|
+
// Capture tmux pane content
|
|
341
|
+
const captureProcess = spawn('tmux', ['capture-pane', '-t', session.sessionName, '-p']);
|
|
342
|
+
let output = '';
|
|
343
|
+
captureProcess.stdout.on('data', (data) => {
|
|
344
|
+
output += data.toString();
|
|
345
|
+
});
|
|
346
|
+
await new Promise((resolve, reject) => {
|
|
347
|
+
captureProcess.on('close', (code) => {
|
|
348
|
+
if (code === 0)
|
|
349
|
+
resolve(undefined);
|
|
350
|
+
else
|
|
351
|
+
reject(new Error(`Tmux exited with code ${code}`));
|
|
352
|
+
});
|
|
353
|
+
captureProcess.on('error', reject);
|
|
354
|
+
});
|
|
355
|
+
return { output };
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
return reply.code(500).send({
|
|
359
|
+
error: 'Failed to capture output',
|
|
360
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
// Kill session
|
|
365
|
+
fastify.delete('/:sessionId', async (request, reply) => {
|
|
366
|
+
const { sessionId } = request.params;
|
|
367
|
+
const startTime = Date.now();
|
|
368
|
+
try {
|
|
369
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
370
|
+
if (!session) {
|
|
371
|
+
console.warn(`[Session Termination] Session not found: ${sessionId}`);
|
|
372
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
373
|
+
}
|
|
374
|
+
console.log(`[Session Termination] Starting termination process for session ${session.sessionName} (ID: ${sessionId})`);
|
|
375
|
+
const cleanup = {
|
|
376
|
+
ttydKilled: false,
|
|
377
|
+
orphanedTtydCleaned: false,
|
|
378
|
+
tmuxKilled: false,
|
|
379
|
+
databaseUpdated: false
|
|
380
|
+
};
|
|
381
|
+
// Enhanced TTYd process cleanup with verification
|
|
382
|
+
if (session.ttydPid) {
|
|
383
|
+
console.log(`[Session Termination] Killing TTYd process ${session.ttydPid} for session ${session.sessionName}`);
|
|
384
|
+
try {
|
|
385
|
+
// First try graceful termination
|
|
386
|
+
process.kill(session.ttydPid, 'SIGTERM');
|
|
387
|
+
// Wait a moment and verify process is killed
|
|
388
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
389
|
+
try {
|
|
390
|
+
process.kill(session.ttydPid, 0); // Check if process still exists
|
|
391
|
+
console.warn(`[Session Termination] TTYd process ${session.ttydPid} still running after SIGTERM, using SIGKILL`);
|
|
392
|
+
process.kill(session.ttydPid, 'SIGKILL');
|
|
393
|
+
}
|
|
394
|
+
catch (killError) {
|
|
395
|
+
// Process doesn't exist anymore, which is what we want
|
|
396
|
+
console.log(`[Session Termination] TTYd process ${session.ttydPid} successfully terminated`);
|
|
397
|
+
}
|
|
398
|
+
cleanup.ttydKilled = true;
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
console.error(`[Session Termination] Failed to kill TTYd process ${session.ttydPid}:`, error);
|
|
402
|
+
// Continue with session cleanup even if ttyd kill fails
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Clean up any orphaned TTYd processes for this session
|
|
406
|
+
console.log(`[Session Termination] Cleaning up orphaned TTYd processes for session ${session.sessionName}`);
|
|
407
|
+
try {
|
|
408
|
+
await cleanupOrphanedTtydProcesses(session.sessionName);
|
|
409
|
+
cleanup.orphanedTtydCleaned = true;
|
|
410
|
+
console.log(`[Session Termination] Orphaned TTYd processes cleaned up for session ${session.sessionName}`);
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
console.error(`[Session Termination] Failed to clean up orphaned TTYd processes:`, error);
|
|
414
|
+
}
|
|
415
|
+
// Kill tmux session with verification
|
|
416
|
+
console.log(`[Session Termination] Killing tmux session ${session.sessionName}`);
|
|
417
|
+
try {
|
|
418
|
+
const tmuxProcess = spawn('tmux', ['kill-session', '-t', session.sessionName]);
|
|
419
|
+
await new Promise((resolve, reject) => {
|
|
420
|
+
let tmuxOutput = '';
|
|
421
|
+
let tmuxError = '';
|
|
422
|
+
tmuxProcess.stdout?.on('data', (data) => {
|
|
423
|
+
tmuxOutput += data.toString();
|
|
424
|
+
});
|
|
425
|
+
tmuxProcess.stderr?.on('data', (data) => {
|
|
426
|
+
tmuxError += data.toString();
|
|
427
|
+
});
|
|
428
|
+
tmuxProcess.on('close', (code) => {
|
|
429
|
+
if (code === 0) {
|
|
430
|
+
console.log(`[Session Termination] Tmux session ${session.sessionName} successfully killed`);
|
|
431
|
+
resolve(undefined);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
console.error(`[Session Termination] Tmux kill failed with code ${code}. Output: ${tmuxOutput}. Error: ${tmuxError}`);
|
|
435
|
+
reject(new Error(`Tmux exited with code ${code}. Error: ${tmuxError}`));
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
tmuxProcess.on('error', (error) => {
|
|
439
|
+
console.error(`[Session Termination] Tmux process error:`, error);
|
|
440
|
+
reject(error);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
cleanup.tmuxKilled = true;
|
|
444
|
+
// Verify tmux session is actually gone
|
|
445
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
446
|
+
try {
|
|
447
|
+
const verifyProcess = spawn('tmux', ['has-session', '-t', session.sessionName]);
|
|
448
|
+
await new Promise((resolve, reject) => {
|
|
449
|
+
verifyProcess.on('close', (code) => {
|
|
450
|
+
if (code === 0) {
|
|
451
|
+
console.warn(`[Session Termination] WARNING: Tmux session ${session.sessionName} still exists after kill command`);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
console.log(`[Session Termination] Verified: Tmux session ${session.sessionName} has been successfully terminated`);
|
|
455
|
+
}
|
|
456
|
+
resolve(undefined);
|
|
457
|
+
});
|
|
458
|
+
verifyProcess.on('error', () => resolve(undefined));
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
catch (verifyError) {
|
|
462
|
+
console.warn(`[Session Termination] Could not verify tmux session termination:`, verifyError);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
console.error(`[Session Termination] Failed to kill tmux session ${session.sessionName}:`, error);
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
// Update database - clear ttyd info and set status to terminated
|
|
470
|
+
console.log(`[Session Termination] Updating database status for session ${session.sessionName}`);
|
|
471
|
+
try {
|
|
472
|
+
fastify.db.updateTmuxSession(session.id, {
|
|
473
|
+
status: 'terminated',
|
|
474
|
+
ttydPort: undefined,
|
|
475
|
+
ttydPid: undefined
|
|
476
|
+
});
|
|
477
|
+
cleanup.databaseUpdated = true;
|
|
478
|
+
console.log(`[Session Termination] Database updated for session ${session.sessionName}`);
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
console.error(`[Session Termination] Failed to update database for session ${session.sessionName}:`, error);
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
// Emit session terminated event
|
|
485
|
+
fastify.io.emit('session:terminated', { sessionId, sessionName: session.sessionName });
|
|
486
|
+
// Final verification that everything is cleaned up
|
|
487
|
+
const verification = await verifySessionTermination(sessionId, session.sessionName, session.ttydPid);
|
|
488
|
+
const executionTime = Date.now() - startTime;
|
|
489
|
+
console.log(`[Session Termination] Successfully terminated session ${session.sessionName} in ${executionTime}ms. Cleanup status:`, cleanup);
|
|
490
|
+
if (!verification.allCleaned) {
|
|
491
|
+
console.warn(`[Session Termination] WARNING: Session ${session.sessionName} termination may be incomplete. Verification:`, verification);
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
success: true,
|
|
495
|
+
cleanup,
|
|
496
|
+
verification,
|
|
497
|
+
executionTime,
|
|
498
|
+
sessionName: session.sessionName
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
const executionTime = Date.now() - startTime;
|
|
503
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
504
|
+
console.error(`[Session Termination] Failed to terminate session ${sessionId} after ${executionTime}ms:`, errorMessage);
|
|
505
|
+
return reply.code(500).send({
|
|
506
|
+
error: 'Failed to kill session',
|
|
507
|
+
details: errorMessage,
|
|
508
|
+
executionTime
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
// Rename session by ID
|
|
513
|
+
fastify.put('/:sessionId/rename', async (request, reply) => {
|
|
514
|
+
const { sessionId } = request.params;
|
|
515
|
+
const { newName } = request.body;
|
|
516
|
+
try {
|
|
517
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
518
|
+
if (!session) {
|
|
519
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
520
|
+
}
|
|
521
|
+
// Rename tmux session using current sessionName
|
|
522
|
+
const tmuxProcess = spawn('tmux', ['rename-session', '-t', session.sessionName, newName]);
|
|
523
|
+
await new Promise((resolve, reject) => {
|
|
524
|
+
tmuxProcess.on('close', (code) => {
|
|
525
|
+
if (code === 0)
|
|
526
|
+
resolve(undefined);
|
|
527
|
+
else
|
|
528
|
+
reject(new Error(`Tmux exited with code ${code}`));
|
|
529
|
+
});
|
|
530
|
+
tmuxProcess.on('error', reject);
|
|
531
|
+
});
|
|
532
|
+
// Update database
|
|
533
|
+
fastify.db.updateTmuxSession(session.id, { sessionName: newName });
|
|
534
|
+
return { success: true };
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
return reply.code(500).send({
|
|
538
|
+
error: 'Failed to rename session',
|
|
539
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
// Toggle mouse mode in tmux session
|
|
544
|
+
fastify.post('/:sessionId/toggle-mouse', async (request, reply) => {
|
|
545
|
+
const { sessionId } = request.params;
|
|
546
|
+
try {
|
|
547
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
548
|
+
if (!session) {
|
|
549
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
550
|
+
}
|
|
551
|
+
// Toggle mouse mode
|
|
552
|
+
const tmuxProcess = spawn('tmux', ['set-option', '-t', session.sessionName, 'mouse']);
|
|
553
|
+
await new Promise((resolve, reject) => {
|
|
554
|
+
tmuxProcess.on('close', (code) => {
|
|
555
|
+
if (code === 0)
|
|
556
|
+
resolve(undefined);
|
|
557
|
+
else
|
|
558
|
+
reject(new Error(`Tmux exited with code ${code}`));
|
|
559
|
+
});
|
|
560
|
+
tmuxProcess.on('error', reject);
|
|
561
|
+
});
|
|
562
|
+
return { success: true };
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
return reply.code(500).send({
|
|
566
|
+
error: 'Failed to toggle mouse mode',
|
|
567
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
// Get ttyd URL for session
|
|
572
|
+
fastify.get('/:sessionId/ttyd', async (request, reply) => {
|
|
573
|
+
const { sessionId } = request.params;
|
|
574
|
+
try {
|
|
575
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
576
|
+
if (!session) {
|
|
577
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
578
|
+
}
|
|
579
|
+
if (!session.ttydPort || !session.ttydPid) {
|
|
580
|
+
return reply.code(404).send({ error: 'TTYd not running for this session' });
|
|
581
|
+
}
|
|
582
|
+
// Check if process is still running
|
|
583
|
+
try {
|
|
584
|
+
process.kill(session.ttydPid, 0);
|
|
585
|
+
return {
|
|
586
|
+
port: session.ttydPort,
|
|
587
|
+
url: `http://localhost:${session.ttydPort}`,
|
|
588
|
+
pid: session.ttydPid
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
// Process is not running, clean up
|
|
593
|
+
fastify.db.updateTmuxSession(session.id, {
|
|
594
|
+
ttydPort: undefined,
|
|
595
|
+
ttydPid: undefined
|
|
596
|
+
});
|
|
597
|
+
return reply.code(404).send({ error: 'TTYd process is no longer running' });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
return reply.code(500).send({
|
|
602
|
+
error: 'Failed to get ttyd URL',
|
|
603
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
// Start ttyd for browser terminal access
|
|
608
|
+
fastify.post('/:sessionId/ttyd', async (request, reply) => {
|
|
609
|
+
const { sessionId } = request.params;
|
|
610
|
+
try {
|
|
611
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
612
|
+
if (!session) {
|
|
613
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
614
|
+
}
|
|
615
|
+
// Check if ttyd is already running for this session
|
|
616
|
+
if (session.ttydPort && session.ttydPid) {
|
|
617
|
+
// Check if process is still running
|
|
618
|
+
try {
|
|
619
|
+
process.kill(session.ttydPid, 0);
|
|
620
|
+
return { port: session.ttydPort, url: `http://localhost:${session.ttydPort}` };
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
// Process is not running, clean up database
|
|
624
|
+
fastify.db.updateTmuxSession(session.id, {
|
|
625
|
+
ttydPort: undefined,
|
|
626
|
+
ttydPid: undefined
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// Clean up any orphaned TTYd processes for this session
|
|
631
|
+
await cleanupOrphanedTtydProcesses(session.sessionName);
|
|
632
|
+
// Find available port with retry logic
|
|
633
|
+
let port;
|
|
634
|
+
let ttydProcess;
|
|
635
|
+
let attempts = 0;
|
|
636
|
+
const maxAttempts = 5;
|
|
637
|
+
while (attempts < maxAttempts) {
|
|
638
|
+
try {
|
|
639
|
+
port = await findAvailablePort(7700 + attempts, 7800);
|
|
640
|
+
// Verify tmux session exists before starting TTYd
|
|
641
|
+
const tmuxCheckProcess = spawn('tmux', ['has-session', '-t', session.sessionName]);
|
|
642
|
+
await new Promise((resolve, reject) => {
|
|
643
|
+
tmuxCheckProcess.on('close', (code) => {
|
|
644
|
+
if (code === 0)
|
|
645
|
+
resolve(undefined);
|
|
646
|
+
else
|
|
647
|
+
reject(new Error(`Tmux session ${session.sessionName} does not exist`));
|
|
648
|
+
});
|
|
649
|
+
tmuxCheckProcess.on('error', reject);
|
|
650
|
+
});
|
|
651
|
+
// Start ttyd with proper error capturing and safer theme parameter
|
|
652
|
+
ttydProcess = spawn('ttyd', [
|
|
653
|
+
'-p', port.toString(),
|
|
654
|
+
'-t', 'fontSize=14',
|
|
655
|
+
'-t', 'theme={"background":"#1e1e1e","foreground":"#cccccc"}',
|
|
656
|
+
'--writable',
|
|
657
|
+
'tmux', 'attach-session', '-t', session.sessionName
|
|
658
|
+
], {
|
|
659
|
+
detached: true,
|
|
660
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
661
|
+
env: { ...process.env, PATH: process.env.PATH }, // Ensure proper environment
|
|
662
|
+
cwd: process.cwd() // Ensure proper working directory
|
|
663
|
+
});
|
|
664
|
+
// Capture stderr for debugging
|
|
665
|
+
let stderrData = '';
|
|
666
|
+
if (ttydProcess.stderr) {
|
|
667
|
+
ttydProcess.stderr.on('data', (data) => {
|
|
668
|
+
stderrData += data.toString();
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
// Wait a moment to see if the process starts successfully
|
|
672
|
+
await new Promise((resolve, reject) => {
|
|
673
|
+
let resolved = false;
|
|
674
|
+
const timeout = setTimeout(() => {
|
|
675
|
+
if (!resolved) {
|
|
676
|
+
resolved = true;
|
|
677
|
+
resolve(true); // Assume success if no immediate error
|
|
678
|
+
}
|
|
679
|
+
}, 2000); // Increased timeout to 2 seconds
|
|
680
|
+
ttydProcess.on('error', (err) => {
|
|
681
|
+
if (!resolved) {
|
|
682
|
+
resolved = true;
|
|
683
|
+
clearTimeout(timeout);
|
|
684
|
+
reject(new Error(`TTYd process error: ${err.message}. Stderr: ${stderrData}`));
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
ttydProcess.on('exit', (code) => {
|
|
688
|
+
if (!resolved && code !== 0) {
|
|
689
|
+
resolved = true;
|
|
690
|
+
clearTimeout(timeout);
|
|
691
|
+
reject(new Error(`TTYd exited with code ${code}. Stderr: ${stderrData}`));
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
ttydProcess.unref();
|
|
696
|
+
// Update database with ttyd info
|
|
697
|
+
fastify.db.updateTmuxSession(session.id, {
|
|
698
|
+
ttydPort: port,
|
|
699
|
+
ttydPid: ttydProcess.pid
|
|
700
|
+
});
|
|
701
|
+
// Emit ttyd started event
|
|
702
|
+
fastify.io.emit('session:ttyd-started', { sessionId, port });
|
|
703
|
+
// Build the proxy URL: the agent's single tunnel + /terminal/:sessionId/
|
|
704
|
+
// This replaces per-session cloudflared tunnels — the agent proxies ttyd
|
|
705
|
+
// through its own API at /terminal/:sessionId
|
|
706
|
+
const agentTunnel = getAgentTunnelUrl();
|
|
707
|
+
const terminalProxyUrl = agentTunnel
|
|
708
|
+
? `${agentTunnel}/terminal/${sessionId}/`
|
|
709
|
+
: null;
|
|
710
|
+
console.log(`[TTYd] Started on port ${port}, proxy URL: ${terminalProxyUrl || 'no tunnel (local only)'}`);
|
|
711
|
+
return {
|
|
712
|
+
port,
|
|
713
|
+
pid: ttydProcess.pid,
|
|
714
|
+
url: `http://localhost:${port}`,
|
|
715
|
+
terminalUrl: terminalProxyUrl,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
catch (error) {
|
|
719
|
+
console.log(`TTYd start attempt ${attempts + 1} failed:`, error);
|
|
720
|
+
attempts++;
|
|
721
|
+
if (ttydProcess && ttydProcess.pid) {
|
|
722
|
+
try {
|
|
723
|
+
process.kill(ttydProcess.pid, 'SIGTERM');
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
// Ignore kill errors
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (attempts >= maxAttempts) {
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
// Wait before retry
|
|
733
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
return reply.code(500).send({
|
|
739
|
+
error: 'Failed to start ttyd',
|
|
740
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
// Verify session termination status
|
|
745
|
+
fastify.get('/:sessionId/termination-status', async (request, reply) => {
|
|
746
|
+
const { sessionId } = request.params;
|
|
747
|
+
try {
|
|
748
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
749
|
+
if (!session) {
|
|
750
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
751
|
+
}
|
|
752
|
+
const verification = await verifySessionTermination(sessionId, session.sessionName, session.ttydPid);
|
|
753
|
+
return {
|
|
754
|
+
sessionId,
|
|
755
|
+
sessionName: session.sessionName,
|
|
756
|
+
databaseStatus: session.status,
|
|
757
|
+
verification,
|
|
758
|
+
isFullyTerminated: verification.allCleaned && session.status === 'terminated'
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
return reply.code(500).send({
|
|
763
|
+
error: 'Failed to verify session termination',
|
|
764
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
// Stop ttyd for session
|
|
769
|
+
fastify.post('/:sessionId/ttyd/stop', async (request, reply) => {
|
|
770
|
+
const { sessionId } = request.params;
|
|
771
|
+
try {
|
|
772
|
+
const session = fastify.db.getTmuxSession(sessionId);
|
|
773
|
+
if (!session) {
|
|
774
|
+
return reply.code(404).send({ error: 'Session not found' });
|
|
775
|
+
}
|
|
776
|
+
if (!session.ttydPid) {
|
|
777
|
+
return reply.code(400).send({ error: 'No ttyd process running for this session' });
|
|
778
|
+
}
|
|
779
|
+
// Kill ttyd process
|
|
780
|
+
try {
|
|
781
|
+
process.kill(session.ttydPid, 'SIGTERM');
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
// Process might already be dead
|
|
785
|
+
}
|
|
786
|
+
// Update database
|
|
787
|
+
fastify.db.updateTmuxSession(session.id, {
|
|
788
|
+
ttydPort: undefined,
|
|
789
|
+
ttydPid: undefined
|
|
790
|
+
});
|
|
791
|
+
// Emit ttyd stopped event
|
|
792
|
+
fastify.io.emit('session:ttyd-stopped', { sessionId });
|
|
793
|
+
return { success: true };
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
return reply.code(500).send({
|
|
797
|
+
error: 'Failed to stop ttyd',
|
|
798
|
+
details: error instanceof Error ? error.message : 'Unknown error'
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
};
|
|
803
|
+
// Helper function to clean up orphaned TTYd processes
|
|
804
|
+
async function cleanupOrphanedTtydProcesses(sessionName) {
|
|
805
|
+
const { spawn } = await import('child_process');
|
|
806
|
+
try {
|
|
807
|
+
console.log(`[TTYd Cleanup] Searching for orphaned TTYd processes for session ${sessionName}`);
|
|
808
|
+
// Find TTYd processes for this session
|
|
809
|
+
const findProcess = spawn('pgrep', ['-f', `ttyd.*${sessionName}`]);
|
|
810
|
+
let processIds = '';
|
|
811
|
+
findProcess.stdout.on('data', (data) => {
|
|
812
|
+
processIds += data.toString();
|
|
813
|
+
});
|
|
814
|
+
await new Promise((resolve) => {
|
|
815
|
+
findProcess.on('close', resolve);
|
|
816
|
+
});
|
|
817
|
+
if (processIds.trim()) {
|
|
818
|
+
const pids = processIds.trim().split('\n').filter(pid => pid);
|
|
819
|
+
console.log(`[TTYd Cleanup] Found ${pids.length} orphaned TTYd processes for session ${sessionName}, cleaning up...`);
|
|
820
|
+
for (const pid of pids) {
|
|
821
|
+
try {
|
|
822
|
+
const pidNum = parseInt(pid);
|
|
823
|
+
console.log(`[TTYd Cleanup] Killing orphaned TTYd process ${pidNum}`);
|
|
824
|
+
// First try graceful termination
|
|
825
|
+
process.kill(pidNum, 'SIGTERM');
|
|
826
|
+
// Wait a moment and verify process is killed
|
|
827
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
828
|
+
try {
|
|
829
|
+
process.kill(pidNum, 0); // Check if process still exists
|
|
830
|
+
console.warn(`[TTYd Cleanup] TTYd process ${pidNum} still running after SIGTERM, using SIGKILL`);
|
|
831
|
+
process.kill(pidNum, 'SIGKILL');
|
|
832
|
+
}
|
|
833
|
+
catch (killError) {
|
|
834
|
+
// Process doesn't exist anymore, which is what we want
|
|
835
|
+
console.log(`[TTYd Cleanup] TTYd process ${pidNum} successfully terminated`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
console.error(`[TTYd Cleanup] Failed to kill TTYd process ${pid}:`, error);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
// Wait a moment for processes to terminate
|
|
843
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
844
|
+
// Verify all processes are gone
|
|
845
|
+
try {
|
|
846
|
+
const verifyProcess = spawn('pgrep', ['-f', `ttyd.*${sessionName}`]);
|
|
847
|
+
let remainingProcesses = '';
|
|
848
|
+
verifyProcess.stdout.on('data', (data) => {
|
|
849
|
+
remainingProcesses += data.toString();
|
|
850
|
+
});
|
|
851
|
+
await new Promise((resolve) => {
|
|
852
|
+
verifyProcess.on('close', resolve);
|
|
853
|
+
});
|
|
854
|
+
if (remainingProcesses.trim()) {
|
|
855
|
+
const remainingPids = remainingProcesses.trim().split('\n').filter(pid => pid);
|
|
856
|
+
console.warn(`[TTYd Cleanup] WARNING: ${remainingPids.length} TTYd processes still running after cleanup: ${remainingPids.join(', ')}`);
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
console.log(`[TTYd Cleanup] All orphaned TTYd processes for session ${sessionName} successfully cleaned up`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
catch (verifyError) {
|
|
863
|
+
console.warn(`[TTYd Cleanup] Could not verify TTYd cleanup:`, verifyError);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
console.log(`[TTYd Cleanup] No orphaned TTYd processes found for session ${sessionName}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
console.error(`[TTYd Cleanup] Error cleaning up orphaned TTYd processes for session ${sessionName}:`, error);
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
// Helper function to check if a process is still running
|
|
876
|
+
async function isProcessRunning(pid) {
|
|
877
|
+
try {
|
|
878
|
+
process.kill(pid, 0); // Signal 0 just checks if process exists
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
// Helper function to check if a tmux session exists
|
|
886
|
+
async function doesTmuxSessionExist(sessionName) {
|
|
887
|
+
const { spawn } = await import('child_process');
|
|
888
|
+
try {
|
|
889
|
+
const checkProcess = spawn('tmux', ['has-session', '-t', sessionName]);
|
|
890
|
+
return new Promise((resolve) => {
|
|
891
|
+
checkProcess.on('close', (code) => {
|
|
892
|
+
resolve(code === 0);
|
|
893
|
+
});
|
|
894
|
+
checkProcess.on('error', () => {
|
|
895
|
+
resolve(false);
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
// Helper function to get all running TTYd processes for a session
|
|
904
|
+
async function getRunningTtydProcesses(sessionName) {
|
|
905
|
+
const { spawn } = await import('child_process');
|
|
906
|
+
try {
|
|
907
|
+
const findProcess = spawn('pgrep', ['-f', `ttyd.*${sessionName}`]);
|
|
908
|
+
let processIds = '';
|
|
909
|
+
findProcess.stdout.on('data', (data) => {
|
|
910
|
+
processIds += data.toString();
|
|
911
|
+
});
|
|
912
|
+
await new Promise((resolve) => {
|
|
913
|
+
findProcess.on('close', resolve);
|
|
914
|
+
});
|
|
915
|
+
if (processIds.trim()) {
|
|
916
|
+
return processIds.trim().split('\n').filter(pid => pid).map(pid => parseInt(pid));
|
|
917
|
+
}
|
|
918
|
+
return [];
|
|
919
|
+
}
|
|
920
|
+
catch (error) {
|
|
921
|
+
console.error(`[Process Check] Error getting TTYd processes for session ${sessionName}:`, error);
|
|
922
|
+
return [];
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
// Enhanced health check endpoint for session termination verification
|
|
926
|
+
async function verifySessionTermination(sessionId, sessionName, ttydPid) {
|
|
927
|
+
console.log(`[Session Verification] Verifying termination of session ${sessionName} (ID: ${sessionId})`);
|
|
928
|
+
const tmuxExists = await doesTmuxSessionExist(sessionName);
|
|
929
|
+
const ttydProcesses = await getRunningTtydProcesses(sessionName);
|
|
930
|
+
const targetTtydRunning = ttydPid ? await isProcessRunning(ttydPid) : false;
|
|
931
|
+
const allCleaned = !tmuxExists && ttydProcesses.length === 0 && !targetTtydRunning;
|
|
932
|
+
console.log(`[Session Verification] Session ${sessionName} verification result:`, {
|
|
933
|
+
tmuxExists,
|
|
934
|
+
ttydProcesses,
|
|
935
|
+
targetTtydRunning,
|
|
936
|
+
allCleaned
|
|
937
|
+
});
|
|
938
|
+
return {
|
|
939
|
+
tmuxExists,
|
|
940
|
+
ttydProcesses,
|
|
941
|
+
targetTtydRunning,
|
|
942
|
+
allCleaned
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
// Helper function to find available port with better detection
|
|
946
|
+
async function findAvailablePort(startPort, endPort) {
|
|
947
|
+
const net = await import('net');
|
|
948
|
+
const { spawn } = await import('child_process');
|
|
949
|
+
for (let port = startPort; port <= endPort; port++) {
|
|
950
|
+
// First check if the port is bound using netstat/lsof
|
|
951
|
+
const isPortOccupied = await new Promise((resolve) => {
|
|
952
|
+
const checkProcess = spawn('lsof', ['-i', `:${port}`, '-t']);
|
|
953
|
+
let hasOutput = false;
|
|
954
|
+
checkProcess.stdout.on('data', (_data) => {
|
|
955
|
+
hasOutput = true;
|
|
956
|
+
});
|
|
957
|
+
checkProcess.on('close', (code) => {
|
|
958
|
+
// If lsof found processes using the port, it's occupied
|
|
959
|
+
resolve(hasOutput);
|
|
960
|
+
});
|
|
961
|
+
checkProcess.on('error', () => {
|
|
962
|
+
// If lsof fails, fall back to basic check
|
|
963
|
+
resolve(false);
|
|
964
|
+
});
|
|
965
|
+
// Timeout the check
|
|
966
|
+
setTimeout(() => {
|
|
967
|
+
checkProcess.kill();
|
|
968
|
+
resolve(false);
|
|
969
|
+
}, 1000);
|
|
970
|
+
});
|
|
971
|
+
if (isPortOccupied) {
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
// Double-check by trying to bind to the port
|
|
975
|
+
const available = await new Promise((resolve) => {
|
|
976
|
+
const server = net.createServer();
|
|
977
|
+
server.once('error', () => resolve(false));
|
|
978
|
+
server.once('listening', () => {
|
|
979
|
+
server.close();
|
|
980
|
+
resolve(true);
|
|
981
|
+
});
|
|
982
|
+
server.listen(port, 'localhost');
|
|
983
|
+
});
|
|
984
|
+
if (available) {
|
|
985
|
+
return port;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
throw new Error(`No available ports between ${startPort} and ${endPort}`);
|
|
989
|
+
}
|
|
990
|
+
// Get all tmux sessions on the system
|
|
991
|
+
async function getSystemTmuxSessions() {
|
|
992
|
+
const { spawn } = await import('child_process');
|
|
993
|
+
try {
|
|
994
|
+
console.log('[System Sessions] Discovering all tmux sessions on system');
|
|
995
|
+
const listProcess = spawn('tmux', ['list-sessions', '-F', '#{session_name}|#{session_attached}|#{session_windows}|#{session_created}|#{session_activity}|#{session_id}']);
|
|
996
|
+
let output = '';
|
|
997
|
+
let stderr = '';
|
|
998
|
+
listProcess.stdout.on('data', (data) => {
|
|
999
|
+
output += data.toString();
|
|
1000
|
+
});
|
|
1001
|
+
listProcess.stderr.on('data', (data) => {
|
|
1002
|
+
stderr += data.toString();
|
|
1003
|
+
});
|
|
1004
|
+
await new Promise((resolve, reject) => {
|
|
1005
|
+
listProcess.on('close', (code) => {
|
|
1006
|
+
if (code === 0) {
|
|
1007
|
+
resolve(undefined);
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
// If no sessions exist, tmux returns exit code 1
|
|
1011
|
+
if (code === 1 && stderr.includes('no server running')) {
|
|
1012
|
+
resolve(undefined);
|
|
1013
|
+
}
|
|
1014
|
+
else {
|
|
1015
|
+
reject(new Error(`tmux list-sessions failed with code ${code}: ${stderr}`));
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
listProcess.on('error', reject);
|
|
1020
|
+
});
|
|
1021
|
+
if (!output.trim()) {
|
|
1022
|
+
console.log('[System Sessions] No tmux sessions found on system');
|
|
1023
|
+
return [];
|
|
1024
|
+
}
|
|
1025
|
+
const sessions = output.trim().split('\n').map(line => {
|
|
1026
|
+
const [name, attached, windows, created, lastActivity, sessionId] = line.split('|');
|
|
1027
|
+
return {
|
|
1028
|
+
name,
|
|
1029
|
+
attached: attached === '1',
|
|
1030
|
+
windows: parseInt(windows) || 0,
|
|
1031
|
+
created: new Date(parseInt(created) * 1000).toISOString(),
|
|
1032
|
+
lastActivity: new Date(parseInt(lastActivity) * 1000).toISOString(),
|
|
1033
|
+
sessionId,
|
|
1034
|
+
};
|
|
1035
|
+
});
|
|
1036
|
+
console.log(`[System Sessions] Found ${sessions.length} tmux sessions on system`);
|
|
1037
|
+
return sessions;
|
|
1038
|
+
}
|
|
1039
|
+
catch (error) {
|
|
1040
|
+
console.error('[System Sessions] Error getting system tmux sessions:', error);
|
|
1041
|
+
// Return empty array instead of throwing - no sessions is not an error
|
|
1042
|
+
return [];
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// Get all ttyd processes on the system
|
|
1046
|
+
async function getSystemTtydProcesses() {
|
|
1047
|
+
const { spawn } = await import('child_process');
|
|
1048
|
+
try {
|
|
1049
|
+
console.log('[System Processes] Discovering all ttyd processes on system');
|
|
1050
|
+
// Use ps to find ttyd processes with detailed information
|
|
1051
|
+
const psProcess = spawn('ps', ['aux']);
|
|
1052
|
+
let output = '';
|
|
1053
|
+
psProcess.stdout.on('data', (data) => {
|
|
1054
|
+
output += data.toString();
|
|
1055
|
+
});
|
|
1056
|
+
await new Promise((resolve, reject) => {
|
|
1057
|
+
psProcess.on('close', (code) => {
|
|
1058
|
+
if (code === 0)
|
|
1059
|
+
resolve(undefined);
|
|
1060
|
+
else
|
|
1061
|
+
reject(new Error(`ps command failed with code ${code}`));
|
|
1062
|
+
});
|
|
1063
|
+
psProcess.on('error', reject);
|
|
1064
|
+
});
|
|
1065
|
+
// Parse ps output to find ttyd processes
|
|
1066
|
+
const lines = output.split('\n');
|
|
1067
|
+
const ttydProcesses = [];
|
|
1068
|
+
for (const line of lines) {
|
|
1069
|
+
if (line.includes('ttyd') && !line.includes('grep')) {
|
|
1070
|
+
const parts = line.trim().split(/\s+/);
|
|
1071
|
+
if (parts.length >= 11) {
|
|
1072
|
+
const user = parts[0];
|
|
1073
|
+
const pid = parseInt(parts[1]);
|
|
1074
|
+
const startTime = parts[8];
|
|
1075
|
+
const command = parts.slice(10).join(' ');
|
|
1076
|
+
// Extract port from command line
|
|
1077
|
+
let port = 0;
|
|
1078
|
+
let sessionName = '';
|
|
1079
|
+
const portMatch = command.match(/-p\s+(\d+)/);
|
|
1080
|
+
if (portMatch) {
|
|
1081
|
+
port = parseInt(portMatch[1]);
|
|
1082
|
+
}
|
|
1083
|
+
// Extract session name from tmux attach command
|
|
1084
|
+
const sessionMatch = command.match(/tmux\s+attach-session\s+-t\s+(\S+)/);
|
|
1085
|
+
if (sessionMatch) {
|
|
1086
|
+
sessionName = sessionMatch[1];
|
|
1087
|
+
}
|
|
1088
|
+
ttydProcesses.push({
|
|
1089
|
+
pid,
|
|
1090
|
+
port,
|
|
1091
|
+
sessionName,
|
|
1092
|
+
command,
|
|
1093
|
+
startTime,
|
|
1094
|
+
user,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
console.log(`[System Processes] Found ${ttydProcesses.length} ttyd processes on system`);
|
|
1100
|
+
return ttydProcesses;
|
|
1101
|
+
}
|
|
1102
|
+
catch (error) {
|
|
1103
|
+
console.error('[System Processes] Error getting system ttyd processes:', error);
|
|
1104
|
+
return [];
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
// Kill multiple tmux sessions
|
|
1108
|
+
async function killSystemTmuxSessions(sessionNames, force = false) {
|
|
1109
|
+
const { spawn } = await import('child_process');
|
|
1110
|
+
const results = [];
|
|
1111
|
+
console.log(`[System Sessions] Killing ${sessionNames.length} tmux sessions, force: ${force}`);
|
|
1112
|
+
for (const sessionName of sessionNames) {
|
|
1113
|
+
try {
|
|
1114
|
+
console.log(`[System Sessions] Killing tmux session: ${sessionName}`);
|
|
1115
|
+
const killProcess = spawn('tmux', ['kill-session', '-t', sessionName]);
|
|
1116
|
+
await new Promise((resolve, reject) => {
|
|
1117
|
+
let stderr = '';
|
|
1118
|
+
killProcess.stderr?.on('data', (data) => {
|
|
1119
|
+
stderr += data.toString();
|
|
1120
|
+
});
|
|
1121
|
+
killProcess.on('close', (code) => {
|
|
1122
|
+
if (code === 0) {
|
|
1123
|
+
resolve(undefined);
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
reject(new Error(`tmux kill-session failed with code ${code}: ${stderr}`));
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
killProcess.on('error', reject);
|
|
1130
|
+
});
|
|
1131
|
+
results.push({
|
|
1132
|
+
target: sessionName,
|
|
1133
|
+
success: true,
|
|
1134
|
+
});
|
|
1135
|
+
console.log(`[System Sessions] Successfully killed tmux session: ${sessionName}`);
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1139
|
+
console.error(`[System Sessions] Failed to kill tmux session ${sessionName}:`, errorMessage);
|
|
1140
|
+
results.push({
|
|
1141
|
+
target: sessionName,
|
|
1142
|
+
success: false,
|
|
1143
|
+
error: errorMessage,
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return results;
|
|
1148
|
+
}
|
|
1149
|
+
// Kill multiple ttyd processes
|
|
1150
|
+
async function killSystemTtydProcesses(pids, force = false) {
|
|
1151
|
+
const results = [];
|
|
1152
|
+
console.log(`[System Processes] Killing ${pids.length} ttyd processes, force: ${force}`);
|
|
1153
|
+
for (const pid of pids) {
|
|
1154
|
+
try {
|
|
1155
|
+
console.log(`[System Processes] Killing ttyd process: ${pid}`);
|
|
1156
|
+
const signal = force ? 'SIGKILL' : 'SIGTERM';
|
|
1157
|
+
process.kill(pid, signal);
|
|
1158
|
+
// Wait a moment and verify process is killed
|
|
1159
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
1160
|
+
try {
|
|
1161
|
+
process.kill(pid, 0); // Check if process still exists
|
|
1162
|
+
if (!force) {
|
|
1163
|
+
console.warn(`[System Processes] Process ${pid} still running after SIGTERM, using SIGKILL`);
|
|
1164
|
+
process.kill(pid, 'SIGKILL');
|
|
1165
|
+
}
|
|
1166
|
+
else {
|
|
1167
|
+
throw new Error(`Process ${pid} still running after SIGKILL`);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
catch (killError) {
|
|
1171
|
+
// Process doesn't exist anymore, which is what we want
|
|
1172
|
+
console.log(`[System Processes] Successfully killed ttyd process: ${pid}`);
|
|
1173
|
+
}
|
|
1174
|
+
results.push({
|
|
1175
|
+
target: pid.toString(),
|
|
1176
|
+
success: true,
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
catch (error) {
|
|
1180
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1181
|
+
console.error(`[System Processes] Failed to kill ttyd process ${pid}:`, errorMessage);
|
|
1182
|
+
results.push({
|
|
1183
|
+
target: pid.toString(),
|
|
1184
|
+
success: false,
|
|
1185
|
+
error: errorMessage,
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return results;
|
|
1190
|
+
}
|
|
1191
|
+
//# sourceMappingURL=tmux.js.map
|