@dmsdc-ai/aigentry-telepty 0.1.56 → 0.1.58

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.
@@ -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 });
@@ -667,10 +673,21 @@ async function main() {
667
673
  process.exit(1);
668
674
  }
669
675
 
670
- // Default session ID = command name
676
+ // Default session ID = {folder}-{cli} (e.g. aigentry-dustcraw-claude)
671
677
  if (!sessionId) {
672
- sessionId = path.basename(command);
678
+ const folder = path.basename(process.cwd());
679
+ const cli = path.basename(command).replace(/\..*$/, '');
680
+ sessionId = `${folder}-${cli}`;
681
+ }
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}"`);
673
688
  }
689
+ delete process.env.TELEPTY_SESSION_ID;
690
+ process.env.TELEPTY_SESSION_ID = sessionId;
674
691
 
675
692
  await ensureDaemonRunning({ requiredCapabilities: ['wrapped-sessions'] });
676
693
 
@@ -735,7 +752,9 @@ async function main() {
735
752
  }
736
753
 
737
754
  // Connect to daemon WebSocket with auto-reconnect
738
- const wsUrl = `ws://${REMOTE_HOST}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(TOKEN)}`;
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`;
739
758
  let daemonWs = null;
740
759
  let wsReady = false;
741
760
  let reconnectAttempts = 0;
@@ -776,10 +795,17 @@ async function main() {
776
795
  try {
777
796
  const msg = JSON.parse(message);
778
797
  if (msg.type === 'inject') {
779
- const isFollowUpCr = msg.data === '\r' && (Date.now() - lastInjectTextTime) < 1000;
780
- if (promptReady || isFollowUpCr) {
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) {
781
807
  child.write(msg.data);
782
- if (msg.data !== '\r' && msg.data.length > 1) {
808
+ if (!isCr && msg.data.length > 1) {
783
809
  promptReady = false;
784
810
  lastInjectTextTime = Date.now();
785
811
  }
@@ -1151,6 +1177,243 @@ async function main() {
1151
1177
  return;
1152
1178
  }
1153
1179
 
1180
+ if (cmd === 'session' && args[1] === 'start') {
1181
+ // Generate kitty session file and launch
1182
+ const configArg = args.find(a => a.startsWith('--config='));
1183
+ const configPath = configArg ? configArg.split('=').slice(1).join('=') : null;
1184
+ const cliArg = args.find(a => a.startsWith('--cli='));
1185
+ const cli = cliArg ? cliArg.split('=')[1] : 'claude --dangerously-skip-permissions';
1186
+ const projectsDir = args.find(a => a.startsWith('--dir=')) ? args.find(a => a.startsWith('--dir=')).split('=')[1] : process.cwd();
1187
+
1188
+ // Discover project folders (subdirectories with .git)
1189
+ let projects;
1190
+ if (configPath) {
1191
+ projects = JSON.parse(fs.readFileSync(configPath, 'utf8')).projects;
1192
+ } else {
1193
+ projects = fs.readdirSync(projectsDir, { withFileTypes: true })
1194
+ .filter(d => d.isDirectory() && fs.existsSync(path.join(projectsDir, d.name, '.git')))
1195
+ .map(d => ({ name: d.name, cwd: path.join(projectsDir, d.name) }));
1196
+ }
1197
+
1198
+ if (projects.length === 0) {
1199
+ console.error('❌ No git projects found in', projectsDir);
1200
+ process.exit(1);
1201
+ }
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
+
1219
+ // Generate kitty session file
1220
+ const sessionFile = path.join(os.tmpdir(), `telepty-session-${Date.now()}.conf`);
1221
+ let conf = '# Auto-generated telepty session\n';
1222
+ projects.forEach((p, i) => {
1223
+ const name = p.name;
1224
+ const cwd = p.cwd || path.join(projectsDir, name);
1225
+ const sessionId = `${name}-${cli.split(' ')[0]}`;
1226
+ if (i === 0) {
1227
+ conf += `new_tab ${name}\n`;
1228
+ } else {
1229
+ conf += `\nnew_tab ${name}\n`;
1230
+ }
1231
+ conf += `layout tall\n`;
1232
+ conf += `cd ${cwd}\n`;
1233
+ conf += `title ${name}\n`;
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`;
1237
+ });
1238
+
1239
+ fs.writeFileSync(sessionFile, conf);
1240
+ console.log(`✅ Kitty session file: ${sessionFile}`);
1241
+ console.log(` ${projects.length} projects, CLI: ${cli}`);
1242
+
1243
+ // Auto-launch if --launch flag
1244
+ if (args.includes('--launch')) {
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}`);
1413
+ }
1414
+ return;
1415
+ }
1416
+
1154
1417
  if (cmd === 'deliberate') {
1155
1418
  await ensureDaemonRunning();
1156
1419
  const subCmd = args[1];
@@ -1674,6 +1937,7 @@ Usage:
1674
1937
  telepty listen Listen to the event bus and print JSON to stdout
1675
1938
  telepty monitor Human-readable real-time billboard of bus events
1676
1939
  telepty update Update telepty to the latest version
1940
+ telepty layout [grid|tall|stack] Arrange kitty windows on screen (default: grid)
1677
1941
 
1678
1942
  Handoff Commands:
1679
1943
  handoff list [--status=S] List handoffs (filter: pending/claimed/executing/completed)
package/daemon.js CHANGED
@@ -785,7 +785,10 @@ 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)
@@ -795,22 +798,37 @@ app.post('/api/sessions/:id/inject', (req, res) => {
795
798
 
796
799
  if (!no_enter) {
797
800
  setTimeout(() => {
801
+ // Primary: osascript keystroke (reliable across all CLIs)
802
+ const submitStrategy = getSubmitStrategy(session.command);
803
+ const keyCombo = submitStrategy === 'osascript_cmd_enter' ? 'cmd_enter' : 'return_key';
804
+ const osascriptOk = submitViaOsascript(id, keyCombo);
805
+
806
+ if (!osascriptOk) {
807
+ // Fallback: kitty send-text → WS
808
+ console.log(`[INJECT] osascript submit failed for ${id}, trying fallback`);
809
+ if (wid && sock) {
810
+ try {
811
+ require('child_process').execSync(`kitty @ --to unix:${sock} send-text --match id:${wid} $'\\r'`, {
812
+ timeout: 3000, stdio: ['pipe', 'pipe', 'pipe']
813
+ });
814
+ } catch {
815
+ writeToSession('\r');
816
+ }
817
+ } else {
818
+ writeToSession('\r');
819
+ }
820
+ }
821
+
822
+ // Update tab title regardless of submit method
798
823
  if (wid && sock) {
799
824
  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
825
  require('child_process').execSync(`kitty @ --to unix:${sock} set-tab-title --match id:${wid} '⚡ telepty :: ${id}'`, {
804
826
  timeout: 2000, stdio: ['pipe', 'pipe', 'pipe']
805
827
  });
806
- } catch {
807
- writeToSession('\r');
808
- }
809
- } else {
810
- writeToSession('\r');
828
+ } catch {}
811
829
  }
812
830
  }, 500);
813
- submitResult = { deferred: true, strategy: kittyOk ? 'kitty_text_key' : 'ws_split_cr' };
831
+ submitResult = { deferred: true, strategy: 'osascript_with_fallback' };
814
832
  }
815
833
  } else {
816
834
  // Spawned sessions: direct PTY write
@@ -942,14 +960,21 @@ app.delete('/api/sessions/:id', (req, res) => {
942
960
  function busAutoRoute(msg) {
943
961
  const eventType = msg.type || msg.kind;
944
962
  const isRoutable = (eventType === 'turn_request' || eventType === 'deliberation_route_turn') && (msg.target || msg.target_session_id);
945
- if (!isRoutable) return;
963
+ if (!isRoutable) {
964
+ // Log all bus messages for debugging (excluding health checks)
965
+ if (eventType && eventType !== 'session_health') {
966
+ console.log(`[BUS] Event: ${eventType} (not routable)`);
967
+ }
968
+ return;
969
+ }
946
970
 
947
971
  const rawTarget = (msg.target || msg.target_session_id).split('@')[0];
948
- console.log(`[BUS-ROUTE] Received ${eventType}: target=${rawTarget}`);
972
+ const turnId = (msg.payload && msg.payload.turn_id) || null;
973
+ console.log(`[BUS-ROUTE] ${eventType}: target=${rawTarget} turn=${turnId} msg_id=${msg.message_id || 'none'}`);
949
974
  const targetId = resolveSessionAlias(rawTarget);
950
975
  const targetSession = targetId ? sessions[targetId] : null;
951
976
  if (!targetSession) {
952
- console.log(`[BUS-ROUTE] Target ${rawTarget} not found`);
977
+ console.log(`[BUS-ROUTE] Target ${rawTarget} not found among: ${Object.keys(sessions).join(', ')}`);
953
978
  return;
954
979
  }
955
980
 
@@ -1002,6 +1027,8 @@ function busAutoRoute(msg) {
1002
1027
  source_host: MACHINE_ID,
1003
1028
  target_agent: targetId,
1004
1029
  source_type: 'bus_auto_route',
1030
+ turn_id: (msg.payload && msg.payload.turn_id) || null,
1031
+ original_message_id: msg.message_id || null,
1005
1032
  delivered,
1006
1033
  timestamp: new Date().toISOString()
1007
1034
  });
@@ -1327,6 +1354,21 @@ wss.on('connection', (ws, req) => {
1327
1354
  const url = new URL(req.url, 'http://' + req.headers.host);
1328
1355
  const sessionId = url.pathname.split('/').pop();
1329
1356
  const session = sessions[sessionId];
1357
+ // ?owner=1 indicates the allow bridge (PTY owner), not an attach viewer
1358
+ const isOwnerConnect = url.searchParams.get('owner') === '1';
1359
+
1360
+ // Ping/pong heartbeat — detect and terminate stale TCP half-open connections (30s interval)
1361
+ let isAlive = true;
1362
+ ws.on('pong', () => { isAlive = true; });
1363
+ const pingInterval = setInterval(() => {
1364
+ if (!isAlive) {
1365
+ console.log(`[WS] Terminating stale connection (no pong) for ${sessionId}`);
1366
+ ws.terminate();
1367
+ return;
1368
+ }
1369
+ isAlive = false;
1370
+ ws.ping();
1371
+ }, 30000);
1330
1372
 
1331
1373
  if (!session) {
1332
1374
  // Auto-register wrapped session on WS connect (supports reconnect after daemon restart)
@@ -1363,10 +1405,17 @@ wss.on('connection', (ws, req) => {
1363
1405
 
1364
1406
  const activeSession = sessions[sessionId];
1365
1407
 
1366
- // For wrapped sessions, first connector becomes the owner
1367
- if (activeSession.type === 'wrapped' && !activeSession.ownerWs) {
1408
+ // For wrapped sessions, first connector OR explicit ?owner=1 claim becomes the owner.
1409
+ // ?owner=1 reclaim handles the stale-ownerWs bug: allow bridge reconnects but stale TCP
1410
+ // half-open connection still holds ownerWs slot → reconnect wrongly becomes a viewer.
1411
+ if (activeSession.type === 'wrapped' && (!activeSession.ownerWs || isOwnerConnect)) {
1412
+ if (isOwnerConnect && activeSession.ownerWs && activeSession.ownerWs !== ws) {
1413
+ // Terminate the stale owner connection before claiming ownership
1414
+ console.log(`[WS] Replacing stale ownerWs for session ${sessionId}`);
1415
+ activeSession.ownerWs.terminate();
1416
+ }
1368
1417
  activeSession.ownerWs = ws;
1369
- console.log(`[WS] Wrap owner connected for session ${sessionId} (Total: ${activeSession.clients.size})`);
1418
+ console.log(`[WS] Wrap owner ${isOwnerConnect && activeSession.clients.size > 1 ? 're-' : ''}connected for session ${sessionId} (Total: ${activeSession.clients.size})`);
1370
1419
  } else {
1371
1420
  console.log(`[WS] Client attached to session ${sessionId} (Total: ${activeSession.clients.size})`);
1372
1421
  }
@@ -1408,6 +1457,7 @@ wss.on('connection', (ws, req) => {
1408
1457
  });
1409
1458
 
1410
1459
  ws.on('close', () => {
1460
+ clearInterval(pingInterval);
1411
1461
  activeSession.clients.delete(ws);
1412
1462
  if (activeSession.type === 'wrapped' && ws === activeSession.ownerWs) {
1413
1463
  activeSession.ownerWs = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
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",
@@ -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 지원 검토