@agent-relay/wrapper 0.1.0
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/dist/__fixtures__/claude-outputs.d.ts +49 -0
- package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/claude-outputs.js +443 -0
- package/dist/__fixtures__/claude-outputs.js.map +1 -0
- package/dist/__fixtures__/codex-outputs.d.ts +9 -0
- package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/codex-outputs.js +94 -0
- package/dist/__fixtures__/codex-outputs.js.map +1 -0
- package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
- package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
- package/dist/__fixtures__/gemini-outputs.js +144 -0
- package/dist/__fixtures__/gemini-outputs.js.map +1 -0
- package/dist/__fixtures__/index.d.ts +68 -0
- package/dist/__fixtures__/index.d.ts.map +1 -0
- package/dist/__fixtures__/index.js +44 -0
- package/dist/__fixtures__/index.js.map +1 -0
- package/dist/auth-detection.d.ts +49 -0
- package/dist/auth-detection.d.ts.map +1 -0
- package/dist/auth-detection.js +199 -0
- package/dist/auth-detection.js.map +1 -0
- package/dist/base-wrapper.d.ts +225 -0
- package/dist/base-wrapper.d.ts.map +1 -0
- package/dist/base-wrapper.js +572 -0
- package/dist/base-wrapper.js.map +1 -0
- package/dist/client.d.ts +254 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +801 -0
- package/dist/client.js.map +1 -0
- package/dist/id-generator.d.ts +35 -0
- package/dist/id-generator.d.ts.map +1 -0
- package/dist/id-generator.js +60 -0
- package/dist/id-generator.js.map +1 -0
- package/dist/idle-detector.d.ts +110 -0
- package/dist/idle-detector.d.ts.map +1 -0
- package/dist/idle-detector.js +304 -0
- package/dist/idle-detector.js.map +1 -0
- package/dist/inbox.d.ts +37 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +73 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/parser.d.ts +236 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +1238 -0
- package/dist/parser.js.map +1 -0
- package/dist/prompt-composer.d.ts +67 -0
- package/dist/prompt-composer.d.ts.map +1 -0
- package/dist/prompt-composer.js +168 -0
- package/dist/prompt-composer.js.map +1 -0
- package/dist/relay-pty-orchestrator.d.ts +407 -0
- package/dist/relay-pty-orchestrator.d.ts.map +1 -0
- package/dist/relay-pty-orchestrator.js +1885 -0
- package/dist/relay-pty-orchestrator.js.map +1 -0
- package/dist/shared.d.ts +201 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +341 -0
- package/dist/shared.js.map +1 -0
- package/dist/stuck-detector.d.ts +161 -0
- package/dist/stuck-detector.d.ts.map +1 -0
- package/dist/stuck-detector.js +402 -0
- package/dist/stuck-detector.js.map +1 -0
- package/dist/tmux-resolver.d.ts +55 -0
- package/dist/tmux-resolver.d.ts.map +1 -0
- package/dist/tmux-resolver.js +175 -0
- package/dist/tmux-resolver.js.map +1 -0
- package/dist/tmux-wrapper.d.ts +345 -0
- package/dist/tmux-wrapper.d.ts.map +1 -0
- package/dist/tmux-wrapper.js +1747 -0
- package/dist/tmux-wrapper.js.map +1 -0
- package/dist/trajectory-integration.d.ts +292 -0
- package/dist/trajectory-integration.d.ts.map +1 -0
- package/dist/trajectory-integration.js +979 -0
- package/dist/trajectory-integration.js.map +1 -0
- package/dist/wrapper-types.d.ts +41 -0
- package/dist/wrapper-types.d.ts.map +1 -0
- package/dist/wrapper-types.js +7 -0
- package/dist/wrapper-types.js.map +1 -0
- package/package.json +63 -0
- package/src/__fixtures__/claude-outputs.ts +471 -0
- package/src/__fixtures__/codex-outputs.ts +99 -0
- package/src/__fixtures__/gemini-outputs.ts +151 -0
- package/src/__fixtures__/index.ts +47 -0
- package/src/auth-detection.ts +244 -0
- package/src/base-wrapper.test.ts +540 -0
- package/src/base-wrapper.ts +741 -0
- package/src/client.test.ts +262 -0
- package/src/client.ts +984 -0
- package/src/id-generator.test.ts +71 -0
- package/src/id-generator.ts +69 -0
- package/src/idle-detector.test.ts +390 -0
- package/src/idle-detector.ts +370 -0
- package/src/inbox.test.ts +233 -0
- package/src/inbox.ts +89 -0
- package/src/index.ts +170 -0
- package/src/parser.regression.test.ts +251 -0
- package/src/parser.test.ts +1359 -0
- package/src/parser.ts +1477 -0
- package/src/prompt-composer.test.ts +219 -0
- package/src/prompt-composer.ts +231 -0
- package/src/relay-pty-orchestrator.test.ts +1027 -0
- package/src/relay-pty-orchestrator.ts +2270 -0
- package/src/shared.test.ts +221 -0
- package/src/shared.ts +454 -0
- package/src/stuck-detector.test.ts +303 -0
- package/src/stuck-detector.ts +511 -0
- package/src/tmux-resolver.test.ts +104 -0
- package/src/tmux-resolver.ts +207 -0
- package/src/tmux-wrapper.test.ts +316 -0
- package/src/tmux-wrapper.ts +2010 -0
- package/src/trajectory-detection.test.ts +151 -0
- package/src/trajectory-integration.ts +1261 -0
- package/src/wrapper-types.ts +45 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UniversalIdleDetector - Detect when an agent is waiting for input
|
|
3
|
+
*
|
|
4
|
+
* Works across all CLI tools (Claude, Codex, Gemini, Aider, etc.) by combining:
|
|
5
|
+
* 1. Process state inspection via /proc/{pid}/stat (Linux, 95% confidence)
|
|
6
|
+
* 2. Output silence analysis (cross-platform, 60-80% confidence)
|
|
7
|
+
* 3. Natural ending detection (heuristic, 60% confidence)
|
|
8
|
+
*
|
|
9
|
+
* The hybrid approach ensures reliable idle detection regardless of CLI type.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
|
|
14
|
+
export interface IdleSignal {
|
|
15
|
+
source: 'process_state' | 'output_silence' | 'natural_ending';
|
|
16
|
+
confidence: number; // 0-1
|
|
17
|
+
timestamp: number;
|
|
18
|
+
details?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface IdleResult {
|
|
22
|
+
isIdle: boolean;
|
|
23
|
+
confidence: number;
|
|
24
|
+
signals: IdleSignal[];
|
|
25
|
+
/** True if agent appears to be in an editor mode (vim INSERT, etc.) */
|
|
26
|
+
inEditorMode?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IdleDetectorConfig {
|
|
30
|
+
/** Minimum silence duration to consider for idle (ms) */
|
|
31
|
+
minSilenceMs?: number;
|
|
32
|
+
/** Output buffer size limit */
|
|
33
|
+
bufferLimit?: number;
|
|
34
|
+
/** Confidence threshold for idle detection (0-1) */
|
|
35
|
+
confidenceThreshold?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_CONFIG: Required<IdleDetectorConfig> = {
|
|
39
|
+
minSilenceMs: 500,
|
|
40
|
+
bufferLimit: 10000,
|
|
41
|
+
confidenceThreshold: 0.7,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Universal idle detector for any CLI-based agent.
|
|
46
|
+
*/
|
|
47
|
+
export class UniversalIdleDetector {
|
|
48
|
+
private lastOutputTime = 0;
|
|
49
|
+
private outputBuffer = '';
|
|
50
|
+
private pid: number | null = null;
|
|
51
|
+
private config: Required<IdleDetectorConfig>;
|
|
52
|
+
|
|
53
|
+
constructor(config: IdleDetectorConfig = {}) {
|
|
54
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
55
|
+
// Initialize lastOutputTime to now to avoid immediate false positives
|
|
56
|
+
this.lastOutputTime = Date.now();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Set the PID of the agent process to monitor.
|
|
61
|
+
* Required for Linux process state inspection.
|
|
62
|
+
*/
|
|
63
|
+
setPid(pid: number): void {
|
|
64
|
+
this.pid = pid;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get the current PID being monitored.
|
|
69
|
+
*/
|
|
70
|
+
getPid(): number | null {
|
|
71
|
+
return this.pid;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Process output chunk from the agent.
|
|
76
|
+
* Call this for every output received from the agent process.
|
|
77
|
+
*/
|
|
78
|
+
onOutput(chunk: string): void {
|
|
79
|
+
this.lastOutputTime = Date.now();
|
|
80
|
+
this.outputBuffer += chunk;
|
|
81
|
+
|
|
82
|
+
// Keep buffer bounded
|
|
83
|
+
if (this.outputBuffer.length > this.config.bufferLimit) {
|
|
84
|
+
this.outputBuffer = this.outputBuffer.slice(-Math.floor(this.config.bufferLimit / 2));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if the agent process is blocked on read (waiting for input).
|
|
90
|
+
* This is the most reliable signal - the OS knows when a process is waiting.
|
|
91
|
+
*
|
|
92
|
+
* Linux-only; returns null on other platforms.
|
|
93
|
+
*/
|
|
94
|
+
private isProcessWaitingForInput(): { waiting: boolean; wchan?: string } | null {
|
|
95
|
+
if (process.platform !== 'linux' || !this.pid) {
|
|
96
|
+
return null; // Can't determine on non-Linux
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// Check process state from /proc/{pid}/stat
|
|
101
|
+
// State codes: R=running, S=sleeping, D=disk sleep, Z=zombie, T=stopped
|
|
102
|
+
const statPath = `/proc/${this.pid}/stat`;
|
|
103
|
+
const stat = fs.readFileSync(statPath, 'utf-8');
|
|
104
|
+
const fields = stat.split(' ');
|
|
105
|
+
const state = fields[2]; // Third field is state
|
|
106
|
+
|
|
107
|
+
// S (sleeping) often means waiting for I/O
|
|
108
|
+
if (state !== 'S') {
|
|
109
|
+
return { waiting: false }; // Running or other state = not waiting
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// More precise: check what the process is blocked on
|
|
113
|
+
const wchanPath = `/proc/${this.pid}/wchan`;
|
|
114
|
+
const wchan = fs.readFileSync(wchanPath, 'utf-8').trim();
|
|
115
|
+
|
|
116
|
+
// Common wait channels for terminal input
|
|
117
|
+
const inputWaitChannels = [
|
|
118
|
+
'wait_woken',
|
|
119
|
+
'poll_schedule_timeout',
|
|
120
|
+
'do_select',
|
|
121
|
+
'n_tty_read',
|
|
122
|
+
'unix_stream_read_generic',
|
|
123
|
+
'pipe_read',
|
|
124
|
+
'ep_poll', // epoll wait
|
|
125
|
+
'futex_wait_queue', // sometimes seen
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
const isWaiting = inputWaitChannels.some(ch => wchan.includes(ch));
|
|
129
|
+
return { waiting: isWaiting, wchan };
|
|
130
|
+
} catch {
|
|
131
|
+
return null; // Process may have exited or permission denied
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get milliseconds since last output.
|
|
137
|
+
*/
|
|
138
|
+
private getOutputSilenceMs(): number {
|
|
139
|
+
return Date.now() - this.lastOutputTime;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Check if the agent is in an editor mode (vim INSERT, REPLACE, etc.).
|
|
144
|
+
* When in editor mode, message injection should be delayed.
|
|
145
|
+
*/
|
|
146
|
+
isInEditorMode(): boolean {
|
|
147
|
+
// Check the last portion of output for editor mode indicators
|
|
148
|
+
const lastOutput = this.outputBuffer.slice(-500);
|
|
149
|
+
|
|
150
|
+
// Vim/Neovim mode indicators
|
|
151
|
+
const editorModePatterns = [
|
|
152
|
+
/-- INSERT --/i,
|
|
153
|
+
/-- REPLACE --/i,
|
|
154
|
+
/-- VISUAL --/i,
|
|
155
|
+
/-- VISUAL LINE --/i,
|
|
156
|
+
/-- VISUAL BLOCK --/i,
|
|
157
|
+
/-- SELECT --/i,
|
|
158
|
+
/-- TERMINAL --/i,
|
|
159
|
+
// Emacs indicators
|
|
160
|
+
/\*\*\* Emacs/,
|
|
161
|
+
/M-x/,
|
|
162
|
+
// nano indicators
|
|
163
|
+
/GNU nano/,
|
|
164
|
+
/\^G Get Help/,
|
|
165
|
+
// less/more pager
|
|
166
|
+
/:\s*$/, // Pager prompt
|
|
167
|
+
/lines \d+-\d+/, // Pager line indicator
|
|
168
|
+
// Git interactive rebase
|
|
169
|
+
/pick [a-f0-9]+/,
|
|
170
|
+
/# Rebase [a-f0-9]+/,
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
return editorModePatterns.some(pattern => pattern.test(lastOutput));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check if the last output ends "naturally" (complete thought vs mid-sentence).
|
|
178
|
+
* Helps distinguish between pauses in output and waiting for input.
|
|
179
|
+
*/
|
|
180
|
+
private hasNaturalEnding(): boolean {
|
|
181
|
+
const lastChars = this.outputBuffer.slice(-100).trim();
|
|
182
|
+
if (lastChars.length === 0) return true;
|
|
183
|
+
|
|
184
|
+
// Positive signals: output ended cleanly
|
|
185
|
+
const naturalEndings = [
|
|
186
|
+
/[.!?]\s*$/, // Sentence ended
|
|
187
|
+
/```\s*$/, // Code block closed
|
|
188
|
+
/\n\n$/, // Paragraph break
|
|
189
|
+
/>\s*$/, // Prompt character
|
|
190
|
+
/\$\s*$/, // Shell prompt
|
|
191
|
+
/>>>\s*$/, // Python/Aider prompt
|
|
192
|
+
/❯\s*$/, // Fancy prompts
|
|
193
|
+
/λ\s*$/, // Lambda prompts
|
|
194
|
+
/→\s*$/, // Arrow prompts
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
// Negative signals: output mid-thought
|
|
198
|
+
const midThought = [
|
|
199
|
+
/[,;:]\s*$/, // Comma, semicolon = more coming
|
|
200
|
+
/\w$/, // Ended mid-word (no trailing space)
|
|
201
|
+
/[-–—]\s*$/, // Dash = continuation
|
|
202
|
+
/\(\s*$/, // Open paren
|
|
203
|
+
/\[\s*$/, // Open bracket
|
|
204
|
+
/\{\s*$/, // Open brace
|
|
205
|
+
/\\\s*$/, // Line continuation
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
for (const pattern of midThought) {
|
|
209
|
+
if (pattern.test(lastChars)) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const pattern of naturalEndings) {
|
|
215
|
+
if (pattern.test(lastChars)) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Default: assume natural if no negative signals and some silence
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Determine if the agent is idle and ready for input.
|
|
226
|
+
* Combines multiple signals for reliability across all CLI types.
|
|
227
|
+
*/
|
|
228
|
+
checkIdle(options: { minSilenceMs?: number } = {}): IdleResult {
|
|
229
|
+
const minSilence = options.minSilenceMs ?? this.config.minSilenceMs;
|
|
230
|
+
const signals: IdleSignal[] = [];
|
|
231
|
+
|
|
232
|
+
// Signal 1: Process state (most reliable on Linux)
|
|
233
|
+
const processState = this.isProcessWaitingForInput();
|
|
234
|
+
if (processState !== null) {
|
|
235
|
+
if (processState.waiting) {
|
|
236
|
+
signals.push({
|
|
237
|
+
source: 'process_state',
|
|
238
|
+
confidence: 0.95, // Very high - OS-level truth
|
|
239
|
+
timestamp: Date.now(),
|
|
240
|
+
details: processState.wchan,
|
|
241
|
+
});
|
|
242
|
+
} else {
|
|
243
|
+
// Process is actively running - definitely not idle
|
|
244
|
+
return {
|
|
245
|
+
isIdle: false,
|
|
246
|
+
confidence: 0.95,
|
|
247
|
+
signals: [{
|
|
248
|
+
source: 'process_state',
|
|
249
|
+
confidence: 0.95,
|
|
250
|
+
timestamp: Date.now(),
|
|
251
|
+
details: 'process running',
|
|
252
|
+
}],
|
|
253
|
+
inEditorMode: this.isInEditorMode(),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// processState === null means we can't determine (non-Linux)
|
|
258
|
+
|
|
259
|
+
// Signal 2: Output silence
|
|
260
|
+
const silenceMs = this.getOutputSilenceMs();
|
|
261
|
+
if (silenceMs > minSilence) {
|
|
262
|
+
// Confidence scales with silence duration (up to 0.8)
|
|
263
|
+
// 500ms = 0.13, 1000ms = 0.27, 2000ms = 0.53, 3000ms = 0.8
|
|
264
|
+
const silenceConfidence = Math.min(silenceMs / 3000, 0.8);
|
|
265
|
+
signals.push({
|
|
266
|
+
source: 'output_silence',
|
|
267
|
+
confidence: silenceConfidence,
|
|
268
|
+
timestamp: Date.now(),
|
|
269
|
+
details: `${silenceMs}ms`,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Signal 3: Natural ending (only if some silence)
|
|
274
|
+
if (silenceMs > 200 && this.hasNaturalEnding()) {
|
|
275
|
+
signals.push({
|
|
276
|
+
source: 'natural_ending',
|
|
277
|
+
confidence: 0.6,
|
|
278
|
+
timestamp: Date.now(),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// No signals = not idle
|
|
283
|
+
if (signals.length === 0) {
|
|
284
|
+
return { isIdle: false, confidence: 0, signals: [], inEditorMode: this.isInEditorMode() };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Combine signals
|
|
288
|
+
// Use max confidence, boosted if multiple signals agree
|
|
289
|
+
const maxConfidence = Math.max(...signals.map(s => s.confidence));
|
|
290
|
+
const boost = signals.length > 1 ? 0.1 : 0;
|
|
291
|
+
const combinedConfidence = Math.min(maxConfidence + boost, 1.0);
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
isIdle: combinedConfidence >= this.config.confidenceThreshold,
|
|
295
|
+
confidence: combinedConfidence,
|
|
296
|
+
signals,
|
|
297
|
+
inEditorMode: this.isInEditorMode(),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Wait for idle state with timeout.
|
|
303
|
+
* Returns the idle result when achieved or after timeout.
|
|
304
|
+
*/
|
|
305
|
+
async waitForIdle(timeoutMs = 30000, pollMs = 200): Promise<IdleResult> {
|
|
306
|
+
const startTime = Date.now();
|
|
307
|
+
|
|
308
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
309
|
+
const result = this.checkIdle();
|
|
310
|
+
if (result.isIdle) {
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Timeout - return current state
|
|
317
|
+
return this.checkIdle();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Reset state (call when agent starts new response).
|
|
322
|
+
*/
|
|
323
|
+
reset(): void {
|
|
324
|
+
this.outputBuffer = '';
|
|
325
|
+
this.lastOutputTime = Date.now();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get time since last output in milliseconds.
|
|
330
|
+
*/
|
|
331
|
+
getTimeSinceLastOutput(): number {
|
|
332
|
+
return this.getOutputSilenceMs();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Get the PID of a process running in a tmux pane.
|
|
338
|
+
* Uses tmux list-panes with format specifier.
|
|
339
|
+
*/
|
|
340
|
+
export async function getTmuxPanePid(
|
|
341
|
+
tmuxPath: string,
|
|
342
|
+
sessionName: string
|
|
343
|
+
): Promise<number | null> {
|
|
344
|
+
const { exec } = await import('node:child_process');
|
|
345
|
+
const { promisify } = await import('node:util');
|
|
346
|
+
const execAsync = promisify(exec);
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
// Get the PID of the command running in the pane
|
|
350
|
+
const { stdout } = await execAsync(
|
|
351
|
+
`"${tmuxPath}" list-panes -t ${sessionName} -F "#{pane_pid}" 2>/dev/null`
|
|
352
|
+
);
|
|
353
|
+
const pid = parseInt(stdout.trim(), 10);
|
|
354
|
+
return isNaN(pid) ? null : pid;
|
|
355
|
+
} catch {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Create an idle detector configured for the current platform.
|
|
362
|
+
* On non-Linux platforms, process state inspection is unavailable but
|
|
363
|
+
* output silence analysis still works.
|
|
364
|
+
*/
|
|
365
|
+
export function createIdleDetector(
|
|
366
|
+
config: IdleDetectorConfig = {},
|
|
367
|
+
options: { quiet?: boolean } = {}
|
|
368
|
+
): UniversalIdleDetector {
|
|
369
|
+
return new UniversalIdleDetector(config);
|
|
370
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { describe, expect, it, afterEach, beforeEach } from 'vitest';
|
|
4
|
+
import { InboxManager, DEFAULT_INBOX_DIR } from './inbox.js';
|
|
5
|
+
|
|
6
|
+
function makeTempDir(): string {
|
|
7
|
+
const base = path.join(process.cwd(), '.tmp-wrapper-inbox-tests');
|
|
8
|
+
fs.mkdirSync(base, { recursive: true });
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(base, 'inbox-'));
|
|
10
|
+
return dir;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cleanup(dir: string): void {
|
|
14
|
+
if (fs.existsSync(dir)) {
|
|
15
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('InboxManager', () => {
|
|
20
|
+
let tempDir: string;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tempDir = makeTempDir();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (tempDir) {
|
|
28
|
+
cleanup(tempDir);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('DEFAULT_INBOX_DIR', () => {
|
|
33
|
+
it('has expected default value', () => {
|
|
34
|
+
expect(DEFAULT_INBOX_DIR).toBe('/tmp/agent-relay');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('constructor', () => {
|
|
39
|
+
it('uses default inbox directory when not provided', () => {
|
|
40
|
+
const manager = new InboxManager({ agentName: 'TestAgent' });
|
|
41
|
+
expect(manager.getInboxPath()).toBe(
|
|
42
|
+
path.join(DEFAULT_INBOX_DIR, 'TestAgent', 'inbox.md')
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('uses custom inbox directory when provided', () => {
|
|
47
|
+
const manager = new InboxManager({
|
|
48
|
+
agentName: 'TestAgent',
|
|
49
|
+
inboxDir: tempDir,
|
|
50
|
+
});
|
|
51
|
+
expect(manager.getInboxPath()).toBe(
|
|
52
|
+
path.join(tempDir, 'TestAgent', 'inbox.md')
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('init', () => {
|
|
58
|
+
it('creates agent directory if it does not exist', () => {
|
|
59
|
+
const manager = new InboxManager({
|
|
60
|
+
agentName: 'NewAgent',
|
|
61
|
+
inboxDir: tempDir,
|
|
62
|
+
});
|
|
63
|
+
manager.init();
|
|
64
|
+
|
|
65
|
+
const agentDir = path.join(tempDir, 'NewAgent');
|
|
66
|
+
expect(fs.existsSync(agentDir)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('creates empty inbox file', () => {
|
|
70
|
+
const manager = new InboxManager({
|
|
71
|
+
agentName: 'NewAgent',
|
|
72
|
+
inboxDir: tempDir,
|
|
73
|
+
});
|
|
74
|
+
manager.init();
|
|
75
|
+
|
|
76
|
+
const inboxPath = manager.getInboxPath();
|
|
77
|
+
expect(fs.existsSync(inboxPath)).toBe(true);
|
|
78
|
+
expect(fs.readFileSync(inboxPath, 'utf-8')).toBe('');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('clears existing inbox on init', () => {
|
|
82
|
+
const agentDir = path.join(tempDir, 'ExistingAgent');
|
|
83
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
84
|
+
const inboxPath = path.join(agentDir, 'inbox.md');
|
|
85
|
+
fs.writeFileSync(inboxPath, 'Old content');
|
|
86
|
+
|
|
87
|
+
const manager = new InboxManager({
|
|
88
|
+
agentName: 'ExistingAgent',
|
|
89
|
+
inboxDir: tempDir,
|
|
90
|
+
});
|
|
91
|
+
manager.init();
|
|
92
|
+
|
|
93
|
+
expect(fs.readFileSync(inboxPath, 'utf-8')).toBe('');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('getInboxPath', () => {
|
|
98
|
+
it('returns correct path', () => {
|
|
99
|
+
const manager = new InboxManager({
|
|
100
|
+
agentName: 'TestAgent',
|
|
101
|
+
inboxDir: tempDir,
|
|
102
|
+
});
|
|
103
|
+
expect(manager.getInboxPath()).toBe(
|
|
104
|
+
path.join(tempDir, 'TestAgent', 'inbox.md')
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('addMessage', () => {
|
|
110
|
+
it('adds message to empty inbox with header', () => {
|
|
111
|
+
const manager = new InboxManager({
|
|
112
|
+
agentName: 'TestAgent',
|
|
113
|
+
inboxDir: tempDir,
|
|
114
|
+
});
|
|
115
|
+
manager.init();
|
|
116
|
+
|
|
117
|
+
manager.addMessage('SenderAgent', 'Hello World');
|
|
118
|
+
|
|
119
|
+
const content = fs.readFileSync(manager.getInboxPath(), 'utf-8');
|
|
120
|
+
expect(content).toContain('# 📬 INBOX');
|
|
121
|
+
expect(content).toContain('## Message from SenderAgent |');
|
|
122
|
+
expect(content).toContain('Hello World');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('appends multiple messages', () => {
|
|
126
|
+
const manager = new InboxManager({
|
|
127
|
+
agentName: 'TestAgent',
|
|
128
|
+
inboxDir: tempDir,
|
|
129
|
+
});
|
|
130
|
+
manager.init();
|
|
131
|
+
|
|
132
|
+
manager.addMessage('Agent1', 'First message');
|
|
133
|
+
manager.addMessage('Agent2', 'Second message');
|
|
134
|
+
|
|
135
|
+
const content = fs.readFileSync(manager.getInboxPath(), 'utf-8');
|
|
136
|
+
expect(content).toContain('## Message from Agent1 |');
|
|
137
|
+
expect(content).toContain('First message');
|
|
138
|
+
expect(content).toContain('## Message from Agent2 |');
|
|
139
|
+
expect(content).toContain('Second message');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('includes timestamp in ISO format', () => {
|
|
143
|
+
const manager = new InboxManager({
|
|
144
|
+
agentName: 'TestAgent',
|
|
145
|
+
inboxDir: tempDir,
|
|
146
|
+
});
|
|
147
|
+
manager.init();
|
|
148
|
+
|
|
149
|
+
manager.addMessage('Sender', 'Test');
|
|
150
|
+
|
|
151
|
+
const content = fs.readFileSync(manager.getInboxPath(), 'utf-8');
|
|
152
|
+
// Check timestamp is present (at least the date part)
|
|
153
|
+
expect(content).toMatch(/## Message from Sender \| \d{4}-\d{2}-\d{2}/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('handles adding to non-existent inbox file', () => {
|
|
157
|
+
const agentDir = path.join(tempDir, 'NoInit');
|
|
158
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
159
|
+
|
|
160
|
+
const manager = new InboxManager({
|
|
161
|
+
agentName: 'NoInit',
|
|
162
|
+
inboxDir: tempDir,
|
|
163
|
+
});
|
|
164
|
+
// Don't call init()
|
|
165
|
+
|
|
166
|
+
manager.addMessage('Sender', 'Message without init');
|
|
167
|
+
|
|
168
|
+
const content = fs.readFileSync(manager.getInboxPath(), 'utf-8');
|
|
169
|
+
expect(content).toContain('# 📬 INBOX');
|
|
170
|
+
expect(content).toContain('Message without init');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('clear', () => {
|
|
175
|
+
it('clears inbox content', () => {
|
|
176
|
+
const manager = new InboxManager({
|
|
177
|
+
agentName: 'TestAgent',
|
|
178
|
+
inboxDir: tempDir,
|
|
179
|
+
});
|
|
180
|
+
manager.init();
|
|
181
|
+
manager.addMessage('Sender', 'Content to clear');
|
|
182
|
+
|
|
183
|
+
manager.clear();
|
|
184
|
+
|
|
185
|
+
expect(fs.readFileSync(manager.getInboxPath(), 'utf-8')).toBe('');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('hasMessages', () => {
|
|
190
|
+
it('returns false when inbox does not exist', () => {
|
|
191
|
+
const manager = new InboxManager({
|
|
192
|
+
agentName: 'NoExist',
|
|
193
|
+
inboxDir: tempDir,
|
|
194
|
+
});
|
|
195
|
+
expect(manager.hasMessages()).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns false when inbox is empty', () => {
|
|
199
|
+
const manager = new InboxManager({
|
|
200
|
+
agentName: 'TestAgent',
|
|
201
|
+
inboxDir: tempDir,
|
|
202
|
+
});
|
|
203
|
+
manager.init();
|
|
204
|
+
expect(manager.hasMessages()).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('returns false when inbox has only header', () => {
|
|
208
|
+
const agentDir = path.join(tempDir, 'TestAgent');
|
|
209
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
210
|
+
fs.writeFileSync(
|
|
211
|
+
path.join(agentDir, 'inbox.md'),
|
|
212
|
+
'# 📬 INBOX\n'
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const manager = new InboxManager({
|
|
216
|
+
agentName: 'TestAgent',
|
|
217
|
+
inboxDir: tempDir,
|
|
218
|
+
});
|
|
219
|
+
expect(manager.hasMessages()).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('returns true when inbox has messages', () => {
|
|
223
|
+
const manager = new InboxManager({
|
|
224
|
+
agentName: 'TestAgent',
|
|
225
|
+
inboxDir: tempDir,
|
|
226
|
+
});
|
|
227
|
+
manager.init();
|
|
228
|
+
manager.addMessage('Sender', 'A message');
|
|
229
|
+
|
|
230
|
+
expect(manager.hasMessages()).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
package/src/inbox.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based Inbox Manager
|
|
3
|
+
* Writes incoming messages to a file that the CLI agent reads itself.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_INBOX_DIR = '/tmp/agent-relay';
|
|
10
|
+
|
|
11
|
+
export interface InboxConfig {
|
|
12
|
+
agentName: string;
|
|
13
|
+
inboxDir: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class InboxManager {
|
|
17
|
+
private config: InboxConfig;
|
|
18
|
+
private inboxPath: string;
|
|
19
|
+
|
|
20
|
+
constructor(config: Partial<InboxConfig> & { agentName: string }) {
|
|
21
|
+
this.config = {
|
|
22
|
+
inboxDir: DEFAULT_INBOX_DIR,
|
|
23
|
+
...config,
|
|
24
|
+
};
|
|
25
|
+
this.inboxPath = path.join(this.config.inboxDir, this.config.agentName, 'inbox.md');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initialize inbox directory and file.
|
|
30
|
+
*/
|
|
31
|
+
init(): void {
|
|
32
|
+
const dir = path.dirname(this.inboxPath);
|
|
33
|
+
if (!fs.existsSync(dir)) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
// Start with empty inbox
|
|
37
|
+
this.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the inbox file path (for telling the agent where to read).
|
|
42
|
+
*/
|
|
43
|
+
getInboxPath(): string {
|
|
44
|
+
return this.inboxPath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Add a message to the inbox.
|
|
49
|
+
*/
|
|
50
|
+
addMessage(from: string, body: string): void {
|
|
51
|
+
const timestamp = new Date().toISOString();
|
|
52
|
+
const entry = `\n## Message from ${from} | ${timestamp}\n${body}\n`;
|
|
53
|
+
|
|
54
|
+
// Read existing content
|
|
55
|
+
let content = '';
|
|
56
|
+
if (fs.existsSync(this.inboxPath)) {
|
|
57
|
+
content = fs.readFileSync(this.inboxPath, 'utf-8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// If empty, add header
|
|
61
|
+
if (!content.trim()) {
|
|
62
|
+
content = `# 📬 INBOX - CHECK AND RESPOND TO ALL MESSAGES\n`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Append new message
|
|
66
|
+
content += entry;
|
|
67
|
+
|
|
68
|
+
// Atomic write (write to temp, then rename)
|
|
69
|
+
const tmpPath = `${this.inboxPath}.tmp`;
|
|
70
|
+
fs.writeFileSync(tmpPath, content, 'utf-8');
|
|
71
|
+
fs.renameSync(tmpPath, this.inboxPath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Clear the inbox.
|
|
76
|
+
*/
|
|
77
|
+
clear(): void {
|
|
78
|
+
fs.writeFileSync(this.inboxPath, '', 'utf-8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if inbox has messages.
|
|
83
|
+
*/
|
|
84
|
+
hasMessages(): boolean {
|
|
85
|
+
if (!fs.existsSync(this.inboxPath)) return false;
|
|
86
|
+
const content = fs.readFileSync(this.inboxPath, 'utf-8');
|
|
87
|
+
return content.includes('## Message from');
|
|
88
|
+
}
|
|
89
|
+
}
|