agent-relay 2.0.32 → 2.0.34
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/README.md +44 -0
- package/dist/index.cjs +7231 -6234
- package/package.json +19 -18
- package/packages/api-types/.trajectories/active/traj_xbsvuzogscey.json +15 -0
- package/packages/api-types/.trajectories/index.json +12 -0
- package/packages/api-types/package.json +1 -1
- package/packages/benchmark/package.json +4 -4
- package/packages/bridge/dist/spawner.d.ts.map +1 -1
- package/packages/bridge/dist/spawner.js +127 -0
- package/packages/bridge/dist/spawner.js.map +1 -1
- package/packages/bridge/package.json +8 -8
- package/packages/bridge/src/spawner.ts +137 -0
- package/packages/cli-tester/package.json +1 -1
- package/packages/config/package.json +2 -2
- package/packages/continuity/package.json +1 -1
- package/packages/daemon/package.json +12 -12
- package/packages/hooks/package.json +4 -4
- package/packages/mcp/package.json +3 -3
- package/packages/memory/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/protocol/package.json +1 -1
- package/packages/resiliency/package.json +1 -1
- package/packages/sdk/package.json +2 -2
- package/packages/spawner/package.json +1 -1
- package/packages/state/package.json +1 -1
- package/packages/storage/package.json +2 -2
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
- package/packages/wrapper/dist/base-wrapper.d.ts.map +1 -1
- package/packages/wrapper/dist/base-wrapper.js +27 -7
- package/packages/wrapper/dist/base-wrapper.js.map +1 -1
- package/packages/wrapper/dist/client.d.ts +27 -0
- package/packages/wrapper/dist/client.d.ts.map +1 -1
- package/packages/wrapper/dist/client.js +116 -0
- package/packages/wrapper/dist/client.js.map +1 -1
- package/packages/wrapper/dist/index.d.ts +3 -0
- package/packages/wrapper/dist/index.d.ts.map +1 -1
- package/packages/wrapper/dist/index.js +6 -0
- package/packages/wrapper/dist/index.js.map +1 -1
- package/packages/wrapper/dist/opencode-api.d.ts +106 -0
- package/packages/wrapper/dist/opencode-api.d.ts.map +1 -0
- package/packages/wrapper/dist/opencode-api.js +219 -0
- package/packages/wrapper/dist/opencode-api.js.map +1 -0
- package/packages/wrapper/dist/opencode-wrapper.d.ts +157 -0
- package/packages/wrapper/dist/opencode-wrapper.d.ts.map +1 -0
- package/packages/wrapper/dist/opencode-wrapper.js +414 -0
- package/packages/wrapper/dist/opencode-wrapper.js.map +1 -0
- package/packages/wrapper/dist/relay-pty-orchestrator.d.ts.map +1 -1
- package/packages/wrapper/dist/relay-pty-orchestrator.js +18 -0
- package/packages/wrapper/dist/relay-pty-orchestrator.js.map +1 -1
- package/packages/wrapper/dist/wrapper-events.d.ts +489 -0
- package/packages/wrapper/dist/wrapper-events.d.ts.map +1 -0
- package/packages/wrapper/dist/wrapper-events.js +252 -0
- package/packages/wrapper/dist/wrapper-events.js.map +1 -0
- package/packages/wrapper/package.json +7 -6
- package/packages/wrapper/src/base-wrapper.ts +23 -7
- package/packages/wrapper/src/client.test.ts +92 -3
- package/packages/wrapper/src/client.ts +163 -0
- package/packages/wrapper/src/index.ts +29 -0
- package/packages/wrapper/src/opencode-api.test.ts +292 -0
- package/packages/wrapper/src/opencode-api.ts +285 -0
- package/packages/wrapper/src/opencode-wrapper.ts +513 -0
- package/packages/wrapper/src/relay-pty-orchestrator.test.ts +176 -0
- package/packages/wrapper/src/relay-pty-orchestrator.ts +20 -0
- package/packages/wrapper/src/wrapper-events.ts +395 -0
- package/scripts/postinstall.js +147 -2
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCodeWrapper - Wrapper for opencode CLI with HTTP API support
|
|
3
|
+
*
|
|
4
|
+
* This wrapper supports two modes of message injection:
|
|
5
|
+
* 1. HTTP API mode: Uses opencode serve's /tui/append-prompt endpoint
|
|
6
|
+
* 2. PTY fallback: Falls back to PTY-based injection when HTTP is unavailable
|
|
7
|
+
*
|
|
8
|
+
* The wrapper automatically detects which mode to use based on:
|
|
9
|
+
* - Whether `opencode serve` is running (checks localhost:4096)
|
|
10
|
+
* - Configuration options (httpApi.enabled, httpApi.fallbackToPty)
|
|
11
|
+
*
|
|
12
|
+
* Usage with HTTP API:
|
|
13
|
+
* ```
|
|
14
|
+
* const wrapper = new OpenCodeWrapper({
|
|
15
|
+
* name: 'MyAgent',
|
|
16
|
+
* command: 'opencode',
|
|
17
|
+
* httpApi: {
|
|
18
|
+
* enabled: true,
|
|
19
|
+
* baseUrl: 'http://localhost:4096',
|
|
20
|
+
* }
|
|
21
|
+
* });
|
|
22
|
+
* await wrapper.start();
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @see https://github.com/anomalyco/opencode
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
29
|
+
import { BaseWrapper, type BaseWrapperConfig } from './base-wrapper.js';
|
|
30
|
+
import { OpenCodeApi, type OpenCodeApiConfig } from './opencode-api.js';
|
|
31
|
+
import { OutputParser, type ParsedCommand } from './parser.js';
|
|
32
|
+
import { buildInjectionString, type QueuedMessage } from './shared.js';
|
|
33
|
+
|
|
34
|
+
export interface OpenCodeWrapperConfig extends BaseWrapperConfig {
|
|
35
|
+
/** HTTP API configuration */
|
|
36
|
+
httpApi?: OpenCodeApiConfig & {
|
|
37
|
+
/** Enable HTTP API mode (default: true when wrapping opencode) */
|
|
38
|
+
enabled?: boolean;
|
|
39
|
+
/** Fall back to PTY injection if HTTP is unavailable (default: true) */
|
|
40
|
+
fallbackToPty?: boolean;
|
|
41
|
+
/** Auto-start opencode serve if not running (default: false) */
|
|
42
|
+
autoStartServe?: boolean;
|
|
43
|
+
/** Wait for serve to be available in milliseconds (default: 5000) */
|
|
44
|
+
waitForServeMs?: number;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Wrapper for opencode CLI with HTTP API support
|
|
50
|
+
*/
|
|
51
|
+
export class OpenCodeWrapper extends BaseWrapper {
|
|
52
|
+
protected override config: OpenCodeWrapperConfig;
|
|
53
|
+
|
|
54
|
+
// OpenCode API client
|
|
55
|
+
private api: OpenCodeApi;
|
|
56
|
+
private httpApiAvailable = false;
|
|
57
|
+
|
|
58
|
+
// Process management (for PTY fallback mode)
|
|
59
|
+
private process?: ChildProcess;
|
|
60
|
+
private outputBuffer = '';
|
|
61
|
+
|
|
62
|
+
// Output parser for relay commands
|
|
63
|
+
private parser: OutputParser;
|
|
64
|
+
|
|
65
|
+
// Serve process (if auto-started)
|
|
66
|
+
private serveProcess?: ChildProcess;
|
|
67
|
+
|
|
68
|
+
constructor(config: OpenCodeWrapperConfig) {
|
|
69
|
+
// Default to opencode CLI type
|
|
70
|
+
super({ ...config, cliType: config.cliType ?? 'opencode' });
|
|
71
|
+
this.config = config;
|
|
72
|
+
|
|
73
|
+
// Initialize API client
|
|
74
|
+
this.api = new OpenCodeApi({
|
|
75
|
+
baseUrl: config.httpApi?.baseUrl,
|
|
76
|
+
password: config.httpApi?.password,
|
|
77
|
+
timeout: config.httpApi?.timeout,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Initialize parser with relay prefix
|
|
81
|
+
this.parser = new OutputParser({
|
|
82
|
+
prefix: this.relayPrefix,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// =========================================================================
|
|
87
|
+
// Lifecycle
|
|
88
|
+
// =========================================================================
|
|
89
|
+
|
|
90
|
+
async start(): Promise<void> {
|
|
91
|
+
if (this.running) return;
|
|
92
|
+
|
|
93
|
+
// Try to use HTTP API mode first (if enabled)
|
|
94
|
+
if (this.config.httpApi?.enabled !== false) {
|
|
95
|
+
// Check if opencode serve is available
|
|
96
|
+
this.httpApiAvailable = await this.api.isAvailable();
|
|
97
|
+
|
|
98
|
+
if (!this.httpApiAvailable && this.config.httpApi?.autoStartServe) {
|
|
99
|
+
// Auto-start opencode serve
|
|
100
|
+
await this.startServe();
|
|
101
|
+
this.httpApiAvailable = await this.api.waitForAvailable(
|
|
102
|
+
this.config.httpApi?.waitForServeMs ?? 5000
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (this.httpApiAvailable) {
|
|
107
|
+
console.log('[OpenCodeWrapper] Using HTTP API mode');
|
|
108
|
+
await this.startHttpMode();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if we should fall back to PTY
|
|
113
|
+
if (this.config.httpApi?.fallbackToPty === false) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'OpenCode serve is not available and fallbackToPty is disabled. ' +
|
|
116
|
+
'Start opencode serve or enable fallbackToPty.'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log('[OpenCodeWrapper] HTTP API unavailable, falling back to PTY mode');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fall back to PTY mode
|
|
124
|
+
await this.startPtyMode();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async stop(): Promise<void> {
|
|
128
|
+
this.running = false;
|
|
129
|
+
|
|
130
|
+
// Disconnect from relay daemon
|
|
131
|
+
this.client.disconnect();
|
|
132
|
+
|
|
133
|
+
// Stop the main process
|
|
134
|
+
if (this.process) {
|
|
135
|
+
this.process.kill();
|
|
136
|
+
this.process = undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Stop the serve process if we started it
|
|
140
|
+
if (this.serveProcess) {
|
|
141
|
+
this.serveProcess.kill();
|
|
142
|
+
this.serveProcess = undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// =========================================================================
|
|
147
|
+
// HTTP API Mode
|
|
148
|
+
// =========================================================================
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Start in HTTP API mode
|
|
152
|
+
* In this mode, we don't spawn opencode ourselves - we communicate via HTTP API
|
|
153
|
+
*/
|
|
154
|
+
private async startHttpMode(): Promise<void> {
|
|
155
|
+
this.running = true;
|
|
156
|
+
|
|
157
|
+
// Connect to relay daemon
|
|
158
|
+
await this.client.connect();
|
|
159
|
+
|
|
160
|
+
// Subscribe to opencode events for output parsing
|
|
161
|
+
this.subscribeToEvents();
|
|
162
|
+
|
|
163
|
+
// Show a toast to indicate relay is connected
|
|
164
|
+
await this.api.showToast('Agent Relay connected', { variant: 'success', duration: 2000 });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Subscribe to opencode SSE events for output parsing
|
|
169
|
+
*/
|
|
170
|
+
private subscribeToEvents(): void {
|
|
171
|
+
this.api.subscribeToEvents(
|
|
172
|
+
event => {
|
|
173
|
+
// Parse events for relay commands
|
|
174
|
+
if (event.type === 'message' || event.type === 'assistant_message') {
|
|
175
|
+
const content = typeof event.data === 'string' ? event.data : JSON.stringify(event.data);
|
|
176
|
+
this.handleOutput(content);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
error => {
|
|
180
|
+
console.error('[OpenCodeWrapper] SSE error:', error.message);
|
|
181
|
+
// Attempt to reconnect after a delay
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
if (this.running && this.httpApiAvailable) {
|
|
184
|
+
this.subscribeToEvents();
|
|
185
|
+
}
|
|
186
|
+
}, 5000);
|
|
187
|
+
}
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// =========================================================================
|
|
192
|
+
// PTY Fallback Mode
|
|
193
|
+
// =========================================================================
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Start in PTY mode (fallback when HTTP is unavailable)
|
|
197
|
+
*/
|
|
198
|
+
private async startPtyMode(): Promise<void> {
|
|
199
|
+
this.running = true;
|
|
200
|
+
|
|
201
|
+
// Connect to relay daemon
|
|
202
|
+
await this.client.connect();
|
|
203
|
+
|
|
204
|
+
// Spawn opencode process
|
|
205
|
+
const args = this.config.args ?? [];
|
|
206
|
+
this.process = spawn(this.config.command, args, {
|
|
207
|
+
cwd: this.config.cwd,
|
|
208
|
+
env: { ...process.env, ...this.config.env },
|
|
209
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Handle stdout
|
|
213
|
+
this.process.stdout?.on('data', (data: Buffer) => {
|
|
214
|
+
const text = data.toString();
|
|
215
|
+
this.outputBuffer += text;
|
|
216
|
+
this.handleOutput(text);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Handle stderr
|
|
220
|
+
this.process.stderr?.on('data', (data: Buffer) => {
|
|
221
|
+
const text = data.toString();
|
|
222
|
+
this.outputBuffer += text;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Handle exit
|
|
226
|
+
this.process.on('exit', (code, signal) => {
|
|
227
|
+
this.running = false;
|
|
228
|
+
this.emit('exit', code, signal);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// =========================================================================
|
|
233
|
+
// Output Handling
|
|
234
|
+
// =========================================================================
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Handle output from opencode (either via SSE or PTY)
|
|
238
|
+
*/
|
|
239
|
+
private handleOutput(text: string): void {
|
|
240
|
+
// Feed to idle detector
|
|
241
|
+
this.idleDetector.onOutput(text);
|
|
242
|
+
|
|
243
|
+
// Feed to stuck detector
|
|
244
|
+
this.stuckDetector.onOutput(text);
|
|
245
|
+
|
|
246
|
+
// Parse for relay commands
|
|
247
|
+
const result = this.parser.parse(text);
|
|
248
|
+
for (const cmd of result.commands) {
|
|
249
|
+
this.handleParsedCommand(cmd);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Handle a parsed relay command
|
|
255
|
+
*/
|
|
256
|
+
private handleParsedCommand(cmd: ParsedCommand): void {
|
|
257
|
+
// Send message via relay client
|
|
258
|
+
this.client.sendMessage(
|
|
259
|
+
cmd.to,
|
|
260
|
+
cmd.body,
|
|
261
|
+
cmd.kind,
|
|
262
|
+
cmd.data,
|
|
263
|
+
cmd.thread
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// =========================================================================
|
|
268
|
+
// Message Injection
|
|
269
|
+
// =========================================================================
|
|
270
|
+
|
|
271
|
+
protected async performInjection(content: string): Promise<void> {
|
|
272
|
+
if (this.httpApiAvailable) {
|
|
273
|
+
await this.performHttpInjection(content);
|
|
274
|
+
} else {
|
|
275
|
+
await this.performPtyInjection(content);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Inject content via HTTP API
|
|
281
|
+
*/
|
|
282
|
+
private async performHttpInjection(content: string): Promise<void> {
|
|
283
|
+
const result = await this.api.appendPrompt(content);
|
|
284
|
+
if (!result.success) {
|
|
285
|
+
throw new Error(`HTTP injection failed: ${result.error}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Inject content via PTY stdin
|
|
291
|
+
*/
|
|
292
|
+
private async performPtyInjection(content: string): Promise<void> {
|
|
293
|
+
if (!this.process?.stdin) {
|
|
294
|
+
throw new Error('PTY stdin not available');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Write to stdin
|
|
298
|
+
this.process.stdin.write(content + '\n');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
protected getCleanOutput(): string {
|
|
302
|
+
return this.outputBuffer;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// =========================================================================
|
|
306
|
+
// Serve Process Management
|
|
307
|
+
// =========================================================================
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Auto-start opencode serve
|
|
311
|
+
*/
|
|
312
|
+
private async startServe(): Promise<void> {
|
|
313
|
+
console.log('[OpenCodeWrapper] Auto-starting opencode serve...');
|
|
314
|
+
|
|
315
|
+
this.serveProcess = spawn('opencode', ['serve'], {
|
|
316
|
+
cwd: this.config.cwd,
|
|
317
|
+
env: { ...process.env, ...this.config.env },
|
|
318
|
+
stdio: 'ignore',
|
|
319
|
+
detached: true,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Don't wait for serve process - it runs in background
|
|
323
|
+
this.serveProcess.unref();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// =========================================================================
|
|
327
|
+
// Message Queue Processing (override for HTTP mode optimization)
|
|
328
|
+
// =========================================================================
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Process the message queue
|
|
332
|
+
* Override to use HTTP API's direct injection when available
|
|
333
|
+
*/
|
|
334
|
+
protected async processMessageQueue(): Promise<void> {
|
|
335
|
+
if (this.isInjecting || this.messageQueue.length === 0) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Check if we should wait for idle (only in PTY mode)
|
|
340
|
+
if (!this.httpApiAvailable) {
|
|
341
|
+
const idleResult = this.idleDetector.checkIdle();
|
|
342
|
+
if (!idleResult.isIdle) {
|
|
343
|
+
// In PTY mode, wait for idle before injection
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
this.isInjecting = true;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
// Sort by importance (higher first) and process
|
|
352
|
+
const sortedQueue = [...this.messageQueue].sort(
|
|
353
|
+
(a, b) => (b.importance ?? 0) - (a.importance ?? 0)
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
for (const msg of sortedQueue) {
|
|
357
|
+
const injectionString = buildInjectionString(msg);
|
|
358
|
+
await this.performInjection(injectionString);
|
|
359
|
+
|
|
360
|
+
// Remove from queue
|
|
361
|
+
const index = this.messageQueue.indexOf(msg);
|
|
362
|
+
if (index !== -1) {
|
|
363
|
+
this.messageQueue.splice(index, 1);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Update metrics
|
|
367
|
+
this.injectionMetrics.successFirstTry++;
|
|
368
|
+
this.injectionMetrics.total++;
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
this.injectionMetrics.failed++;
|
|
372
|
+
this.injectionMetrics.total++;
|
|
373
|
+
console.error('[OpenCodeWrapper] Injection error:', error);
|
|
374
|
+
} finally {
|
|
375
|
+
this.isInjecting = false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// =========================================================================
|
|
380
|
+
// Spawner Compatibility Methods
|
|
381
|
+
// =========================================================================
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Get the process ID (undefined in HTTP mode, defined in PTY mode)
|
|
385
|
+
*/
|
|
386
|
+
get pid(): number | undefined {
|
|
387
|
+
return this.process?.pid;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Log path (not applicable for OpenCodeWrapper)
|
|
392
|
+
*/
|
|
393
|
+
get logPath(): string | undefined {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Kill the wrapper (alias for stop())
|
|
399
|
+
*/
|
|
400
|
+
async kill(): Promise<void> {
|
|
401
|
+
await this.stop();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Write to the process stdin (PTY mode only)
|
|
406
|
+
*/
|
|
407
|
+
write(data: string): void {
|
|
408
|
+
if (this.process?.stdin) {
|
|
409
|
+
this.process.stdin.write(data);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Inject a task into the agent
|
|
415
|
+
* @param task - The task description to inject
|
|
416
|
+
* @param _from - The sender name (used for formatting)
|
|
417
|
+
* @returns true if injection succeeded
|
|
418
|
+
*/
|
|
419
|
+
async injectTask(task: string, _from?: string): Promise<boolean> {
|
|
420
|
+
try {
|
|
421
|
+
await this.performInjection(task);
|
|
422
|
+
return true;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
console.error('[OpenCodeWrapper] Task injection failed:', error);
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Get output lines (for compatibility with spawner)
|
|
431
|
+
*/
|
|
432
|
+
getOutput(limit?: number): string[] {
|
|
433
|
+
const lines = this.outputBuffer.split('\n');
|
|
434
|
+
return limit ? lines.slice(-limit) : lines;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Get raw output (for compatibility with spawner)
|
|
439
|
+
*/
|
|
440
|
+
getRawOutput(): string {
|
|
441
|
+
return this.outputBuffer;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Wait until the wrapper is ready to receive messages
|
|
446
|
+
* In HTTP mode, this checks if the API is available
|
|
447
|
+
* In PTY mode, this waits for the process to start
|
|
448
|
+
*/
|
|
449
|
+
async waitUntilReadyForMessages(timeoutMs = 15000, _pollMs = 100): Promise<boolean> {
|
|
450
|
+
if (!this.running) {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (this.httpApiAvailable) {
|
|
455
|
+
// HTTP mode: check if API responds
|
|
456
|
+
return await this.api.waitForAvailable(timeoutMs);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// PTY mode: process is ready if stdin is available
|
|
460
|
+
return this.process?.stdin !== null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Wait until CLI is ready (alias for waitUntilReadyForMessages)
|
|
465
|
+
*/
|
|
466
|
+
async waitUntilCliReady(timeoutMs = 15000, pollMs = 100): Promise<boolean> {
|
|
467
|
+
return this.waitUntilReadyForMessages(timeoutMs, pollMs);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// =========================================================================
|
|
471
|
+
// Public API
|
|
472
|
+
// =========================================================================
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Check if HTTP API mode is active
|
|
476
|
+
*/
|
|
477
|
+
get isHttpApiMode(): boolean {
|
|
478
|
+
return this.httpApiAvailable;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Get the OpenCode API client for advanced operations
|
|
483
|
+
*/
|
|
484
|
+
get openCodeApi(): OpenCodeApi {
|
|
485
|
+
return this.api;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Switch to a specific session
|
|
490
|
+
*/
|
|
491
|
+
async switchSession(sessionId: string): Promise<boolean> {
|
|
492
|
+
if (!this.httpApiAvailable) {
|
|
493
|
+
console.warn('[OpenCodeWrapper] Cannot switch sessions in PTY mode');
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const result = await this.api.selectSession(sessionId);
|
|
498
|
+
return result.success;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* List available sessions
|
|
503
|
+
*/
|
|
504
|
+
async listSessions(): Promise<{ id: string; title?: string }[]> {
|
|
505
|
+
if (!this.httpApiAvailable) {
|
|
506
|
+
console.warn('[OpenCodeWrapper] Cannot list sessions in PTY mode');
|
|
507
|
+
return [];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const result = await this.api.listSessions();
|
|
511
|
+
return result.data ?? [];
|
|
512
|
+
}
|
|
513
|
+
}
|
|
@@ -1180,6 +1180,182 @@ describe('RelayPtyOrchestrator', () => {
|
|
|
1180
1180
|
processQueueSpy.mockRestore();
|
|
1181
1181
|
});
|
|
1182
1182
|
});
|
|
1183
|
+
|
|
1184
|
+
describe('output buffer management', () => {
|
|
1185
|
+
it('trims rawBuffer when it exceeds MAX_OUTPUT_BUFFER_SIZE', async () => {
|
|
1186
|
+
orchestrator = new RelayPtyOrchestrator({
|
|
1187
|
+
name: 'TestAgent',
|
|
1188
|
+
command: 'claude',
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
await orchestrator.start();
|
|
1192
|
+
|
|
1193
|
+
// Access private MAX_OUTPUT_BUFFER_SIZE constant (10MB)
|
|
1194
|
+
const MAX_SIZE = 10 * 1024 * 1024;
|
|
1195
|
+
|
|
1196
|
+
// Generate data larger than the max size
|
|
1197
|
+
const chunkSize = 1024 * 1024; // 1MB chunks
|
|
1198
|
+
const numChunks = 12; // 12MB total (exceeds 10MB limit)
|
|
1199
|
+
|
|
1200
|
+
for (let i = 0; i < numChunks; i++) {
|
|
1201
|
+
const chunk = `chunk-${i}-${'x'.repeat(chunkSize - 10)}\n`;
|
|
1202
|
+
mockProcess.stdout!.emit('data', Buffer.from(chunk));
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Buffer should be trimmed to MAX_SIZE
|
|
1206
|
+
const rawOutput = orchestrator.getRawOutput();
|
|
1207
|
+
expect(rawOutput.length).toBeLessThanOrEqual(MAX_SIZE);
|
|
1208
|
+
|
|
1209
|
+
// Should contain the most recent chunks (tail of buffer)
|
|
1210
|
+
expect(rawOutput).toContain('chunk-11'); // Last chunk
|
|
1211
|
+
expect(rawOutput).not.toContain('chunk-0'); // First chunk should be trimmed
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
it('adjusts lastParsedLength when buffer is trimmed', async () => {
|
|
1215
|
+
orchestrator = new RelayPtyOrchestrator({
|
|
1216
|
+
name: 'TestAgent',
|
|
1217
|
+
command: 'claude',
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
await orchestrator.start();
|
|
1221
|
+
|
|
1222
|
+
// Access lastParsedLength via reflection
|
|
1223
|
+
const getLastParsedLength = () => (orchestrator as any).lastParsedLength;
|
|
1224
|
+
|
|
1225
|
+
// Emit some initial output to set lastParsedLength
|
|
1226
|
+
const initialData = 'Initial output that sets parse position\n';
|
|
1227
|
+
mockProcess.stdout!.emit('data', Buffer.from(initialData));
|
|
1228
|
+
|
|
1229
|
+
// Allow parsing to run
|
|
1230
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
1231
|
+
|
|
1232
|
+
const initialParsedLength = getLastParsedLength();
|
|
1233
|
+
expect(initialParsedLength).toBeGreaterThan(0);
|
|
1234
|
+
|
|
1235
|
+
// Now emit a lot of data to trigger buffer trimming
|
|
1236
|
+
const MAX_SIZE = 10 * 1024 * 1024;
|
|
1237
|
+
const largeData = 'x'.repeat(MAX_SIZE + 1000);
|
|
1238
|
+
mockProcess.stdout!.emit('data', Buffer.from(largeData));
|
|
1239
|
+
|
|
1240
|
+
// lastParsedLength should be adjusted to stay in sync
|
|
1241
|
+
const adjustedParsedLength = getLastParsedLength();
|
|
1242
|
+
|
|
1243
|
+
// After trimming, lastParsedLength should be reduced by the trim amount
|
|
1244
|
+
// or set to 0 if the trim amount exceeds the previous value
|
|
1245
|
+
expect(adjustedParsedLength).toBeLessThanOrEqual(MAX_SIZE);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
it('still detects relay commands after buffer trimming', async () => {
|
|
1249
|
+
orchestrator = new RelayPtyOrchestrator({
|
|
1250
|
+
name: 'TestAgent',
|
|
1251
|
+
command: 'claude',
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
await orchestrator.start();
|
|
1255
|
+
|
|
1256
|
+
// Access the client mock
|
|
1257
|
+
const client = (orchestrator as any).client;
|
|
1258
|
+
client.sendMessage.mockClear();
|
|
1259
|
+
|
|
1260
|
+
// Emit a lot of data to fill the buffer
|
|
1261
|
+
const MAX_SIZE = 10 * 1024 * 1024;
|
|
1262
|
+
const filler = 'x'.repeat(MAX_SIZE + 1000);
|
|
1263
|
+
mockProcess.stdout!.emit('data', Buffer.from(filler));
|
|
1264
|
+
|
|
1265
|
+
// Now emit a relay command after the buffer was trimmed
|
|
1266
|
+
mockProcess.stdout!.emit('data', Buffer.from('\n->relay:Bob Post-trim message\n'));
|
|
1267
|
+
|
|
1268
|
+
// Allow parsing
|
|
1269
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1270
|
+
|
|
1271
|
+
// The relay command should still be detected
|
|
1272
|
+
expect(client.sendMessage).toHaveBeenCalled();
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
it('still detects summary blocks after buffer trimming', async () => {
|
|
1276
|
+
orchestrator = new RelayPtyOrchestrator({
|
|
1277
|
+
name: 'TestAgent',
|
|
1278
|
+
command: 'claude',
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
const summaryHandler = vi.fn();
|
|
1282
|
+
orchestrator.on('summary', summaryHandler);
|
|
1283
|
+
|
|
1284
|
+
await orchestrator.start();
|
|
1285
|
+
|
|
1286
|
+
// Emit a lot of data to trigger trimming
|
|
1287
|
+
const MAX_SIZE = 10 * 1024 * 1024;
|
|
1288
|
+
const filler = 'x'.repeat(MAX_SIZE + 1000);
|
|
1289
|
+
mockProcess.stdout!.emit('data', Buffer.from(filler));
|
|
1290
|
+
|
|
1291
|
+
// Now emit a summary block
|
|
1292
|
+
mockProcess.stdout!.emit('data', Buffer.from(
|
|
1293
|
+
'\n[[SUMMARY]]{"currentTask": "Post-trim task"}[[/SUMMARY]]\n'
|
|
1294
|
+
));
|
|
1295
|
+
|
|
1296
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1297
|
+
|
|
1298
|
+
// Summary should still be detected
|
|
1299
|
+
expect(summaryHandler).toHaveBeenCalled();
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it('does not lose data during buffer trimming', async () => {
|
|
1303
|
+
orchestrator = new RelayPtyOrchestrator({
|
|
1304
|
+
name: 'TestAgent',
|
|
1305
|
+
command: 'claude',
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
await orchestrator.start();
|
|
1309
|
+
|
|
1310
|
+
// Emit data in chunks, each with a unique marker
|
|
1311
|
+
const markers: string[] = [];
|
|
1312
|
+
for (let i = 0; i < 20; i++) {
|
|
1313
|
+
const marker = `MARKER_${i}_${Date.now()}`;
|
|
1314
|
+
markers.push(marker);
|
|
1315
|
+
// Each chunk is 1MB
|
|
1316
|
+
const chunk = marker + '_' + 'x'.repeat(1024 * 1024 - marker.length - 2) + '\n';
|
|
1317
|
+
mockProcess.stdout!.emit('data', Buffer.from(chunk));
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const rawOutput = orchestrator.getRawOutput();
|
|
1321
|
+
|
|
1322
|
+
// The most recent markers should be present (within the 10MB limit)
|
|
1323
|
+
// Approximately the last 10 markers should be there
|
|
1324
|
+
const recentMarkers = markers.slice(-10);
|
|
1325
|
+
for (const marker of recentMarkers) {
|
|
1326
|
+
expect(rawOutput).toContain(marker);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// The oldest markers should be trimmed
|
|
1330
|
+
const oldMarkers = markers.slice(0, 5);
|
|
1331
|
+
for (const marker of oldMarkers) {
|
|
1332
|
+
expect(rawOutput).not.toContain(marker);
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
it('handles rapid output without memory exhaustion', async () => {
|
|
1337
|
+
orchestrator = new RelayPtyOrchestrator({
|
|
1338
|
+
name: 'TestAgent',
|
|
1339
|
+
command: 'claude',
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
await orchestrator.start();
|
|
1343
|
+
|
|
1344
|
+
// Simulate rapid output (like a build log or test output)
|
|
1345
|
+
const iterations = 100;
|
|
1346
|
+
const chunkSize = 100 * 1024; // 100KB chunks
|
|
1347
|
+
|
|
1348
|
+
for (let i = 0; i < iterations; i++) {
|
|
1349
|
+
const chunk = `iteration-${i}: ${'log'.repeat(chunkSize / 3)}\n`;
|
|
1350
|
+
mockProcess.stdout!.emit('data', Buffer.from(chunk));
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Should not throw RangeError
|
|
1354
|
+
const rawOutput = orchestrator.getRawOutput();
|
|
1355
|
+
const MAX_SIZE = 10 * 1024 * 1024;
|
|
1356
|
+
expect(rawOutput.length).toBeLessThanOrEqual(MAX_SIZE);
|
|
1357
|
+
});
|
|
1358
|
+
});
|
|
1183
1359
|
});
|
|
1184
1360
|
|
|
1185
1361
|
describe('RelayPtyOrchestrator integration', () => {
|