@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/session-state.js
ADDED
|
@@ -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 };
|