@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.
Files changed (115) hide show
  1. package/dist/__fixtures__/claude-outputs.d.ts +49 -0
  2. package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
  3. package/dist/__fixtures__/claude-outputs.js +443 -0
  4. package/dist/__fixtures__/claude-outputs.js.map +1 -0
  5. package/dist/__fixtures__/codex-outputs.d.ts +9 -0
  6. package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
  7. package/dist/__fixtures__/codex-outputs.js +94 -0
  8. package/dist/__fixtures__/codex-outputs.js.map +1 -0
  9. package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
  10. package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
  11. package/dist/__fixtures__/gemini-outputs.js +144 -0
  12. package/dist/__fixtures__/gemini-outputs.js.map +1 -0
  13. package/dist/__fixtures__/index.d.ts +68 -0
  14. package/dist/__fixtures__/index.d.ts.map +1 -0
  15. package/dist/__fixtures__/index.js +44 -0
  16. package/dist/__fixtures__/index.js.map +1 -0
  17. package/dist/auth-detection.d.ts +49 -0
  18. package/dist/auth-detection.d.ts.map +1 -0
  19. package/dist/auth-detection.js +199 -0
  20. package/dist/auth-detection.js.map +1 -0
  21. package/dist/base-wrapper.d.ts +225 -0
  22. package/dist/base-wrapper.d.ts.map +1 -0
  23. package/dist/base-wrapper.js +572 -0
  24. package/dist/base-wrapper.js.map +1 -0
  25. package/dist/client.d.ts +254 -0
  26. package/dist/client.d.ts.map +1 -0
  27. package/dist/client.js +801 -0
  28. package/dist/client.js.map +1 -0
  29. package/dist/id-generator.d.ts +35 -0
  30. package/dist/id-generator.d.ts.map +1 -0
  31. package/dist/id-generator.js +60 -0
  32. package/dist/id-generator.js.map +1 -0
  33. package/dist/idle-detector.d.ts +110 -0
  34. package/dist/idle-detector.d.ts.map +1 -0
  35. package/dist/idle-detector.js +304 -0
  36. package/dist/idle-detector.js.map +1 -0
  37. package/dist/inbox.d.ts +37 -0
  38. package/dist/inbox.d.ts.map +1 -0
  39. package/dist/inbox.js +73 -0
  40. package/dist/inbox.js.map +1 -0
  41. package/dist/index.d.ts +37 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +47 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/parser.d.ts +236 -0
  46. package/dist/parser.d.ts.map +1 -0
  47. package/dist/parser.js +1238 -0
  48. package/dist/parser.js.map +1 -0
  49. package/dist/prompt-composer.d.ts +67 -0
  50. package/dist/prompt-composer.d.ts.map +1 -0
  51. package/dist/prompt-composer.js +168 -0
  52. package/dist/prompt-composer.js.map +1 -0
  53. package/dist/relay-pty-orchestrator.d.ts +407 -0
  54. package/dist/relay-pty-orchestrator.d.ts.map +1 -0
  55. package/dist/relay-pty-orchestrator.js +1885 -0
  56. package/dist/relay-pty-orchestrator.js.map +1 -0
  57. package/dist/shared.d.ts +201 -0
  58. package/dist/shared.d.ts.map +1 -0
  59. package/dist/shared.js +341 -0
  60. package/dist/shared.js.map +1 -0
  61. package/dist/stuck-detector.d.ts +161 -0
  62. package/dist/stuck-detector.d.ts.map +1 -0
  63. package/dist/stuck-detector.js +402 -0
  64. package/dist/stuck-detector.js.map +1 -0
  65. package/dist/tmux-resolver.d.ts +55 -0
  66. package/dist/tmux-resolver.d.ts.map +1 -0
  67. package/dist/tmux-resolver.js +175 -0
  68. package/dist/tmux-resolver.js.map +1 -0
  69. package/dist/tmux-wrapper.d.ts +345 -0
  70. package/dist/tmux-wrapper.d.ts.map +1 -0
  71. package/dist/tmux-wrapper.js +1747 -0
  72. package/dist/tmux-wrapper.js.map +1 -0
  73. package/dist/trajectory-integration.d.ts +292 -0
  74. package/dist/trajectory-integration.d.ts.map +1 -0
  75. package/dist/trajectory-integration.js +979 -0
  76. package/dist/trajectory-integration.js.map +1 -0
  77. package/dist/wrapper-types.d.ts +41 -0
  78. package/dist/wrapper-types.d.ts.map +1 -0
  79. package/dist/wrapper-types.js +7 -0
  80. package/dist/wrapper-types.js.map +1 -0
  81. package/package.json +63 -0
  82. package/src/__fixtures__/claude-outputs.ts +471 -0
  83. package/src/__fixtures__/codex-outputs.ts +99 -0
  84. package/src/__fixtures__/gemini-outputs.ts +151 -0
  85. package/src/__fixtures__/index.ts +47 -0
  86. package/src/auth-detection.ts +244 -0
  87. package/src/base-wrapper.test.ts +540 -0
  88. package/src/base-wrapper.ts +741 -0
  89. package/src/client.test.ts +262 -0
  90. package/src/client.ts +984 -0
  91. package/src/id-generator.test.ts +71 -0
  92. package/src/id-generator.ts +69 -0
  93. package/src/idle-detector.test.ts +390 -0
  94. package/src/idle-detector.ts +370 -0
  95. package/src/inbox.test.ts +233 -0
  96. package/src/inbox.ts +89 -0
  97. package/src/index.ts +170 -0
  98. package/src/parser.regression.test.ts +251 -0
  99. package/src/parser.test.ts +1359 -0
  100. package/src/parser.ts +1477 -0
  101. package/src/prompt-composer.test.ts +219 -0
  102. package/src/prompt-composer.ts +231 -0
  103. package/src/relay-pty-orchestrator.test.ts +1027 -0
  104. package/src/relay-pty-orchestrator.ts +2270 -0
  105. package/src/shared.test.ts +221 -0
  106. package/src/shared.ts +454 -0
  107. package/src/stuck-detector.test.ts +303 -0
  108. package/src/stuck-detector.ts +511 -0
  109. package/src/tmux-resolver.test.ts +104 -0
  110. package/src/tmux-resolver.ts +207 -0
  111. package/src/tmux-wrapper.test.ts +316 -0
  112. package/src/tmux-wrapper.ts +2010 -0
  113. package/src/trajectory-detection.test.ts +151 -0
  114. package/src/trajectory-integration.ts +1261 -0
  115. 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
+ }