@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,511 @@
1
+ /**
2
+ * StuckDetector - Detect when an agent is stuck
3
+ *
4
+ * Implements agent-relay-501: Stuck detection heuristics
5
+ *
6
+ * Detects five stuck conditions:
7
+ * 1. Extended idle (no output for 10+ minutes)
8
+ * 2. Error loop (same error message repeated 3+ times)
9
+ * 3. Output loop (same output pattern repeated 3+ times)
10
+ * 4. Tool loop (same file operated on 10+ times in 5 minutes)
11
+ * 5. Output flood (abnormally high output rate suggesting infinite loop)
12
+ *
13
+ * NOTE: Message intent detection (agent says "I'll send" but doesn't) was removed
14
+ * because pattern-based NLP detection is unreliable. A protocol-level approach
15
+ * (detecting stale outbox files) should be implemented in relay-pty instead.
16
+ *
17
+ * Emits 'stuck' event when detected, with reason and details.
18
+ */
19
+
20
+ import { EventEmitter } from 'node:events';
21
+
22
+ export type StuckReason = 'extended_idle' | 'error_loop' | 'output_loop' | 'tool_loop' | 'output_flood';
23
+
24
+ /**
25
+ * Tracked tool invocation for loop detection
26
+ */
27
+ interface ToolInvocation {
28
+ tool: string; // 'Read', 'Write', 'Edit', 'Bash', etc.
29
+ target: string; // File path or command
30
+ timestamp: number;
31
+ }
32
+
33
+ export interface StuckEvent {
34
+ reason: StuckReason;
35
+ details: string;
36
+ timestamp: number;
37
+ /** Time since last output in ms (for extended_idle) */
38
+ idleDurationMs?: number;
39
+ /** Repeated content (for loops) */
40
+ repeatedContent?: string;
41
+ /** Number of repetitions (for loops) */
42
+ repetitions?: number;
43
+ /** Target file/command (for tool_loop) */
44
+ targetFile?: string;
45
+ /** Tool name (for tool_loop) */
46
+ toolName?: string;
47
+ /** Output rate in lines per minute (for output_flood) */
48
+ linesPerMinute?: number;
49
+ }
50
+
51
+ export interface StuckDetectorConfig {
52
+ /** Duration of inactivity before considered stuck (ms, default: 10 minutes) */
53
+ extendedIdleMs?: number;
54
+ /** Number of repeated outputs before considered stuck (default: 3) */
55
+ loopThreshold?: number;
56
+ /** Check interval (ms, default: 30 seconds) */
57
+ checkIntervalMs?: number;
58
+ /** Minimum output length to consider for loop detection */
59
+ minLoopLength?: number;
60
+ /** Error patterns to detect (regexes) */
61
+ errorPatterns?: RegExp[];
62
+ /** Threshold for same file operations before considered stuck (default: 10) */
63
+ toolLoopThreshold?: number;
64
+ /** Time window for tool loop detection (ms, default: 5 minutes) */
65
+ toolLoopWindowMs?: number;
66
+ /** Output lines per minute threshold for flood detection (default: 5000) */
67
+ outputFloodLinesPerMinute?: number;
68
+ /** Minimum duration before flood detection activates (ms, default: 2 minutes) */
69
+ outputFloodMinDurationMs?: number;
70
+ }
71
+
72
+ const DEFAULT_CONFIG: Required<StuckDetectorConfig> = {
73
+ extendedIdleMs: 10 * 60 * 1000, // 10 minutes
74
+ loopThreshold: 3,
75
+ checkIntervalMs: 30 * 1000, // 30 seconds
76
+ minLoopLength: 20, // Minimum chars to consider a meaningful loop
77
+ errorPatterns: [
78
+ /error:/i,
79
+ /failed:/i,
80
+ /exception:/i,
81
+ /timeout/i,
82
+ /connection refused/i,
83
+ /permission denied/i,
84
+ /command not found/i,
85
+ /no such file/i,
86
+ ],
87
+ toolLoopThreshold: 10, // Same file operated on 10+ times = likely stuck
88
+ toolLoopWindowMs: 5 * 60 * 1000, // 5 minute window for tool loop detection
89
+ outputFloodLinesPerMinute: 5000, // 5000+ lines/min is abnormal
90
+ outputFloodMinDurationMs: 2 * 60 * 1000, // Wait 2 minutes before flood detection
91
+ };
92
+
93
+ /** Patterns to extract tool invocations from Claude Code output */
94
+ const TOOL_PATTERNS = [
95
+ // Claude Code tool patterns: ⏺ Write(path), ⏺ Read(path), etc.
96
+ /[⏺●]\s*(Read|Write|Edit|Glob|Grep|Bash)\(([^)]+)\)/g,
97
+ // Alternative patterns without symbols
98
+ /\b(Read|Write|Edit)\s*\(\s*([^)]+)\s*\)/g,
99
+ ];
100
+
101
+ export class StuckDetector extends EventEmitter {
102
+ private config: Required<StuckDetectorConfig>;
103
+ private lastOutputTime = Date.now();
104
+ private recentOutputs: string[] = [];
105
+ private checkInterval: NodeJS.Timeout | null = null;
106
+ private isStuck = false;
107
+ private stuckReason: StuckReason | null = null;
108
+
109
+ // Tool loop detection
110
+ private toolInvocations: ToolInvocation[] = [];
111
+
112
+ // Output flood detection
113
+ private outputLineCount = 0;
114
+ private outputStartTime = Date.now();
115
+
116
+ constructor(config: StuckDetectorConfig = {}) {
117
+ super();
118
+ this.config = { ...DEFAULT_CONFIG, ...config };
119
+ }
120
+
121
+ /**
122
+ * Start monitoring for stuck conditions.
123
+ * Call this after the agent process starts.
124
+ */
125
+ start(): void {
126
+ this.stop(); // Clear any existing interval
127
+ this.isStuck = false;
128
+ this.stuckReason = null;
129
+ this.lastOutputTime = Date.now();
130
+ this.recentOutputs = [];
131
+ this.toolInvocations = [];
132
+ this.outputLineCount = 0;
133
+ this.outputStartTime = Date.now();
134
+
135
+ this.checkInterval = setInterval(() => {
136
+ this.checkStuck();
137
+ }, this.config.checkIntervalMs);
138
+ }
139
+
140
+ /**
141
+ * Stop monitoring.
142
+ */
143
+ stop(): void {
144
+ if (this.checkInterval) {
145
+ clearInterval(this.checkInterval);
146
+ this.checkInterval = null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Feed output to the detector.
152
+ * Call this for every output chunk from the agent.
153
+ */
154
+ onOutput(chunk: string): void {
155
+ this.lastOutputTime = Date.now();
156
+
157
+ // Track output volume (count newlines)
158
+ const lineCount = (chunk.match(/\n/g) || []).length;
159
+ this.outputLineCount += lineCount;
160
+
161
+ // Extract and track tool invocations
162
+ this.extractToolInvocations(chunk);
163
+
164
+ // Normalize and store recent output
165
+ const normalized = this.normalizeOutput(chunk);
166
+ if (normalized.length >= this.config.minLoopLength) {
167
+ this.recentOutputs.push(normalized);
168
+
169
+ // Keep only recent outputs (5x threshold for pattern detection)
170
+ const maxOutputs = this.config.loopThreshold * 5;
171
+ if (this.recentOutputs.length > maxOutputs) {
172
+ this.recentOutputs = this.recentOutputs.slice(-maxOutputs);
173
+ }
174
+ }
175
+
176
+ // If we were stuck, check if we're unstuck now
177
+ if (this.isStuck) {
178
+ this.isStuck = false;
179
+ this.stuckReason = null;
180
+ this.emit('unstuck', { timestamp: Date.now() });
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Extract tool invocations from output and track them.
186
+ */
187
+ private extractToolInvocations(chunk: string): void {
188
+ const now = Date.now();
189
+
190
+ // Strip ANSI codes for cleaner matching
191
+ // eslint-disable-next-line no-control-regex
192
+ const clean = chunk.replace(/\x1B(?:\[[0-9;?]*[A-Za-z]|\].*?(?:\x07|\x1B\\)|[@-Z\\-_])/g, '');
193
+
194
+ // Track what we've already matched in this chunk to prevent duplicates
195
+ const matchedInChunk = new Set<string>();
196
+
197
+ for (const pattern of TOOL_PATTERNS) {
198
+ // Reset lastIndex for global regex
199
+ pattern.lastIndex = 0;
200
+ let match;
201
+ while ((match = pattern.exec(clean)) !== null) {
202
+ const tool = match[1];
203
+ const target = match[2].trim();
204
+
205
+ // Normalize file paths (remove ~ prefix, trailing whitespace)
206
+ const normalizedTarget = target
207
+ .replace(/^~\//, '')
208
+ .replace(/\s+$/, '');
209
+
210
+ // Deduplicate within this chunk (multiple patterns may match same invocation)
211
+ const key = `${tool}:${normalizedTarget}`;
212
+ if (matchedInChunk.has(key)) continue;
213
+ matchedInChunk.add(key);
214
+
215
+ this.toolInvocations.push({
216
+ tool,
217
+ target: normalizedTarget,
218
+ timestamp: now,
219
+ });
220
+ }
221
+ }
222
+
223
+ // Prune old invocations outside the window
224
+ const windowStart = now - this.config.toolLoopWindowMs;
225
+ this.toolInvocations = this.toolInvocations.filter(
226
+ inv => inv.timestamp >= windowStart
227
+ );
228
+ }
229
+
230
+ /**
231
+ * Normalize output for comparison (strip ANSI, trim, lowercase).
232
+ */
233
+ private normalizeOutput(output: string): string {
234
+ // Strip ANSI escape codes
235
+ // eslint-disable-next-line no-control-regex
236
+ let normalized = output.replace(/\x1B(?:\[[0-9;?]*[A-Za-z]|\].*?(?:\x07|\x1B\\)|[@-Z\\-_])/g, '');
237
+ // Normalize whitespace
238
+ normalized = normalized.replace(/\s+/g, ' ').trim().toLowerCase();
239
+ return normalized;
240
+ }
241
+
242
+ /**
243
+ * Check for stuck conditions.
244
+ */
245
+ private checkStuck(): void {
246
+ // Don't re-emit if already stuck
247
+ if (this.isStuck) return;
248
+
249
+ // Check 1: Extended idle
250
+ const idleDuration = Date.now() - this.lastOutputTime;
251
+ if (idleDuration >= this.config.extendedIdleMs) {
252
+ this.emitStuck({
253
+ reason: 'extended_idle',
254
+ details: `No output for ${Math.round(idleDuration / 60000)} minutes`,
255
+ timestamp: Date.now(),
256
+ idleDurationMs: idleDuration,
257
+ });
258
+ return;
259
+ }
260
+
261
+ // Check 2: Error loop
262
+ const errorLoop = this.detectErrorLoop();
263
+ if (errorLoop) {
264
+ this.emitStuck({
265
+ reason: 'error_loop',
266
+ details: `Same error repeated ${errorLoop.count} times`,
267
+ timestamp: Date.now(),
268
+ repeatedContent: errorLoop.error,
269
+ repetitions: errorLoop.count,
270
+ });
271
+ return;
272
+ }
273
+
274
+ // Check 3: Output loop
275
+ const outputLoop = this.detectOutputLoop();
276
+ if (outputLoop) {
277
+ this.emitStuck({
278
+ reason: 'output_loop',
279
+ details: `Same output repeated ${outputLoop.count} times`,
280
+ timestamp: Date.now(),
281
+ repeatedContent: outputLoop.output.substring(0, 100),
282
+ repetitions: outputLoop.count,
283
+ });
284
+ return;
285
+ }
286
+
287
+ // Check 4: Tool loop (same file operated on repeatedly)
288
+ const toolLoop = this.detectToolLoop();
289
+ if (toolLoop) {
290
+ this.emitStuck({
291
+ reason: 'tool_loop',
292
+ details: `${toolLoop.tool} called on "${toolLoop.target}" ${toolLoop.count} times in ${Math.round(this.config.toolLoopWindowMs / 60000)} minutes`,
293
+ timestamp: Date.now(),
294
+ targetFile: toolLoop.target,
295
+ toolName: toolLoop.tool,
296
+ repetitions: toolLoop.count,
297
+ });
298
+ return;
299
+ }
300
+
301
+ // Check 5: Output flood (abnormally high output rate)
302
+ const flood = this.detectOutputFlood();
303
+ if (flood) {
304
+ this.emitStuck({
305
+ reason: 'output_flood',
306
+ details: `Abnormally high output: ${flood.linesPerMinute.toFixed(0)} lines/min over ${Math.round(flood.durationMs / 60000)} minutes`,
307
+ timestamp: Date.now(),
308
+ linesPerMinute: flood.linesPerMinute,
309
+ });
310
+ return;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Detect repeated error messages.
316
+ */
317
+ private detectErrorLoop(): { error: string; count: number } | null {
318
+ const errorOutputs: string[] = [];
319
+
320
+ for (const output of this.recentOutputs) {
321
+ for (const pattern of this.config.errorPatterns) {
322
+ if (pattern.test(output)) {
323
+ errorOutputs.push(output);
324
+ break;
325
+ }
326
+ }
327
+ }
328
+
329
+ if (errorOutputs.length < this.config.loopThreshold) {
330
+ return null;
331
+ }
332
+
333
+ // Check if the same error appears repeatedly
334
+ const errorCounts = new Map<string, number>();
335
+ for (const error of errorOutputs) {
336
+ const count = (errorCounts.get(error) || 0) + 1;
337
+ errorCounts.set(error, count);
338
+
339
+ if (count >= this.config.loopThreshold) {
340
+ return { error, count };
341
+ }
342
+ }
343
+
344
+ return null;
345
+ }
346
+
347
+ /**
348
+ * Detect repeated output patterns (not necessarily errors).
349
+ */
350
+ private detectOutputLoop(): { output: string; count: number } | null {
351
+ if (this.recentOutputs.length < this.config.loopThreshold) {
352
+ return null;
353
+ }
354
+
355
+ // Check for identical consecutive outputs
356
+ const outputCounts = new Map<string, number>();
357
+ for (const output of this.recentOutputs) {
358
+ const count = (outputCounts.get(output) || 0) + 1;
359
+ outputCounts.set(output, count);
360
+
361
+ if (count >= this.config.loopThreshold) {
362
+ return { output, count };
363
+ }
364
+ }
365
+
366
+ return null;
367
+ }
368
+
369
+ /**
370
+ * Detect when the same file is being operated on repeatedly.
371
+ * This catches cases like an agent repeatedly reading/writing the same file
372
+ * in a loop, even if the output content differs each time.
373
+ */
374
+ private detectToolLoop(): { tool: string; target: string; count: number } | null {
375
+ if (this.toolInvocations.length < this.config.toolLoopThreshold) {
376
+ return null;
377
+ }
378
+
379
+ // Count operations per file (combining all tool types)
380
+ const fileCounts = new Map<string, { count: number; tools: Set<string> }>();
381
+
382
+ for (const inv of this.toolInvocations) {
383
+ const existing = fileCounts.get(inv.target);
384
+ if (existing) {
385
+ existing.count++;
386
+ existing.tools.add(inv.tool);
387
+ } else {
388
+ fileCounts.set(inv.target, { count: 1, tools: new Set([inv.tool]) });
389
+ }
390
+ }
391
+
392
+ // Find files that exceed the threshold
393
+ for (const [target, data] of Array.from(fileCounts.entries())) {
394
+ if (data.count >= this.config.toolLoopThreshold) {
395
+ // Report the most common tool used on this file
396
+ const toolCounts = new Map<string, number>();
397
+ for (const inv of this.toolInvocations) {
398
+ if (inv.target === target) {
399
+ toolCounts.set(inv.tool, (toolCounts.get(inv.tool) || 0) + 1);
400
+ }
401
+ }
402
+
403
+ let maxTool = 'Unknown';
404
+ let maxCount = 0;
405
+ for (const [tool, toolCount] of Array.from(toolCounts.entries())) {
406
+ if (toolCount > maxCount) {
407
+ maxTool = tool;
408
+ maxCount = toolCount;
409
+ }
410
+ }
411
+
412
+ return { tool: maxTool, target, count: data.count };
413
+ }
414
+ }
415
+
416
+ return null;
417
+ }
418
+
419
+ /**
420
+ * Detect abnormally high output rates that suggest an infinite loop.
421
+ * Only triggers after minimum duration to avoid false positives during
422
+ * normal high-output operations (like builds or tests).
423
+ */
424
+ private detectOutputFlood(): { linesPerMinute: number; durationMs: number } | null {
425
+ const durationMs = Date.now() - this.outputStartTime;
426
+
427
+ // Don't check until minimum duration has passed
428
+ if (durationMs < this.config.outputFloodMinDurationMs) {
429
+ return null;
430
+ }
431
+
432
+ const durationMinutes = durationMs / 60000;
433
+ const linesPerMinute = this.outputLineCount / durationMinutes;
434
+
435
+ if (linesPerMinute >= this.config.outputFloodLinesPerMinute) {
436
+ return { linesPerMinute, durationMs };
437
+ }
438
+
439
+ return null;
440
+ }
441
+
442
+ /**
443
+ * Emit stuck event.
444
+ */
445
+ private emitStuck(event: StuckEvent): void {
446
+ this.isStuck = true;
447
+ this.stuckReason = event.reason;
448
+ this.emit('stuck', event);
449
+ }
450
+
451
+ /**
452
+ * Check if currently detected as stuck.
453
+ */
454
+ getIsStuck(): boolean {
455
+ return this.isStuck;
456
+ }
457
+
458
+ /**
459
+ * Get the reason for being stuck (if stuck).
460
+ */
461
+ getStuckReason(): StuckReason | null {
462
+ return this.stuckReason;
463
+ }
464
+
465
+ /**
466
+ * Get time since last output in milliseconds.
467
+ */
468
+ getIdleDuration(): number {
469
+ return Date.now() - this.lastOutputTime;
470
+ }
471
+
472
+ /**
473
+ * Reset state.
474
+ */
475
+ reset(): void {
476
+ this.isStuck = false;
477
+ this.stuckReason = null;
478
+ this.lastOutputTime = Date.now();
479
+ this.recentOutputs = [];
480
+ this.toolInvocations = [];
481
+ this.outputLineCount = 0;
482
+ this.outputStartTime = Date.now();
483
+ }
484
+
485
+ /**
486
+ * Get current output statistics (useful for debugging).
487
+ */
488
+ getOutputStats(): { lineCount: number; durationMs: number; linesPerMinute: number } {
489
+ const durationMs = Date.now() - this.outputStartTime;
490
+ const durationMinutes = Math.max(durationMs / 60000, 0.001); // Avoid division by zero
491
+ return {
492
+ lineCount: this.outputLineCount,
493
+ durationMs,
494
+ linesPerMinute: this.outputLineCount / durationMinutes,
495
+ };
496
+ }
497
+
498
+ /**
499
+ * Get recent tool invocations (useful for debugging).
500
+ */
501
+ getToolInvocations(): ToolInvocation[] {
502
+ return [...this.toolInvocations];
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Create a stuck detector with default configuration.
508
+ */
509
+ export function createStuckDetector(config: StuckDetectorConfig = {}): StuckDetector {
510
+ return new StuckDetector(config);
511
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tmux Resolver Tests
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+ import {
7
+ getTmuxPath,
8
+ resolveTmux,
9
+ isTmuxAvailable,
10
+ checkTmuxVersion,
11
+ getPlatformIdentifier,
12
+ TmuxNotFoundError,
13
+ getBundledTmuxDir,
14
+ getBundledTmuxPath,
15
+ MIN_TMUX_VERSION,
16
+ } from './tmux-resolver.js';
17
+
18
+ describe('tmux-resolver', () => {
19
+ describe('constants', () => {
20
+ it('should export MIN_TMUX_VERSION', () => {
21
+ expect(MIN_TMUX_VERSION).toBe('3.0');
22
+ });
23
+
24
+ it('should export bundled path functions', () => {
25
+ expect(typeof getBundledTmuxDir).toBe('function');
26
+ expect(typeof getBundledTmuxPath).toBe('function');
27
+ });
28
+ });
29
+
30
+ describe('getPlatformIdentifier', () => {
31
+ it('should return platform identifier for supported platforms', () => {
32
+ const identifier = getPlatformIdentifier();
33
+ // Should be one of the supported platforms or null
34
+ if (identifier !== null) {
35
+ expect([
36
+ 'macos-arm64',
37
+ 'macos-x86_64',
38
+ 'linux-arm64',
39
+ 'linux-x86_64',
40
+ ]).toContain(identifier);
41
+ }
42
+ });
43
+ });
44
+
45
+ describe('TmuxNotFoundError', () => {
46
+ it('should be an Error instance', () => {
47
+ const error = new TmuxNotFoundError();
48
+ expect(error).toBeInstanceOf(Error);
49
+ });
50
+
51
+ it('should have name TmuxNotFoundError', () => {
52
+ const error = new TmuxNotFoundError();
53
+ expect(error.name).toBe('TmuxNotFoundError');
54
+ });
55
+
56
+ it('should include installation instructions', () => {
57
+ const error = new TmuxNotFoundError();
58
+ expect(error.message).toContain('tmux is required');
59
+ });
60
+ });
61
+
62
+ describe('resolveTmux', () => {
63
+ it('should return TmuxInfo or null', () => {
64
+ const result = resolveTmux();
65
+ if (result !== null) {
66
+ expect(result).toHaveProperty('path');
67
+ expect(result).toHaveProperty('version');
68
+ expect(result).toHaveProperty('isBundled');
69
+ expect(typeof result.path).toBe('string');
70
+ expect(typeof result.version).toBe('string');
71
+ expect(typeof result.isBundled).toBe('boolean');
72
+ }
73
+ });
74
+ });
75
+
76
+ describe('isTmuxAvailable', () => {
77
+ it('should return boolean', () => {
78
+ expect(typeof isTmuxAvailable()).toBe('boolean');
79
+ });
80
+ });
81
+
82
+ describe('checkTmuxVersion', () => {
83
+ it('should return version check result', () => {
84
+ const result = checkTmuxVersion();
85
+ expect(result).toHaveProperty('ok');
86
+ expect(result).toHaveProperty('version');
87
+ expect(result).toHaveProperty('minimum');
88
+ expect(typeof result.ok).toBe('boolean');
89
+ expect(result.minimum).toBe(MIN_TMUX_VERSION);
90
+ });
91
+ });
92
+
93
+ describe('getTmuxPath', () => {
94
+ it('should return path string or throw TmuxNotFoundError', () => {
95
+ try {
96
+ const path = getTmuxPath();
97
+ expect(typeof path).toBe('string');
98
+ expect(path.length).toBeGreaterThan(0);
99
+ } catch (error) {
100
+ expect(error).toBeInstanceOf(TmuxNotFoundError);
101
+ }
102
+ });
103
+ });
104
+ });