@dmsdc-ai/aigentry-telepty 0.1.88 → 0.1.89

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;
@@ -1000,10 +1001,41 @@ async function main() {
1000
1001
  const cmdBase = path.basename(command).replace(/\..*$/, '');
1001
1002
  const promptPattern = PROMPT_PATTERNS[cmdBase] || /[❯>$#%]\s*$/;
1002
1003
  let promptReady = false; // wait for CLI prompt before accepting inject
1003
- const injectQueue = [];
1004
1004
  let lastUserInputTime = 0; // timestamp of last user keystroke
1005
1005
  const IDLE_THRESHOLD = 2000; // ms after last user input to consider idle
1006
1006
 
1007
+ // Mailbox-backed inject queue (replaces in-memory array for crash resilience)
1008
+ const bridgeMailbox = new FileMailbox({
1009
+ root: path.join(os.homedir(), '.aigentry', 'mailbox', 'bridge'),
1010
+ });
1011
+ const bridgeTarget = sessionId;
1012
+ let bridgeMsgSeq = 0;
1013
+ let bridgePendingCount = 0;
1014
+
1015
+ // Recover undelivered messages from a previous crash
1016
+ try {
1017
+ const leftover = bridgeMailbox.peek(bridgeTarget).filter(m => m.state === 'pending' || m.state === 'in_flight');
1018
+ bridgePendingCount = leftover.length;
1019
+ if (bridgePendingCount > 0) {
1020
+ console.log(`\x1b[33m[BRIDGE] Recovered ${bridgePendingCount} undelivered message(s) from previous session\x1b[0m`);
1021
+ }
1022
+ } catch {}
1023
+
1024
+ function enqueueBridgeMessage(text) {
1025
+ const msgId = `${sessionId}:${Date.now()}:${++bridgeMsgSeq}`;
1026
+ try {
1027
+ bridgeMailbox.enqueue({
1028
+ msg_id: msgId, from: 'daemon', to: bridgeTarget,
1029
+ payload: text, created_at: Math.floor(Date.now() / 1000), attempt: 0,
1030
+ });
1031
+ bridgePendingCount++;
1032
+ } catch (err) {
1033
+ // Fallback: write directly if mailbox fails
1034
+ console.error(`[BRIDGE] Mailbox enqueue failed, writing directly: ${err.message}`);
1035
+ child.write(text);
1036
+ }
1037
+ }
1038
+
1007
1039
  function isIdle() {
1008
1040
  return promptReady && (Date.now() - lastUserInputTime > IDLE_THRESHOLD);
1009
1041
  }
@@ -1011,34 +1043,47 @@ async function main() {
1011
1043
  let queueFlushTimer = null;
1012
1044
  let idleCheckTimer = null;
1013
1045
 
1014
- function flushInjectQueue() {
1046
+ function flushBridgeMailbox() {
1015
1047
  if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
1016
1048
  if (idleCheckTimer) { clearInterval(idleCheckTimer); idleCheckTimer = null; }
1017
- if (injectQueue.length === 0) return;
1018
- const batch = injectQueue.splice(0);
1049
+ if (bridgePendingCount === 0) return;
1019
1050
  let delay = 0;
1020
- for (const item of batch) {
1021
- setTimeout(() => child.write(item), delay);
1022
- delay += item === '\r' ? 0 : 100;
1023
- }
1051
+ const batch = [];
1052
+ // Dequeue all pending messages
1053
+ while (true) {
1054
+ const msg = bridgeMailbox.dequeue(bridgeTarget);
1055
+ if (!msg) break;
1056
+ batch.push(msg);
1057
+ }
1058
+ if (batch.length === 0) { bridgePendingCount = 0; return; }
1059
+ for (const msg of batch) {
1060
+ const text = msg.payload;
1061
+ const msgId = msg.msg_id;
1062
+ setTimeout(() => {
1063
+ child.write(text);
1064
+ try { bridgeMailbox.ack(bridgeTarget, msgId); } catch {}
1065
+ }, delay);
1066
+ delay += text === '\r' ? 0 : 100;
1067
+ }
1068
+ bridgePendingCount = Math.max(0, bridgePendingCount - batch.length);
1024
1069
  promptReady = false;
1025
1070
  }
1026
1071
  function scheduleIdleFlush() {
1027
1072
  if (idleCheckTimer) return;
1028
1073
  // Poll every 500ms for idle state
1029
1074
  idleCheckTimer = setInterval(() => {
1030
- if (isIdle() && injectQueue.length > 0) {
1031
- flushInjectQueue();
1075
+ if (isIdle() && bridgePendingCount > 0) {
1076
+ flushBridgeMailbox();
1032
1077
  }
1033
1078
  }, 500);
1034
- // Safety: flush after 15s regardless (prevent stuck queue)
1079
+ // Safety: flush after 5s regardless (prevent stuck queue when prompt not detected)
1035
1080
  if (!queueFlushTimer) {
1036
1081
  queueFlushTimer = setTimeout(() => {
1037
1082
  queueFlushTimer = null;
1038
- if (injectQueue.length > 0) {
1039
- flushInjectQueue();
1083
+ if (bridgePendingCount > 0) {
1084
+ flushBridgeMailbox();
1040
1085
  }
1041
- }, 15000);
1086
+ }, 5000);
1042
1087
  }
1043
1088
  }
1044
1089
 
@@ -1104,11 +1149,11 @@ async function main() {
1104
1149
  }
1105
1150
 
1106
1151
  const isCr = chunk === '\r';
1107
- if (isCr && injectQueue.length > 0) {
1152
+ if (isCr && bridgePendingCount > 0) {
1108
1153
  // CR with pending queued text — queue CR too and flush immediately.
1109
- injectQueue.push(chunk);
1154
+ enqueueBridgeMessage(chunk);
1110
1155
  if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
1111
- flushInjectQueue();
1156
+ flushBridgeMailbox();
1112
1157
  } else if (isCr) {
1113
1158
  // CR always written immediately — never idle-gated.
1114
1159
  child.write(chunk);
@@ -1119,7 +1164,7 @@ async function main() {
1119
1164
  lastInjectTextTime = Date.now();
1120
1165
  } else {
1121
1166
  // Text when not idle — queue for safe delivery.
1122
- injectQueue.push(chunk);
1167
+ enqueueBridgeMessage(chunk);
1123
1168
  scheduleIdleFlush();
1124
1169
  }
1125
1170
  }
@@ -1184,6 +1229,8 @@ async function main() {
1184
1229
 
1185
1230
  allowSessionClosed = true;
1186
1231
  cleanupTerminal();
1232
+ // Purge bridge mailbox on clean exit (undelivered messages are stale)
1233
+ try { bridgeMailbox.purge(bridgeTarget); } catch {}
1187
1234
  process.stdout.write(`\x1b]0;\x07`);
1188
1235
  fetchWithAuth(`${DAEMON_URL}/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }).catch(() => {});
1189
1236
  if (reconnectTimer) clearTimeout(reconnectTimer);
@@ -1220,7 +1267,7 @@ async function main() {
1220
1267
  // Detect prompt in output to enable inject delivery
1221
1268
  if (promptPattern.test(data)) {
1222
1269
  promptReady = true;
1223
- flushInjectQueue();
1270
+ flushBridgeMailbox();
1224
1271
  // Notify daemon that CLI is ready for inject
1225
1272
  if (!readyNotified && wsReady && daemonWs.readyState === 1) {
1226
1273
  readyNotified = true;
@@ -1257,7 +1304,7 @@ async function main() {
1257
1304
  }
1258
1305
  if (promptPattern.test(data)) {
1259
1306
  promptReady = true;
1260
- flushInjectQueue();
1307
+ flushBridgeMailbox();
1261
1308
  if (wsReady && daemonWs.readyState === 1) {
1262
1309
  daemonWs.send(JSON.stringify({ type: 'ready' }));
1263
1310
  }
@@ -1656,6 +1703,76 @@ async function main() {
1656
1703
  return;
1657
1704
  }
1658
1705
 
1706
+ if (cmd === 'status') {
1707
+ const statusSessionId = args[1] || process.env.TELEPTY_SESSION_ID;
1708
+ if (!statusSessionId) {
1709
+ console.error('❌ Usage: telepty status <session_id>');
1710
+ process.exit(1);
1711
+ }
1712
+
1713
+ try {
1714
+ const target = await resolveSessionTarget(statusSessionId);
1715
+ if (!target) {
1716
+ console.error(`❌ Session '${statusSessionId}' was not found on any discovered host.`);
1717
+ process.exit(1);
1718
+ }
1719
+
1720
+ const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/state`);
1721
+ const data = await res.json();
1722
+ if (!res.ok) {
1723
+ console.error(`❌ ${formatApiError(data)}`);
1724
+ process.exit(1);
1725
+ }
1726
+
1727
+ const auto = data.auto || {};
1728
+ const selfReport = data.self_report || {};
1729
+
1730
+ // Color-coded state display
1731
+ const stateColors = {
1732
+ running: '\x1b[32m', // green
1733
+ idle: '\x1b[33m', // yellow
1734
+ thinking: '\x1b[36m', // cyan
1735
+ stuck: '\x1b[31m', // red
1736
+ waiting_input: '\x1b[35m', // magenta
1737
+ };
1738
+ const stateColor = stateColors[auto.state] || '\x1b[37m';
1739
+ const reset = '\x1b[0m';
1740
+
1741
+ console.log(`\n Session: \x1b[36m${data.session_id}${reset}`);
1742
+ console.log(` Auto State: ${stateColor}${auto.state || 'unknown'}${reset} (confidence: ${auto.confidence != null ? (auto.confidence * 100).toFixed(0) + '%' : '?'})`);
1743
+ if (auto.since) {
1744
+ const durationMs = auto.duration_ms || 0;
1745
+ const durationStr = durationMs < 60000
1746
+ ? `${(durationMs / 1000).toFixed(0)}s`
1747
+ : `${(durationMs / 60000).toFixed(1)}m`;
1748
+ console.log(` Since: ${auto.since} (${durationStr} ago)`);
1749
+ }
1750
+ if (auto.detail) {
1751
+ console.log(` Trigger: ${auto.detail.trigger || '-'}`);
1752
+ if (auto.detail.matched_line) console.log(` Matched: "${auto.detail.matched_line}"`);
1753
+ if (auto.detail.silence_ms) console.log(` Silence: ${(auto.detail.silence_ms / 1000).toFixed(1)}s`);
1754
+ if (auto.detail.repeat_count) console.log(` Error repeats: ${auto.detail.repeat_count}`);
1755
+ }
1756
+ if (auto.last_output_preview) {
1757
+ const preview = auto.last_output_preview.replace(/\n/g, '\\n').slice(-80);
1758
+ console.log(` Last output: "${preview}"`);
1759
+ }
1760
+
1761
+ if (selfReport.phase) {
1762
+ console.log(`\n Self-report:`);
1763
+ console.log(` Phase: ${selfReport.phase}`);
1764
+ if (selfReport.current_task) console.log(` Task: ${selfReport.current_task}`);
1765
+ if (selfReport.blocker) console.log(` Blocker: \x1b[31m${selfReport.blocker}${reset}`);
1766
+ if (selfReport.needs_input) console.log(` Needs input: \x1b[35myes${reset}`);
1767
+ }
1768
+ console.log('');
1769
+ } catch (e) {
1770
+ console.error(`❌ ${e.message || 'Failed to get session state.'}`);
1771
+ process.exit(1);
1772
+ }
1773
+ return;
1774
+ }
1775
+
1659
1776
  if (cmd === 'status-report') {
1660
1777
  const reportArgs = args.slice(1);
1661
1778
  let sessionId = process.env.TELEPTY_SESSION_ID || undefined;
package/daemon.js CHANGED
@@ -9,6 +9,10 @@ const pkg = require('./package.json');
9
9
  const { claimDaemonState, clearDaemonState } = require('./daemon-control');
10
10
  const { checkEntitlement } = require('./entitlement');
11
11
  const terminalBackend = require('./terminal-backend');
12
+ const { FileMailbox } = require('./src/mailbox/index');
13
+ const { DeliveryEngine } = require('./src/mailbox/delivery');
14
+ const { UnixSocketNotifier } = require('./src/mailbox/notifier');
15
+ const { SessionStateManager } = require('./session-state');
12
16
 
13
17
  const config = getConfig();
14
18
  const EXPECTED_TOKEN = config.authToken;
@@ -21,6 +25,23 @@ const SESSION_CLEANUP_SECONDS = Math.max(SESSION_STALE_SECONDS, Number(process.e
21
25
  const DELIVERY_TIMEOUT_MS = Math.max(100, Number(process.env.TELEPTY_DELIVERY_TIMEOUT_MS || 5000));
22
26
  const HEALTH_POLL_MS = Math.max(100, Number(process.env.TELEPTY_HEALTH_POLL_MS || 10000));
23
27
 
28
+ // Session state machine manager — auto-detects session state from PTY output
29
+ const sessionStateManager = new SessionStateManager({
30
+ idle_timeout_ms: Number(process.env.TELEPTY_STATE_IDLE_TIMEOUT_MS || 5000),
31
+ stuck_repeat_count: Number(process.env.TELEPTY_STATE_STUCK_REPEAT_COUNT || 3),
32
+ stuck_window_ms: Number(process.env.TELEPTY_STATE_STUCK_WINDOW_MS || 180000),
33
+ thinking_timeout_ms:Number(process.env.TELEPTY_STATE_THINKING_TIMEOUT_MS || 300000),
34
+ });
35
+
36
+ // Broadcast state transitions to the bus
37
+ sessionStateManager.onTransition((sessionId, from, to, detail) => {
38
+ const session = sessions[sessionId];
39
+ if (!session) return;
40
+ broadcastSessionEvent('session_auto_state', sessionId, session, {
41
+ extra: { auto_state: to, auto_state_from: from, auto_detail: detail }
42
+ });
43
+ });
44
+
24
45
  function persistSessions() {
25
46
  try {
26
47
  const data = {};
@@ -227,12 +248,9 @@ function getSessionHealthStatus(session, options = {}) {
227
248
  const isSocketPath = endpoint.startsWith('/');
228
249
  if (isSocketPath) {
229
250
  try {
230
- fs.accessSync(endpoint, fs.constants.F_OK);
231
- return 'CONNECTED';
251
+ const stat = fs.statSync(endpoint);
252
+ return stat.isSocket() ? 'CONNECTED' : 'DISCONNECTED';
232
253
  } catch {
233
- session.deliveryEndpoint = null;
234
- if (session.delivery) session.delivery.address = null;
235
- session.lastDisconnectedAt = session.lastDisconnectedAt || new Date().toISOString();
236
254
  return 'DISCONNECTED';
237
255
  }
238
256
  }
@@ -461,6 +479,7 @@ async function writeDataToSession(id, session, data) {
461
479
  try {
462
480
  const resp = JSON.parse(responseBuf.trim());
463
481
  if (resp.status === 'Error' || resp.success === false) {
482
+ console.log(`[UDS] Delivery rejected by ${id}: ${resp.error || resp.message || 'unknown'}`);
464
483
  resolve(buildErrorBody('DELIVERY_REJECTED', resp.error || resp.message || 'Target rejected the payload.', {
465
484
  httpStatus: 502,
466
485
  detail: resp
@@ -470,11 +489,15 @@ async function writeDataToSession(id, session, data) {
470
489
  } catch {
471
490
  // Non-JSON response — treat as success (legacy endpoints)
472
491
  }
492
+ } else {
493
+ console.log(`[UDS] Empty response from ${id} — delivery unconfirmed (aterm may not have processed)`);
473
494
  }
474
495
  resolve({ success: true });
475
496
  });
476
497
  sock.on('error', (err) => {
477
498
  clearTimeout(timeout);
499
+ console.log(`[UDS] Connection error for ${id} at ${session.delivery.address}: ${err.message}`);
500
+ markSessionDisconnected(session);
478
501
  resolve(buildErrorBody('DISCONNECTED', 'UDS endpoint is unreachable.', {
479
502
  httpStatus: 503,
480
503
  detail: err.message
@@ -538,34 +561,67 @@ async function deliverInjectionToSession(id, session, prompt, options = {}) {
538
561
  return { success: false, ...injectFailure };
539
562
  }
540
563
 
541
- const textResult = await writeDataToSession(id, session, prompt);
542
- if (!textResult.success) {
543
- return textResult;
544
- }
564
+ // Build the payload: text + CR for non-aterm sessions (aterm handles Enter internally)
565
+ const payload = (!options.noEnter && session.type !== 'aterm')
566
+ ? prompt + '\r'
567
+ : prompt;
545
568
 
546
- if (!options.noEnter && session.type !== 'aterm') {
547
- const submitDelay = session.type === 'wrapped' ? 500 : 300;
548
- setTimeout(async () => {
549
- const submitResult = await writeDataToSession(id, session, '\r');
550
- if (!submitResult.success) {
551
- emitInjectFailureEvent(id, submitResult.code, submitResult.error, {
552
- phase: 'submit',
553
- source: options.source || 'inject'
554
- }, session);
555
- }
556
- }, submitDelay);
557
- }
569
+ const from = options.from || 'daemon';
570
+ const msgId = `${from}:${Date.now()}:${crypto.randomUUID().slice(0, 8)}`;
558
571
 
559
- session.lastActivityAt = new Date(now).toISOString();
560
- return {
561
- success: true,
562
- strategy: session.type === 'wrapped'
563
- ? 'ws_split_cr'
564
- : session.type === 'aterm'
565
- ? (session.delivery && session.delivery.transport === 'unix_socket' ? 'aterm_uds' : 'aterm_endpoint')
566
- : 'pty_split_cr',
567
- submit: options.noEnter ? 'skipped' : 'deferred'
568
- };
572
+ try {
573
+ const ack = mailbox.enqueue({
574
+ msg_id: msgId,
575
+ from,
576
+ to: id,
577
+ payload,
578
+ created_at: Math.floor(now / 1000),
579
+ attempt: 0,
580
+ });
581
+
582
+ // Notify aterm sessions immediately via UDS wake
583
+ if (session.type === 'aterm') {
584
+ mailboxNotifier.notify(id);
585
+ }
586
+
587
+ // Trigger immediate delivery tick for low-latency
588
+ mailboxDelivery.tick().catch(() => {});
589
+
590
+ session.lastActivityAt = new Date(now).toISOString();
591
+ return {
592
+ success: true,
593
+ msg_id: msgId,
594
+ queued: ack.queued,
595
+ pending: ack.pending,
596
+ strategy: 'mailbox',
597
+ submit: options.noEnter ? 'skipped' : 'included'
598
+ };
599
+ } catch (err) {
600
+ console.error(`[MAILBOX] Enqueue failed for ${id}: ${err.message}`);
601
+ // Fallback: direct delivery (backward compat during migration)
602
+ const textResult = await writeDataToSession(id, session, prompt);
603
+ if (!textResult.success) return textResult;
604
+
605
+ if (!options.noEnter && session.type !== 'aterm') {
606
+ const submitDelay = session.type === 'wrapped' ? 500 : 300;
607
+ setTimeout(async () => {
608
+ const submitResult = await writeDataToSession(id, session, '\r');
609
+ if (!submitResult.success) {
610
+ emitInjectFailureEvent(id, submitResult.code, submitResult.error, {
611
+ phase: 'submit',
612
+ source: options.source || 'inject'
613
+ }, session);
614
+ }
615
+ }, submitDelay);
616
+ }
617
+
618
+ session.lastActivityAt = new Date(now).toISOString();
619
+ return {
620
+ success: true,
621
+ strategy: 'direct_fallback',
622
+ submit: options.noEnter ? 'skipped' : 'deferred'
623
+ };
624
+ }
569
625
  }
570
626
 
571
627
  function appendToOutputRing(session, data) {
@@ -606,6 +662,7 @@ function serializeSession(id, session, options = {}) {
606
662
  const disconnectedMs = getSessionDisconnectedMs(session, nowMs);
607
663
  const transport = buildSessionTransportBlock(session, { nowMs });
608
664
  const semantic = buildSessionSemanticBlock(session);
665
+ const autoState = sessionStateManager.getState(id);
609
666
 
610
667
  return {
611
668
  id,
@@ -633,7 +690,15 @@ function serializeSession(id, session, options = {}) {
633
690
  disconnectedSeconds: disconnectedMs === null ? null : Math.floor(disconnectedMs / 1000),
634
691
  lastStateReportAt: session.lastStateReportAt || null,
635
692
  transport,
636
- semantic
693
+ semantic,
694
+ autoState: autoState ? { state: autoState.state, since: autoState.since, confidence: autoState.confidence } : null,
695
+ mailbox: (() => {
696
+ try {
697
+ const pending = mailbox.peek(id).filter(m => m.state === 'pending' || m.state === 'in_flight');
698
+ const deadLetter = mailbox.peekDeadLetter(id);
699
+ return { pending: pending.length, dead_letter: deadLetter.length };
700
+ } catch { return { pending: 0, dead_letter: 0 }; }
701
+ })()
637
702
  };
638
703
  }
639
704
 
@@ -797,6 +862,7 @@ app.post('/api/sessions/spawn', (req, res) => {
797
862
  }
798
863
 
799
864
  appendToOutputRing(currentSession, data);
865
+ sessionStateManager.feed(sessionRecord.id, data);
800
866
 
801
867
  // Send to direct WS clients
802
868
  currentSession.clients.forEach(ws => {
@@ -804,6 +870,9 @@ app.post('/api/sessions/spawn', (req, res) => {
804
870
  });
805
871
  });
806
872
 
873
+ // Register session with state machine
874
+ sessionStateManager.register(session_id);
875
+
807
876
  ptyProcess.onExit(({ exitCode, signal }) => {
808
877
  const currentId = sessionRecord.id;
809
878
  console.log(`[EXIT] Session ${currentId} exited with code ${exitCode}`);
@@ -811,6 +880,7 @@ app.post('/api/sessions/spawn', (req, res) => {
811
880
  sessionRecord.clients.forEach(ws => ws.close(1000, 'Session exited'));
812
881
  if (sessions[currentId] === sessionRecord) {
813
882
  delete sessions[currentId];
883
+ sessionStateManager.unregister(currentId);
814
884
  }
815
885
  });
816
886
 
@@ -908,6 +978,7 @@ app.post('/api/sessions/register', (req, res) => {
908
978
  }
909
979
 
910
980
  sessions[session_id] = sessionRecord;
981
+ sessionStateManager.register(session_id);
911
982
 
912
983
  const busMsg = JSON.stringify({
913
984
  type: 'session_register',
@@ -949,6 +1020,26 @@ app.get('/api/sessions/:id', (req, res) => {
949
1020
  });
950
1021
  });
951
1022
 
1023
+ // Auto-detected session state (from PTY output pattern analysis)
1024
+ app.get('/api/sessions/:id/state', (req, res) => {
1025
+ const requestedId = req.params.id;
1026
+ const resolvedId = resolveSessionAlias(requestedId);
1027
+ if (!resolvedId) return respondWithError(res, 404, 'SESSION_NOT_FOUND', 'Session not found', { requested: requestedId });
1028
+ if (!sessions[resolvedId]) return respondWithError(res, 404, 'SESSION_NOT_FOUND', 'Session not found', { requested: requestedId });
1029
+
1030
+ const autoState = sessionStateManager.getState(resolvedId);
1031
+ const session = sessions[resolvedId];
1032
+ const semantic = buildSessionSemanticBlock(session);
1033
+
1034
+ res.json({
1035
+ session_id: resolvedId,
1036
+ auto: autoState || { state: 'unknown', detail: 'no state machine registered' },
1037
+ self_report: semantic,
1038
+ last_state_report_at: session.lastStateReportAt || null,
1039
+ });
1040
+ });
1041
+
1042
+ // Self-reported session state (explicit POST from session)
952
1043
  app.post('/api/sessions/:id/state', (req, res) => {
953
1044
  const requestedId = req.params.id;
954
1045
  const resolvedId = resolveSessionAlias(requestedId);
@@ -977,10 +1068,59 @@ app.get('/api/meta', (req, res) => {
977
1068
  port: Number(PORT),
978
1069
  machine_id: MACHINE_ID,
979
1070
  terminal: DETECTED_TERMINAL,
980
- capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon', 'handoff-inbox', 'deliberation-threads', 'cross-machine']
1071
+ capabilities: ['sessions', 'wrapped-sessions', 'skill-installer', 'singleton-daemon', 'handoff-inbox', 'deliberation-threads', 'cross-machine', 'mailbox']
981
1072
  });
982
1073
  });
983
1074
 
1075
+ // --- Mailbox API endpoints ---
1076
+
1077
+ app.get('/api/sessions/:id/mailbox', (req, res) => {
1078
+ const id = resolveSessionAlias(req.params.id);
1079
+ if (!id || !sessions[id]) return res.status(404).json({ error: 'Session not found' });
1080
+ try {
1081
+ const pending = mailbox.peek(id);
1082
+ const deadLetter = mailbox.peekDeadLetter(id);
1083
+ res.json({ session_id: id, pending, dead_letter: deadLetter });
1084
+ } catch (err) {
1085
+ res.status(500).json({ error: err.message });
1086
+ }
1087
+ });
1088
+
1089
+ app.post('/api/sessions/:id/mailbox/ack', (req, res) => {
1090
+ const id = resolveSessionAlias(req.params.id);
1091
+ if (!id || !sessions[id]) return res.status(404).json({ error: 'Session not found' });
1092
+ const { msg_id } = req.body;
1093
+ if (!msg_id) return res.status(400).json({ error: 'msg_id is required' });
1094
+ try {
1095
+ mailbox.ack(id, msg_id);
1096
+ res.json({ success: true, msg_id });
1097
+ } catch (err) {
1098
+ res.status(500).json({ error: err.message });
1099
+ }
1100
+ });
1101
+
1102
+ app.delete('/api/sessions/:id/mailbox', (req, res) => {
1103
+ const id = resolveSessionAlias(req.params.id);
1104
+ if (!id || !sessions[id]) return res.status(404).json({ error: 'Session not found' });
1105
+ try {
1106
+ mailbox.purge(id);
1107
+ res.json({ success: true, session_id: id });
1108
+ } catch (err) {
1109
+ res.status(500).json({ error: err.message });
1110
+ }
1111
+ });
1112
+
1113
+ app.delete('/api/sessions/:id/mailbox/dead-letter', (req, res) => {
1114
+ const id = resolveSessionAlias(req.params.id);
1115
+ if (!id || !sessions[id]) return res.status(404).json({ error: 'Session not found' });
1116
+ try {
1117
+ mailbox.purgeDeadLetter(id);
1118
+ res.json({ success: true, session_id: id });
1119
+ } catch (err) {
1120
+ res.status(500).json({ error: err.message });
1121
+ }
1122
+ });
1123
+
984
1124
  // Peer management endpoint (for cross-machine module)
985
1125
  app.get('/api/peers', (req, res) => {
986
1126
  try {
@@ -1336,7 +1476,8 @@ app.post('/api/sessions/:id/inject', async (req, res) => {
1336
1476
  try {
1337
1477
  const delivery = await deliverInjectionToSession(id, session, finalPrompt, {
1338
1478
  noEnter: !!no_enter,
1339
- source: 'inject'
1479
+ source: 'inject',
1480
+ from: from || 'inject'
1340
1481
  });
1341
1482
  if (!delivery.success) {
1342
1483
  emitInjectFailureEvent(id, delivery.code, delivery.error, {
@@ -1475,9 +1616,11 @@ app.patch('/api/sessions/:id', (req, res) => {
1475
1616
  if (!new_id) return res.status(400).json({ error: 'new_id is required' });
1476
1617
  if (sessions[new_id]) return res.status(409).json({ error: `Session ID '${new_id}' is already in use.` });
1477
1618
 
1478
- // Move session to new key
1619
+ // Move session to new key (including state machine)
1479
1620
  sessions[new_id] = session;
1480
1621
  delete sessions[id];
1622
+ sessionStateManager.unregister(id);
1623
+ sessionStateManager.register(new_id);
1481
1624
  session.id = new_id;
1482
1625
 
1483
1626
  // Broadcast rename to bus
@@ -1511,12 +1654,16 @@ app.delete('/api/sessions/:id', (req, res) => {
1511
1654
  session.ptyProcess.kill();
1512
1655
  }
1513
1656
  delete sessions[id];
1657
+ sessionStateManager.unregister(id);
1658
+ try { mailbox.purge(id); } catch {}
1514
1659
  console.log(`[KILL] Session ${id} removed`);
1515
1660
  persistSessions();
1516
1661
  res.json({ success: true, status: 'closing' });
1517
1662
  } catch (err) {
1518
1663
  // Even if kill fails, remove from registry
1519
1664
  delete sessions[id];
1665
+ sessionStateManager.unregister(id);
1666
+ try { mailbox.purge(id); } catch {}
1520
1667
  persistSessions();
1521
1668
  console.log(`[KILL] Session ${id} force-removed (process cleanup error: ${err.message})`);
1522
1669
  res.json({ success: true, status: 'force-removed' });
@@ -1851,6 +1998,46 @@ const server = app.listen(PORT, HOST, () => {
1851
1998
  console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
1852
1999
  });
1853
2000
 
2001
+ // --- Mailbox system initialization ---
2002
+ const mailbox = new FileMailbox();
2003
+ const mailboxNotifier = new UnixSocketNotifier({ coalesceMs: 25 });
2004
+
2005
+ // Resolve aterm UDS socket path for a session
2006
+ mailboxNotifier.setSocketResolver((sessionId) => {
2007
+ const session = sessions[sessionId];
2008
+ if (!session || session.type !== 'aterm') return null;
2009
+ return (session.delivery && session.delivery.transport === 'unix_socket' && session.delivery.address) || null;
2010
+ });
2011
+
2012
+ // Delivery engine: dequeue → writeDataToSession → ack/nack
2013
+ const mailboxDelivery = new DeliveryEngine(mailbox, {
2014
+ pollMs: 200,
2015
+ sessionResolver: () => Object.keys(sessions),
2016
+ deliverFn: async (sessionId, msg) => {
2017
+ const session = sessions[sessionId];
2018
+ if (!session) return { success: false, error: 'Session not found' };
2019
+ const result = await writeDataToSession(sessionId, session, msg.payload);
2020
+ if (result.success) {
2021
+ session.lastActivityAt = new Date().toISOString();
2022
+ }
2023
+ return result;
2024
+ },
2025
+ onDelivery: (sessionId, msgId, result) => {
2026
+ const session = sessions[sessionId];
2027
+ if (!session) return;
2028
+ if (result.success) {
2029
+ broadcastSessionEvent('mailbox_delivered', sessionId, session, {
2030
+ extra: { msg_id: msgId }
2031
+ });
2032
+ } else {
2033
+ broadcastSessionEvent('mailbox_delivery_failed', sessionId, session, {
2034
+ extra: { msg_id: msgId, error: result.error }
2035
+ });
2036
+ }
2037
+ },
2038
+ });
2039
+ mailboxDelivery.start();
2040
+
1854
2041
  const IDLE_THRESHOLD_SECONDS = 60;
1855
2042
  setInterval(() => {
1856
2043
  const now = Date.now();
@@ -1912,18 +2099,24 @@ setInterval(() => {
1912
2099
  }
1913
2100
 
1914
2101
  // Periodically verify aterm socket existence — triggers health transition
2102
+ // NOTE: Do NOT nullify delivery address here. The address is preserved so that
2103
+ // if aterm restarts and the socket reappears, health check recovers automatically.
1915
2104
  if (session.type === 'aterm') {
1916
2105
  const atermEndpoint = session.deliveryEndpoint || (session.delivery && session.delivery.address);
1917
2106
  if (atermEndpoint && atermEndpoint.startsWith('/')) {
2107
+ let socketAlive = false;
1918
2108
  try {
1919
- fs.accessSync(atermEndpoint, fs.constants.F_OK);
2109
+ const stat = fs.statSync(atermEndpoint);
2110
+ socketAlive = stat.isSocket();
1920
2111
  } catch {
1921
- session.deliveryEndpoint = null;
1922
- if (session.delivery) session.delivery.address = null;
1923
- if (!session.lastDisconnectedAt) {
1924
- session.lastDisconnectedAt = new Date().toISOString();
1925
- }
2112
+ socketAlive = false;
2113
+ }
2114
+ if (!socketAlive && !session.lastDisconnectedAt) {
2115
+ markSessionDisconnected(session);
1926
2116
  console.log(`[SWEEP] aterm socket gone for ${id}: ${atermEndpoint}`);
2117
+ } else if (socketAlive && session.lastDisconnectedAt) {
2118
+ markSessionConnected(session);
2119
+ console.log(`[SWEEP] aterm socket recovered for ${id}: ${atermEndpoint}`);
1927
2120
  }
1928
2121
  }
1929
2122
  }
@@ -1947,6 +2140,7 @@ setInterval(() => {
1947
2140
  disconnectedSeconds
1948
2141
  });
1949
2142
  delete sessions[id];
2143
+ sessionStateManager.unregister(id);
1950
2144
  console.log(`[CLEANUP] Removed stale session ${id} after ${disconnectedSeconds}s disconnected`);
1951
2145
  persistSessions();
1952
2146
  }
@@ -2066,6 +2260,7 @@ wss.on('connection', (ws, req) => {
2066
2260
  if (type === 'output') {
2067
2261
  activeSession.lastActivityAt = new Date().toISOString();
2068
2262
  appendToOutputRing(activeSession, data);
2263
+ sessionStateManager.feed(sessionId, data);
2069
2264
  activeSession.clients.forEach(client => {
2070
2265
  if (client !== ws && client.readyState === 1) {
2071
2266
  client.send(JSON.stringify({ type: 'output', data }));
@@ -2210,6 +2405,8 @@ server.on('upgrade', (req, socket, head) => {
2210
2405
  });
2211
2406
 
2212
2407
  function shutdown(code) {
2408
+ mailboxDelivery.stop();
2409
+ mailboxNotifier.cancelAll();
2213
2410
  clearDaemonState(process.pid);
2214
2411
  process.exit(code);
2215
2412
  }