@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,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
|
+
}
|