@dmsdc-ai/aigentry-telepty 0.1.88 → 0.1.90

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/cli.js CHANGED
@@ -18,6 +18,7 @@ const { formatHostLabel, groupSessionsByHost, pickSessionTarget } = require('./s
18
18
  const { buildSharedContextPrompt, createSharedContextDescriptor, ensureSharedContextFile } = require('./shared-context');
19
19
  const { runInteractiveSkillInstaller } = require('./skill-installer');
20
20
  const crossMachine = require('./cross-machine');
21
+ const { FileMailbox } = require('./src/mailbox/index');
21
22
  const args = process.argv.slice(2);
22
23
  let pendingTerminalInputError = null;
23
24
  let simulatedPromptErrorInjected = false;
@@ -356,6 +357,61 @@ function startDetachedDaemon() {
356
357
  cp.unref();
357
358
  }
358
359
 
360
+ async function waitForDaemonHealth(maxMs = 5000) {
361
+ const deadline = Date.now() + maxMs;
362
+ while (Date.now() < deadline) {
363
+ try {
364
+ const meta = await getDaemonMeta('127.0.0.1');
365
+ if (meta && meta.version) return meta;
366
+ } catch {}
367
+ await new Promise(r => setTimeout(r, 300));
368
+ }
369
+ return null;
370
+ }
371
+
372
+ async function restartDaemonGraceful(options = {}) {
373
+ const maxAttempts = options.maxAttempts || 3;
374
+ const requiredCapabilities = options.requiredCapabilities || [];
375
+
376
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
377
+ // (a) Kill existing daemon processes
378
+ const results = cleanupDaemonProcesses();
379
+
380
+ // (b) Wait up to 3s for old processes to fully exit
381
+ if (results.stopped.length > 0) {
382
+ const { isProcessRunning } = require('./daemon-control');
383
+ const killDeadline = Date.now() + 3000;
384
+ for (const item of results.stopped) {
385
+ while (Date.now() < killDeadline && isProcessRunning(item.pid)) {
386
+ await new Promise(r => setTimeout(r, 100));
387
+ }
388
+ }
389
+ }
390
+
391
+ // (c) Start new daemon
392
+ startDetachedDaemon();
393
+
394
+ // (d) Wait for new daemon to respond with correct version
395
+ const meta = await waitForDaemonHealth(5000);
396
+ if (meta && meta.version === pkg.version) {
397
+ const hasCapabilities = requiredCapabilities.every(c => (meta.capabilities || []).includes(c));
398
+ if (hasCapabilities || requiredCapabilities.length === 0) {
399
+ return { success: true, meta, attempt };
400
+ }
401
+ }
402
+
403
+ // Retry with backoff
404
+ if (attempt < maxAttempts) {
405
+ const backoff = 1000 * attempt;
406
+ process.stdout.write(`\x1b[33m⚠️ Daemon restart attempt ${attempt}/${maxAttempts} failed. Retrying in ${backoff / 1000}s...\x1b[0m\n`);
407
+ await new Promise(r => setTimeout(r, backoff));
408
+ }
409
+ }
410
+
411
+ console.error(`\x1b[31m❌ Daemon restart failed after ${maxAttempts} attempts. Run "telepty daemon" manually to start.\x1b[0m`);
412
+ return { success: false, meta: null, attempt: maxAttempts };
413
+ }
414
+
359
415
  function renderInteractiveHeader() {
360
416
  const runtimeInfo = getRuntimeInfo(__dirname);
361
417
  console.clear();
@@ -402,14 +458,13 @@ async function repairLocalDaemon(options = {}) {
402
458
  return { stopped: results.stopped.length, failed: results.failed.length, meta: null };
403
459
  }
404
460
 
405
- startDetachedDaemon();
406
- await new Promise((resolve) => setTimeout(resolve, 1000));
407
- const meta = await getDaemonMeta('127.0.0.1');
408
- const versionMatch = meta && meta.version === pkg.version;
409
- if (meta && !versionMatch) {
410
- process.stdout.write(`\x1b[33m⚠️ Daemon restarted but version mismatch (running v${meta.version}, expected v${pkg.version})\x1b[0m\n`);
411
- }
412
- return { stopped: results.stopped.length, failed: results.failed.length, meta, versionMatch };
461
+ const restartResult = await restartDaemonGraceful();
462
+ return {
463
+ stopped: results.stopped.length,
464
+ failed: results.failed.length,
465
+ meta: restartResult.meta,
466
+ versionMatch: restartResult.success
467
+ };
413
468
  }
414
469
 
415
470
  function getDiscoveryHosts() {
@@ -476,30 +531,22 @@ async function ensureDaemonRunning(options = {}) {
476
531
  // Version mismatch: running daemon is older than installed CLI
477
532
  if (meta && meta.version !== pkg.version) {
478
533
  process.stdout.write(`\x1b[33m⚙️ Daemon version mismatch (running v${meta.version}, installed v${pkg.version}). Restarting...\x1b[0m\n`);
479
- cleanupDaemonProcesses();
534
+ await restartDaemonGraceful({ requiredCapabilities });
535
+ return;
480
536
  } else {
481
537
  return;
482
538
  }
483
539
  } else if (sessionsRes.ok && !meta) {
484
540
  process.stdout.write('\x1b[33m⚙️ Found an older local telepty daemon. Restarting it...\x1b[0m\n');
485
- cleanupDaemonProcesses();
486
541
  } else if (sessionsRes.ok && meta) {
487
542
  process.stdout.write('\x1b[33m⚙️ Found a local telepty daemon without the required features. Restarting it...\x1b[0m\n');
488
- cleanupDaemonProcesses();
489
543
  }
490
544
  } catch (e) {
491
545
  // Continue to auto-start below.
492
546
  }
493
547
 
494
548
  process.stdout.write('\x1b[33m⚙️ Auto-starting local telepty daemon...\x1b[0m\n');
495
- cleanupDaemonProcesses();
496
- startDetachedDaemon();
497
- await new Promise(r => setTimeout(r, 1000));
498
-
499
- const meta = await getDaemonMeta('127.0.0.1');
500
- if (!meta || !requiredCapabilities.every((item) => meta.capabilities.includes(item))) {
501
- console.error('❌ Failed to start a compatible local telepty daemon. Open telepty and choose "Repair local daemon", or rerun the installer.');
502
- }
549
+ await restartDaemonGraceful({ requiredCapabilities });
503
550
  }
504
551
 
505
552
  async function manageInteractiveAttach(sessionId, targetHost) {
@@ -1000,10 +1047,41 @@ async function main() {
1000
1047
  const cmdBase = path.basename(command).replace(/\..*$/, '');
1001
1048
  const promptPattern = PROMPT_PATTERNS[cmdBase] || /[❯>$#%]\s*$/;
1002
1049
  let promptReady = false; // wait for CLI prompt before accepting inject
1003
- const injectQueue = [];
1004
1050
  let lastUserInputTime = 0; // timestamp of last user keystroke
1005
1051
  const IDLE_THRESHOLD = 2000; // ms after last user input to consider idle
1006
1052
 
1053
+ // Mailbox-backed inject queue (replaces in-memory array for crash resilience)
1054
+ const bridgeMailbox = new FileMailbox({
1055
+ root: path.join(os.homedir(), '.aigentry', 'mailbox', 'bridge'),
1056
+ });
1057
+ const bridgeTarget = sessionId;
1058
+ let bridgeMsgSeq = 0;
1059
+ let bridgePendingCount = 0;
1060
+
1061
+ // Recover undelivered messages from a previous crash
1062
+ try {
1063
+ const leftover = bridgeMailbox.peek(bridgeTarget).filter(m => m.state === 'pending' || m.state === 'in_flight');
1064
+ bridgePendingCount = leftover.length;
1065
+ if (bridgePendingCount > 0) {
1066
+ console.log(`\x1b[33m[BRIDGE] Recovered ${bridgePendingCount} undelivered message(s) from previous session\x1b[0m`);
1067
+ }
1068
+ } catch {}
1069
+
1070
+ function enqueueBridgeMessage(text) {
1071
+ const msgId = `${sessionId}:${Date.now()}:${++bridgeMsgSeq}`;
1072
+ try {
1073
+ bridgeMailbox.enqueue({
1074
+ msg_id: msgId, from: 'daemon', to: bridgeTarget,
1075
+ payload: text, created_at: Math.floor(Date.now() / 1000), attempt: 0,
1076
+ });
1077
+ bridgePendingCount++;
1078
+ } catch (err) {
1079
+ // Fallback: write directly if mailbox fails
1080
+ console.error(`[BRIDGE] Mailbox enqueue failed, writing directly: ${err.message}`);
1081
+ child.write(text);
1082
+ }
1083
+ }
1084
+
1007
1085
  function isIdle() {
1008
1086
  return promptReady && (Date.now() - lastUserInputTime > IDLE_THRESHOLD);
1009
1087
  }
@@ -1011,34 +1089,47 @@ async function main() {
1011
1089
  let queueFlushTimer = null;
1012
1090
  let idleCheckTimer = null;
1013
1091
 
1014
- function flushInjectQueue() {
1092
+ function flushBridgeMailbox() {
1015
1093
  if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
1016
1094
  if (idleCheckTimer) { clearInterval(idleCheckTimer); idleCheckTimer = null; }
1017
- if (injectQueue.length === 0) return;
1018
- const batch = injectQueue.splice(0);
1095
+ if (bridgePendingCount === 0) return;
1019
1096
  let delay = 0;
1020
- for (const item of batch) {
1021
- setTimeout(() => child.write(item), delay);
1022
- delay += item === '\r' ? 0 : 100;
1023
- }
1097
+ const batch = [];
1098
+ // Dequeue all pending messages
1099
+ while (true) {
1100
+ const msg = bridgeMailbox.dequeue(bridgeTarget);
1101
+ if (!msg) break;
1102
+ batch.push(msg);
1103
+ }
1104
+ if (batch.length === 0) { bridgePendingCount = 0; return; }
1105
+ for (const msg of batch) {
1106
+ const text = msg.payload;
1107
+ const msgId = msg.msg_id;
1108
+ setTimeout(() => {
1109
+ child.write(text);
1110
+ try { bridgeMailbox.ack(bridgeTarget, msgId); } catch {}
1111
+ }, delay);
1112
+ delay += text === '\r' ? 0 : 100;
1113
+ }
1114
+ bridgePendingCount = Math.max(0, bridgePendingCount - batch.length);
1024
1115
  promptReady = false;
1025
1116
  }
1026
1117
  function scheduleIdleFlush() {
1027
1118
  if (idleCheckTimer) return;
1028
1119
  // Poll every 500ms for idle state
1029
1120
  idleCheckTimer = setInterval(() => {
1030
- if (isIdle() && injectQueue.length > 0) {
1031
- flushInjectQueue();
1121
+ if (isIdle() && bridgePendingCount > 0) {
1122
+ flushBridgeMailbox();
1032
1123
  }
1033
1124
  }, 500);
1034
- // Safety: flush after 15s regardless (prevent stuck queue)
1125
+ // Safety: flush after 5s regardless (prevent stuck queue when prompt not detected)
1035
1126
  if (!queueFlushTimer) {
1036
1127
  queueFlushTimer = setTimeout(() => {
1037
1128
  queueFlushTimer = null;
1038
- if (injectQueue.length > 0) {
1039
- flushInjectQueue();
1129
+ if (bridgePendingCount > 0) {
1130
+ flushBridgeMailbox();
1040
1131
  }
1041
- }, 15000);
1132
+ }, 5000);
1042
1133
  }
1043
1134
  }
1044
1135
 
@@ -1104,11 +1195,11 @@ async function main() {
1104
1195
  }
1105
1196
 
1106
1197
  const isCr = chunk === '\r';
1107
- if (isCr && injectQueue.length > 0) {
1198
+ if (isCr && bridgePendingCount > 0) {
1108
1199
  // CR with pending queued text — queue CR too and flush immediately.
1109
- injectQueue.push(chunk);
1200
+ enqueueBridgeMessage(chunk);
1110
1201
  if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
1111
- flushInjectQueue();
1202
+ flushBridgeMailbox();
1112
1203
  } else if (isCr) {
1113
1204
  // CR always written immediately — never idle-gated.
1114
1205
  child.write(chunk);
@@ -1119,7 +1210,7 @@ async function main() {
1119
1210
  lastInjectTextTime = Date.now();
1120
1211
  } else {
1121
1212
  // Text when not idle — queue for safe delivery.
1122
- injectQueue.push(chunk);
1213
+ enqueueBridgeMessage(chunk);
1123
1214
  scheduleIdleFlush();
1124
1215
  }
1125
1216
  }
@@ -1184,6 +1275,8 @@ async function main() {
1184
1275
 
1185
1276
  allowSessionClosed = true;
1186
1277
  cleanupTerminal();
1278
+ // Purge bridge mailbox on clean exit (undelivered messages are stale)
1279
+ try { bridgeMailbox.purge(bridgeTarget); } catch {}
1187
1280
  process.stdout.write(`\x1b]0;\x07`);
1188
1281
  fetchWithAuth(`${DAEMON_URL}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }).catch(() => {});
1189
1282
  if (reconnectTimer) clearTimeout(reconnectTimer);
@@ -1220,7 +1313,7 @@ async function main() {
1220
1313
  // Detect prompt in output to enable inject delivery
1221
1314
  if (promptPattern.test(data)) {
1222
1315
  promptReady = true;
1223
- flushInjectQueue();
1316
+ flushBridgeMailbox();
1224
1317
  // Notify daemon that CLI is ready for inject
1225
1318
  if (!readyNotified && wsReady && daemonWs.readyState === 1) {
1226
1319
  readyNotified = true;
@@ -1257,7 +1350,7 @@ async function main() {
1257
1350
  }
1258
1351
  if (promptPattern.test(data)) {
1259
1352
  promptReady = true;
1260
- flushInjectQueue();
1353
+ flushBridgeMailbox();
1261
1354
  if (wsReady && daemonWs.readyState === 1) {
1262
1355
  daemonWs.send(JSON.stringify({ type: 'ready' }));
1263
1356
  }
@@ -1656,6 +1749,76 @@ async function main() {
1656
1749
  return;
1657
1750
  }
1658
1751
 
1752
+ if (cmd === 'status') {
1753
+ const statusSessionId = args[1] || process.env.TELEPTY_SESSION_ID;
1754
+ if (!statusSessionId) {
1755
+ console.error('❌ Usage: telepty status <session_id>');
1756
+ process.exit(1);
1757
+ }
1758
+
1759
+ try {
1760
+ const target = await resolveSessionTarget(statusSessionId);
1761
+ if (!target) {
1762
+ console.error(`❌ Session '${statusSessionId}' was not found on any discovered host.`);
1763
+ process.exit(1);
1764
+ }
1765
+
1766
+ const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/state`);
1767
+ const data = await res.json();
1768
+ if (!res.ok) {
1769
+ console.error(`❌ ${formatApiError(data)}`);
1770
+ process.exit(1);
1771
+ }
1772
+
1773
+ const auto = data.auto || {};
1774
+ const selfReport = data.self_report || {};
1775
+
1776
+ // Color-coded state display
1777
+ const stateColors = {
1778
+ running: '\x1b[32m', // green
1779
+ idle: '\x1b[33m', // yellow
1780
+ thinking: '\x1b[36m', // cyan
1781
+ stuck: '\x1b[31m', // red
1782
+ waiting_input: '\x1b[35m', // magenta
1783
+ };
1784
+ const stateColor = stateColors[auto.state] || '\x1b[37m';
1785
+ const reset = '\x1b[0m';
1786
+
1787
+ console.log(`\n Session: \x1b[36m${data.session_id}${reset}`);
1788
+ console.log(` Auto State: ${stateColor}${auto.state || 'unknown'}${reset} (confidence: ${auto.confidence != null ? (auto.confidence * 100).toFixed(0) + '%' : '?'})`);
1789
+ if (auto.since) {
1790
+ const durationMs = auto.duration_ms || 0;
1791
+ const durationStr = durationMs < 60000
1792
+ ? `${(durationMs / 1000).toFixed(0)}s`
1793
+ : `${(durationMs / 60000).toFixed(1)}m`;
1794
+ console.log(` Since: ${auto.since} (${durationStr} ago)`);
1795
+ }
1796
+ if (auto.detail) {
1797
+ console.log(` Trigger: ${auto.detail.trigger || '-'}`);
1798
+ if (auto.detail.matched_line) console.log(` Matched: "${auto.detail.matched_line}"`);
1799
+ if (auto.detail.silence_ms) console.log(` Silence: ${(auto.detail.silence_ms / 1000).toFixed(1)}s`);
1800
+ if (auto.detail.repeat_count) console.log(` Error repeats: ${auto.detail.repeat_count}`);
1801
+ }
1802
+ if (auto.last_output_preview) {
1803
+ const preview = auto.last_output_preview.replace(/\n/g, '\\n').slice(-80);
1804
+ console.log(` Last output: "${preview}"`);
1805
+ }
1806
+
1807
+ if (selfReport.phase) {
1808
+ console.log(`\n Self-report:`);
1809
+ console.log(` Phase: ${selfReport.phase}`);
1810
+ if (selfReport.current_task) console.log(` Task: ${selfReport.current_task}`);
1811
+ if (selfReport.blocker) console.log(` Blocker: \x1b[31m${selfReport.blocker}${reset}`);
1812
+ if (selfReport.needs_input) console.log(` Needs input: \x1b[35myes${reset}`);
1813
+ }
1814
+ console.log('');
1815
+ } catch (e) {
1816
+ console.error(`❌ ${e.message || 'Failed to get session state.'}`);
1817
+ process.exit(1);
1818
+ }
1819
+ return;
1820
+ }
1821
+
1659
1822
  if (cmd === 'status-report') {
1660
1823
  const reportArgs = args.slice(1);
1661
1824
  let sessionId = process.env.TELEPTY_SESSION_ID || undefined;