@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 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 res = await fetchWithAuth(`${DAEMON_URL}/api/sessions`);
77
- if (res.ok) return; // Already running
78
- } catch (e) {
79
- // Not running, let's start it
80
- process.stdout.write('\x1b[33m⚙️ Auto-starting local telepty daemon...\x1b[0m\n');
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
- cp.unref();
86
-
87
- // Wait a brief moment for the daemon to boot up
88
- await new Promise(r => setTimeout(r, 1000));
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 inputHandler = null;
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
- if (process.stdin.isTTY) process.stdin.setRawMode(true);
103
- inputHandler = (d) => ws.send(JSON.stringify({ type: 'input', data: d.toString() }));
104
- resizeHandler = () => ws.send(JSON.stringify({ type: 'resize', cols: process.stdout.columns, rows: process.stdout.rows }));
105
- process.stdin.on('data', inputHandler);
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 (inputHandler) process.stdin.off('data', inputHandler);
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
- try {
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
- const cp = spawn(process.argv[0], [process.argv[1], 'daemon'], {
186
- detached: true,
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
- // Enter raw mode and relay stdin to local PTY
484
- if (process.stdin.isTTY) process.stdin.setRawMode(true);
485
-
486
- process.stdin.on('data', (data) => {
487
- child.write(data.toString());
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
- if (process.stdin.isTTY) process.stdin.setRawMode(false);
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 inputHandler = null;
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
- if (process.stdin.isTTY) {
564
- process.stdin.setRawMode(true);
565
- }
566
-
567
- inputHandler = (data) => {
568
- ws.send(JSON.stringify({ type: 'input', data: data.toString() }));
569
- };
570
- process.stdin.on('data', inputHandler);
571
-
572
- resizeHandler = () => {
573
- ws.send(JSON.stringify({
574
- type: 'resize',
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 (inputHandler) process.stdin.off('data', inputHandler);
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
- Start-Process -FilePath node -ArgumentList "$teleptyPath daemon" -WindowStyle Hidden
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.7",
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": "",