@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,221 @@
1
+ /**
2
+ * Unit tests for shared wrapper utilities
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { buildInjectionString, type QueuedMessage } from './shared.js';
7
+
8
+ describe('buildInjectionString', () => {
9
+ const baseMessage: QueuedMessage = {
10
+ from: 'TestAgent',
11
+ body: 'Hello world',
12
+ messageId: 'abc12345-6789-0123-4567-890123456789',
13
+ };
14
+
15
+ describe('sender name display', () => {
16
+ it('uses msg.from when from is not _DashboardUI', () => {
17
+ const msg: QueuedMessage = {
18
+ ...baseMessage,
19
+ from: 'RegularAgent',
20
+ };
21
+ const result = buildInjectionString(msg);
22
+ expect(result).toContain('Relay message from RegularAgent');
23
+ });
24
+
25
+ it('uses msg.from when from is _DashboardUI but no senderName in data', () => {
26
+ const msg: QueuedMessage = {
27
+ ...baseMessage,
28
+ from: '_DashboardUI',
29
+ };
30
+ const result = buildInjectionString(msg);
31
+ expect(result).toContain('Relay message from _DashboardUI');
32
+ });
33
+
34
+ it('uses senderName when from is _DashboardUI and senderName exists', () => {
35
+ const msg: QueuedMessage = {
36
+ ...baseMessage,
37
+ from: '_DashboardUI',
38
+ data: { senderName: 'GitHubUser123' },
39
+ };
40
+ const result = buildInjectionString(msg);
41
+ expect(result).toContain('Relay message from GitHubUser123');
42
+ expect(result).not.toContain('_DashboardUI');
43
+ });
44
+
45
+ it('uses msg.from when senderName is not a string', () => {
46
+ const msg: QueuedMessage = {
47
+ ...baseMessage,
48
+ from: '_DashboardUI',
49
+ data: { senderName: 12345 }, // not a string
50
+ };
51
+ const result = buildInjectionString(msg);
52
+ expect(result).toContain('Relay message from _DashboardUI');
53
+ });
54
+
55
+ it('uses msg.from when senderName is empty string', () => {
56
+ const msg: QueuedMessage = {
57
+ ...baseMessage,
58
+ from: '_DashboardUI',
59
+ data: { senderName: '' },
60
+ };
61
+ // Empty string is falsy but still a string - our check uses typeof === 'string'
62
+ // So empty string will be used (which may show as empty sender)
63
+ // This is intentional - empty senderName shouldn't happen in practice
64
+ const result = buildInjectionString(msg);
65
+ expect(result).toContain('Relay message from ['); // empty between 'from' and '['
66
+ });
67
+
68
+ it('does not use senderName when from is not _DashboardUI even if senderName exists', () => {
69
+ const msg: QueuedMessage = {
70
+ ...baseMessage,
71
+ from: 'OtherAgent',
72
+ data: { senderName: 'ShouldNotBeUsed' },
73
+ };
74
+ const result = buildInjectionString(msg);
75
+ expect(result).toContain('Relay message from OtherAgent');
76
+ expect(result).not.toContain('ShouldNotBeUsed');
77
+ });
78
+ });
79
+
80
+ describe('message formatting', () => {
81
+ it('includes short message ID', () => {
82
+ const result = buildInjectionString(baseMessage);
83
+ expect(result).toContain('[abc12345]');
84
+ });
85
+
86
+ it('includes thread hint when present', () => {
87
+ const msg: QueuedMessage = {
88
+ ...baseMessage,
89
+ thread: 'issue-123',
90
+ };
91
+ const result = buildInjectionString(msg);
92
+ expect(result).toContain('[thread:issue-123]');
93
+ });
94
+
95
+ it('includes channel hint for broadcasts', () => {
96
+ const msg: QueuedMessage = {
97
+ ...baseMessage,
98
+ originalTo: '*',
99
+ };
100
+ const result = buildInjectionString(msg);
101
+ expect(result).toContain('[#general]');
102
+ });
103
+
104
+ it('includes channel hint for channel messages', () => {
105
+ const msg: QueuedMessage = {
106
+ ...baseMessage,
107
+ originalTo: '#random',
108
+ };
109
+ const result = buildInjectionString(msg);
110
+ expect(result).toContain('[#random]');
111
+ });
112
+
113
+ it('includes importance indicator for high importance', () => {
114
+ const msg: QueuedMessage = {
115
+ ...baseMessage,
116
+ importance: 80,
117
+ };
118
+ const result = buildInjectionString(msg);
119
+ expect(result).toContain('[!!]');
120
+ });
121
+
122
+ it('includes importance indicator for medium importance', () => {
123
+ const msg: QueuedMessage = {
124
+ ...baseMessage,
125
+ importance: 60,
126
+ };
127
+ const result = buildInjectionString(msg);
128
+ expect(result).toContain('[!]');
129
+ expect(result).not.toContain('[!!]');
130
+ });
131
+ });
132
+
133
+ describe('double-wrapping prevention', () => {
134
+ it('returns body as-is when already formatted', () => {
135
+ const alreadyFormatted = 'Relay message from Alice [abc12345]: Hello world';
136
+ const msg: QueuedMessage = {
137
+ ...baseMessage,
138
+ body: alreadyFormatted,
139
+ };
140
+ const result = buildInjectionString(msg);
141
+ // Should NOT double-wrap
142
+ expect(result).toBe(alreadyFormatted);
143
+ expect(result).not.toContain('Relay message from TestAgent');
144
+ });
145
+
146
+ it('returns body as-is when already formatted with thread hint', () => {
147
+ const alreadyFormatted = 'Relay message from Alice [abc12345] [thread:task-123]: Hello world';
148
+ const msg: QueuedMessage = {
149
+ ...baseMessage,
150
+ body: alreadyFormatted,
151
+ };
152
+ const result = buildInjectionString(msg);
153
+ expect(result).toBe(alreadyFormatted);
154
+ });
155
+
156
+ it('returns body as-is when already formatted with channel hint', () => {
157
+ const alreadyFormatted = 'Relay message from Alice [abc12345] [#general]: Hello world';
158
+ const msg: QueuedMessage = {
159
+ ...baseMessage,
160
+ body: alreadyFormatted,
161
+ };
162
+ const result = buildInjectionString(msg);
163
+ expect(result).toBe(alreadyFormatted);
164
+ });
165
+
166
+ it('returns body as-is when already formatted with importance', () => {
167
+ const alreadyFormatted = 'Relay message from Alice [abc12345] [!!]: Urgent task';
168
+ const msg: QueuedMessage = {
169
+ ...baseMessage,
170
+ body: alreadyFormatted,
171
+ };
172
+ const result = buildInjectionString(msg);
173
+ expect(result).toBe(alreadyFormatted);
174
+ });
175
+
176
+ it('strips ANSI from already-formatted messages', () => {
177
+ // ANSI escape for bold
178
+ const withAnsi = '\x1b[1mRelay message from Alice [abc12345]: Hello\x1b[0m';
179
+ const msg: QueuedMessage = {
180
+ ...baseMessage,
181
+ body: withAnsi,
182
+ };
183
+ const result = buildInjectionString(msg);
184
+ expect(result).toBe('Relay message from Alice [abc12345]: Hello');
185
+ });
186
+
187
+ it('normalizes whitespace in already-formatted messages', () => {
188
+ const withNewlines = 'Relay message from Alice [abc12345]: Hello\nworld\ntest';
189
+ const msg: QueuedMessage = {
190
+ ...baseMessage,
191
+ body: withNewlines,
192
+ };
193
+ const result = buildInjectionString(msg);
194
+ expect(result).toBe('Relay message from Alice [abc12345]: Hello world test');
195
+ });
196
+
197
+ it('formats normally when body does not start with relay prefix', () => {
198
+ const normalBody = 'Hello world';
199
+ const msg: QueuedMessage = {
200
+ ...baseMessage,
201
+ body: normalBody,
202
+ };
203
+ const result = buildInjectionString(msg);
204
+ expect(result).toContain('Relay message from TestAgent');
205
+ expect(result).toContain('[abc12345]');
206
+ expect(result).toContain(': Hello world');
207
+ });
208
+
209
+ it('formats normally when body contains but does not start with relay prefix', () => {
210
+ // Body mentions relay message but doesn't start with it
211
+ const bodyWithMention = 'Please check the Relay message from Alice above';
212
+ const msg: QueuedMessage = {
213
+ ...baseMessage,
214
+ body: bodyWithMention,
215
+ };
216
+ const result = buildInjectionString(msg);
217
+ expect(result).toContain('Relay message from TestAgent');
218
+ expect(result).toContain(': Please check the Relay message from Alice above');
219
+ });
220
+ });
221
+ });
package/src/shared.ts ADDED
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Shared types and utilities for TmuxWrapper and PtyWrapper
3
+ *
4
+ * This module contains common code to prevent drift between the two
5
+ * wrapper implementations and reduce duplication.
6
+ */
7
+
8
+ import type { SyncMeta } from '@agent-relay/protocol/types';
9
+
10
+ /**
11
+ * Message queued for injection into an agent's terminal
12
+ */
13
+ export interface QueuedMessage {
14
+ from: string;
15
+ body: string;
16
+ messageId: string;
17
+ thread?: string;
18
+ importance?: number;
19
+ data?: Record<string, unknown>;
20
+ sync?: SyncMeta;
21
+ /** Original 'to' field - '*' indicates broadcast */
22
+ originalTo?: string;
23
+ }
24
+
25
+ /**
26
+ * Result of an injection attempt with retry
27
+ */
28
+ export interface InjectionResult {
29
+ success: boolean;
30
+ attempts: number;
31
+ fallbackUsed?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Metrics tracking injection reliability
36
+ */
37
+ export interface InjectionMetrics {
38
+ total: number;
39
+ successFirstTry: number;
40
+ successWithRetry: number;
41
+ failed: number;
42
+ }
43
+
44
+ /**
45
+ * CLI types for special handling
46
+ */
47
+ export type CliType = 'claude' | 'codex' | 'gemini' | 'droid' | 'opencode' | 'cursor' | 'spawned' | 'other';
48
+
49
+ /**
50
+ * Injection timing constants
51
+ *
52
+ * Performance tuning (2024-01):
53
+ * - QUEUE_PROCESS_DELAY_MS: 500ms → 100ms (5x faster message throughput)
54
+ * - STABILITY_POLL_MS: 200ms → 100ms (faster idle detection)
55
+ * - ENTER_DELAY_MS: 100ms → 50ms (faster message completion)
56
+ * - RETRY_BACKOFF_MS: 300ms → 200ms (faster recovery)
57
+ *
58
+ * Use AdaptiveThrottle class for dynamic backpressure handling.
59
+ */
60
+ export const INJECTION_CONSTANTS = {
61
+ /** Maximum retry attempts for injection */
62
+ MAX_RETRIES: 3,
63
+ /** Timeout for output stability check (ms) */
64
+ STABILITY_TIMEOUT_MS: 3000,
65
+ /** Polling interval for stability check (ms) - reduced from 200ms */
66
+ STABILITY_POLL_MS: 100,
67
+ /** Required consecutive stable polls before injection */
68
+ REQUIRED_STABLE_POLLS: 2,
69
+ /** Timeout for injection verification (ms) */
70
+ VERIFICATION_TIMEOUT_MS: 2000,
71
+ /** Delay between message and Enter key (ms) - reduced from 100ms */
72
+ ENTER_DELAY_MS: 50,
73
+ /** Backoff multiplier for retries (ms per attempt) - reduced from 300ms */
74
+ RETRY_BACKOFF_MS: 200,
75
+ /** Base delay between processing queued messages (ms) - reduced from 500ms */
76
+ QUEUE_PROCESS_DELAY_MS: 100,
77
+ /** Maximum delay when under backpressure (ms) */
78
+ QUEUE_PROCESS_DELAY_MAX_MS: 500,
79
+ /** Threshold for increasing delay (consecutive failures) */
80
+ BACKPRESSURE_THRESHOLD: 2,
81
+ } as const;
82
+
83
+ /**
84
+ * Adaptive throttle for message queue processing.
85
+ * Increases delay when failures occur, decreases on success.
86
+ *
87
+ * This allows fast messaging under normal conditions (~100ms between messages)
88
+ * while automatically backing off when the system is under stress.
89
+ */
90
+ export class AdaptiveThrottle {
91
+ private consecutiveFailures = 0;
92
+ private currentDelay: number = INJECTION_CONSTANTS.QUEUE_PROCESS_DELAY_MS;
93
+
94
+ /** Get current delay in milliseconds */
95
+ getDelay(): number {
96
+ return this.currentDelay;
97
+ }
98
+
99
+ /** Record a successful injection - decrease delay */
100
+ recordSuccess(): void {
101
+ this.consecutiveFailures = 0;
102
+ // Gradually decrease delay on success (exponential decay toward minimum)
103
+ this.currentDelay = Math.max(
104
+ INJECTION_CONSTANTS.QUEUE_PROCESS_DELAY_MS,
105
+ Math.floor(this.currentDelay * 0.7)
106
+ );
107
+ }
108
+
109
+ /** Record a failed injection - increase delay if threshold exceeded */
110
+ recordFailure(): void {
111
+ this.consecutiveFailures++;
112
+ if (this.consecutiveFailures >= INJECTION_CONSTANTS.BACKPRESSURE_THRESHOLD) {
113
+ // Increase delay when under backpressure (exponential backoff)
114
+ this.currentDelay = Math.min(
115
+ INJECTION_CONSTANTS.QUEUE_PROCESS_DELAY_MAX_MS,
116
+ Math.floor(this.currentDelay * 1.5)
117
+ );
118
+ }
119
+ }
120
+
121
+ /** Reset to default state */
122
+ reset(): void {
123
+ this.consecutiveFailures = 0;
124
+ this.currentDelay = INJECTION_CONSTANTS.QUEUE_PROCESS_DELAY_MS;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Strip ANSI escape codes from a string.
130
+ * Converts cursor movements to spaces to preserve visual layout.
131
+ */
132
+ export function stripAnsi(str: string): string {
133
+ // Convert cursor forward movements to spaces (CSI n C)
134
+ // eslint-disable-next-line no-control-regex
135
+ let result = str.replace(/\x1B\[(\d+)C/g, (_m, n) => ' '.repeat(parseInt(n, 10) || 1));
136
+
137
+ // Convert single cursor right (CSI C) to space
138
+ // eslint-disable-next-line no-control-regex
139
+ result = result.replace(/\x1B\[C/g, ' ');
140
+
141
+ // Remove carriage returns (causes text overwriting issues)
142
+ result = result.replace(/\r(?!\n)/g, '');
143
+
144
+ // Strip ANSI escape sequences (with \x1B prefix)
145
+ // eslint-disable-next-line no-control-regex
146
+ result = result.replace(/\x1B(?:\[[0-9;?]*[A-Za-z]|\].*?(?:\x07|\x1B\\)|[@-Z\\-_])/g, '');
147
+
148
+ // Strip orphaned CSI sequences that lost their escape byte
149
+ // Requires at least one digit or question mark to avoid stripping legitimate text like [Agent
150
+ result = result.replace(/^\s*(\[(?:\?|\d)\d*[A-Za-z])+\s*/g, '');
151
+
152
+ return result;
153
+ }
154
+
155
+ /**
156
+ * Sleep for a given number of milliseconds
157
+ */
158
+ export function sleep(ms: number): Promise<void> {
159
+ return new Promise((resolve) => setTimeout(resolve, ms));
160
+ }
161
+
162
+ /**
163
+ * Build the injection string for a relay message.
164
+ * Format: Relay message from {from} [{shortId}]{hints}: {body}
165
+ *
166
+ * If the body is already formatted (starts with "Relay message from"),
167
+ * returns it as-is to prevent double-wrapping.
168
+ */
169
+ export function buildInjectionString(msg: QueuedMessage): string {
170
+ // Check if body is already formatted (prevents double-wrapping)
171
+ // This can happen when:
172
+ // - Delivering queued/pending messages that were already formatted
173
+ // - Agent output includes quoted relay messages that get re-processed
174
+ // Strip ANSI first so escape codes don't interfere with detection
175
+ const sanitizedBody = stripAnsi(msg.body || '').replace(/[\r\n]+/g, ' ').trim();
176
+ if (sanitizedBody.startsWith('Relay message from ')) {
177
+ // Already formatted - return as-is
178
+ return sanitizedBody;
179
+ }
180
+
181
+ const shortId = msg.messageId.substring(0, 8);
182
+
183
+ // Use senderName from data if available (for dashboard messages sent via _DashboardUI)
184
+ // This allows showing the actual GitHub username instead of the system client name
185
+ const displayFrom = (msg.from === '_DashboardUI' && typeof msg.data?.senderName === 'string')
186
+ ? msg.data.senderName
187
+ : msg.from;
188
+
189
+ // Thread hint
190
+ const threadHint = msg.thread ? ` [thread:${msg.thread}]` : '';
191
+
192
+ // Importance indicator: [!!] for high (>75), [!] for medium (>50)
193
+ const importanceHint =
194
+ msg.importance !== undefined && msg.importance > 75
195
+ ? ' [!!]'
196
+ : msg.importance !== undefined && msg.importance > 50
197
+ ? ' [!]'
198
+ : '';
199
+
200
+ // Channel indicator for channel messages and broadcasts
201
+ // originalTo will be '*' for broadcasts or the channel name (e.g., '#general') for channel messages
202
+ // Make it clear that replies should go to the channel, not the sender
203
+ const channelHint = msg.originalTo === '*'
204
+ ? ' [#general] (reply to #general, not sender)'
205
+ : msg.originalTo?.startsWith('#')
206
+ ? ` [${msg.originalTo}] (reply to ${msg.originalTo}, not sender)`
207
+ : '';
208
+
209
+ // Extract attachment file paths if present
210
+ let attachmentHint = '';
211
+ if (msg.data?.attachments && Array.isArray(msg.data.attachments)) {
212
+ const filePaths = (msg.data.attachments as Array<{ filePath?: string }>)
213
+ .map((att) => att.filePath)
214
+ .filter((p): p is string => typeof p === 'string');
215
+ if (filePaths.length > 0) {
216
+ attachmentHint = ` [Attachments: ${filePaths.join(', ')}]`;
217
+ }
218
+ }
219
+
220
+ return `Relay message from ${displayFrom} [${shortId}]${threadHint}${importanceHint}${channelHint}${attachmentHint}: ${sanitizedBody}`;
221
+ }
222
+
223
+ /**
224
+ * Calculate injection success rate from metrics
225
+ */
226
+ export function calculateSuccessRate(metrics: InjectionMetrics): number {
227
+ if (metrics.total === 0) return 100;
228
+ const successful = metrics.successFirstTry + metrics.successWithRetry;
229
+ return Math.round((successful / metrics.total) * 10000) / 100;
230
+ }
231
+
232
+ /**
233
+ * Create a fresh injection metrics object
234
+ */
235
+ export function createInjectionMetrics(): InjectionMetrics {
236
+ return {
237
+ total: 0,
238
+ successFirstTry: 0,
239
+ successWithRetry: 0,
240
+ failed: 0,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Detect CLI type from command string
246
+ */
247
+ export function detectCliType(command: string): CliType {
248
+ const cmdLower = command.toLowerCase();
249
+ if (cmdLower.includes('gemini')) return 'gemini';
250
+ if (cmdLower.includes('codex')) return 'codex';
251
+ if (cmdLower.includes('claude')) return 'claude';
252
+ if (cmdLower.includes('droid')) return 'droid';
253
+ if (cmdLower.includes('opencode')) return 'opencode';
254
+ if (cmdLower.includes('cursor')) return 'cursor';
255
+ return 'other';
256
+ }
257
+
258
+ /**
259
+ * Get the default relay prefix (unified for all agent types)
260
+ */
261
+ export function getDefaultRelayPrefix(): string {
262
+ return '->relay:';
263
+ }
264
+
265
+ /**
266
+ * CLI-specific quirks and handling
267
+ */
268
+ export const CLI_QUIRKS = {
269
+ /**
270
+ * CLIs that support bracketed paste mode.
271
+ * Others may interpret the escape sequences literally.
272
+ */
273
+ supportsBracketedPaste: (cli: CliType): boolean => {
274
+ return cli === 'claude' || cli === 'codex' || cli === 'gemini' || cli === 'opencode' || cli === 'cursor';
275
+ },
276
+
277
+ /**
278
+ * Gemini interprets certain keywords (While, For, If, etc.) as shell commands.
279
+ * Wrap message in backticks to prevent shell keyword interpretation.
280
+ */
281
+ wrapForGemini: (body: string): string => {
282
+ return `\`${body.replace(/`/g, "'")}\``;
283
+ },
284
+
285
+ /**
286
+ * Get prompt pattern regex for a CLI type.
287
+ * Used to detect when input line is clear.
288
+ */
289
+ getPromptPattern: (cli: CliType): RegExp => {
290
+ const patterns: Record<CliType, RegExp> = {
291
+ claude: /^[>›»]\s*$/,
292
+ gemini: /^[>›»]\s*$/,
293
+ codex: /^[>›»]\s*$/,
294
+ droid: /^[>›»]\s*$/,
295
+ opencode: /^[>›»]\s*$/,
296
+ cursor: /^[>›»]\s*$/,
297
+ spawned: /^[>›»]\s*$/,
298
+ other: /^[>$%#➜›»]\s*$/,
299
+ };
300
+ return patterns[cli] || patterns.other;
301
+ },
302
+
303
+ /**
304
+ * Check if a line looks like a shell prompt (for Gemini safety check).
305
+ * Gemini can drop into shell mode - we skip injection to avoid executing commands.
306
+ */
307
+ isShellPrompt: (line: string): boolean => {
308
+ const clean = stripAnsi(line).trim();
309
+ return /^\$\s*$/.test(clean) || /^\s*\$\s*$/.test(clean);
310
+ },
311
+ } as const;
312
+
313
+ /**
314
+ * Callbacks for wrapper-specific injection operations.
315
+ * These allow the shared injection logic to work with both
316
+ * TmuxWrapper (tmux paste) and PtyWrapper (PTY write).
317
+ */
318
+ export interface InjectionCallbacks {
319
+ /** Get current output content for verification */
320
+ getOutput: () => Promise<string>;
321
+ /** Perform the actual injection (write to terminal) */
322
+ performInjection: (injection: string) => Promise<void>;
323
+ /** Log a message (debug/info level) */
324
+ log: (message: string) => void;
325
+ /** Log an error message */
326
+ logError: (message: string) => void;
327
+ /** Get the injection metrics object to update */
328
+ getMetrics: () => InjectionMetrics;
329
+ /**
330
+ * Skip verification and trust that write succeeded.
331
+ * Set to true for PTY-based injection where CLIs don't echo input.
332
+ * When true, injection succeeds on first attempt without verification.
333
+ */
334
+ skipVerification?: boolean;
335
+ }
336
+
337
+ /**
338
+ * Verify that an injected message appeared in the output.
339
+ * Uses a callback to get output content, allowing different backends
340
+ * (tmux capture-pane, PTY buffer) to be used.
341
+ *
342
+ * @param shortId - First 8 chars of message ID
343
+ * @param from - Sender name
344
+ * @param getOutput - Callback to retrieve current output
345
+ * @returns true if message pattern found in output
346
+ */
347
+ export async function verifyInjection(
348
+ shortId: string,
349
+ from: string,
350
+ getOutput: () => Promise<string>
351
+ ): Promise<boolean> {
352
+ const expectedPattern = `Relay message from ${from} [${shortId}]`;
353
+ const startTime = Date.now();
354
+
355
+ while (Date.now() - startTime < INJECTION_CONSTANTS.VERIFICATION_TIMEOUT_MS) {
356
+ try {
357
+ const output = await getOutput();
358
+ if (output.includes(expectedPattern)) {
359
+ return true;
360
+ }
361
+ } catch {
362
+ // Output retrieval failed, verification fails
363
+ return false;
364
+ }
365
+
366
+ await sleep(100);
367
+ }
368
+
369
+ return false;
370
+ }
371
+
372
+ /**
373
+ * Inject a message with retry logic and verification.
374
+ * Includes dedup check to prevent double-injection race condition.
375
+ *
376
+ * This consolidates the retry/verification logic that was duplicated
377
+ * in TmuxWrapper and PtyWrapper.
378
+ *
379
+ * @param injection - The formatted injection string
380
+ * @param shortId - First 8 chars of message ID for verification
381
+ * @param from - Sender name for verification pattern
382
+ * @param callbacks - Wrapper-specific callbacks for injection operations
383
+ * @returns Result indicating success/failure and attempt count
384
+ */
385
+ export async function injectWithRetry(
386
+ injection: string,
387
+ shortId: string,
388
+ from: string,
389
+ callbacks: InjectionCallbacks
390
+ ): Promise<InjectionResult> {
391
+ const metrics = callbacks.getMetrics();
392
+ metrics.total++;
393
+
394
+ // Skip verification mode: trust that write() succeeds without checking output
395
+ // Used for PTY-based injection where CLIs don't echo input back
396
+ if (callbacks.skipVerification) {
397
+ try {
398
+ await callbacks.performInjection(injection);
399
+ metrics.successFirstTry++;
400
+ return { success: true, attempts: 1 };
401
+ } catch (err: any) {
402
+ callbacks.logError(`Injection error: ${err?.message || err}`);
403
+ metrics.failed++;
404
+ return { success: false, attempts: 1 };
405
+ }
406
+ }
407
+
408
+ for (let attempt = 0; attempt < INJECTION_CONSTANTS.MAX_RETRIES; attempt++) {
409
+ try {
410
+ // On retry attempts, first check if message already exists (race condition fix)
411
+ // Previous injection may have succeeded but verification timed out
412
+ if (attempt > 0) {
413
+ const alreadyExists = await verifyInjection(shortId, from, callbacks.getOutput);
414
+ if (alreadyExists) {
415
+ metrics.successWithRetry++;
416
+ callbacks.log(`Message already present (late verification), skipping re-injection`);
417
+ return { success: true, attempts: attempt + 1 };
418
+ }
419
+ }
420
+
421
+ // Perform the injection
422
+ await callbacks.performInjection(injection);
423
+
424
+ // Verify it appeared in output
425
+ const verified = await verifyInjection(shortId, from, callbacks.getOutput);
426
+
427
+ if (verified) {
428
+ if (attempt === 0) {
429
+ metrics.successFirstTry++;
430
+ } else {
431
+ metrics.successWithRetry++;
432
+ callbacks.log(`Injection succeeded on attempt ${attempt + 1}`);
433
+ }
434
+ return { success: true, attempts: attempt + 1 };
435
+ }
436
+
437
+ // Not verified - log and retry
438
+ callbacks.log(
439
+ `Injection not verified, attempt ${attempt + 1}/${INJECTION_CONSTANTS.MAX_RETRIES}`
440
+ );
441
+
442
+ // Backoff before retry
443
+ if (attempt < INJECTION_CONSTANTS.MAX_RETRIES - 1) {
444
+ await sleep(INJECTION_CONSTANTS.RETRY_BACKOFF_MS * (attempt + 1));
445
+ }
446
+ } catch (err: any) {
447
+ callbacks.logError(`Injection error on attempt ${attempt + 1}: ${err?.message || err}`);
448
+ }
449
+ }
450
+
451
+ // All retries failed
452
+ metrics.failed++;
453
+ return { success: false, attempts: INJECTION_CONSTANTS.MAX_RETRIES };
454
+ }