@dmsdc-ai/aigentry-telepty 0.1.4 → 0.1.6

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/daemon.js CHANGED
@@ -35,9 +35,34 @@ const PORT = process.env.PORT || 3848;
35
35
  const HOST = process.env.HOST || '0.0.0.0';
36
36
 
37
37
  const sessions = {};
38
+ const STRIPPED_SESSION_ENV_KEYS = [
39
+ 'CLAUDECODE',
40
+ 'CODEX_CI',
41
+ 'CODEX_THREAD_ID'
42
+ ];
43
+
44
+ function buildSessionEnv(sessionId) {
45
+ const env = {
46
+ ...process.env,
47
+ TERM: os.platform() === 'win32' ? undefined : 'xterm-256color',
48
+ TELEPTY_SESSION_ID: sessionId
49
+ };
50
+
51
+ for (const key of STRIPPED_SESSION_ENV_KEYS) {
52
+ delete env[key];
53
+ }
54
+
55
+ for (const key of Object.keys(env)) {
56
+ if (key.startsWith('CLAUDECODE_')) {
57
+ delete env[key];
58
+ }
59
+ }
60
+
61
+ return env;
62
+ }
38
63
 
39
64
  app.post('/api/sessions/spawn', (req, res) => {
40
- const { session_id, command, args = [], cwd = process.cwd(), cols = 80, rows = 30 } = req.body;
65
+ const { session_id, command, args = [], cwd = process.cwd(), cols = 80, rows = 30, type = 'AGENT' } = req.body;
41
66
  if (!session_id) return res.status(400).json({ error: 'session_id is strictly required.' });
42
67
  if (sessions[session_id]) return res.status(409).json({ error: `Session ID '${session_id}' is already active.` });
43
68
  if (!command) return res.status(400).json({ error: 'command is required' });
@@ -49,15 +74,18 @@ app.post('/api/sessions/spawn', (req, res) => {
49
74
  try {
50
75
  console.log(`[SPAWN] Spawning ${shell} with args:`, shellArgs, "in cwd:", cwd);
51
76
 
52
- // Create a custom prompt for Linux/Mac so the user sees the session ID.
53
- // For bash/zsh, we can inject a custom PS1 variable.
54
- let customEnv = { ...process.env, TERM: isWin ? undefined : 'xterm-256color', TELEPTY_SESSION_ID: session_id };
77
+ const customEnv = buildSessionEnv(session_id);
55
78
 
56
79
  if (!isWin) {
80
+ const label = type.toUpperCase();
81
+ const colorCode = label === 'USER' ? '32' : '35'; // USER: Green (32), AGENT: Magenta (35)
82
+ const zshColor = label === 'USER' ? 'green' : 'magenta';
83
+
57
84
  if (command.includes('bash')) {
58
- customEnv.PS1 = `\\[\\e[36m\\][telepty: ${session_id}]\\[\\e[0m\\] \\w \\$ `;
85
+ customEnv.PS1 = `\\[\\e[${colorCode}m\\][${label}: ${session_id}]\\[\\e[0m\\] \\w \\$ `;
59
86
  } else if (command.includes('zsh')) {
60
- customEnv.PROMPT = `%F{cyan}[telepty: ${session_id}]%f %~ %# `;
87
+ customEnv.DISABLE_AUTO_TITLE = 'true';
88
+ customEnv.PROMPT = `%F{${zshColor}}[${label}: ${session_id}]%f %~ %# `;
61
89
  }
62
90
  }
63
91
 
@@ -69,24 +97,51 @@ app.post('/api/sessions/spawn', (req, res) => {
69
97
  env: customEnv
70
98
  });
71
99
 
72
- sessions[session_id] = {
100
+ const sessionRecord = {
101
+ id: session_id,
102
+ type: 'spawned',
73
103
  ptyProcess,
74
104
  command,
75
105
  cwd,
76
106
  createdAt: new Date().toISOString(),
77
- clients: new Set()
107
+ clients: new Set(),
108
+ isClosing: false
78
109
  };
110
+ sessions[session_id] = sessionRecord;
111
+
112
+ // Broadcast session creation to bus
113
+ const spawnMsg = JSON.stringify({
114
+ type: 'session_spawn',
115
+ sender: 'daemon',
116
+ session_id,
117
+ command,
118
+ cwd,
119
+ timestamp: new Date().toISOString()
120
+ });
121
+ busClients.forEach(client => {
122
+ if (client.readyState === 1) client.send(spawnMsg);
123
+ });
79
124
 
80
125
  ptyProcess.onData((data) => {
81
- sessions[session_id].clients.forEach(ws => {
126
+ const currentSession = sessions[sessionRecord.id];
127
+ if (!currentSession || currentSession !== sessionRecord) {
128
+ return;
129
+ }
130
+
131
+ // Send to direct WS clients
132
+ currentSession.clients.forEach(ws => {
82
133
  if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
83
134
  });
84
135
  });
85
136
 
86
137
  ptyProcess.onExit(({ exitCode, signal }) => {
87
- console.log(`[EXIT] Session ${session_id} exited with code ${exitCode}`);
88
- sessions[session_id].clients.forEach(ws => ws.close(1000, 'Session exited'));
89
- delete sessions[session_id];
138
+ const currentId = sessionRecord.id;
139
+ console.log(`[EXIT] Session ${currentId} exited with code ${exitCode}`);
140
+ sessionRecord.isClosing = true;
141
+ sessionRecord.clients.forEach(ws => ws.close(1000, 'Session exited'));
142
+ if (sessions[currentId] === sessionRecord) {
143
+ delete sessions[currentId];
144
+ }
90
145
  });
91
146
 
92
147
  console.log(`[SPAWN] Created session ${session_id} (${command})`);
@@ -96,9 +151,44 @@ app.post('/api/sessions/spawn', (req, res) => {
96
151
  }
97
152
  });
98
153
 
154
+ app.post('/api/sessions/register', (req, res) => {
155
+ const { session_id, command, cwd = process.cwd() } = req.body;
156
+ if (!session_id) return res.status(400).json({ error: 'session_id is required' });
157
+ if (sessions[session_id]) return res.status(409).json({ error: `Session ID '${session_id}' is already active.` });
158
+
159
+ const sessionRecord = {
160
+ id: session_id,
161
+ type: 'wrapped',
162
+ ptyProcess: null,
163
+ ownerWs: null,
164
+ command: command || 'wrapped',
165
+ cwd,
166
+ createdAt: new Date().toISOString(),
167
+ clients: new Set(),
168
+ isClosing: false
169
+ };
170
+ sessions[session_id] = sessionRecord;
171
+
172
+ const busMsg = JSON.stringify({
173
+ type: 'session_register',
174
+ sender: 'daemon',
175
+ session_id,
176
+ command: sessionRecord.command,
177
+ cwd,
178
+ timestamp: new Date().toISOString()
179
+ });
180
+ busClients.forEach(client => {
181
+ if (client.readyState === 1) client.send(busMsg);
182
+ });
183
+
184
+ console.log(`[REGISTER] Registered wrapped session ${session_id}`);
185
+ res.status(201).json({ session_id, type: 'wrapped', command: sessionRecord.command, cwd });
186
+ });
187
+
99
188
  app.get('/api/sessions', (req, res) => {
100
189
  const list = Object.entries(sessions).map(([id, session]) => ({
101
190
  id,
191
+ type: session.type || 'spawned',
102
192
  command: session.command,
103
193
  cwd: session.cwd,
104
194
  createdAt: session.createdAt,
@@ -118,8 +208,30 @@ app.post('/api/sessions/multicast/inject', (req, res) => {
118
208
  const session = sessions[id];
119
209
  if (session) {
120
210
  try {
121
- session.ptyProcess.write(`${prompt}\r`);
122
- results.successful.push(id);
211
+ const injectData = `${prompt}\r`;
212
+ if (session.type === 'wrapped') {
213
+ if (session.ownerWs && session.ownerWs.readyState === 1) {
214
+ session.ownerWs.send(JSON.stringify({ type: 'inject', data: injectData }));
215
+ results.successful.push(id);
216
+ } else {
217
+ results.failed.push({ id, error: 'Wrap process not connected' });
218
+ }
219
+ } else {
220
+ session.ptyProcess.write(injectData);
221
+ results.successful.push(id);
222
+ }
223
+
224
+ // Broadcast injection to bus
225
+ const busMsg = JSON.stringify({
226
+ type: 'injection',
227
+ sender: 'cli',
228
+ target_agent: id,
229
+ content: prompt,
230
+ timestamp: new Date().toISOString()
231
+ });
232
+ busClients.forEach(client => {
233
+ if (client.readyState === 1) client.send(busMsg);
234
+ });
123
235
  } catch (err) {
124
236
  results.failed.push({ id, error: err.message });
125
237
  }
@@ -138,14 +250,40 @@ app.post('/api/sessions/broadcast/inject', (req, res) => {
138
250
  const results = { successful: [], failed: [] };
139
251
 
140
252
  Object.keys(sessions).forEach(id => {
253
+ const session = sessions[id];
141
254
  try {
142
- sessions[id].ptyProcess.write(`${prompt}\r`);
143
- results.successful.push(id);
255
+ const injectData = `${prompt}\r`;
256
+ if (session.type === 'wrapped') {
257
+ if (session.ownerWs && session.ownerWs.readyState === 1) {
258
+ session.ownerWs.send(JSON.stringify({ type: 'inject', data: injectData }));
259
+ results.successful.push(id);
260
+ } else {
261
+ results.failed.push({ id, error: 'Wrap process not connected' });
262
+ }
263
+ } else {
264
+ session.ptyProcess.write(injectData);
265
+ results.successful.push(id);
266
+ }
144
267
  } catch (err) {
145
268
  results.failed.push({ id, error: err.message });
146
269
  }
147
270
  });
148
271
 
272
+ // Send a single bus event for the entire broadcast (not per-session)
273
+ if (results.successful.length > 0) {
274
+ const busMsg = JSON.stringify({
275
+ type: 'injection',
276
+ sender: 'cli',
277
+ target_agent: 'all',
278
+ content: prompt,
279
+ session_ids: results.successful,
280
+ timestamp: new Date().toISOString()
281
+ });
282
+ busClients.forEach(client => {
283
+ if (client.readyState === 1) client.send(busMsg);
284
+ });
285
+ }
286
+
149
287
  res.json({ success: true, results });
150
288
  });
151
289
 
@@ -156,23 +294,80 @@ app.post('/api/sessions/:id/inject', (req, res) => {
156
294
  if (!session) return res.status(404).json({ error: 'Session not found' });
157
295
  if (!prompt) return res.status(400).json({ error: 'prompt is required' });
158
296
  try {
159
- session.ptyProcess.write(no_enter ? prompt : `${prompt}\r`);
297
+ const injectData = no_enter ? prompt : `${prompt}\r`;
298
+ if (session.type === 'wrapped') {
299
+ if (session.ownerWs && session.ownerWs.readyState === 1) {
300
+ session.ownerWs.send(JSON.stringify({ type: 'inject', data: injectData }));
301
+ } else {
302
+ return res.status(503).json({ error: 'Wrap process is not connected' });
303
+ }
304
+ } else {
305
+ session.ptyProcess.write(injectData);
306
+ }
160
307
  console.log(`[INJECT] Wrote to session ${id}`);
308
+
309
+ const busMsg = JSON.stringify({
310
+ type: 'injection',
311
+ sender: 'cli',
312
+ target_agent: id,
313
+ content: prompt,
314
+ timestamp: new Date().toISOString()
315
+ });
316
+ busClients.forEach(client => {
317
+ if (client.readyState === 1) client.send(busMsg);
318
+ });
319
+
161
320
  res.json({ success: true });
162
321
  } catch (err) {
163
322
  res.status(500).json({ error: err.message });
164
323
  }
165
324
  });
166
325
 
326
+ app.patch('/api/sessions/:id', (req, res) => {
327
+ const { id } = req.params;
328
+ const { new_id } = req.body;
329
+ const session = sessions[id];
330
+ if (!session) return res.status(404).json({ error: 'Session not found' });
331
+ if (!new_id) return res.status(400).json({ error: 'new_id is required' });
332
+ if (sessions[new_id]) return res.status(409).json({ error: `Session ID '${new_id}' is already in use.` });
333
+
334
+ // Move session to new key
335
+ sessions[new_id] = session;
336
+ delete sessions[id];
337
+ session.id = new_id;
338
+
339
+ // Broadcast rename to bus
340
+ const busMsg = JSON.stringify({
341
+ type: 'session_rename',
342
+ sender: 'daemon',
343
+ old_id: id,
344
+ new_id,
345
+ timestamp: new Date().toISOString()
346
+ });
347
+ busClients.forEach(client => {
348
+ if (client.readyState === 1) client.send(busMsg);
349
+ });
350
+
351
+ console.log(`[RENAME] Session '${id}' renamed to '${new_id}'`);
352
+ res.json({ success: true, old_id: id, new_id });
353
+ });
354
+
167
355
  app.delete('/api/sessions/:id', (req, res) => {
168
356
  const { id } = req.params;
169
357
  const session = sessions[id];
170
358
  if (!session) return res.status(404).json({ error: 'Session not found' });
359
+ if (session.isClosing) return res.json({ success: true, status: 'closing' });
171
360
  try {
172
- session.ptyProcess.kill();
173
- delete sessions[id];
174
- console.log(`[KILL] Session ${id} forcefully closed`);
175
- res.json({ success: true });
361
+ session.isClosing = true;
362
+ if (session.type === 'wrapped') {
363
+ session.clients.forEach(ws => ws.close(1000, 'Session destroyed'));
364
+ delete sessions[id];
365
+ console.log(`[KILL] Wrapped session ${id} removed`);
366
+ } else {
367
+ session.ptyProcess.kill();
368
+ console.log(`[KILL] Session ${id} forcefully closed`);
369
+ }
370
+ res.json({ success: true, status: 'closing' });
176
371
  } catch (err) {
177
372
  res.status(500).json({ error: err.message });
178
373
  }
@@ -215,15 +410,44 @@ wss.on('connection', (ws, req) => {
215
410
  }
216
411
 
217
412
  session.clients.add(ws);
218
- console.log(`[WS] Client attached to session \${sessionId} (Total: \${session.clients.size})`);
413
+
414
+ // For wrapped sessions, first connector becomes the owner
415
+ if (session.type === 'wrapped' && !session.ownerWs) {
416
+ session.ownerWs = ws;
417
+ console.log(`[WS] Wrap owner connected for session ${sessionId} (Total: ${session.clients.size})`);
418
+ } else {
419
+ console.log(`[WS] Client attached to session ${sessionId} (Total: ${session.clients.size})`);
420
+ }
219
421
 
220
422
  ws.on('message', (message) => {
221
423
  try {
222
424
  const { type, data, cols, rows } = JSON.parse(message);
223
- if (type === 'input') {
224
- session.ptyProcess.write(data);
225
- } else if (type === 'resize') {
226
- session.ptyProcess.resize(cols, rows);
425
+
426
+ if (session.type === 'wrapped') {
427
+ if (ws === session.ownerWs) {
428
+ // Owner sending output -> broadcast to other clients
429
+ if (type === 'output') {
430
+ session.clients.forEach(client => {
431
+ if (client !== ws && client.readyState === 1) {
432
+ client.send(JSON.stringify({ type: 'output', data }));
433
+ }
434
+ });
435
+ }
436
+ } else {
437
+ // Non-owner client input -> forward to owner as inject
438
+ if (type === 'input' && session.ownerWs && session.ownerWs.readyState === 1) {
439
+ session.ownerWs.send(JSON.stringify({ type: 'inject', data }));
440
+ } else if (type === 'resize' && session.ownerWs && session.ownerWs.readyState === 1) {
441
+ session.ownerWs.send(JSON.stringify({ type: 'resize', cols, rows }));
442
+ }
443
+ }
444
+ } else {
445
+ // Existing spawned session logic
446
+ if (type === 'input') {
447
+ session.ptyProcess.write(data);
448
+ } else if (type === 'resize') {
449
+ session.ptyProcess.resize(cols, rows);
450
+ }
227
451
  }
228
452
  } catch (e) {
229
453
  console.error('[WS] Invalid message format', e);
@@ -232,7 +456,17 @@ wss.on('connection', (ws, req) => {
232
456
 
233
457
  ws.on('close', () => {
234
458
  session.clients.delete(ws);
235
- console.log(`[WS] Client detached from session \${sessionId} (Total: \${session.clients.size})`);
459
+ if (session.type === 'wrapped' && ws === session.ownerWs) {
460
+ session.ownerWs = null;
461
+ console.log(`[WS] Wrap owner disconnected from session ${sessionId} (Total: ${session.clients.size})`);
462
+ // Clean up wrapped session when owner disconnects and no other clients
463
+ if (session.clients.size === 0 && !session.isClosing) {
464
+ delete sessions[sessionId];
465
+ console.log(`[CLEANUP] Wrapped session ${sessionId} removed (owner disconnected)`);
466
+ }
467
+ } else {
468
+ console.log(`[WS] Client detached from session ${sessionId} (Total: ${session.clients.size})`);
469
+ }
236
470
  });
237
471
  });
238
472
 
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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": "echo \"Error: no test specified\" && exit 1"
10
+ "test": "node --test test/auth.test.js test/daemon.test.js test/cli.test.js",
11
+ "test:watch": "node --test --watch test/auth.test.js test/daemon.test.js test/cli.test.js",
12
+ "test:ci": "node --test --test-reporter=spec test/auth.test.js test/daemon.test.js test/cli.test.js"
11
13
  },
12
14
  "keywords": [],
13
15
  "author": "",
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const { test, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ // Use a temp directory to isolate tests from the real ~/.telepty config.
10
+ // We patch os.homedir() before requiring auth.js so the module-level
11
+ // constants (CONFIG_DIR, CONFIG_FILE) resolve to the temp dir.
12
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'telepty-test-'));
13
+ const originalHomedir = os.homedir.bind(os);
14
+
15
+ before(() => {
16
+ os.homedir = () => tmpDir;
17
+ // Ensure auth.js is loaded fresh with the patched homedir
18
+ delete require.cache[require.resolve('../auth.js')];
19
+ });
20
+
21
+ after(() => {
22
+ os.homedir = originalHomedir;
23
+ delete require.cache[require.resolve('../auth.js')];
24
+ fs.rmSync(tmpDir, { recursive: true, force: true });
25
+ });
26
+
27
+ test('getConfig() returns an object with authToken property', () => {
28
+ const { getConfig } = require('../auth.js');
29
+ const config = getConfig();
30
+ assert.ok(config !== null && typeof config === 'object', 'config should be an object');
31
+ assert.ok('authToken' in config, 'config should have authToken property');
32
+ assert.equal(typeof config.authToken, 'string', 'authToken should be a string');
33
+ });
34
+
35
+ test('authToken is a valid UUID v4 format', () => {
36
+ const { getConfig } = require('../auth.js');
37
+ const config = getConfig();
38
+ const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
39
+ assert.match(config.authToken, uuidV4Regex, 'authToken should be a valid UUID v4');
40
+ });
41
+
42
+ test('config object has createdAt field', () => {
43
+ const { getConfig } = require('../auth.js');
44
+ const config = getConfig();
45
+ assert.ok('createdAt' in config, 'config should have createdAt property');
46
+ const date = new Date(config.createdAt);
47
+ assert.ok(!isNaN(date.getTime()), 'createdAt should be a valid ISO date string');
48
+ });
49
+
50
+ test('calling getConfig() twice returns the same token (persistence)', () => {
51
+ const { getConfig } = require('../auth.js');
52
+ const config1 = getConfig();
53
+ const config2 = getConfig();
54
+ assert.equal(config1.authToken, config2.authToken, 'authToken should be identical across calls');
55
+ assert.equal(config1.createdAt, config2.createdAt, 'createdAt should be identical across calls');
56
+ });
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const { afterEach, beforeEach, test } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { createSessionId, startTestDaemon, stripAnsi, waitFor } = require('../test-support/daemon-harness');
6
+
7
+ let harness;
8
+
9
+ function collectJsonMessages(ws) {
10
+ const messages = [];
11
+ ws.on('message', (chunk) => {
12
+ try {
13
+ messages.push(JSON.parse(chunk.toString()));
14
+ } catch {
15
+ // Ignore malformed payloads in tests.
16
+ }
17
+ });
18
+ return messages;
19
+ }
20
+
21
+ beforeEach(async () => {
22
+ harness = await startTestDaemon();
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await harness.stop();
27
+ });
28
+
29
+ test('telepty list prints active sessions from the configured host and port', async () => {
30
+ const sessionId = createSessionId('cli-list');
31
+ await harness.spawnSession(sessionId);
32
+
33
+ const result = await harness.runCli(['list']);
34
+ assert.equal(result.code, 0, result.stderr);
35
+
36
+ const output = stripAnsi(`${result.stdout}\n${result.stderr}`);
37
+ assert.match(output, new RegExp(sessionId));
38
+ assert.match(output, /Active Sessions/i);
39
+ });
40
+
41
+ test('telepty inject forwards input to the target PTY session', async () => {
42
+ const sessionId = createSessionId('cli-inject');
43
+ await harness.spawnSession(sessionId);
44
+
45
+ const ws = await harness.connectSession(sessionId);
46
+ const outputs = collectJsonMessages(ws);
47
+ const token = createSessionId('cli-token');
48
+
49
+ const result = await harness.runCli(['inject', sessionId, `echo ${token}`]);
50
+ assert.equal(result.code, 0, result.stderr);
51
+
52
+ await waitFor(() => outputs.some((message) => (
53
+ message.type === 'output' && String(message.data).includes(token)
54
+ )), { timeoutMs: 7000, description: 'CLI inject output' });
55
+
56
+ ws.close();
57
+ });