@dmsdc-ai/aigentry-telepty 0.1.7 → 0.1.9
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/README.md +7 -0
- package/cli.js +101 -82
- package/daemon-control.js +223 -0
- package/daemon.js +43 -0
- package/install.js +16 -1
- package/install.ps1 +7 -1
- package/install.sh +6 -1
- package/interactive-terminal.js +54 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ iwr -useb https://raw.githubusercontent.com/dmsdc-ai/aigentry-telepty/main/insta
|
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
*These single commands will install the package globally and automatically configure it to run as a background service specific to your OS (`systemd` for Linux, `launchd` for macOS, or a detached background process for Windows).*
|
|
24
|
+
The installer now stops older local telepty daemons before starting the new one, so updates do not leave duplicate background processes behind.
|
|
24
25
|
|
|
25
26
|
## Seamless Usage
|
|
26
27
|
|
|
@@ -56,6 +57,12 @@ npm run test:watch
|
|
|
56
57
|
|
|
57
58
|
The automated suite covers config generation, daemon HTTP APIs, WebSocket attach/output flow, bus events, session deletion regressions, and CLI smoke tests against a real daemon process.
|
|
58
59
|
|
|
60
|
+
If you ever need to manually clear stale local daemon processes:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
telepty cleanup-daemons
|
|
64
|
+
```
|
|
65
|
+
|
|
59
66
|
## Skill Installation
|
|
60
67
|
|
|
61
68
|
The package installer opens the telepty skill TUI automatically when you run it in a terminal.
|
package/cli.js
CHANGED
|
@@ -9,6 +9,8 @@ const prompts = require('prompts');
|
|
|
9
9
|
const updateNotifier = require('update-notifier');
|
|
10
10
|
const pkg = require('./package.json');
|
|
11
11
|
const { getConfig } = require('./auth');
|
|
12
|
+
const { cleanupDaemonProcesses } = require('./daemon-control');
|
|
13
|
+
const { attachInteractiveTerminal } = require('./interactive-terminal');
|
|
12
14
|
const { runInteractiveSkillInstaller } = require('./skill-installer');
|
|
13
15
|
const args = process.argv.slice(2);
|
|
14
16
|
|
|
@@ -31,6 +33,28 @@ const fetchWithAuth = (url, options = {}) => {
|
|
|
31
33
|
return fetch(url, { ...options, headers });
|
|
32
34
|
};
|
|
33
35
|
|
|
36
|
+
async function getDaemonMeta(host = REMOTE_HOST) {
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetchWithAuth(`http://${host}:${PORT}/api/meta`, {
|
|
39
|
+
signal: AbortSignal.timeout(1500)
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return await res.json();
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function startDetachedDaemon() {
|
|
51
|
+
const cp = spawn(process.argv[0], [process.argv[1], 'daemon'], {
|
|
52
|
+
detached: true,
|
|
53
|
+
stdio: 'ignore'
|
|
54
|
+
});
|
|
55
|
+
cp.unref();
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
async function discoverSessions() {
|
|
35
59
|
await ensureDaemonRunning();
|
|
36
60
|
const hosts = ['127.0.0.1'];
|
|
@@ -70,51 +94,66 @@ async function discoverSessions() {
|
|
|
70
94
|
return allSessions;
|
|
71
95
|
}
|
|
72
96
|
|
|
73
|
-
async function ensureDaemonRunning() {
|
|
97
|
+
async function ensureDaemonRunning(options = {}) {
|
|
74
98
|
if (REMOTE_HOST !== '127.0.0.1') return; // Only auto-start local daemon
|
|
99
|
+
|
|
100
|
+
const requiredCapabilities = options.requiredCapabilities || [];
|
|
101
|
+
|
|
75
102
|
try {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const cp = spawn(process.argv[0], [process.argv[1], 'daemon'], {
|
|
82
|
-
detached: true,
|
|
83
|
-
stdio: 'ignore'
|
|
103
|
+
const meta = await getDaemonMeta('127.0.0.1');
|
|
104
|
+
const hasCapabilities = meta && requiredCapabilities.every((item) => meta.capabilities.includes(item));
|
|
105
|
+
|
|
106
|
+
const sessionsRes = await fetchWithAuth(`${DAEMON_URL}/api/sessions`, {
|
|
107
|
+
signal: AbortSignal.timeout(1500)
|
|
84
108
|
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
109
|
+
|
|
110
|
+
if (sessionsRes.ok && hasCapabilities) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (sessionsRes.ok && !meta) {
|
|
115
|
+
process.stdout.write('\x1b[33m⚙️ Found an older local telepty daemon. Restarting it...\x1b[0m\n');
|
|
116
|
+
cleanupDaemonProcesses();
|
|
117
|
+
} else if (sessionsRes.ok && meta) {
|
|
118
|
+
process.stdout.write('\x1b[33m⚙️ Found a local telepty daemon without the required features. Restarting it...\x1b[0m\n');
|
|
119
|
+
cleanupDaemonProcesses();
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// Continue to auto-start below.
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
process.stdout.write('\x1b[33m⚙️ Auto-starting local telepty daemon...\x1b[0m\n');
|
|
126
|
+
cleanupDaemonProcesses();
|
|
127
|
+
startDetachedDaemon();
|
|
128
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
129
|
+
|
|
130
|
+
const meta = await getDaemonMeta('127.0.0.1');
|
|
131
|
+
if (!meta || !requiredCapabilities.every((item) => meta.capabilities.includes(item))) {
|
|
132
|
+
console.error('❌ Failed to start a compatible local telepty daemon. Try `telepty cleanup-daemons` or rerun the installer.');
|
|
89
133
|
}
|
|
90
134
|
}
|
|
91
135
|
|
|
92
136
|
async function manageInteractiveAttach(sessionId, targetHost) {
|
|
93
137
|
const wsUrl = `ws://${targetHost}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(TOKEN)}`;
|
|
94
138
|
const ws = new WebSocket(wsUrl);
|
|
95
|
-
let
|
|
96
|
-
let resizeHandler = null;
|
|
139
|
+
let cleanupTerminal = null;
|
|
97
140
|
return new Promise((resolve) => {
|
|
98
141
|
ws.on('open', () => {
|
|
99
142
|
// Set Ghostty tab title to show session ID
|
|
100
143
|
process.stdout.write(`\x1b]0;⚡ telepty :: ${sessionId}\x07`);
|
|
101
144
|
console.log(`\n\x1b[32mEntered room '${sessionId}'.\x1b[0m\n`);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
process.stdout.on('resize', resizeHandler);
|
|
107
|
-
resizeHandler();
|
|
145
|
+
cleanupTerminal = attachInteractiveTerminal(process.stdin, process.stdout, {
|
|
146
|
+
onData: (d) => ws.send(JSON.stringify({ type: 'input', data: d.toString() })),
|
|
147
|
+
onResize: () => ws.send(JSON.stringify({ type: 'resize', cols: process.stdout.columns, rows: process.stdout.rows }))
|
|
148
|
+
});
|
|
108
149
|
});
|
|
109
150
|
ws.on('message', m => {
|
|
110
151
|
const msg = JSON.parse(m);
|
|
111
152
|
if (msg.type === 'output') process.stdout.write(msg.data);
|
|
112
153
|
});
|
|
113
154
|
ws.on('close', async () => {
|
|
114
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
115
155
|
process.stdout.write(`\x1b]0;\x07`); // Restore default terminal title
|
|
116
|
-
if (
|
|
117
|
-
if (resizeHandler) process.stdout.off('resize', resizeHandler);
|
|
156
|
+
if (cleanupTerminal) cleanupTerminal();
|
|
118
157
|
|
|
119
158
|
// Check if other clients are still attached before destroying
|
|
120
159
|
try {
|
|
@@ -164,11 +203,7 @@ async function manageInteractive() {
|
|
|
164
203
|
try {
|
|
165
204
|
execSync('npm install -g @dmsdc-ai/aigentry-telepty@latest', { stdio: 'inherit' });
|
|
166
205
|
console.log('\n\x1b[32m✅ Update complete! Restarting daemon...\x1b[0m');
|
|
167
|
-
|
|
168
|
-
const os = require('os');
|
|
169
|
-
if (os.platform() === 'win32') execSync('taskkill /IM node.exe /FI "WINDOWTITLE eq telepty daemon*" /F', { stdio: 'ignore' });
|
|
170
|
-
else execSync('pkill -f "telepty daemon"', { stdio: 'ignore' });
|
|
171
|
-
} catch(e) {}
|
|
206
|
+
cleanupDaemonProcesses();
|
|
172
207
|
} catch (e) {
|
|
173
208
|
console.error('\n❌ Update failed.\n');
|
|
174
209
|
}
|
|
@@ -182,11 +217,8 @@ async function manageInteractive() {
|
|
|
182
217
|
|
|
183
218
|
if (response.action === 'daemon') {
|
|
184
219
|
console.log('\n\x1b[33mStarting daemon in background...\x1b[0m');
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
stdio: 'ignore'
|
|
188
|
-
});
|
|
189
|
-
cp.unref();
|
|
220
|
+
cleanupDaemonProcesses();
|
|
221
|
+
startDetachedDaemon();
|
|
190
222
|
console.log('✅ Daemon started.\n');
|
|
191
223
|
continue;
|
|
192
224
|
}
|
|
@@ -317,16 +349,7 @@ async function main() {
|
|
|
317
349
|
try {
|
|
318
350
|
execSync('npm install -g @dmsdc-ai/aigentry-telepty@latest', { stdio: 'inherit' });
|
|
319
351
|
console.log('\n\x1b[32m✅ Update complete! Restarting daemon...\x1b[0m');
|
|
320
|
-
|
|
321
|
-
// Kill local daemon if running, so it auto-restarts on next command
|
|
322
|
-
try {
|
|
323
|
-
if (os.platform() === 'win32') {
|
|
324
|
-
execSync('taskkill /IM node.exe /FI "WINDOWTITLE eq telepty daemon*" /F', { stdio: 'ignore' });
|
|
325
|
-
} else {
|
|
326
|
-
execSync('pkill -f "telepty daemon"', { stdio: 'ignore' });
|
|
327
|
-
}
|
|
328
|
-
} catch (e) {} // Ignore if not running
|
|
329
|
-
|
|
352
|
+
cleanupDaemonProcesses();
|
|
330
353
|
console.log('🎉 You are now using the latest version.');
|
|
331
354
|
} catch (e) {
|
|
332
355
|
console.error('\n❌ Update failed. Please try running: npm install -g @dmsdc-ai/aigentry-telepty@latest');
|
|
@@ -334,6 +357,16 @@ async function main() {
|
|
|
334
357
|
return;
|
|
335
358
|
}
|
|
336
359
|
|
|
360
|
+
if (cmd === 'cleanup-daemons') {
|
|
361
|
+
const results = cleanupDaemonProcesses();
|
|
362
|
+
console.log(`Stopped ${results.stopped.length} telepty daemon(s).`);
|
|
363
|
+
if (results.failed.length > 0) {
|
|
364
|
+
console.log(`Failed to stop ${results.failed.length} daemon(s).`);
|
|
365
|
+
process.exitCode = 1;
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
337
370
|
if (cmd === 'daemon') {
|
|
338
371
|
console.log('Starting telepty daemon...');
|
|
339
372
|
require('./daemon.js');
|
|
@@ -415,7 +448,7 @@ async function main() {
|
|
|
415
448
|
sessionId = path.basename(command);
|
|
416
449
|
}
|
|
417
450
|
|
|
418
|
-
await ensureDaemonRunning();
|
|
451
|
+
await ensureDaemonRunning({ requiredCapabilities: ['wrapped-sessions'] });
|
|
419
452
|
|
|
420
453
|
// Register session with daemon
|
|
421
454
|
try {
|
|
@@ -480,11 +513,13 @@ async function main() {
|
|
|
480
513
|
process.stdout.write(`\x1b]0;⚡ telepty :: ${sessionId}\x07`);
|
|
481
514
|
console.log(`\x1b[32m⚡ '${command}' is now session '\x1b[36m${sessionId}\x1b[32m'. Inject allowed.\x1b[0m\n`);
|
|
482
515
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
516
|
+
const cleanupTerminal = attachInteractiveTerminal(process.stdin, process.stdout, {
|
|
517
|
+
onData: (data) => {
|
|
518
|
+
child.write(data.toString());
|
|
519
|
+
},
|
|
520
|
+
onResize: () => {
|
|
521
|
+
child.resize(process.stdout.columns, process.stdout.rows);
|
|
522
|
+
}
|
|
488
523
|
});
|
|
489
524
|
|
|
490
525
|
// Relay PTY output to current terminal + send to daemon for attach clients
|
|
@@ -495,14 +530,9 @@ async function main() {
|
|
|
495
530
|
}
|
|
496
531
|
});
|
|
497
532
|
|
|
498
|
-
// Handle terminal resize
|
|
499
|
-
process.stdout.on('resize', () => {
|
|
500
|
-
child.resize(process.stdout.columns, process.stdout.rows);
|
|
501
|
-
});
|
|
502
|
-
|
|
503
533
|
// Handle child exit
|
|
504
534
|
child.onExit(({ exitCode }) => {
|
|
505
|
-
|
|
535
|
+
cleanupTerminal();
|
|
506
536
|
process.stdout.write(`\x1b]0;\x07`);
|
|
507
537
|
console.log(`\n\x1b[33mSession '${sessionId}' exited (code ${exitCode}).\x1b[0m`);
|
|
508
538
|
|
|
@@ -551,8 +581,7 @@ async function main() {
|
|
|
551
581
|
|
|
552
582
|
const wsUrl = `ws://${targetHost}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(TOKEN)}`;
|
|
553
583
|
const ws = new WebSocket(wsUrl);
|
|
554
|
-
let
|
|
555
|
-
let resizeHandler = null;
|
|
584
|
+
let cleanupTerminal = null;
|
|
556
585
|
|
|
557
586
|
ws.on('open', () => {
|
|
558
587
|
// Set Ghostty tab title to show session ID
|
|
@@ -560,25 +589,18 @@ async function main() {
|
|
|
560
589
|
process.stdout.write(`\x1b]0;⚡ telepty :: ${sessionId}${hostSuffix}\x07`);
|
|
561
590
|
console.log(`\x1b[32mEntered room '${sessionId}'${hostSuffix ? ` (${targetHost})` : ''}.\x1b[0m\n`);
|
|
562
591
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
cols: process.stdout.columns,
|
|
576
|
-
rows: process.stdout.rows
|
|
577
|
-
}));
|
|
578
|
-
};
|
|
579
|
-
|
|
580
|
-
process.stdout.on('resize', resizeHandler);
|
|
581
|
-
resizeHandler(); // Initial resize
|
|
592
|
+
cleanupTerminal = attachInteractiveTerminal(process.stdin, process.stdout, {
|
|
593
|
+
onData: (data) => {
|
|
594
|
+
ws.send(JSON.stringify({ type: 'input', data: data.toString() }));
|
|
595
|
+
},
|
|
596
|
+
onResize: () => {
|
|
597
|
+
ws.send(JSON.stringify({
|
|
598
|
+
type: 'resize',
|
|
599
|
+
cols: process.stdout.columns,
|
|
600
|
+
rows: process.stdout.rows
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
});
|
|
582
604
|
});
|
|
583
605
|
|
|
584
606
|
ws.on('message', (message) => {
|
|
@@ -589,12 +611,8 @@ async function main() {
|
|
|
589
611
|
});
|
|
590
612
|
|
|
591
613
|
ws.on('close', async (code, reason) => {
|
|
592
|
-
if (process.stdin.isTTY) {
|
|
593
|
-
process.stdin.setRawMode(false);
|
|
594
|
-
}
|
|
595
614
|
process.stdout.write(`\x1b]0;\x07`); // Restore default terminal title
|
|
596
|
-
if (
|
|
597
|
-
if (resizeHandler) process.stdout.off('resize', resizeHandler);
|
|
615
|
+
if (cleanupTerminal) cleanupTerminal();
|
|
598
616
|
|
|
599
617
|
// Check if other clients are still attached before destroying
|
|
600
618
|
try {
|
|
@@ -758,6 +776,7 @@ Usage:
|
|
|
758
776
|
telepty multicast <id1,id2> "<prompt>" Inject text into multiple specific sessions
|
|
759
777
|
telepty broadcast "<prompt>" Inject text into ALL active sessions
|
|
760
778
|
telepty rename <old_id> <new_id> Rename a session (updates terminal title too)
|
|
779
|
+
telepty cleanup-daemons Stop old local telepty daemon processes
|
|
761
780
|
telepty listen Listen to the event bus and print JSON to stdout
|
|
762
781
|
telepty monitor Human-readable real-time billboard of bus events
|
|
763
782
|
telepty update Update telepty to the latest version
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execFileSync, execSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const TELEPTY_DIR = path.join(os.homedir(), '.telepty');
|
|
9
|
+
const DAEMON_STATE_FILE = path.join(TELEPTY_DIR, 'daemon-state.json');
|
|
10
|
+
|
|
11
|
+
function ensureTeleptyDir() {
|
|
12
|
+
fs.mkdirSync(TELEPTY_DIR, { recursive: true, mode: 0o700 });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sleepMs(ms) {
|
|
16
|
+
const buffer = new SharedArrayBuffer(4);
|
|
17
|
+
const view = new Int32Array(buffer);
|
|
18
|
+
Atomics.wait(view, 0, 0, ms);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isProcessRunning(pid) {
|
|
22
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return error.code === 'EPERM';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readDaemonState() {
|
|
35
|
+
if (!fs.existsSync(DAEMON_STATE_FILE)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(fs.readFileSync(DAEMON_STATE_FILE, 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeDaemonState(state) {
|
|
47
|
+
ensureTeleptyDir();
|
|
48
|
+
fs.writeFileSync(DAEMON_STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clearDaemonState(expectedPid) {
|
|
52
|
+
if (!fs.existsSync(DAEMON_STATE_FILE)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (expectedPid === undefined) {
|
|
57
|
+
fs.rmSync(DAEMON_STATE_FILE, { force: true });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const current = readDaemonState();
|
|
62
|
+
if (!current || current.pid === expectedPid) {
|
|
63
|
+
fs.rmSync(DAEMON_STATE_FILE, { force: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function claimDaemonState(details) {
|
|
68
|
+
ensureTeleptyDir();
|
|
69
|
+
const current = readDaemonState();
|
|
70
|
+
|
|
71
|
+
if (current && current.pid !== process.pid) {
|
|
72
|
+
if (isProcessRunning(current.pid)) {
|
|
73
|
+
return { claimed: false, current };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
clearDaemonState(current.pid);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const state = {
|
|
80
|
+
pid: process.pid,
|
|
81
|
+
host: details.host,
|
|
82
|
+
port: details.port,
|
|
83
|
+
startedAt: new Date().toISOString(),
|
|
84
|
+
version: details.version
|
|
85
|
+
};
|
|
86
|
+
writeDaemonState(state);
|
|
87
|
+
return { claimed: true, state };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isLikelyTeleptyDaemon(commandLine) {
|
|
91
|
+
const text = String(commandLine || '').toLowerCase();
|
|
92
|
+
if (!text) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (text.includes('telepty daemon')) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (text.includes('cli.js daemon')) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return text.includes('daemon.js') && text.includes('aigentry-telepty');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listUnixProcesses() {
|
|
108
|
+
const output = execSync('ps -axo pid=,command=', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
109
|
+
return output.split('\n')
|
|
110
|
+
.map((line) => line.trim())
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.map((line) => {
|
|
113
|
+
const match = line.match(/^(\d+)\s+(.*)$/);
|
|
114
|
+
if (!match) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return { pid: Number(match[1]), commandLine: match[2] };
|
|
118
|
+
})
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function listWindowsProcesses() {
|
|
123
|
+
const script = 'Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress';
|
|
124
|
+
const output = execFileSync('powershell.exe', ['-NoProfile', '-Command', script], {
|
|
125
|
+
encoding: 'utf8',
|
|
126
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
127
|
+
}).trim();
|
|
128
|
+
|
|
129
|
+
if (!output) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const records = JSON.parse(output);
|
|
134
|
+
const list = Array.isArray(records) ? records : [records];
|
|
135
|
+
return list
|
|
136
|
+
.map((item) => ({
|
|
137
|
+
pid: Number(item.ProcessId),
|
|
138
|
+
commandLine: item.CommandLine || ''
|
|
139
|
+
}))
|
|
140
|
+
.filter((item) => Number.isInteger(item.pid) && item.pid > 0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function listDaemonProcesses() {
|
|
144
|
+
let processes = [];
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
processes = process.platform === 'win32' ? listWindowsProcesses() : listUnixProcesses();
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return processes.filter((item) => item.pid !== process.pid && isLikelyTeleptyDaemon(item.commandLine));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function stopDaemonProcess(pid) {
|
|
156
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
if (process.platform === 'win32') {
|
|
162
|
+
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
process.kill(pid, 'SIGTERM');
|
|
167
|
+
const deadline = Date.now() + 1500;
|
|
168
|
+
while (Date.now() < deadline) {
|
|
169
|
+
if (!isProcessRunning(pid)) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
sleepMs(50);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
process.kill(pid, 'SIGKILL');
|
|
176
|
+
return true;
|
|
177
|
+
} catch {
|
|
178
|
+
return !isProcessRunning(pid);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function cleanupDaemonProcesses() {
|
|
183
|
+
const targets = new Map();
|
|
184
|
+
const state = readDaemonState();
|
|
185
|
+
|
|
186
|
+
if (state && Number.isInteger(state.pid) && state.pid > 0 && state.pid !== process.pid) {
|
|
187
|
+
targets.set(state.pid, { pid: state.pid, source: 'state-file' });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const item of listDaemonProcesses()) {
|
|
191
|
+
if (!targets.has(item.pid)) {
|
|
192
|
+
targets.set(item.pid, { pid: item.pid, source: 'process-scan', commandLine: item.commandLine });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const stopped = [];
|
|
197
|
+
const failed = [];
|
|
198
|
+
|
|
199
|
+
for (const item of targets.values()) {
|
|
200
|
+
if (stopDaemonProcess(item.pid)) {
|
|
201
|
+
stopped.push(item);
|
|
202
|
+
} else {
|
|
203
|
+
failed.push(item);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const nextState = readDaemonState();
|
|
208
|
+
if (nextState && !isProcessRunning(nextState.pid)) {
|
|
209
|
+
clearDaemonState(nextState.pid);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { stopped, failed };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
DAEMON_STATE_FILE,
|
|
217
|
+
claimDaemonState,
|
|
218
|
+
cleanupDaemonProcesses,
|
|
219
|
+
clearDaemonState,
|
|
220
|
+
isProcessRunning,
|
|
221
|
+
listDaemonProcesses,
|
|
222
|
+
readDaemonState
|
|
223
|
+
};
|
package/daemon.js
CHANGED
|
@@ -4,6 +4,8 @@ const pty = require('node-pty');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const { WebSocketServer } = require('ws');
|
|
6
6
|
const { getConfig } = require('./auth');
|
|
7
|
+
const pkg = require('./package.json');
|
|
8
|
+
const { claimDaemonState, clearDaemonState } = require('./daemon-control');
|
|
7
9
|
|
|
8
10
|
const config = getConfig();
|
|
9
11
|
const EXPECTED_TOKEN = config.authToken;
|
|
@@ -33,6 +35,14 @@ app.use((req, res, next) => {
|
|
|
33
35
|
const PORT = process.env.PORT || 3848;
|
|
34
36
|
|
|
35
37
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
38
|
+
process.title = 'telepty-daemon';
|
|
39
|
+
|
|
40
|
+
const daemonClaim = claimDaemonState({ host: HOST, port: Number(PORT), version: pkg.version });
|
|
41
|
+
if (!daemonClaim.claimed) {
|
|
42
|
+
const current = daemonClaim.current;
|
|
43
|
+
console.log(`[DAEMON] telepty daemon already running (pid ${current.pid}, port ${current.port}). Exiting.`);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
36
46
|
|
|
37
47
|
const sessions = {};
|
|
38
48
|
const STRIPPED_SESSION_ENV_KEYS = [
|
|
@@ -197,6 +207,17 @@ app.get('/api/sessions', (req, res) => {
|
|
|
197
207
|
res.json(list);
|
|
198
208
|
});
|
|
199
209
|
|
|
210
|
+
app.get('/api/meta', (req, res) => {
|
|
211
|
+
res.json({
|
|
212
|
+
name: pkg.name,
|
|
213
|
+
version: pkg.version,
|
|
214
|
+
pid: process.pid,
|
|
215
|
+
host: HOST,
|
|
216
|
+
port: Number(PORT),
|
|
217
|
+
capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon']
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
200
221
|
app.post('/api/sessions/multicast/inject', (req, res) => {
|
|
201
222
|
const { session_ids, prompt } = req.body;
|
|
202
223
|
if (!prompt) return res.status(400).json({ error: 'prompt is required' });
|
|
@@ -396,6 +417,17 @@ const server = app.listen(PORT, HOST, () => {
|
|
|
396
417
|
console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
|
|
397
418
|
});
|
|
398
419
|
|
|
420
|
+
server.on('error', (error) => {
|
|
421
|
+
clearDaemonState(process.pid);
|
|
422
|
+
|
|
423
|
+
if (error && error.code === 'EADDRINUSE') {
|
|
424
|
+
console.error(`[DAEMON] Port ${PORT} is already in use. Another process is blocking telepty.`);
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
throw error;
|
|
429
|
+
});
|
|
430
|
+
|
|
399
431
|
|
|
400
432
|
const wss = new WebSocketServer({ noServer: true });
|
|
401
433
|
|
|
@@ -522,3 +554,14 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
522
554
|
socket.destroy();
|
|
523
555
|
}
|
|
524
556
|
});
|
|
557
|
+
|
|
558
|
+
function shutdown(code) {
|
|
559
|
+
clearDaemonState(process.pid);
|
|
560
|
+
process.exit(code);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
process.on('SIGINT', () => shutdown(0));
|
|
564
|
+
process.on('SIGTERM', () => shutdown(0));
|
|
565
|
+
process.on('exit', () => {
|
|
566
|
+
clearDaemonState(process.pid);
|
|
567
|
+
});
|
package/install.js
CHANGED
|
@@ -4,6 +4,7 @@ const { execSync, spawn } = require('child_process');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const { cleanupDaemonProcesses } = require('./daemon-control');
|
|
7
8
|
const { runInteractiveSkillInstaller } = require('./skill-installer');
|
|
8
9
|
|
|
9
10
|
console.log("🚀 Installing @dmsdc-ai/aigentry-telepty...");
|
|
@@ -26,6 +27,15 @@ function resolveInstalledPackageRoot() {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
function cleanupLocalDaemons() {
|
|
31
|
+
console.log('🧹 Cleaning up existing telepty daemons...');
|
|
32
|
+
const results = cleanupDaemonProcesses();
|
|
33
|
+
console.log(` Stopped ${results.stopped.length} daemon(s).`);
|
|
34
|
+
if (results.failed.length > 0) {
|
|
35
|
+
console.warn(` Could not stop ${results.failed.length} daemon(s).`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
async function installSkills() {
|
|
30
40
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
31
41
|
console.log('⏭️ Skipping interactive skill installation (no TTY).');
|
|
@@ -65,6 +75,7 @@ async function installSkills() {
|
|
|
65
75
|
const platform = os.platform();
|
|
66
76
|
|
|
67
77
|
if (platform === 'win32') {
|
|
78
|
+
cleanupLocalDaemons();
|
|
68
79
|
console.log("⚙️ Setting up Windows background process...");
|
|
69
80
|
const subprocess = spawn(teleptyPath, ['daemon'], {
|
|
70
81
|
detached: true,
|
|
@@ -78,6 +89,8 @@ async function installSkills() {
|
|
|
78
89
|
console.log("⚙️ Setting up macOS launchd service...");
|
|
79
90
|
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.aigentry.telepty.plist');
|
|
80
91
|
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
92
|
+
try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`); } catch(e){}
|
|
93
|
+
cleanupLocalDaemons();
|
|
81
94
|
|
|
82
95
|
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
83
96
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -98,7 +111,6 @@ async function installSkills() {
|
|
|
98
111
|
</plist>`;
|
|
99
112
|
|
|
100
113
|
fs.writeFileSync(plistPath, plistContent);
|
|
101
|
-
try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`); } catch(e){}
|
|
102
114
|
run(`launchctl load "${plistPath}"`);
|
|
103
115
|
console.log("✅ macOS LaunchAgent installed and started.");
|
|
104
116
|
|
|
@@ -108,6 +120,8 @@ async function installSkills() {
|
|
|
108
120
|
execSync('systemctl --version', { stdio: 'ignore' });
|
|
109
121
|
if (process.getuid && process.getuid() === 0) {
|
|
110
122
|
console.log("⚙️ Setting up systemd service for Linux...");
|
|
123
|
+
try { execSync('systemctl stop telepty', { stdio: 'ignore' }); } catch(e) {}
|
|
124
|
+
cleanupLocalDaemons();
|
|
111
125
|
const serviceContent = `[Unit]
|
|
112
126
|
Description=Telepty Daemon
|
|
113
127
|
After=network.target
|
|
@@ -133,6 +147,7 @@ WantedBy=multi-user.target`;
|
|
|
133
147
|
|
|
134
148
|
// Fallback for Linux without systemd or non-root
|
|
135
149
|
console.log("⚠️ Skipping systemd (no root or no systemd). Starting in background...");
|
|
150
|
+
cleanupLocalDaemons();
|
|
136
151
|
const subprocess = spawn(teleptyPath, ['daemon'], {
|
|
137
152
|
detached: true,
|
|
138
153
|
stdio: 'ignore'
|
package/install.ps1
CHANGED
|
@@ -26,7 +26,13 @@ if (!$teleptyCmd) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
$teleptyPath = $teleptyCmd.Source
|
|
29
|
-
|
|
29
|
+
try {
|
|
30
|
+
& $teleptyPath cleanup-daemons | Out-Null
|
|
31
|
+
} catch {
|
|
32
|
+
Write-Host "Warning: Could not clean up existing telepty daemons." -ForegroundColor Yellow
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Start-Process -FilePath $teleptyPath -ArgumentList "daemon" -WindowStyle Hidden
|
|
30
36
|
Write-Host "Success: Windows daemon started in background." -ForegroundColor Green
|
|
31
37
|
|
|
32
38
|
Write-Host "`nInstallation complete! Telepty daemon is running." -ForegroundColor Cyan
|
package/install.sh
CHANGED
|
@@ -56,6 +56,9 @@ if command -v systemctl &> /dev/null && [ -d "/etc/systemd/system" ]; then
|
|
|
56
56
|
SUDO_CMD=""
|
|
57
57
|
fi
|
|
58
58
|
|
|
59
|
+
$SUDO_CMD systemctl stop telepty 2>/dev/null || true
|
|
60
|
+
"$TELEPTY_PATH" cleanup-daemons >/dev/null 2>&1 || true
|
|
61
|
+
|
|
59
62
|
$SUDO_CMD bash -c "cat <<EOF > /etc/systemd/system/telepty.service
|
|
60
63
|
[Unit]
|
|
61
64
|
Description=Telepty Daemon
|
|
@@ -80,6 +83,8 @@ EOF"
|
|
|
80
83
|
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
|
81
84
|
PLIST_PATH="$HOME/Library/LaunchAgents/com.aigentry.telepty.plist"
|
|
82
85
|
mkdir -p "$HOME/Library/LaunchAgents"
|
|
86
|
+
launchctl unload "$PLIST_PATH" 2>/dev/null || true
|
|
87
|
+
"$TELEPTY_PATH" cleanup-daemons >/dev/null 2>&1 || true
|
|
83
88
|
cat <<EOF > "$PLIST_PATH"
|
|
84
89
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
85
90
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -104,11 +109,11 @@ elif [[ "$OSTYPE" == "darwin"* ]]; then
|
|
|
104
109
|
</dict>
|
|
105
110
|
</plist>
|
|
106
111
|
EOF
|
|
107
|
-
launchctl unload "$PLIST_PATH" 2>/dev/null || true
|
|
108
112
|
launchctl load "$PLIST_PATH"
|
|
109
113
|
echo "✅ macOS LaunchAgent installed and started. (Auto-starts on boot)"
|
|
110
114
|
else
|
|
111
115
|
echo "⚠️ Skipping OS-level service setup (Termux or missing systemd). Starting in background..."
|
|
116
|
+
"$TELEPTY_PATH" cleanup-daemons >/dev/null 2>&1 || true
|
|
112
117
|
nohup $TELEPTY_PATH daemon > /dev/null 2>&1 &
|
|
113
118
|
echo "✅ Daemon started in background. (Note: Will not auto-start on device reboot)"
|
|
114
119
|
fi
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function removeListener(stream, eventName, handler) {
|
|
4
|
+
if (!handler || !stream) {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (typeof stream.off === 'function') {
|
|
9
|
+
stream.off(eventName, handler);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (typeof stream.removeListener === 'function') {
|
|
14
|
+
stream.removeListener(eventName, handler);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function attachInteractiveTerminal(input, output, handlers = {}) {
|
|
19
|
+
const { onData = null, onResize = null } = handlers;
|
|
20
|
+
|
|
21
|
+
if (input && input.isTTY && typeof input.setRawMode === 'function') {
|
|
22
|
+
input.setRawMode(true);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (input && typeof input.resume === 'function') {
|
|
26
|
+
input.resume();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (input && onData) {
|
|
30
|
+
input.on('data', onData);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (output && onResize) {
|
|
34
|
+
output.on('resize', onResize);
|
|
35
|
+
onResize();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return () => {
|
|
39
|
+
removeListener(input, 'data', onData);
|
|
40
|
+
removeListener(output, 'resize', onResize);
|
|
41
|
+
|
|
42
|
+
if (input && input.isTTY && typeof input.setRawMode === 'function') {
|
|
43
|
+
input.setRawMode(false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (input && typeof input.pause === 'function') {
|
|
47
|
+
input.pause();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
attachInteractiveTerminal
|
|
54
|
+
};
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmsdc-ai/aigentry-telepty",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"main": "daemon.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"telepty": "cli.js",
|
|
7
7
|
"telepty-install": "install.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "node --test test/auth.test.js test/daemon.test.js test/cli.test.js test/skill-installer.test.js",
|
|
11
|
-
"test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/cli.test.js test/skill-installer.test.js",
|
|
12
|
-
"test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/cli.test.js test/skill-installer.test.js"
|
|
10
|
+
"test": "node --test test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js",
|
|
11
|
+
"test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js",
|
|
12
|
+
"test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/daemon-singleton.test.js test/cli.test.js test/skill-installer.test.js test/interactive-terminal.test.js"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [],
|
|
15
15
|
"author": "",
|