@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.
@@ -0,0 +1,496 @@
1
+ // session-state.js — PTY output-based session state machine for telepty.
2
+ //
3
+ // Automatically detects session state from PTY output patterns:
4
+ // running — PTY output actively flowing
5
+ // idle — no output for idle_timeout_ms + prompt pattern detected
6
+ // thinking — AI CLI spinner/progress patterns detected
7
+ // stuck — same error repeated stuck_repeat_count times within stuck_window_ms
8
+ // waiting_input— Y/n or interactive prompt pattern detected
9
+ //
10
+ // Usage:
11
+ // const { SessionStateMachine } = require('./session-state');
12
+ // const sm = new SessionStateMachine(sessionId, config);
13
+ // sm.feed(data); // call on every PTY output chunk
14
+ // sm.getState(); // → { state, since, confidence, last_output_preview, detail }
15
+ // sm.onTransition(callback); // (from, to, detail) => {}
16
+ // sm.destroy(); // cleanup timers
17
+
18
+ 'use strict';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // States
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const STATES = Object.freeze({
25
+ RUNNING: 'running',
26
+ IDLE: 'idle',
27
+ THINKING: 'thinking',
28
+ STUCK: 'stuck',
29
+ WAITING_INPUT: 'waiting_input',
30
+ });
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Default configurable thresholds
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const DEFAULT_CONFIG = Object.freeze({
37
+ idle_timeout_ms: 5000, // 5s silence + prompt → idle
38
+ stuck_repeat_count: 3, // same error N times → stuck
39
+ stuck_window_ms: 180000, // 3 min window for stuck detection
40
+ thinking_timeout_ms: 300000, // 5 min thinking before → stuck
41
+ poll_interval_ms: 1000, // state check tick interval
42
+ output_preview_len: 200, // last N chars for preview
43
+ error_dedup_len: 120, // error line length for dedup fingerprint
44
+ });
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Pattern sets (all terminal-agnostic, CLI-agnostic)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ // Shell prompt patterns — last line of output looks like a prompt
51
+ const PROMPT_PATTERNS = [
52
+ /[$#%>❯›»] *$/, // common shell prompts
53
+ />>> *$/, // python REPL
54
+ /\.\.\. *$/, // python continuation
55
+ /\(.*\) *[$#>] *$/, // virtualenv / conda prefix
56
+ /^\[.*@.*\][$#] *$/m, // [user@host]$
57
+ ];
58
+
59
+ // AI CLI thinking indicators (spinner frames, progress text)
60
+ const THINKING_PATTERNS = [
61
+ /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/, // braille spinner frames
62
+ /[⣾⣽⣻⢿⡿⣟⣯⣷]/, // braille spinner alt
63
+ /[|/\-\\]\s/, // classic spinner |/-\
64
+ /[◐◓◑◒]/, // circle spinner
65
+ /[⠁⠂⠄⡀⢀⠠⠐⠈]/, // dot spinner
66
+ /\bthinking\b/i, // literal "thinking"
67
+ /\banalyzing\b/i, // literal "analyzing"
68
+ /\bprocessing\b/i, // literal "processing"
69
+ /\bwriting\b/i, // Claude Code "Writing..."
70
+ /\breading\b/i, // Claude Code "Reading..."
71
+ /\bsearching\b/i, // Claude Code "Searching..."
72
+ /\bplanning\b/i, // Claude Code "Planning..."
73
+ /\.{3,}\s*$/, // trailing dots "..."
74
+ ];
75
+
76
+ // Interactive input prompts — session is waiting for user input
77
+ const WAITING_INPUT_PATTERNS = [
78
+ /\[Y\/n\]/i, // [Y/n]
79
+ /\(y\/N\)/i, // (y/N)
80
+ /\[yes\/no\]/i, // [yes/no]
81
+ /\bpress enter\b/i, // press enter
82
+ /\bcontinue\?\s*$/i, // Continue?
83
+ /\bproceed\?\s*$/i, // Proceed?
84
+ /\bconfirm\?\s*$/i, // Confirm?
85
+ /\boverwrite\?\s*$/i, // Overwrite?
86
+ /\(y\)\s*$/i, // (y)
87
+ /\breplace\?\s*$/i, // Replace?
88
+ /\bpassword[:\s]*$/i, // Password:
89
+ /\bpassphrase[:\s]*$/i, // Passphrase:
90
+ /\btoken[:\s]*$/i, // Token:
91
+ /\benter .*[:\s]*$/i, // Enter something:
92
+ ];
93
+
94
+ // Error patterns for stuck detection
95
+ const ERROR_PATTERNS = [
96
+ /\berror\b[:\[]/i,
97
+ /\bError:/,
98
+ /\bFAILED\b/,
99
+ /\bfailed\b/,
100
+ /\bpanic\b/i,
101
+ /\bfatal\b/i,
102
+ /\bEXCEPTION\b/i,
103
+ /\btraceback\b/i,
104
+ /\bsegmentation fault\b/i,
105
+ /\bcommand not found\b/i,
106
+ /\bpermission denied\b/i,
107
+ /\bENOENT\b/,
108
+ /\bEACCES\b/,
109
+ /\bECONNREFUSED\b/,
110
+ ];
111
+
112
+ // ANSI escape stripper
113
+ const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b\[[\?]?[0-9;]*[hlm]/g;
114
+
115
+ function stripAnsi(str) {
116
+ return str.replace(ANSI_RE, '');
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // SessionStateMachine
121
+ // ---------------------------------------------------------------------------
122
+
123
+ class SessionStateMachine {
124
+ constructor(sessionId, config = {}) {
125
+ this.sessionId = sessionId;
126
+ this.config = { ...DEFAULT_CONFIG, ...config };
127
+
128
+ // Current state
129
+ this._state = STATES.RUNNING;
130
+ this._since = Date.now();
131
+ this._confidence = 0.5;
132
+ this._detail = null;
133
+
134
+ // Output tracking
135
+ this._lastOutputAt = Date.now();
136
+ this._lastOutputPreview = '';
137
+ this._recentLines = []; // last N stripped lines
138
+ this._maxRecentLines = 50;
139
+
140
+ // Stuck detection: error fingerprints with timestamps
141
+ this._errorHistory = []; // [{ fingerprint, timestamp }]
142
+
143
+ // Thinking start time (for thinking → stuck timeout)
144
+ this._thinkingStartedAt = null;
145
+
146
+ // Transition listeners
147
+ this._listeners = [];
148
+
149
+ // Periodic state check
150
+ this._pollTimer = setInterval(() => this._tick(), this.config.poll_interval_ms);
151
+ }
152
+
153
+ // --- Public API ---
154
+
155
+ feed(data) {
156
+ if (typeof data !== 'string' || data.length === 0) return;
157
+
158
+ const now = Date.now();
159
+ this._lastOutputAt = now;
160
+
161
+ // Store preview (last N chars, raw)
162
+ const previewLen = this.config.output_preview_len;
163
+ this._lastOutputPreview = (this._lastOutputPreview + data).slice(-previewLen);
164
+
165
+ // Strip ANSI and split into lines for pattern analysis
166
+ const cleaned = stripAnsi(data);
167
+ const lines = cleaned.split(/\r?\n/).filter(l => l.trim().length > 0);
168
+
169
+ for (const line of lines) {
170
+ this._recentLines.push({ text: line.trim(), timestamp: now });
171
+ }
172
+ // Trim to max
173
+ while (this._recentLines.length > this._maxRecentLines) {
174
+ this._recentLines.shift();
175
+ }
176
+
177
+ // Run detection pipeline (order matters: most specific first)
178
+ this._detect(now);
179
+ }
180
+
181
+ getState() {
182
+ return {
183
+ state: this._state,
184
+ since: new Date(this._since).toISOString(),
185
+ since_ms: this._since,
186
+ duration_ms: Date.now() - this._since,
187
+ confidence: this._confidence,
188
+ last_output_at: new Date(this._lastOutputAt).toISOString(),
189
+ last_output_preview: this._lastOutputPreview.slice(-this.config.output_preview_len),
190
+ detail: this._detail,
191
+ };
192
+ }
193
+
194
+ onTransition(callback) {
195
+ this._listeners.push(callback);
196
+ }
197
+
198
+ reconfigure(config) {
199
+ Object.assign(this.config, config);
200
+ }
201
+
202
+ destroy() {
203
+ if (this._pollTimer) {
204
+ clearInterval(this._pollTimer);
205
+ this._pollTimer = null;
206
+ }
207
+ this._listeners = [];
208
+ }
209
+
210
+ // --- Internal ---
211
+
212
+ _transition(newState, confidence, detail) {
213
+ if (newState === this._state) {
214
+ // Update confidence/detail without firing transition
215
+ this._confidence = confidence;
216
+ this._detail = detail;
217
+ return;
218
+ }
219
+
220
+ const from = this._state;
221
+ this._state = newState;
222
+ this._since = Date.now();
223
+ this._confidence = confidence;
224
+ this._detail = detail;
225
+
226
+ // Thinking timer management
227
+ if (newState === STATES.THINKING) {
228
+ this._thinkingStartedAt = this._thinkingStartedAt || Date.now();
229
+ } else {
230
+ this._thinkingStartedAt = null;
231
+ }
232
+
233
+ for (const cb of this._listeners) {
234
+ try {
235
+ cb(from, newState, {
236
+ session_id: this.sessionId,
237
+ confidence,
238
+ detail,
239
+ timestamp: new Date().toISOString(),
240
+ });
241
+ } catch (e) {
242
+ // Don't let listener errors break the state machine
243
+ }
244
+ }
245
+ }
246
+
247
+ _detect(now) {
248
+ const lastLine = this._recentLines.length > 0
249
+ ? this._recentLines[this._recentLines.length - 1].text
250
+ : '';
251
+
252
+ // --- Priority 1: waiting_input (most specific, must act on immediately) ---
253
+ if (this._matchesAny(lastLine, WAITING_INPUT_PATTERNS)) {
254
+ this._transition(STATES.WAITING_INPUT, 0.9, {
255
+ trigger: 'pattern',
256
+ matched_line: lastLine.slice(0, 100),
257
+ });
258
+ return;
259
+ }
260
+
261
+ // --- Priority 2: stuck detection (repeated errors) ---
262
+ this._trackErrors(now);
263
+ const stuckResult = this._checkStuck(now);
264
+ if (stuckResult) {
265
+ this._transition(STATES.STUCK, stuckResult.confidence, stuckResult.detail);
266
+ return;
267
+ }
268
+
269
+ // --- Priority 3: thinking (AI spinner/progress) ---
270
+ if (this._matchesAny(lastLine, THINKING_PATTERNS)) {
271
+ this._transition(STATES.THINKING, 0.8, {
272
+ trigger: 'pattern',
273
+ matched_line: lastLine.slice(0, 100),
274
+ });
275
+ return;
276
+ }
277
+
278
+ // --- Priority 4: running (we just received output, not matching other patterns) ---
279
+ this._transition(STATES.RUNNING, 0.9, {
280
+ trigger: 'output_received',
281
+ });
282
+ }
283
+
284
+ _tick() {
285
+ const now = Date.now();
286
+ const silenceMs = now - this._lastOutputAt;
287
+
288
+ // Thinking → stuck after timeout
289
+ if (this._state === STATES.THINKING && this._thinkingStartedAt) {
290
+ const thinkingDuration = now - this._thinkingStartedAt;
291
+ if (thinkingDuration > this.config.thinking_timeout_ms) {
292
+ this._transition(STATES.STUCK, 0.7, {
293
+ trigger: 'thinking_timeout',
294
+ thinking_duration_ms: thinkingDuration,
295
+ });
296
+ return;
297
+ }
298
+ }
299
+
300
+ // Silence → idle (only if last output looks like a prompt)
301
+ if (silenceMs > this.config.idle_timeout_ms) {
302
+ // Don't override stuck or waiting_input with idle
303
+ if (this._state === STATES.STUCK || this._state === STATES.WAITING_INPUT) {
304
+ return;
305
+ }
306
+
307
+ const lastLine = this._recentLines.length > 0
308
+ ? this._recentLines[this._recentLines.length - 1].text
309
+ : '';
310
+
311
+ const hasPrompt = this._matchesAny(lastLine, PROMPT_PATTERNS);
312
+ const confidence = hasPrompt ? 0.9 : 0.6;
313
+
314
+ this._transition(STATES.IDLE, confidence, {
315
+ trigger: hasPrompt ? 'prompt_detected' : 'silence_timeout',
316
+ silence_ms: silenceMs,
317
+ last_line: lastLine.slice(0, 100),
318
+ });
319
+ }
320
+ }
321
+
322
+ _trackErrors(now) {
323
+ const cutoff = now - this.config.stuck_window_ms;
324
+ // Expire old errors
325
+ this._errorHistory = this._errorHistory.filter(e => e.timestamp > cutoff);
326
+
327
+ // Check recent lines for errors
328
+ for (const entry of this._recentLines) {
329
+ if (entry._errorTracked) continue;
330
+ entry._errorTracked = true;
331
+
332
+ if (this._matchesAny(entry.text, ERROR_PATTERNS)) {
333
+ const fingerprint = entry.text.slice(0, this.config.error_dedup_len).toLowerCase().trim();
334
+ this._errorHistory.push({ fingerprint, timestamp: entry.timestamp });
335
+ }
336
+ }
337
+ }
338
+
339
+ _checkStuck(now) {
340
+ if (this._errorHistory.length < this.config.stuck_repeat_count) {
341
+ return null;
342
+ }
343
+
344
+ // Count fingerprint occurrences
345
+ const counts = {};
346
+ for (const e of this._errorHistory) {
347
+ counts[e.fingerprint] = (counts[e.fingerprint] || 0) + 1;
348
+ }
349
+
350
+ for (const [fp, count] of Object.entries(counts)) {
351
+ if (count >= this.config.stuck_repeat_count) {
352
+ return {
353
+ confidence: Math.min(0.95, 0.7 + (count - this.config.stuck_repeat_count) * 0.05),
354
+ detail: {
355
+ trigger: 'repeated_error',
356
+ error_fingerprint: fp,
357
+ repeat_count: count,
358
+ window_ms: this.config.stuck_window_ms,
359
+ },
360
+ };
361
+ }
362
+ }
363
+
364
+ return null;
365
+ }
366
+
367
+ _matchesAny(text, patterns) {
368
+ for (const pat of patterns) {
369
+ if (pat.test(text)) return true;
370
+ }
371
+ return false;
372
+ }
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // SessionStateManager — manages state machines for all sessions
377
+ // ---------------------------------------------------------------------------
378
+
379
+ class SessionStateManager {
380
+ constructor(config = {}) {
381
+ this.config = config;
382
+ this._machines = new Map(); // sessionId → SessionStateMachine
383
+ this._globalListeners = []; // (sessionId, from, to, detail) => {}
384
+ }
385
+
386
+ /**
387
+ * Initialize state tracking for a session.
388
+ */
389
+ register(sessionId, sessionConfig = {}) {
390
+ if (this._machines.has(sessionId)) {
391
+ return this._machines.get(sessionId);
392
+ }
393
+
394
+ const mergedConfig = { ...this.config, ...sessionConfig };
395
+ const sm = new SessionStateMachine(sessionId, mergedConfig);
396
+
397
+ // Wire global listeners
398
+ sm.onTransition((from, to, detail) => {
399
+ for (const cb of this._globalListeners) {
400
+ try {
401
+ cb(sessionId, from, to, detail);
402
+ } catch (e) {
403
+ // swallow
404
+ }
405
+ }
406
+ });
407
+
408
+ this._machines.set(sessionId, sm);
409
+ return sm;
410
+ }
411
+
412
+ /**
413
+ * Feed PTY output for a session.
414
+ */
415
+ feed(sessionId, data) {
416
+ const sm = this._machines.get(sessionId);
417
+ if (sm) sm.feed(data);
418
+ }
419
+
420
+ /**
421
+ * Get state for a session.
422
+ */
423
+ getState(sessionId) {
424
+ const sm = this._machines.get(sessionId);
425
+ if (!sm) return null;
426
+ return sm.getState();
427
+ }
428
+
429
+ /**
430
+ * Get all session states.
431
+ */
432
+ getAllStates() {
433
+ const result = {};
434
+ for (const [id, sm] of this._machines) {
435
+ result[id] = sm.getState();
436
+ }
437
+ return result;
438
+ }
439
+
440
+ /**
441
+ * Unregister and cleanup a session's state machine.
442
+ */
443
+ unregister(sessionId) {
444
+ const sm = this._machines.get(sessionId);
445
+ if (sm) {
446
+ sm.destroy();
447
+ this._machines.delete(sessionId);
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Reconfigure thresholds for a session (or all if no sessionId).
453
+ */
454
+ reconfigure(config, sessionId) {
455
+ if (sessionId) {
456
+ const sm = this._machines.get(sessionId);
457
+ if (sm) sm.reconfigure(config);
458
+ } else {
459
+ Object.assign(this.config, config);
460
+ for (const sm of this._machines.values()) {
461
+ sm.reconfigure(config);
462
+ }
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Register a listener for ALL session state transitions.
468
+ */
469
+ onTransition(callback) {
470
+ this._globalListeners.push(callback);
471
+ }
472
+
473
+ /**
474
+ * Cleanup all state machines.
475
+ */
476
+ destroyAll() {
477
+ for (const sm of this._machines.values()) {
478
+ sm.destroy();
479
+ }
480
+ this._machines.clear();
481
+ this._globalListeners = [];
482
+ }
483
+ }
484
+
485
+ module.exports = {
486
+ STATES,
487
+ DEFAULT_CONFIG,
488
+ SessionStateMachine,
489
+ SessionStateManager,
490
+ // Exported for testing
491
+ PROMPT_PATTERNS,
492
+ THINKING_PATTERNS,
493
+ WAITING_INPUT_PATTERNS,
494
+ ERROR_PATTERNS,
495
+ stripAnsi,
496
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const os = require('os');
5
+
6
+ /**
7
+ * MailboxConfig — default configuration for FileMailbox.
8
+ * Override via AIGENTRY_MAILBOX_DIR env or constructor options.
9
+ */
10
+
11
+ const DEFAULT_ROOT = path.join(os.homedir(), '.aigentry', 'mailbox');
12
+
13
+ const DEFAULTS = {
14
+ /** Root directory for all mailbox storage. */
15
+ root: process.env.AIGENTRY_MAILBOX_DIR || DEFAULT_ROOT,
16
+ /** Max retry attempts before dead-lettering. */
17
+ maxRetries: 3,
18
+ /** Initial retry backoff in seconds. Doubles each attempt: 1s, 2s, 4s. */
19
+ retryBackoffSecs: 1,
20
+ /** Message TTL in seconds (24h). */
21
+ ttlSecs: 86400,
22
+ /** In-flight timeout: auto-nack if ACK not received within this window. */
23
+ inflightTimeoutSecs: 30,
24
+ /** Compact inbox.jsonl after this many acked entries. */
25
+ compactionThreshold: 100,
26
+ /** Delivery engine poll interval in ms. */
27
+ deliveryPollMs: 200,
28
+ /** Notification coalesce window in ms. */
29
+ notifyCoalesceMs: 25,
30
+ };
31
+
32
+ function createConfig(overrides = {}) {
33
+ return { ...DEFAULTS, ...overrides };
34
+ }
35
+
36
+ module.exports = { createConfig, DEFAULTS };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * DeliveryEngine — polls mailbox for pending messages and delivers them.
5
+ *
6
+ * For each registered session, dequeues messages and calls the deliverFn callback.
7
+ * On success: ack. On failure: nack (retry with backoff, then dead-letter).
8
+ *
9
+ * Also handles:
10
+ * - In-flight timeout recovery (auto-nack stuck messages)
11
+ * - TTL expiry (expire stale pending messages)
12
+ */
13
+ class DeliveryEngine {
14
+ /**
15
+ * @param {FileMailbox} mailbox
16
+ * @param {Object} options
17
+ * @param {Function} options.deliverFn - async (sessionId, message) => { success: boolean, error?: string }
18
+ * @param {Function} options.sessionResolver - () => string[] (list of active session IDs)
19
+ * @param {number} options.pollMs - polling interval (default: 200ms)
20
+ * @param {Function} options.onDelivery - optional callback (sessionId, msgId, result)
21
+ */
22
+ constructor(mailbox, options = {}) {
23
+ this.mailbox = mailbox;
24
+ this.deliverFn = options.deliverFn;
25
+ this.sessionResolver = options.sessionResolver || (() => this.mailbox.listSessions());
26
+ this.pollMs = options.pollMs || mailbox.config.deliveryPollMs || 200;
27
+ this.onDelivery = options.onDelivery || null;
28
+ this._timer = null;
29
+ this._running = false;
30
+ this._tickInProgress = false;
31
+ }
32
+
33
+ /**
34
+ * Start the delivery engine polling loop.
35
+ */
36
+ start() {
37
+ if (this._running) return;
38
+ this._running = true;
39
+ this._timer = setInterval(() => this._tick(), this.pollMs);
40
+ if (this._timer.unref) this._timer.unref();
41
+ console.log(`[MAILBOX] DeliveryEngine started (poll: ${this.pollMs}ms)`);
42
+ }
43
+
44
+ /**
45
+ * Stop the delivery engine.
46
+ */
47
+ stop() {
48
+ this._running = false;
49
+ if (this._timer) {
50
+ clearInterval(this._timer);
51
+ this._timer = null;
52
+ }
53
+ console.log('[MAILBOX] DeliveryEngine stopped');
54
+ }
55
+
56
+ /**
57
+ * Force a single delivery tick (for testing or immediate delivery after enqueue).
58
+ */
59
+ async tick() {
60
+ return this._tick();
61
+ }
62
+
63
+ async _tick() {
64
+ if (this._tickInProgress) return;
65
+ this._tickInProgress = true;
66
+
67
+ try {
68
+ const sessionIds = this.sessionResolver();
69
+
70
+ for (const sessionId of sessionIds) {
71
+ // 1. Recover in-flight timeouts
72
+ try {
73
+ const recovered = this.mailbox.recoverInflight(sessionId);
74
+ if (recovered > 0) {
75
+ console.log(`[MAILBOX] Recovered ${recovered} in-flight message(s) for ${sessionId}`);
76
+ }
77
+ } catch (err) {
78
+ console.error(`[MAILBOX] recoverInflight error for ${sessionId}: ${err.message}`);
79
+ }
80
+
81
+ // 2. Expire stale messages
82
+ try {
83
+ const expired = this.mailbox.expireStale(sessionId);
84
+ if (expired > 0) {
85
+ console.log(`[MAILBOX] Expired ${expired} stale message(s) for ${sessionId}`);
86
+ }
87
+ } catch (err) {
88
+ console.error(`[MAILBOX] expireStale error for ${sessionId}: ${err.message}`);
89
+ }
90
+
91
+ // 3. Dequeue and deliver
92
+ try {
93
+ const msg = this.mailbox.dequeue(sessionId);
94
+ if (!msg) continue;
95
+
96
+ if (!this.deliverFn) {
97
+ // No delivery function — auto-ack (testing mode)
98
+ this.mailbox.ack(sessionId, msg.msg_id);
99
+ continue;
100
+ }
101
+
102
+ let result;
103
+ try {
104
+ result = await this.deliverFn(sessionId, msg);
105
+ } catch (err) {
106
+ result = { success: false, error: err.message };
107
+ }
108
+
109
+ if (result && result.success) {
110
+ this.mailbox.ack(sessionId, msg.msg_id);
111
+ if (this.onDelivery) {
112
+ this.onDelivery(sessionId, msg.msg_id, { success: true });
113
+ }
114
+ } else {
115
+ const reason = (result && result.error) || 'delivery failed';
116
+ this.mailbox.nack(sessionId, msg.msg_id, reason);
117
+ console.log(`[MAILBOX] Delivery failed for ${sessionId}/${msg.msg_id}: ${reason} (attempt ${msg.attempt})`);
118
+ if (this.onDelivery) {
119
+ this.onDelivery(sessionId, msg.msg_id, { success: false, error: reason });
120
+ }
121
+ }
122
+ } catch (err) {
123
+ console.error(`[MAILBOX] Delivery loop error for ${sessionId}: ${err.message}`);
124
+ }
125
+ }
126
+ } finally {
127
+ this._tickInProgress = false;
128
+ }
129
+ }
130
+ }
131
+
132
+ module.exports = { DeliveryEngine };