@dmsdc-ai/aigentry-telepty 0.1.57 → 0.1.59
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/URGENT_ISSUES.resolved.md +1 -0
- package/cli.js +216 -9
- package/daemon.js +58 -14
- package/package.json +2 -1
- package/session-routing.js +14 -0
- package/tui.js +301 -0
- package/URGENT_ISSUES.md +0 -31
|
@@ -0,0 +1 @@
|
|
|
1
|
+
RESOLVED - all 3 urgent issues fixed in 40b8e47
|
package/cli.js
CHANGED
|
@@ -598,6 +598,12 @@ async function main() {
|
|
|
598
598
|
return;
|
|
599
599
|
}
|
|
600
600
|
|
|
601
|
+
if (cmd === 'tui' || cmd === 'dashboard') {
|
|
602
|
+
const { TuiDashboard } = require('./tui');
|
|
603
|
+
new TuiDashboard();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
601
607
|
if (cmd === 'list') {
|
|
602
608
|
try {
|
|
603
609
|
const sessions = await discoverSessions({ silent: true });
|
|
@@ -674,6 +680,15 @@ async function main() {
|
|
|
674
680
|
sessionId = `${folder}-${cli}`;
|
|
675
681
|
}
|
|
676
682
|
|
|
683
|
+
// Override inherited TELEPTY_SESSION_ID — prevent parent session hijacking
|
|
684
|
+
// When launched via kitty @ launch, the parent's env leaks through.
|
|
685
|
+
// With --id flag, we always use the explicitly requested session ID.
|
|
686
|
+
if (process.env.TELEPTY_SESSION_ID && process.env.TELEPTY_SESSION_ID !== sessionId) {
|
|
687
|
+
console.error(`⚠️ [allow] Overriding inherited TELEPTY_SESSION_ID="${process.env.TELEPTY_SESSION_ID}" → "${sessionId}"`);
|
|
688
|
+
}
|
|
689
|
+
delete process.env.TELEPTY_SESSION_ID;
|
|
690
|
+
process.env.TELEPTY_SESSION_ID = sessionId;
|
|
691
|
+
|
|
677
692
|
await ensureDaemonRunning({ requiredCapabilities: ['wrapped-sessions'] });
|
|
678
693
|
|
|
679
694
|
// Register session with daemon
|
|
@@ -737,7 +752,9 @@ async function main() {
|
|
|
737
752
|
}
|
|
738
753
|
|
|
739
754
|
// Connect to daemon WebSocket with auto-reconnect
|
|
740
|
-
|
|
755
|
+
// owner=1 tells daemon this is the allow bridge (owner), not an attach viewer.
|
|
756
|
+
// Daemon uses this to reclaim ownership even if a stale ownerWs is still registered.
|
|
757
|
+
const wsUrl = `ws://${REMOTE_HOST}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(TOKEN)}&owner=1`;
|
|
741
758
|
let daemonWs = null;
|
|
742
759
|
let wsReady = false;
|
|
743
760
|
let reconnectAttempts = 0;
|
|
@@ -778,10 +795,17 @@ async function main() {
|
|
|
778
795
|
try {
|
|
779
796
|
const msg = JSON.parse(message);
|
|
780
797
|
if (msg.type === 'inject') {
|
|
781
|
-
const
|
|
782
|
-
if (
|
|
798
|
+
const isCr = msg.data === '\r';
|
|
799
|
+
if (isCr && injectQueue.length > 0) {
|
|
800
|
+
// CR with pending queued text — queue CR too and flush immediately.
|
|
801
|
+
// Prevents the busy-session bug where CR fires before queued text,
|
|
802
|
+
// causing empty submit followed by orphaned text without Return.
|
|
803
|
+
injectQueue.push(msg.data);
|
|
804
|
+
if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
|
|
805
|
+
flushInjectQueue();
|
|
806
|
+
} else if (isCr || promptReady) {
|
|
783
807
|
child.write(msg.data);
|
|
784
|
-
if (
|
|
808
|
+
if (!isCr && msg.data.length > 1) {
|
|
785
809
|
promptReady = false;
|
|
786
810
|
lastInjectTextTime = Date.now();
|
|
787
811
|
}
|
|
@@ -1176,6 +1200,22 @@ async function main() {
|
|
|
1176
1200
|
process.exit(1);
|
|
1177
1201
|
}
|
|
1178
1202
|
|
|
1203
|
+
// Resolve full paths (kitty @ launch has no shell PATH)
|
|
1204
|
+
const cliParts = cli.split(' ');
|
|
1205
|
+
let teleptyFullPath, cliFullPath;
|
|
1206
|
+
try {
|
|
1207
|
+
teleptyFullPath = execSync('which telepty', { encoding: 'utf8' }).trim();
|
|
1208
|
+
} catch {
|
|
1209
|
+
teleptyFullPath = process.argv[1];
|
|
1210
|
+
}
|
|
1211
|
+
try {
|
|
1212
|
+
cliFullPath = execSync(`which ${cliParts[0]}`, { encoding: 'utf8' }).trim();
|
|
1213
|
+
} catch {
|
|
1214
|
+
cliFullPath = cliParts[0];
|
|
1215
|
+
}
|
|
1216
|
+
const cliFullArgs = cliParts.slice(1).join(' ');
|
|
1217
|
+
const nodeFullPath = process.execPath; // Bypass #!/usr/bin/env node shebang (nvm not in PATH for non-interactive shells)
|
|
1218
|
+
|
|
1179
1219
|
// Generate kitty session file
|
|
1180
1220
|
const sessionFile = path.join(os.tmpdir(), `telepty-session-${Date.now()}.conf`);
|
|
1181
1221
|
let conf = '# Auto-generated telepty session\n';
|
|
@@ -1191,19 +1231,185 @@ async function main() {
|
|
|
1191
1231
|
conf += `layout tall\n`;
|
|
1192
1232
|
conf += `cd ${cwd}\n`;
|
|
1193
1233
|
conf += `title ${name}\n`;
|
|
1194
|
-
conf += `
|
|
1234
|
+
conf += `env TELEPTY_SESSION_ID=\n`;
|
|
1235
|
+
conf += `env PATH=${process.env.PATH}\n`;
|
|
1236
|
+
conf += `launch --type=window /bin/zsh -c 'unset TELEPTY_SESSION_ID; ${nodeFullPath} ${teleptyFullPath} allow --id ${sessionId} ${cliFullPath}${cliFullArgs ? ' ' + cliFullArgs : ''}'\n`;
|
|
1195
1237
|
});
|
|
1196
1238
|
|
|
1197
1239
|
fs.writeFileSync(sessionFile, conf);
|
|
1198
1240
|
console.log(`✅ Kitty session file: ${sessionFile}`);
|
|
1199
1241
|
console.log(` ${projects.length} projects, CLI: ${cli}`);
|
|
1200
|
-
console.log(`\n Launch: kitty --session ${sessionFile}\n`);
|
|
1201
1242
|
|
|
1202
1243
|
// Auto-launch if --launch flag
|
|
1203
1244
|
if (args.includes('--launch')) {
|
|
1204
|
-
const { spawn } = require('child_process');
|
|
1205
|
-
|
|
1206
|
-
|
|
1245
|
+
const { spawn, execFileSync } = require('child_process');
|
|
1246
|
+
|
|
1247
|
+
// Detect existing kitty instance via remote control socket
|
|
1248
|
+
let kittySocket = null;
|
|
1249
|
+
try {
|
|
1250
|
+
const sockFiles = fs.readdirSync('/tmp').filter(f => f.startsWith('kitty-sock'));
|
|
1251
|
+
if (sockFiles.length > 0) {
|
|
1252
|
+
const candidate = '/tmp/' + sockFiles[0];
|
|
1253
|
+
// Verify socket is alive
|
|
1254
|
+
execFileSync('kitty', ['@', '--to', `unix:${candidate}`, 'ls'], {
|
|
1255
|
+
timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
|
|
1256
|
+
});
|
|
1257
|
+
kittySocket = candidate;
|
|
1258
|
+
}
|
|
1259
|
+
} catch { kittySocket = null; }
|
|
1260
|
+
|
|
1261
|
+
if (kittySocket) {
|
|
1262
|
+
// Launch tabs in existing kitty instance (single Dock icon, kitty @ controllable)
|
|
1263
|
+
let launched = 0;
|
|
1264
|
+
for (const p of projects) {
|
|
1265
|
+
const name = p.name;
|
|
1266
|
+
const cwd = p.cwd || path.join(projectsDir, name);
|
|
1267
|
+
const sessionIdForProject = `${name}-${cli.split(' ')[0]}`;
|
|
1268
|
+
const shellCmd = `unset TELEPTY_SESSION_ID; ${nodeFullPath} ${teleptyFullPath} allow --id ${sessionIdForProject} ${cliFullPath}${cliFullArgs ? ' ' + cliFullArgs : ''}`;
|
|
1269
|
+
const launchArgs = ['@', '--to', `unix:${kittySocket}`,
|
|
1270
|
+
'launch', '--type=tab', '--tab-title', name, '--cwd', cwd,
|
|
1271
|
+
'--env', 'TELEPTY_SESSION_ID=',
|
|
1272
|
+
'--env', `PATH=${process.env.PATH}`,
|
|
1273
|
+
'/bin/zsh', '-c', shellCmd];
|
|
1274
|
+
try {
|
|
1275
|
+
execFileSync('kitty', launchArgs, { timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1276
|
+
launched++;
|
|
1277
|
+
} catch (e) {
|
|
1278
|
+
console.error(`⚠️ Failed to launch tab for ${name}: ${e.message}`);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
console.log(`🚀 ${launched}/${projects.length} tabs launched in existing kitty instance.`);
|
|
1282
|
+
} else {
|
|
1283
|
+
// No existing kitty — start a new instance with session file
|
|
1284
|
+
console.log(`\n Launch: kitty --session ${sessionFile}\n`);
|
|
1285
|
+
spawn('kitty', ['--session', sessionFile], { detached: true, stdio: 'ignore' }).unref();
|
|
1286
|
+
console.log('🚀 Kitty launched (new instance).');
|
|
1287
|
+
}
|
|
1288
|
+
} else {
|
|
1289
|
+
console.log(`\n Launch: kitty --session ${sessionFile}\n`);
|
|
1290
|
+
}
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (cmd === 'layout') {
|
|
1295
|
+
const layoutType = args[1] || 'grid';
|
|
1296
|
+
const validLayouts = ['grid', 'tall', 'stack'];
|
|
1297
|
+
if (!validLayouts.includes(layoutType)) {
|
|
1298
|
+
console.error(`❌ Invalid layout: ${layoutType}. Valid: ${validLayouts.join(', ')}`);
|
|
1299
|
+
process.exit(1);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
await ensureDaemonRunning();
|
|
1303
|
+
const { execSync } = require('child_process');
|
|
1304
|
+
|
|
1305
|
+
// Get active session count for grid calculation
|
|
1306
|
+
try {
|
|
1307
|
+
const sessionsRes = await fetchWithAuth(`${DAEMON_URL}/api/sessions`);
|
|
1308
|
+
const sessionsList = await sessionsRes.json();
|
|
1309
|
+
const activeIds = Object.keys(sessionsList);
|
|
1310
|
+
if (activeIds.length === 0) {
|
|
1311
|
+
console.log('No active sessions to arrange.');
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
} catch (e) {
|
|
1315
|
+
console.error('❌ Could not fetch sessions:', e.message);
|
|
1316
|
+
process.exit(1);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Detect kitty process name
|
|
1320
|
+
let processName = 'kitty';
|
|
1321
|
+
try {
|
|
1322
|
+
execSync(`osascript -e 'tell application "System Events" to get name of first process whose name is "kitty"'`, {
|
|
1323
|
+
timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
|
|
1324
|
+
});
|
|
1325
|
+
} catch {
|
|
1326
|
+
processName = 'stable';
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Get screen dimensions
|
|
1330
|
+
let screenW = 2560, screenH = 1440;
|
|
1331
|
+
try {
|
|
1332
|
+
const bounds = execSync(`osascript -e 'tell application "Finder" to get bounds of window of desktop'`, {
|
|
1333
|
+
encoding: 'utf8', timeout: 3000
|
|
1334
|
+
}).trim();
|
|
1335
|
+
const parts = bounds.split(', ');
|
|
1336
|
+
screenW = parseInt(parts[2]);
|
|
1337
|
+
screenH = parseInt(parts[3]);
|
|
1338
|
+
} catch {}
|
|
1339
|
+
|
|
1340
|
+
const menuBarH = 25;
|
|
1341
|
+
const dockH = 70;
|
|
1342
|
+
const usableH = screenH - menuBarH - dockH;
|
|
1343
|
+
|
|
1344
|
+
// Build AppleScript — collect windows from ALL kitty process instances
|
|
1345
|
+
// (kitty --session spawns separate processes, each with its own window)
|
|
1346
|
+
const collectWindows = `
|
|
1347
|
+
set wList to {}
|
|
1348
|
+
set kittyProcs to every process whose name is "${processName}"
|
|
1349
|
+
repeat with p in kittyProcs
|
|
1350
|
+
repeat with w in (every window of p)
|
|
1351
|
+
set end of wList to w
|
|
1352
|
+
end repeat
|
|
1353
|
+
end repeat
|
|
1354
|
+
set n to count of wList
|
|
1355
|
+
if n = 0 then return "0"`;
|
|
1356
|
+
|
|
1357
|
+
let layoutBody;
|
|
1358
|
+
if (layoutType === 'grid') {
|
|
1359
|
+
layoutBody = `
|
|
1360
|
+
set numCols to (n ^ 0.5) as integer
|
|
1361
|
+
if numCols * numCols < n then set numCols to numCols + 1
|
|
1362
|
+
set numRows to ((n - 1) div numCols) + 1
|
|
1363
|
+
set cellW to ${screenW} div numCols
|
|
1364
|
+
set cellH to ${usableH} div numRows
|
|
1365
|
+
repeat with i from 1 to n
|
|
1366
|
+
set c to ((i - 1) mod numCols)
|
|
1367
|
+
set r to ((i - 1) div numCols)
|
|
1368
|
+
set position of (item i of wList) to {c * cellW, ${menuBarH} + r * cellH}
|
|
1369
|
+
set size of (item i of wList) to {cellW, cellH}
|
|
1370
|
+
end repeat`;
|
|
1371
|
+
} else if (layoutType === 'tall') {
|
|
1372
|
+
layoutBody = `
|
|
1373
|
+
set halfW to ${screenW} div 2
|
|
1374
|
+
if n = 1 then
|
|
1375
|
+
set position of (item 1 of wList) to {0, ${menuBarH}}
|
|
1376
|
+
set size of (item 1 of wList) to {${screenW}, ${usableH}}
|
|
1377
|
+
else
|
|
1378
|
+
set position of (item 1 of wList) to {0, ${menuBarH}}
|
|
1379
|
+
set size of (item 1 of wList) to {halfW, ${usableH}}
|
|
1380
|
+
set rightH to ${usableH} div (n - 1)
|
|
1381
|
+
repeat with i from 2 to n
|
|
1382
|
+
set y to ${menuBarH} + ((i - 2) * rightH)
|
|
1383
|
+
set position of (item i of wList) to {halfW, y}
|
|
1384
|
+
set size of (item i of wList) to {halfW, rightH}
|
|
1385
|
+
end repeat
|
|
1386
|
+
end if`;
|
|
1387
|
+
} else if (layoutType === 'stack') {
|
|
1388
|
+
layoutBody = `
|
|
1389
|
+
set cellH to ${usableH} div n
|
|
1390
|
+
repeat with i from 1 to n
|
|
1391
|
+
set y to ${menuBarH} + ((i - 1) * cellH)
|
|
1392
|
+
set position of (item i of wList) to {0, y}
|
|
1393
|
+
set size of (item i of wList) to {${screenW}, cellH}
|
|
1394
|
+
end repeat`;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
const script = `
|
|
1398
|
+
tell application "System Events"
|
|
1399
|
+
${collectWindows}
|
|
1400
|
+
${layoutBody}
|
|
1401
|
+
return n
|
|
1402
|
+
end tell`;
|
|
1403
|
+
|
|
1404
|
+
try {
|
|
1405
|
+
const result = execSync(`osascript -e '${script}'`, { encoding: 'utf8', timeout: 10000 }).trim();
|
|
1406
|
+
if (result === '0') {
|
|
1407
|
+
console.log('⚠️ No kitty windows found. Sessions may be in tabs — use kitty @ launch --type=os-window for separate windows.');
|
|
1408
|
+
} else {
|
|
1409
|
+
console.log(`✅ Layout '${layoutType}' applied to ${result} kitty windows.`);
|
|
1410
|
+
}
|
|
1411
|
+
} catch (e) {
|
|
1412
|
+
console.error(`❌ Layout failed: ${e.message}`);
|
|
1207
1413
|
}
|
|
1208
1414
|
return;
|
|
1209
1415
|
}
|
|
@@ -1731,6 +1937,7 @@ Usage:
|
|
|
1731
1937
|
telepty listen Listen to the event bus and print JSON to stdout
|
|
1732
1938
|
telepty monitor Human-readable real-time billboard of bus events
|
|
1733
1939
|
telepty update Update telepty to the latest version
|
|
1940
|
+
telepty layout [grid|tall|stack] Arrange kitty windows on screen (default: grid)
|
|
1734
1941
|
|
|
1735
1942
|
Handoff Commands:
|
|
1736
1943
|
handoff list [--status=S] List handoffs (filter: pending/claimed/executing/completed)
|
package/daemon.js
CHANGED
|
@@ -785,32 +785,53 @@ app.post('/api/sessions/:id/inject', (req, res) => {
|
|
|
785
785
|
});
|
|
786
786
|
kittyOk = true;
|
|
787
787
|
console.log(`[INJECT] Kitty send-text for ${id} (window ${wid})`);
|
|
788
|
-
} catch {
|
|
788
|
+
} catch {
|
|
789
|
+
// Invalidate cached window ID — window may have changed or been closed
|
|
790
|
+
session.kittyWindowId = null;
|
|
791
|
+
}
|
|
789
792
|
}
|
|
790
793
|
if (!kittyOk) {
|
|
791
794
|
// Fallback: WS (works with new allow bridges that have queue flush)
|
|
792
|
-
writeToSession(finalPrompt);
|
|
795
|
+
const wsOk = writeToSession(finalPrompt);
|
|
796
|
+
if (!wsOk) {
|
|
797
|
+
return res.status(503).json({ error: 'Process not connected' });
|
|
798
|
+
}
|
|
793
799
|
console.log(`[INJECT] WS fallback for ${id}`);
|
|
794
800
|
}
|
|
795
801
|
|
|
796
802
|
if (!no_enter) {
|
|
797
803
|
setTimeout(() => {
|
|
804
|
+
// Primary: osascript keystroke (reliable across all CLIs)
|
|
805
|
+
const submitStrategy = getSubmitStrategy(session.command);
|
|
806
|
+
const keyCombo = submitStrategy === 'osascript_cmd_enter' ? 'cmd_enter' : 'return_key';
|
|
807
|
+
const osascriptOk = submitViaOsascript(id, keyCombo);
|
|
808
|
+
|
|
809
|
+
if (!osascriptOk) {
|
|
810
|
+
// Fallback: kitty send-text → WS
|
|
811
|
+
console.log(`[INJECT] osascript submit failed for ${id}, trying fallback`);
|
|
812
|
+
if (wid && sock) {
|
|
813
|
+
try {
|
|
814
|
+
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} $'\\r'`, {
|
|
815
|
+
timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
|
|
816
|
+
});
|
|
817
|
+
} catch {
|
|
818
|
+
writeToSession('\r');
|
|
819
|
+
}
|
|
820
|
+
} else {
|
|
821
|
+
writeToSession('\r');
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Update tab title regardless of submit method
|
|
798
826
|
if (wid && sock) {
|
|
799
827
|
try {
|
|
800
|
-
require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} $'\\r'`, {
|
|
801
|
-
timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
|
|
802
|
-
});
|
|
803
828
|
require('child_process').execSync(`kitty @ --to unix:${sock} set-tab-title --match id:${wid} '⚡ telepty :: ${id}'`, {
|
|
804
829
|
timeout: 2000, stdio: ['pipe', 'pipe', 'pipe']
|
|
805
830
|
});
|
|
806
|
-
} catch {
|
|
807
|
-
writeToSession('\r');
|
|
808
|
-
}
|
|
809
|
-
} else {
|
|
810
|
-
writeToSession('\r');
|
|
831
|
+
} catch {}
|
|
811
832
|
}
|
|
812
833
|
}, 500);
|
|
813
|
-
submitResult = { deferred: true, strategy:
|
|
834
|
+
submitResult = { deferred: true, strategy: 'osascript_with_fallback' };
|
|
814
835
|
}
|
|
815
836
|
} else {
|
|
816
837
|
// Spawned sessions: direct PTY write
|
|
@@ -1336,6 +1357,21 @@ wss.on('connection', (ws, req) => {
|
|
|
1336
1357
|
const url = new URL(req.url, 'http://' + req.headers.host);
|
|
1337
1358
|
const sessionId = url.pathname.split('/').pop();
|
|
1338
1359
|
const session = sessions[sessionId];
|
|
1360
|
+
// ?owner=1 indicates the allow bridge (PTY owner), not an attach viewer
|
|
1361
|
+
const isOwnerConnect = url.searchParams.get('owner') === '1';
|
|
1362
|
+
|
|
1363
|
+
// Ping/pong heartbeat — detect and terminate stale TCP half-open connections (30s interval)
|
|
1364
|
+
let isAlive = true;
|
|
1365
|
+
ws.on('pong', () => { isAlive = true; });
|
|
1366
|
+
const pingInterval = setInterval(() => {
|
|
1367
|
+
if (!isAlive) {
|
|
1368
|
+
console.log(`[WS] Terminating stale connection (no pong) for ${sessionId}`);
|
|
1369
|
+
ws.terminate();
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
isAlive = false;
|
|
1373
|
+
ws.ping();
|
|
1374
|
+
}, 30000);
|
|
1339
1375
|
|
|
1340
1376
|
if (!session) {
|
|
1341
1377
|
// Auto-register wrapped session on WS connect (supports reconnect after daemon restart)
|
|
@@ -1372,10 +1408,17 @@ wss.on('connection', (ws, req) => {
|
|
|
1372
1408
|
|
|
1373
1409
|
const activeSession = sessions[sessionId];
|
|
1374
1410
|
|
|
1375
|
-
// For wrapped sessions, first connector becomes the owner
|
|
1376
|
-
|
|
1411
|
+
// For wrapped sessions, first connector OR explicit ?owner=1 claim becomes the owner.
|
|
1412
|
+
// ?owner=1 reclaim handles the stale-ownerWs bug: allow bridge reconnects but stale TCP
|
|
1413
|
+
// half-open connection still holds ownerWs slot → reconnect wrongly becomes a viewer.
|
|
1414
|
+
if (activeSession.type === 'wrapped' && (!activeSession.ownerWs || isOwnerConnect)) {
|
|
1415
|
+
if (isOwnerConnect && activeSession.ownerWs && activeSession.ownerWs !== ws) {
|
|
1416
|
+
// Terminate the stale owner connection before claiming ownership
|
|
1417
|
+
console.log(`[WS] Replacing stale ownerWs for session ${sessionId}`);
|
|
1418
|
+
activeSession.ownerWs.terminate();
|
|
1419
|
+
}
|
|
1377
1420
|
activeSession.ownerWs = ws;
|
|
1378
|
-
console.log(`[WS] Wrap owner connected for session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
1421
|
+
console.log(`[WS] Wrap owner ${isOwnerConnect && activeSession.clients.size > 1 ? 're-' : ''}connected for session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
1379
1422
|
} else {
|
|
1380
1423
|
console.log(`[WS] Client attached to session ${sessionId} (Total: ${activeSession.clients.size})`);
|
|
1381
1424
|
}
|
|
@@ -1417,6 +1460,7 @@ wss.on('connection', (ws, req) => {
|
|
|
1417
1460
|
});
|
|
1418
1461
|
|
|
1419
1462
|
ws.on('close', () => {
|
|
1463
|
+
clearInterval(pingInterval);
|
|
1420
1464
|
activeSession.clients.delete(ws);
|
|
1421
1465
|
if (activeSession.type === 'wrapped' && ws === activeSession.ownerWs) {
|
|
1422
1466
|
activeSession.ownerWs = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dmsdc-ai/aigentry-telepty",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59",
|
|
4
4
|
"main": "daemon.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aigentry-telepty": "install.js",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"license": "ISC",
|
|
18
18
|
"description": "",
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"blessed": "^0.1.81",
|
|
20
21
|
"cors": "^2.8.6",
|
|
21
22
|
"express": "^5.2.1",
|
|
22
23
|
"node-pty": "^1.2.0-beta.11",
|
package/session-routing.js
CHANGED
|
@@ -48,6 +48,20 @@ function pickSessionTarget(sessionRef, sessions, defaultHost = '127.0.0.1') {
|
|
|
48
48
|
const matches = sessions.filter((session) => session.id === parsed.id);
|
|
49
49
|
|
|
50
50
|
if (matches.length === 0) {
|
|
51
|
+
// Project-based fallback: match sessions whose ID starts with the requested prefix.
|
|
52
|
+
// e.g., "aigentry-dustcraw" matches "aigentry-dustcraw-001" or "aigentry-dustcraw-claude".
|
|
53
|
+
const prefixMatches = sessions.filter((s) =>
|
|
54
|
+
s.id.startsWith(parsed.id + '-') || s.id.startsWith(parsed.id)
|
|
55
|
+
);
|
|
56
|
+
if (prefixMatches.length === 1) {
|
|
57
|
+
return { id: prefixMatches[0].id, host: prefixMatches[0].host };
|
|
58
|
+
}
|
|
59
|
+
if (prefixMatches.length > 1) {
|
|
60
|
+
// Multiple candidates: prefer same host, then first alphabetically
|
|
61
|
+
const local = prefixMatches.find((s) => s.host === '127.0.0.1');
|
|
62
|
+
const best = local || prefixMatches.sort((a, b) => a.id.localeCompare(b.id))[0];
|
|
63
|
+
return { id: best.id, host: best.host };
|
|
64
|
+
}
|
|
51
65
|
return null;
|
|
52
66
|
}
|
|
53
67
|
|
package/tui.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const blessed = require('blessed');
|
|
4
|
+
const { getConfig } = require('./auth');
|
|
5
|
+
|
|
6
|
+
const PORT = process.env.PORT || 3848;
|
|
7
|
+
const DAEMON_URL = `http://localhost:${PORT}`;
|
|
8
|
+
const POLL_INTERVAL = 2000;
|
|
9
|
+
const STALE_THRESHOLD = 120; // seconds idle before "stale"
|
|
10
|
+
|
|
11
|
+
class TuiDashboard {
|
|
12
|
+
constructor() {
|
|
13
|
+
const cfg = getConfig();
|
|
14
|
+
this.token = cfg.authToken;
|
|
15
|
+
this.sessions = [];
|
|
16
|
+
this.selectedIndex = 0;
|
|
17
|
+
this.pollTimer = null;
|
|
18
|
+
this.busWs = null;
|
|
19
|
+
this.busLog = [];
|
|
20
|
+
this.setupScreen();
|
|
21
|
+
this.startPolling();
|
|
22
|
+
this.connectBus();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── API helpers ──────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
async apiFetch(path, options = {}) {
|
|
28
|
+
const headers = { 'x-telepty-token': this.token, ...options.headers };
|
|
29
|
+
if (options.body) headers['Content-Type'] = 'application/json';
|
|
30
|
+
const res = await fetch(`${DAEMON_URL}${path}`, { ...options, headers });
|
|
31
|
+
return res.json();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async fetchSessions() {
|
|
35
|
+
try {
|
|
36
|
+
this.sessions = await this.apiFetch('/api/sessions');
|
|
37
|
+
this.sessions.sort((a, b) => a.id.localeCompare(b.id));
|
|
38
|
+
if (this.selectedIndex >= this.sessions.length) {
|
|
39
|
+
this.selectedIndex = Math.max(0, this.sessions.length - 1);
|
|
40
|
+
}
|
|
41
|
+
this.renderSessionList();
|
|
42
|
+
} catch {
|
|
43
|
+
this.setStatus('{red-fg}Daemon unreachable{/}');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async injectToSession(id, prompt) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await this.apiFetch(`/api/sessions/${encodeURIComponent(id)}/inject`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
body: JSON.stringify({ prompt })
|
|
52
|
+
});
|
|
53
|
+
if (res.success) {
|
|
54
|
+
this.setStatus(`{green-fg}Injected to ${id}{/}`);
|
|
55
|
+
} else {
|
|
56
|
+
this.setStatus(`{red-fg}Inject failed: ${res.error || 'unknown'}{/}`);
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
this.setStatus(`{red-fg}Inject error: ${e.message}{/}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async broadcastMessage(prompt) {
|
|
64
|
+
try {
|
|
65
|
+
const res = await this.apiFetch('/api/sessions/broadcast/inject', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
body: JSON.stringify({ prompt })
|
|
68
|
+
});
|
|
69
|
+
const ok = res.results?.successful?.length || 0;
|
|
70
|
+
this.setStatus(`{green-fg}Broadcast to ${ok} sessions{/}`);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
this.setStatus(`{red-fg}Broadcast error: ${e.message}{/}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Event Bus ────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
connectBus() {
|
|
79
|
+
try {
|
|
80
|
+
const WebSocket = require('ws');
|
|
81
|
+
this.busWs = new WebSocket(
|
|
82
|
+
`ws://localhost:${PORT}/api/bus?token=${encodeURIComponent(this.token)}`
|
|
83
|
+
);
|
|
84
|
+
this.busWs.on('message', (data) => {
|
|
85
|
+
try {
|
|
86
|
+
const msg = JSON.parse(data);
|
|
87
|
+
const ts = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
88
|
+
let line = `${ts} `;
|
|
89
|
+
if (msg.type === 'inject_written') {
|
|
90
|
+
line += `{cyan-fg}inject{/} -> ${msg.target || '?'}`;
|
|
91
|
+
} else if (msg.type === 'injection') {
|
|
92
|
+
line += `{cyan-fg}broadcast{/} -> ${msg.target_agent || 'all'}`;
|
|
93
|
+
} else if (msg.type === 'message_routed') {
|
|
94
|
+
line += `{yellow-fg}${msg.from || '?'}{/} -> {green-fg}${msg.to || '?'}{/}`;
|
|
95
|
+
} else {
|
|
96
|
+
line += `{white-fg}${msg.type || 'event'}{/}`;
|
|
97
|
+
}
|
|
98
|
+
this.busLog.push(line);
|
|
99
|
+
if (this.busLog.length > 100) this.busLog.shift();
|
|
100
|
+
this.renderBusLog();
|
|
101
|
+
} catch { /* ignore malformed */ }
|
|
102
|
+
});
|
|
103
|
+
this.busWs.on('close', () => {
|
|
104
|
+
setTimeout(() => this.connectBus(), 3000);
|
|
105
|
+
});
|
|
106
|
+
this.busWs.on('error', () => {});
|
|
107
|
+
} catch { /* ws not available */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Screen setup ─────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
setupScreen() {
|
|
113
|
+
this.screen = blessed.screen({
|
|
114
|
+
smartCSR: true,
|
|
115
|
+
title: 'telepty dashboard',
|
|
116
|
+
fullUnicode: true
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Header
|
|
120
|
+
this.header = blessed.box({
|
|
121
|
+
parent: this.screen,
|
|
122
|
+
top: 0, left: 0, width: '100%', height: 1,
|
|
123
|
+
tags: true,
|
|
124
|
+
style: { fg: 'white', bg: 'blue' },
|
|
125
|
+
content: ' {bold}telepty dashboard{/bold}'
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Session list (left panel)
|
|
129
|
+
this.sessionList = blessed.list({
|
|
130
|
+
parent: this.screen,
|
|
131
|
+
top: 1, left: 0, width: '60%', bottom: 3,
|
|
132
|
+
border: { type: 'line' },
|
|
133
|
+
label: ' Sessions ',
|
|
134
|
+
tags: true,
|
|
135
|
+
keys: true,
|
|
136
|
+
vi: true,
|
|
137
|
+
mouse: true,
|
|
138
|
+
scrollbar: { ch: '│', style: { fg: 'cyan' } },
|
|
139
|
+
style: {
|
|
140
|
+
border: { fg: 'cyan' },
|
|
141
|
+
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
142
|
+
item: { fg: 'white' }
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
this.sessionList.on('select item', (item, index) => {
|
|
147
|
+
this.selectedIndex = index;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Event bus log (right panel)
|
|
151
|
+
this.busPanel = blessed.log({
|
|
152
|
+
parent: this.screen,
|
|
153
|
+
top: 1, left: '60%', right: 0, bottom: 3,
|
|
154
|
+
border: { type: 'line' },
|
|
155
|
+
label: ' Event Bus ',
|
|
156
|
+
tags: true,
|
|
157
|
+
scrollbar: { ch: '│', style: { fg: 'yellow' } },
|
|
158
|
+
style: {
|
|
159
|
+
border: { fg: 'yellow' }
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Shortcut bar
|
|
164
|
+
this.shortcutBar = blessed.box({
|
|
165
|
+
parent: this.screen,
|
|
166
|
+
bottom: 1, left: 0, width: '100%', height: 1,
|
|
167
|
+
tags: true,
|
|
168
|
+
style: { fg: 'white', bg: 'gray' },
|
|
169
|
+
content: ' {bold}i{/}:Inject {bold}b{/}:Broadcast {bold}r{/}:Refresh {bold}q{/}:Quit'
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Status bar
|
|
173
|
+
this.statusBar = blessed.box({
|
|
174
|
+
parent: this.screen,
|
|
175
|
+
bottom: 0, left: 0, width: '100%', height: 1,
|
|
176
|
+
tags: true,
|
|
177
|
+
style: { fg: 'white', bg: 'black' },
|
|
178
|
+
content: ' Ready'
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── Keyboard handlers ──────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
this.screen.key(['q', 'C-c'], () => {
|
|
184
|
+
this.cleanup();
|
|
185
|
+
process.exit(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
this.screen.key(['i'], () => {
|
|
189
|
+
const session = this.sessions[this.selectedIndex];
|
|
190
|
+
if (!session) return this.setStatus('{red-fg}No session selected{/}');
|
|
191
|
+
this.promptInput(`Inject to ${session.id}:`, (text) => {
|
|
192
|
+
if (text) this.injectToSession(session.id, text);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
this.screen.key(['b'], () => {
|
|
197
|
+
this.promptInput('Broadcast to all:', (text) => {
|
|
198
|
+
if (text) this.broadcastMessage(text);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
this.screen.key(['r'], () => {
|
|
203
|
+
this.fetchSessions();
|
|
204
|
+
this.setStatus('{green-fg}Refreshed{/}');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
this.sessionList.focus();
|
|
208
|
+
this.screen.render();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Input prompt ─────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
promptInput(label, callback) {
|
|
214
|
+
const inputBox = blessed.textbox({
|
|
215
|
+
parent: this.screen,
|
|
216
|
+
bottom: 0, left: 0, width: '100%', height: 3,
|
|
217
|
+
border: { type: 'line' },
|
|
218
|
+
label: ` ${label} `,
|
|
219
|
+
tags: true,
|
|
220
|
+
inputOnFocus: true,
|
|
221
|
+
style: {
|
|
222
|
+
border: { fg: 'green' },
|
|
223
|
+
fg: 'white'
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
inputBox.focus();
|
|
228
|
+
inputBox.readInput((err, value) => {
|
|
229
|
+
inputBox.destroy();
|
|
230
|
+
this.sessionList.focus();
|
|
231
|
+
this.screen.render();
|
|
232
|
+
if (!err && value) callback(value);
|
|
233
|
+
});
|
|
234
|
+
this.screen.render();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Rendering ────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
getStatusInfo(session) {
|
|
240
|
+
const idle = session.idleSeconds;
|
|
241
|
+
const clients = session.active_clients || 0;
|
|
242
|
+
|
|
243
|
+
if (clients === 0) return { icon: '{red-fg}✕{/}', label: '{red-fg}dead{/}' };
|
|
244
|
+
if (idle !== null && idle > STALE_THRESHOLD) return { icon: '{yellow-fg}○{/}', label: '{yellow-fg}stale{/}' };
|
|
245
|
+
if (idle !== null && idle < 10) return { icon: '{green-fg}●{/}', label: '{green-fg}busy{/}' };
|
|
246
|
+
return { icon: '{green-fg}●{/}', label: '{white-fg}idle{/}' };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
renderSessionList() {
|
|
250
|
+
const items = this.sessions.map((s) => {
|
|
251
|
+
const { icon, label } = this.getStatusInfo(s);
|
|
252
|
+
const shortId = s.id.replace(/^aigentry-/, '').replace(/-claude$/, '');
|
|
253
|
+
return ` ${icon} ${shortId.padEnd(24)} ${label} {gray-fg}C:${s.active_clients}{/}`;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
this.sessionList.setItems(items);
|
|
257
|
+
this.sessionList.setLabel(` Sessions (${this.sessions.length}) `);
|
|
258
|
+
if (this.selectedIndex < items.length) {
|
|
259
|
+
this.sessionList.select(this.selectedIndex);
|
|
260
|
+
}
|
|
261
|
+
this.screen.render();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
renderBusLog() {
|
|
265
|
+
// Show last N lines that fit
|
|
266
|
+
const height = this.busPanel.height - 2;
|
|
267
|
+
const visible = this.busLog.slice(-height);
|
|
268
|
+
this.busPanel.setContent(visible.join('\n'));
|
|
269
|
+
this.screen.render();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
setStatus(msg) {
|
|
273
|
+
this.statusBar.setContent(` ${msg}`);
|
|
274
|
+
this.screen.render();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Lifecycle ────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
startPolling() {
|
|
280
|
+
this.fetchSessions();
|
|
281
|
+
this.pollTimer = setInterval(() => this.fetchSessions(), POLL_INTERVAL);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
cleanup() {
|
|
285
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
286
|
+
if (this.busWs) this.busWs.close();
|
|
287
|
+
this.screen.destroy();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Entry point ──────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
function main() {
|
|
294
|
+
new TuiDashboard();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = { TuiDashboard };
|
|
298
|
+
|
|
299
|
+
if (require.main === module) {
|
|
300
|
+
main();
|
|
301
|
+
}
|
package/URGENT_ISSUES.md
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# Telepty 긴급 수정 사항 3건
|
|
2
|
-
|
|
3
|
-
## 1. 데몬 재시작 시 전체 세션 끊김 → 자동 재접속
|
|
4
|
-
|
|
5
|
-
**현상**: 데몬이 재시작되면 모든 allow 브릿지 세션이 끊기고 수동으로 다시 연결해야 함.
|
|
6
|
-
|
|
7
|
-
**필요한 수정**:
|
|
8
|
-
- allow 브릿지가 데몬 재시작/연결 끊김을 감지
|
|
9
|
-
- 자동 재접속 retry loop (exponential backoff: 1s → 2s → 4s → max 30s)
|
|
10
|
-
- 세션 메타데이터(ID, command, CWD)가 재접속 시 복원되어야 함
|
|
11
|
-
|
|
12
|
-
## 2. inject 시 엔터 미전송
|
|
13
|
-
|
|
14
|
-
**현상**: inject된 메시지가 프롬프트에 표시되지만 엔터가 자동으로 안 눌림. 사용자가 수동으로 엔터를 눌러야 실행됨.
|
|
15
|
-
|
|
16
|
-
**0.1.21에서 수정했다고 했으나 여전히 발생 중.**
|
|
17
|
-
|
|
18
|
-
**필요한 수정**:
|
|
19
|
-
- 모든 CLI(Claude, Codex, Gemini)에서 inject 후 엔터 자동 전송 보장
|
|
20
|
-
- HTTP API inject (POST /api/sessions/:id/inject)도 동일하게 동작해야 함
|
|
21
|
-
- deferred/split_cr 전략이 실제로 엔터를 전송하는지 검증
|
|
22
|
-
|
|
23
|
-
## 3. 통신 대상 세션 소실 시 자동 재검색
|
|
24
|
-
|
|
25
|
-
**현상**: inject 대상 세션이 사라지면 에러 후 종료. 통신 단절.
|
|
26
|
-
|
|
27
|
-
**필요한 수정**:
|
|
28
|
-
- 세션이 사라지면 같은 프로젝트(CWD)에서 새로 생성된 세션을 자동 검색
|
|
29
|
-
- 예: `aigentry-dustcraw-001`이 사라지고 `aigentry-dustcraw-002`가 생기면 자동 라우팅
|
|
30
|
-
- project-based routing: CWD 또는 session name prefix로 매칭
|
|
31
|
-
- `telepty inject --project aigentry-dustcraw "메시지"` 같은 project alias 지원 검토
|