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.
Files changed (68) hide show
  1. package/README.md +44 -0
  2. package/dist/index.cjs +7231 -6234
  3. package/package.json +19 -18
  4. package/packages/api-types/.trajectories/active/traj_xbsvuzogscey.json +15 -0
  5. package/packages/api-types/.trajectories/index.json +12 -0
  6. package/packages/api-types/package.json +1 -1
  7. package/packages/benchmark/package.json +4 -4
  8. package/packages/bridge/dist/spawner.d.ts.map +1 -1
  9. package/packages/bridge/dist/spawner.js +127 -0
  10. package/packages/bridge/dist/spawner.js.map +1 -1
  11. package/packages/bridge/package.json +8 -8
  12. package/packages/bridge/src/spawner.ts +137 -0
  13. package/packages/cli-tester/package.json +1 -1
  14. package/packages/config/package.json +2 -2
  15. package/packages/continuity/package.json +1 -1
  16. package/packages/daemon/package.json +12 -12
  17. package/packages/hooks/package.json +4 -4
  18. package/packages/mcp/package.json +3 -3
  19. package/packages/memory/package.json +2 -2
  20. package/packages/policy/package.json +2 -2
  21. package/packages/protocol/package.json +1 -1
  22. package/packages/resiliency/package.json +1 -1
  23. package/packages/sdk/package.json +2 -2
  24. package/packages/spawner/package.json +1 -1
  25. package/packages/state/package.json +1 -1
  26. package/packages/storage/package.json +2 -2
  27. package/packages/telemetry/package.json +1 -1
  28. package/packages/trajectory/package.json +2 -2
  29. package/packages/user-directory/package.json +2 -2
  30. package/packages/utils/package.json +2 -2
  31. package/packages/wrapper/dist/base-wrapper.d.ts.map +1 -1
  32. package/packages/wrapper/dist/base-wrapper.js +27 -7
  33. package/packages/wrapper/dist/base-wrapper.js.map +1 -1
  34. package/packages/wrapper/dist/client.d.ts +27 -0
  35. package/packages/wrapper/dist/client.d.ts.map +1 -1
  36. package/packages/wrapper/dist/client.js +116 -0
  37. package/packages/wrapper/dist/client.js.map +1 -1
  38. package/packages/wrapper/dist/index.d.ts +3 -0
  39. package/packages/wrapper/dist/index.d.ts.map +1 -1
  40. package/packages/wrapper/dist/index.js +6 -0
  41. package/packages/wrapper/dist/index.js.map +1 -1
  42. package/packages/wrapper/dist/opencode-api.d.ts +106 -0
  43. package/packages/wrapper/dist/opencode-api.d.ts.map +1 -0
  44. package/packages/wrapper/dist/opencode-api.js +219 -0
  45. package/packages/wrapper/dist/opencode-api.js.map +1 -0
  46. package/packages/wrapper/dist/opencode-wrapper.d.ts +157 -0
  47. package/packages/wrapper/dist/opencode-wrapper.d.ts.map +1 -0
  48. package/packages/wrapper/dist/opencode-wrapper.js +414 -0
  49. package/packages/wrapper/dist/opencode-wrapper.js.map +1 -0
  50. package/packages/wrapper/dist/relay-pty-orchestrator.d.ts.map +1 -1
  51. package/packages/wrapper/dist/relay-pty-orchestrator.js +18 -0
  52. package/packages/wrapper/dist/relay-pty-orchestrator.js.map +1 -1
  53. package/packages/wrapper/dist/wrapper-events.d.ts +489 -0
  54. package/packages/wrapper/dist/wrapper-events.d.ts.map +1 -0
  55. package/packages/wrapper/dist/wrapper-events.js +252 -0
  56. package/packages/wrapper/dist/wrapper-events.js.map +1 -0
  57. package/packages/wrapper/package.json +7 -6
  58. package/packages/wrapper/src/base-wrapper.ts +23 -7
  59. package/packages/wrapper/src/client.test.ts +92 -3
  60. package/packages/wrapper/src/client.ts +163 -0
  61. package/packages/wrapper/src/index.ts +29 -0
  62. package/packages/wrapper/src/opencode-api.test.ts +292 -0
  63. package/packages/wrapper/src/opencode-api.ts +285 -0
  64. package/packages/wrapper/src/opencode-wrapper.ts +513 -0
  65. package/packages/wrapper/src/relay-pty-orchestrator.test.ts +176 -0
  66. package/packages/wrapper/src/relay-pty-orchestrator.ts +20 -0
  67. package/packages/wrapper/src/wrapper-events.ts +395 -0
  68. package/scripts/postinstall.js +147 -2
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Tests for OpenCode HTTP API client
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+ import { OpenCodeApi } from './opencode-api.js';
7
+
8
+ describe('OpenCodeApi', () => {
9
+ let originalFetch: typeof fetch;
10
+ let mockFetch: ReturnType<typeof vi.fn>;
11
+
12
+ beforeEach(() => {
13
+ originalFetch = global.fetch;
14
+ mockFetch = vi.fn();
15
+ global.fetch = mockFetch;
16
+ });
17
+
18
+ afterEach(() => {
19
+ global.fetch = originalFetch;
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ describe('constructor', () => {
24
+ it('should use default config values', () => {
25
+ const api = new OpenCodeApi();
26
+ // Access private properties for testing
27
+ expect((api as any).baseUrl).toBe('http://localhost:4096');
28
+ expect((api as any).timeout).toBe(5000);
29
+ });
30
+
31
+ it('should accept custom config', () => {
32
+ const api = new OpenCodeApi({
33
+ baseUrl: 'http://localhost:8080',
34
+ password: 'test-password',
35
+ timeout: 10000,
36
+ });
37
+ expect((api as any).baseUrl).toBe('http://localhost:8080');
38
+ expect((api as any).password).toBe('test-password');
39
+ expect((api as any).timeout).toBe(10000);
40
+ });
41
+ });
42
+
43
+ describe('getHeaders', () => {
44
+ it('should return content-type without auth when no password', () => {
45
+ const api = new OpenCodeApi();
46
+ const headers = (api as any).getHeaders();
47
+ expect(headers['Content-Type']).toBe('application/json');
48
+ expect(headers['Authorization']).toBeUndefined();
49
+ });
50
+
51
+ it('should include basic auth when password is set', () => {
52
+ const api = new OpenCodeApi({ password: 'secret' });
53
+ const headers = (api as any).getHeaders();
54
+ expect(headers['Content-Type']).toBe('application/json');
55
+ expect(headers['Authorization']).toBe('Basic ' + Buffer.from('opencode:secret').toString('base64'));
56
+ });
57
+ });
58
+
59
+ describe('isAvailable', () => {
60
+ it('should return true when server responds', async () => {
61
+ mockFetch.mockResolvedValueOnce({
62
+ ok: true,
63
+ json: () => Promise.resolve({}),
64
+ });
65
+
66
+ const api = new OpenCodeApi();
67
+ const result = await api.isAvailable();
68
+
69
+ expect(result).toBe(true);
70
+ expect(mockFetch).toHaveBeenCalledWith(
71
+ 'http://localhost:4096/config',
72
+ expect.objectContaining({ method: 'GET' })
73
+ );
74
+ });
75
+
76
+ it('should return false when server unavailable', async () => {
77
+ mockFetch.mockRejectedValueOnce(new Error('Connection refused'));
78
+
79
+ const api = new OpenCodeApi();
80
+ const result = await api.isAvailable();
81
+
82
+ expect(result).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe('appendPrompt', () => {
87
+ it('should send text to append-prompt endpoint', async () => {
88
+ mockFetch.mockResolvedValueOnce({
89
+ ok: true,
90
+ json: () => Promise.resolve({}),
91
+ });
92
+
93
+ const api = new OpenCodeApi();
94
+ const result = await api.appendPrompt('Hello, world!');
95
+
96
+ expect(result.success).toBe(true);
97
+ expect(mockFetch).toHaveBeenCalledWith(
98
+ 'http://localhost:4096/tui/append-prompt',
99
+ expect.objectContaining({
100
+ method: 'POST',
101
+ body: JSON.stringify({ text: 'Hello, world!' }),
102
+ })
103
+ );
104
+ });
105
+
106
+ it('should handle errors', async () => {
107
+ mockFetch.mockResolvedValueOnce({
108
+ ok: false,
109
+ status: 500,
110
+ statusText: 'Internal Server Error',
111
+ });
112
+
113
+ const api = new OpenCodeApi();
114
+ const result = await api.appendPrompt('test');
115
+
116
+ expect(result.success).toBe(false);
117
+ expect(result.error).toContain('500');
118
+ });
119
+ });
120
+
121
+ describe('submitPrompt', () => {
122
+ it('should call submit-prompt endpoint', async () => {
123
+ mockFetch.mockResolvedValueOnce({
124
+ ok: true,
125
+ json: () => Promise.resolve({}),
126
+ });
127
+
128
+ const api = new OpenCodeApi();
129
+ const result = await api.submitPrompt();
130
+
131
+ expect(result.success).toBe(true);
132
+ expect(mockFetch).toHaveBeenCalledWith(
133
+ 'http://localhost:4096/tui/submit-prompt',
134
+ expect.objectContaining({ method: 'POST' })
135
+ );
136
+ });
137
+ });
138
+
139
+ describe('clearPrompt', () => {
140
+ it('should call clear-prompt endpoint', async () => {
141
+ mockFetch.mockResolvedValueOnce({
142
+ ok: true,
143
+ json: () => Promise.resolve({}),
144
+ });
145
+
146
+ const api = new OpenCodeApi();
147
+ const result = await api.clearPrompt();
148
+
149
+ expect(result.success).toBe(true);
150
+ expect(mockFetch).toHaveBeenCalledWith(
151
+ 'http://localhost:4096/tui/clear-prompt',
152
+ expect.objectContaining({ method: 'POST' })
153
+ );
154
+ });
155
+ });
156
+
157
+ describe('showToast', () => {
158
+ it('should send toast with default variant and duration', async () => {
159
+ mockFetch.mockResolvedValueOnce({
160
+ ok: true,
161
+ json: () => Promise.resolve({}),
162
+ });
163
+
164
+ const api = new OpenCodeApi();
165
+ const result = await api.showToast('Test message');
166
+
167
+ expect(result.success).toBe(true);
168
+ expect(mockFetch).toHaveBeenCalledWith(
169
+ 'http://localhost:4096/tui/show-toast',
170
+ expect.objectContaining({
171
+ method: 'POST',
172
+ body: JSON.stringify({ message: 'Test message', variant: 'info', duration: 3000 }),
173
+ })
174
+ );
175
+ });
176
+
177
+ it('should send toast with custom variant and duration', async () => {
178
+ mockFetch.mockResolvedValueOnce({
179
+ ok: true,
180
+ json: () => Promise.resolve({}),
181
+ });
182
+
183
+ const api = new OpenCodeApi();
184
+ const result = await api.showToast('Error!', { variant: 'error', duration: 5000 });
185
+
186
+ expect(result.success).toBe(true);
187
+ expect(mockFetch).toHaveBeenCalledWith(
188
+ 'http://localhost:4096/tui/show-toast',
189
+ expect.objectContaining({
190
+ body: JSON.stringify({ message: 'Error!', variant: 'error', duration: 5000 }),
191
+ })
192
+ );
193
+ });
194
+ });
195
+
196
+ describe('executeCommand', () => {
197
+ it('should send command to execute-command endpoint', async () => {
198
+ mockFetch.mockResolvedValueOnce({
199
+ ok: true,
200
+ json: () => Promise.resolve({}),
201
+ });
202
+
203
+ const api = new OpenCodeApi();
204
+ const result = await api.executeCommand('session_new');
205
+
206
+ expect(result.success).toBe(true);
207
+ expect(mockFetch).toHaveBeenCalledWith(
208
+ 'http://localhost:4096/tui/execute-command',
209
+ expect.objectContaining({
210
+ method: 'POST',
211
+ body: JSON.stringify({ command: 'session_new' }),
212
+ })
213
+ );
214
+ });
215
+ });
216
+
217
+ describe('listSessions', () => {
218
+ it('should return sessions from API', async () => {
219
+ const mockSessions = [
220
+ { id: '1', title: 'Session 1', createdAt: '2024-01-01', updatedAt: '2024-01-02' },
221
+ { id: '2', title: 'Session 2', createdAt: '2024-01-03', updatedAt: '2024-01-04' },
222
+ ];
223
+
224
+ mockFetch.mockResolvedValueOnce({
225
+ ok: true,
226
+ json: () => Promise.resolve(mockSessions),
227
+ });
228
+
229
+ const api = new OpenCodeApi();
230
+ const result = await api.listSessions();
231
+
232
+ expect(result.success).toBe(true);
233
+ expect(result.data).toEqual(mockSessions);
234
+ });
235
+ });
236
+
237
+ describe('getCurrentSession', () => {
238
+ it('should return current session', async () => {
239
+ const mockSession = { id: 'current', title: 'Current Session' };
240
+
241
+ mockFetch.mockResolvedValueOnce({
242
+ ok: true,
243
+ json: () => Promise.resolve(mockSession),
244
+ });
245
+
246
+ const api = new OpenCodeApi();
247
+ const result = await api.getCurrentSession();
248
+
249
+ expect(result.success).toBe(true);
250
+ expect(result.data).toEqual(mockSession);
251
+ expect(mockFetch).toHaveBeenCalledWith(
252
+ 'http://localhost:4096/session/current',
253
+ expect.objectContaining({ method: 'GET' })
254
+ );
255
+ });
256
+ });
257
+
258
+ describe('selectSession', () => {
259
+ it('should select session by id', async () => {
260
+ mockFetch.mockResolvedValueOnce({
261
+ ok: true,
262
+ json: () => Promise.resolve({}),
263
+ });
264
+
265
+ const api = new OpenCodeApi();
266
+ const result = await api.selectSession('session-123');
267
+
268
+ expect(result.success).toBe(true);
269
+ expect(mockFetch).toHaveBeenCalledWith(
270
+ 'http://localhost:4096/tui/select-session',
271
+ expect.objectContaining({
272
+ method: 'POST',
273
+ body: JSON.stringify({ sessionID: 'session-123' }),
274
+ })
275
+ );
276
+ });
277
+ });
278
+
279
+ describe('request timeout', () => {
280
+ it('should handle timeout errors', async () => {
281
+ const abortError = new Error('The operation was aborted');
282
+ abortError.name = 'AbortError';
283
+ mockFetch.mockRejectedValueOnce(abortError);
284
+
285
+ const api = new OpenCodeApi({ timeout: 100 });
286
+ const result = await api.appendPrompt('test');
287
+
288
+ expect(result.success).toBe(false);
289
+ expect(result.error).toBe('Request timeout');
290
+ });
291
+ });
292
+ });
@@ -0,0 +1,285 @@
1
+ /**
2
+ * OpenCode HTTP API client
3
+ *
4
+ * Provides integration with opencode serve's HTTP API for:
5
+ * - Injecting text into the TUI input field (append-prompt)
6
+ * - Submitting prompts
7
+ * - Clearing prompts
8
+ * - Session management
9
+ *
10
+ * @see https://github.com/anomalyco/opencode
11
+ */
12
+
13
+ export interface OpenCodeApiConfig {
14
+ /** Base URL for opencode serve (default: http://localhost:4096) */
15
+ baseUrl?: string;
16
+ /** Server password if authentication is enabled (OPENCODE_SERVER_PASSWORD) */
17
+ password?: string;
18
+ /** Request timeout in milliseconds (default: 5000) */
19
+ timeout?: number;
20
+ }
21
+
22
+ export interface OpenCodeSession {
23
+ id: string;
24
+ title?: string;
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ }
28
+
29
+ export interface OpenCodeApiResponse<T = unknown> {
30
+ success: boolean;
31
+ data?: T;
32
+ error?: string;
33
+ }
34
+
35
+ /**
36
+ * OpenCode HTTP API client for interacting with opencode serve
37
+ */
38
+ export class OpenCodeApi {
39
+ private baseUrl: string;
40
+ private password?: string;
41
+ private timeout: number;
42
+
43
+ constructor(config: OpenCodeApiConfig = {}) {
44
+ // Priority: explicit config > OPENCODE_API_URL env > OPENCODE_PORT env > default
45
+ const defaultPort = process.env.OPENCODE_PORT ?? '4096';
46
+ const defaultUrl = process.env.OPENCODE_API_URL ?? `http://localhost:${defaultPort}`;
47
+ this.baseUrl = config.baseUrl ?? defaultUrl;
48
+ this.password = config.password ?? process.env.OPENCODE_SERVER_PASSWORD;
49
+ this.timeout = config.timeout ?? 5000;
50
+ }
51
+
52
+ /**
53
+ * Build authorization headers if password is set
54
+ */
55
+ private getHeaders(): Record<string, string> {
56
+ const headers: Record<string, string> = {
57
+ 'Content-Type': 'application/json',
58
+ };
59
+ if (this.password) {
60
+ // Basic auth with 'opencode' as username
61
+ const auth = Buffer.from(`opencode:${this.password}`).toString('base64');
62
+ headers['Authorization'] = `Basic ${auth}`;
63
+ }
64
+ return headers;
65
+ }
66
+
67
+ /**
68
+ * Make a request to the opencode API
69
+ */
70
+ private async request<T>(
71
+ method: 'GET' | 'POST' | 'DELETE',
72
+ path: string,
73
+ body?: unknown
74
+ ): Promise<OpenCodeApiResponse<T>> {
75
+ const controller = new AbortController();
76
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
77
+
78
+ try {
79
+ const response = await fetch(`${this.baseUrl}${path}`, {
80
+ method,
81
+ headers: this.getHeaders(),
82
+ body: body ? JSON.stringify(body) : undefined,
83
+ signal: controller.signal,
84
+ });
85
+
86
+ clearTimeout(timeoutId);
87
+
88
+ if (!response.ok) {
89
+ return {
90
+ success: false,
91
+ error: `HTTP ${response.status}: ${response.statusText}`,
92
+ };
93
+ }
94
+
95
+ const data = await response.json() as T;
96
+ return { success: true, data };
97
+ } catch (error) {
98
+ clearTimeout(timeoutId);
99
+ if (error instanceof Error) {
100
+ if (error.name === 'AbortError') {
101
+ return { success: false, error: 'Request timeout' };
102
+ }
103
+ return { success: false, error: error.message };
104
+ }
105
+ return { success: false, error: 'Unknown error' };
106
+ }
107
+ }
108
+
109
+ // =========================================================================
110
+ // Health Check
111
+ // =========================================================================
112
+
113
+ /**
114
+ * Check if opencode serve is running and accessible
115
+ */
116
+ async isAvailable(): Promise<boolean> {
117
+ try {
118
+ const response = await this.request<{ version: string }>('GET', '/config');
119
+ return response.success;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Wait for opencode serve to become available
127
+ * @param maxWaitMs Maximum time to wait in milliseconds (default: 10000)
128
+ * @param intervalMs Check interval in milliseconds (default: 500)
129
+ */
130
+ async waitForAvailable(maxWaitMs = 10000, intervalMs = 500): Promise<boolean> {
131
+ const startTime = Date.now();
132
+ while (Date.now() - startTime < maxWaitMs) {
133
+ if (await this.isAvailable()) {
134
+ return true;
135
+ }
136
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
137
+ }
138
+ return false;
139
+ }
140
+
141
+ // =========================================================================
142
+ // TUI Control
143
+ // =========================================================================
144
+
145
+ /**
146
+ * Append text to the TUI input field
147
+ * This is the primary method for injecting relay messages into opencode
148
+ */
149
+ async appendPrompt(text: string): Promise<OpenCodeApiResponse<boolean>> {
150
+ return this.request<boolean>('POST', '/tui/append-prompt', { text });
151
+ }
152
+
153
+ /**
154
+ * Submit the current prompt
155
+ */
156
+ async submitPrompt(): Promise<OpenCodeApiResponse<boolean>> {
157
+ return this.request<boolean>('POST', '/tui/submit-prompt');
158
+ }
159
+
160
+ /**
161
+ * Clear the current prompt
162
+ */
163
+ async clearPrompt(): Promise<OpenCodeApiResponse<boolean>> {
164
+ return this.request<boolean>('POST', '/tui/clear-prompt');
165
+ }
166
+
167
+ /**
168
+ * Show a toast notification in the TUI
169
+ */
170
+ async showToast(
171
+ message: string,
172
+ options?: { variant?: 'info' | 'success' | 'warning' | 'error'; duration?: number }
173
+ ): Promise<OpenCodeApiResponse<boolean>> {
174
+ return this.request<boolean>('POST', '/tui/show-toast', {
175
+ message,
176
+ variant: options?.variant ?? 'info',
177
+ duration: options?.duration ?? 3000,
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Execute a TUI command
183
+ */
184
+ async executeCommand(
185
+ command:
186
+ | 'session_new'
187
+ | 'session_share'
188
+ | 'session_interrupt'
189
+ | 'session_compact'
190
+ | 'agent_cycle'
191
+ | 'messages_page_up'
192
+ | 'messages_page_down'
193
+ ): Promise<OpenCodeApiResponse<boolean>> {
194
+ return this.request<boolean>('POST', '/tui/execute-command', { command });
195
+ }
196
+
197
+ // =========================================================================
198
+ // Session Management
199
+ // =========================================================================
200
+
201
+ /**
202
+ * List all sessions
203
+ */
204
+ async listSessions(): Promise<OpenCodeApiResponse<OpenCodeSession[]>> {
205
+ return this.request<OpenCodeSession[]>('GET', '/session');
206
+ }
207
+
208
+ /**
209
+ * Get the current session
210
+ */
211
+ async getCurrentSession(): Promise<OpenCodeApiResponse<OpenCodeSession>> {
212
+ return this.request<OpenCodeSession>('GET', '/session/current');
213
+ }
214
+
215
+ /**
216
+ * Select a session in the TUI
217
+ */
218
+ async selectSession(sessionId: string): Promise<OpenCodeApiResponse<boolean>> {
219
+ return this.request<boolean>('POST', '/tui/select-session', { sessionID: sessionId });
220
+ }
221
+
222
+ // =========================================================================
223
+ // Events (SSE)
224
+ // =========================================================================
225
+
226
+ /**
227
+ * Subscribe to opencode events via Server-Sent Events
228
+ * Returns an abort function to stop the subscription
229
+ */
230
+ subscribeToEvents(
231
+ onEvent: (event: { type: string; data: unknown }) => void,
232
+ onError?: (error: Error) => void
233
+ ): () => void {
234
+ const controller = new AbortController();
235
+
236
+ const connect = async () => {
237
+ try {
238
+ const response = await fetch(`${this.baseUrl}/event`, {
239
+ headers: this.getHeaders(),
240
+ signal: controller.signal,
241
+ });
242
+
243
+ if (!response.ok || !response.body) {
244
+ throw new Error(`SSE connection failed: ${response.status}`);
245
+ }
246
+
247
+ const reader = response.body.getReader();
248
+ const decoder = new TextDecoder();
249
+ let buffer = '';
250
+
251
+ while (true) {
252
+ const { done, value } = await reader.read();
253
+ if (done) break;
254
+
255
+ buffer += decoder.decode(value, { stream: true });
256
+ const lines = buffer.split('\n');
257
+ buffer = lines.pop() ?? '';
258
+
259
+ for (const line of lines) {
260
+ if (line.startsWith('data: ')) {
261
+ try {
262
+ const data = JSON.parse(line.slice(6));
263
+ onEvent(data);
264
+ } catch {
265
+ // Ignore parse errors
266
+ }
267
+ }
268
+ }
269
+ }
270
+ } catch (error) {
271
+ if (error instanceof Error && error.name !== 'AbortError') {
272
+ onError?.(error);
273
+ }
274
+ }
275
+ };
276
+
277
+ connect();
278
+ return () => controller.abort();
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Default OpenCode API instance
284
+ */
285
+ export const openCodeApi = new OpenCodeApi();