@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 +137 -20
- package/daemon.js +238 -41
- package/package.json +1 -1
- package/protocol/mailbox.md +244 -0
- package/session-state.js +496 -0
- package/src/mailbox/config.js +36 -0
- package/src/mailbox/delivery.js +132 -0
- package/src/mailbox/index.js +384 -0
- package/src/mailbox/notifier.js +103 -0
- package/src/mailbox/storage.js +185 -0
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
|
|
1046
|
+
function flushBridgeMailbox() {
|
|
1015
1047
|
if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
|
|
1016
1048
|
if (idleCheckTimer) { clearInterval(idleCheckTimer); idleCheckTimer = null; }
|
|
1017
|
-
if (
|
|
1018
|
-
const batch = injectQueue.splice(0);
|
|
1049
|
+
if (bridgePendingCount === 0) return;
|
|
1019
1050
|
let delay = 0;
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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() &&
|
|
1031
|
-
|
|
1075
|
+
if (isIdle() && bridgePendingCount > 0) {
|
|
1076
|
+
flushBridgeMailbox();
|
|
1032
1077
|
}
|
|
1033
1078
|
}, 500);
|
|
1034
|
-
// Safety: flush after
|
|
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 (
|
|
1039
|
-
|
|
1083
|
+
if (bridgePendingCount > 0) {
|
|
1084
|
+
flushBridgeMailbox();
|
|
1040
1085
|
}
|
|
1041
|
-
},
|
|
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 &&
|
|
1152
|
+
if (isCr && bridgePendingCount > 0) {
|
|
1108
1153
|
// CR with pending queued text — queue CR too and flush immediately.
|
|
1109
|
-
|
|
1154
|
+
enqueueBridgeMessage(chunk);
|
|
1110
1155
|
if (queueFlushTimer) { clearTimeout(queueFlushTimer); queueFlushTimer = null; }
|
|
1111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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.
|
|
2109
|
+
const stat = fs.statSync(atermEndpoint);
|
|
2110
|
+
socketAlive = stat.isSocket();
|
|
1920
2111
|
} catch {
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
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
|
}
|