@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/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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",