@gricha/perry 0.3.0 → 0.3.2

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.
@@ -1,502 +0,0 @@
1
- import { execInContainer } from '../docker';
2
- // Configuration for connection management
3
- const HEARTBEAT_TIMEOUT_MS = 45000; // Expect heartbeat every 30s, allow 15s grace
4
- const MESSAGE_SEND_TIMEOUT_MS = 30000; // Timeout for sending a message
5
- const SSE_STREAM_TIMEOUT_MS = 120000; // Overall timeout for SSE stream
6
- const SSE_READY_TIMEOUT_MS = 5000; // Timeout waiting for SSE to become ready
7
- const serverPorts = new Map();
8
- const serverStarting = new Map();
9
- async function findAvailablePort(containerName) {
10
- const script = `import socket; s=socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()`;
11
- const result = await execInContainer(containerName, ['python3', '-c', script], {
12
- user: 'workspace',
13
- });
14
- return parseInt(result.stdout.trim(), 10);
15
- }
16
- async function isServerRunning(containerName, port) {
17
- try {
18
- const result = await execInContainer(containerName, ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', `http://localhost:${port}/session`], { user: 'workspace' });
19
- return result.stdout.trim() === '200';
20
- }
21
- catch {
22
- return false;
23
- }
24
- }
25
- async function startServer(containerName) {
26
- const existing = serverPorts.get(containerName);
27
- if (existing && (await isServerRunning(containerName, existing))) {
28
- return existing;
29
- }
30
- const starting = serverStarting.get(containerName);
31
- if (starting) {
32
- return starting;
33
- }
34
- const startPromise = (async () => {
35
- const port = await findAvailablePort(containerName);
36
- console.log(`[opencode-server] Starting server on port ${port} in ${containerName}`);
37
- await execInContainer(containerName, [
38
- 'sh',
39
- '-c',
40
- `nohup opencode serve --port ${port} --hostname 127.0.0.1 > /tmp/opencode-server.log 2>&1 &`,
41
- ], { user: 'workspace' });
42
- for (let i = 0; i < 30; i++) {
43
- await new Promise((resolve) => setTimeout(resolve, 500));
44
- if (await isServerRunning(containerName, port)) {
45
- console.log(`[opencode-server] Server started on port ${port}`);
46
- serverPorts.set(containerName, port);
47
- serverStarting.delete(containerName);
48
- return port;
49
- }
50
- }
51
- serverStarting.delete(containerName);
52
- throw new Error('Failed to start OpenCode server');
53
- })();
54
- serverStarting.set(containerName, startPromise);
55
- return startPromise;
56
- }
57
- export class OpenCodeServerSession {
58
- containerName;
59
- workDir;
60
- sessionId;
61
- model;
62
- sessionModel;
63
- onMessage;
64
- sseProcess = null;
65
- responseComplete = false;
66
- seenToolUse = new Set();
67
- seenToolResult = new Set();
68
- lastHeartbeat = 0;
69
- heartbeatTimer = null;
70
- streamError = null;
71
- constructor(options, onMessage) {
72
- this.containerName = options.containerName;
73
- this.workDir = options.workDir || '/home/workspace';
74
- this.sessionId = options.sessionId;
75
- this.model = options.model;
76
- this.sessionModel = options.model;
77
- this.onMessage = onMessage;
78
- }
79
- /**
80
- * Check session status from OpenCode server.
81
- * Returns the session status or null if session doesn't exist.
82
- */
83
- async getSessionStatus(port) {
84
- if (!this.sessionId)
85
- return null;
86
- try {
87
- const result = await execInContainer(this.containerName, ['curl', '-s', '--max-time', '5', `http://localhost:${port}/session/status`], { user: 'workspace' });
88
- const statuses = JSON.parse(result.stdout);
89
- const status = statuses[this.sessionId];
90
- return status || { type: 'idle' };
91
- }
92
- catch {
93
- return null;
94
- }
95
- }
96
- /**
97
- * Verify session exists before attempting to use it.
98
- */
99
- async verifySession(port) {
100
- if (!this.sessionId)
101
- return true; // No session to verify
102
- try {
103
- const result = await execInContainer(this.containerName, [
104
- 'curl',
105
- '-s',
106
- '-o',
107
- '/dev/null',
108
- '-w',
109
- '%{http_code}',
110
- '--max-time',
111
- '5',
112
- `http://localhost:${port}/session/${this.sessionId}`,
113
- ], { user: 'workspace' });
114
- return result.stdout.trim() === '200';
115
- }
116
- catch {
117
- return false;
118
- }
119
- }
120
- async sendMessage(userMessage) {
121
- const port = await startServer(this.containerName);
122
- const baseUrl = `http://localhost:${port}`;
123
- // Reset error state for new message
124
- this.streamError = null;
125
- this.onMessage({
126
- type: 'system',
127
- content: 'Processing your message...',
128
- timestamp: new Date().toISOString(),
129
- });
130
- try {
131
- // If resuming an existing session, verify it still exists
132
- if (this.sessionId) {
133
- const sessionExists = await this.verifySession(port);
134
- if (!sessionExists) {
135
- console.log(`[opencode-server] Session ${this.sessionId} no longer exists, creating new one`);
136
- this.sessionId = undefined;
137
- this.onMessage({
138
- type: 'system',
139
- content: 'Previous session expired, starting new session...',
140
- timestamp: new Date().toISOString(),
141
- });
142
- }
143
- else {
144
- // Check if session is busy (another client using it)
145
- const status = await this.getSessionStatus(port);
146
- if (status?.type === 'busy') {
147
- this.onMessage({
148
- type: 'system',
149
- content: 'Session is currently busy, waiting for it to become available...',
150
- timestamp: new Date().toISOString(),
151
- });
152
- }
153
- }
154
- }
155
- if (!this.sessionId) {
156
- const sessionPayload = this.model ? JSON.stringify({ model: this.model }) : '{}';
157
- const createResult = await execInContainer(this.containerName, [
158
- 'curl',
159
- '-s',
160
- '--max-time',
161
- String(MESSAGE_SEND_TIMEOUT_MS / 1000),
162
- '-X',
163
- 'POST',
164
- `${baseUrl}/session`,
165
- '-H',
166
- 'Content-Type: application/json',
167
- '-d',
168
- sessionPayload,
169
- ], { user: 'workspace' });
170
- if (createResult.exitCode !== 0) {
171
- throw new Error(`Failed to create session: ${createResult.stderr || 'Unknown error'}`);
172
- }
173
- try {
174
- const session = JSON.parse(createResult.stdout);
175
- this.sessionId = session.id;
176
- }
177
- catch {
178
- throw new Error(`Invalid response from OpenCode server: ${createResult.stdout}`);
179
- }
180
- this.sessionModel = this.model;
181
- this.onMessage({
182
- type: 'system',
183
- content: `Session started ${this.sessionId}`,
184
- timestamp: new Date().toISOString(),
185
- });
186
- }
187
- this.responseComplete = false;
188
- this.seenToolUse.clear();
189
- this.seenToolResult.clear();
190
- // Start SSE stream with timeout
191
- const { ready, done } = await this.startSSEStream(port);
192
- // Wait for SSE stream to be ready with timeout
193
- const readyTimeout = new Promise((_, reject) => {
194
- setTimeout(() => reject(new Error('Timeout waiting for connection to OpenCode server')), SSE_READY_TIMEOUT_MS);
195
- });
196
- try {
197
- await Promise.race([ready, readyTimeout]);
198
- }
199
- catch (err) {
200
- this.cleanup();
201
- throw err;
202
- }
203
- // Now send the message - THIS IS AWAITED now!
204
- const messagePayload = JSON.stringify({
205
- parts: [{ type: 'text', text: userMessage }],
206
- });
207
- const sendResult = await execInContainer(this.containerName, [
208
- 'curl',
209
- '-s',
210
- '--max-time',
211
- String(MESSAGE_SEND_TIMEOUT_MS / 1000),
212
- '-w',
213
- '\n%{http_code}',
214
- '-X',
215
- 'POST',
216
- `${baseUrl}/session/${this.sessionId}/message`,
217
- '-H',
218
- 'Content-Type: application/json',
219
- '-d',
220
- messagePayload,
221
- ], { user: 'workspace' });
222
- // Parse the HTTP status code from the last line
223
- const lines = sendResult.stdout.trim().split('\n');
224
- const httpStatus = lines.pop();
225
- if (sendResult.exitCode !== 0) {
226
- this.cleanup();
227
- throw new Error(`Failed to send message: ${sendResult.stderr || 'Connection failed'}`);
228
- }
229
- if (httpStatus && !httpStatus.startsWith('2')) {
230
- this.cleanup();
231
- const errorBody = lines.join('\n');
232
- throw new Error(`OpenCode server error (HTTP ${httpStatus}): ${errorBody || 'Unknown error'}`);
233
- }
234
- // Wait for the response stream to complete
235
- await done;
236
- // Check if there was a stream error during processing
237
- if (this.streamError) {
238
- throw this.streamError;
239
- }
240
- this.onMessage({
241
- type: 'done',
242
- content: 'Response complete',
243
- timestamp: new Date().toISOString(),
244
- });
245
- }
246
- catch (err) {
247
- console.error('[opencode-server] Error:', err);
248
- this.cleanup();
249
- this.onMessage({
250
- type: 'error',
251
- content: err.message,
252
- timestamp: new Date().toISOString(),
253
- });
254
- }
255
- }
256
- /**
257
- * Clean up resources (timers, processes)
258
- */
259
- cleanup() {
260
- this.stopHeartbeatMonitor();
261
- if (this.sseProcess) {
262
- this.sseProcess.kill();
263
- this.sseProcess = null;
264
- }
265
- }
266
- async startSSEStream(port) {
267
- let resolveReady;
268
- let rejectReady;
269
- let resolveDone;
270
- let rejectDone;
271
- let readyResolved = false;
272
- let doneResolved = false;
273
- const ready = new Promise((resolve, reject) => {
274
- resolveReady = () => {
275
- if (!readyResolved) {
276
- readyResolved = true;
277
- resolve();
278
- }
279
- };
280
- rejectReady = (err) => {
281
- if (!readyResolved) {
282
- readyResolved = true;
283
- reject(err);
284
- }
285
- };
286
- });
287
- const done = new Promise((resolve, reject) => {
288
- resolveDone = () => {
289
- if (!doneResolved) {
290
- doneResolved = true;
291
- this.stopHeartbeatMonitor();
292
- resolve();
293
- }
294
- };
295
- rejectDone = (err) => {
296
- if (!doneResolved) {
297
- doneResolved = true;
298
- this.stopHeartbeatMonitor();
299
- reject(err);
300
- }
301
- };
302
- });
303
- const proc = Bun.spawn([
304
- 'docker',
305
- 'exec',
306
- '-i',
307
- this.containerName,
308
- 'curl',
309
- '-s',
310
- '-N',
311
- '--max-time',
312
- String(SSE_STREAM_TIMEOUT_MS / 1000),
313
- `http://localhost:${port}/event`,
314
- ], {
315
- stdin: 'ignore',
316
- stdout: 'pipe',
317
- stderr: 'pipe',
318
- });
319
- this.sseProcess = proc;
320
- const decoder = new TextDecoder();
321
- let buffer = '';
322
- let hasReceivedData = false;
323
- // Start heartbeat monitoring once we receive data
324
- const startHeartbeatMonitor = () => {
325
- this.lastHeartbeat = Date.now();
326
- this.heartbeatTimer = setInterval(() => {
327
- const timeSinceLastHeartbeat = Date.now() - this.lastHeartbeat;
328
- if (timeSinceLastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
329
- console.error(`[opencode-server] No heartbeat received for ${timeSinceLastHeartbeat}ms, connection may be lost`);
330
- this.streamError = new Error('Connection to OpenCode server lost. Please try again.');
331
- proc.kill();
332
- resolveDone();
333
- }
334
- }, HEARTBEAT_TIMEOUT_MS / 2);
335
- };
336
- const processChunk = (chunk) => {
337
- buffer += decoder.decode(chunk);
338
- if (!hasReceivedData) {
339
- hasReceivedData = true;
340
- startHeartbeatMonitor();
341
- resolveReady();
342
- }
343
- const lines = buffer.split('\n');
344
- buffer = lines.pop() || '';
345
- for (const line of lines) {
346
- if (!line.startsWith('data: '))
347
- continue;
348
- const data = line.slice(6).trim();
349
- if (!data)
350
- continue;
351
- try {
352
- const event = JSON.parse(data);
353
- // Update heartbeat timestamp for any valid event (including heartbeats)
354
- this.lastHeartbeat = Date.now();
355
- // Handle heartbeat events silently
356
- if (event.type === 'server.heartbeat' || event.type === 'server.connected') {
357
- continue;
358
- }
359
- this.handleEvent(event);
360
- if (event.type === 'session.idle') {
361
- this.responseComplete = true;
362
- proc.kill();
363
- resolveDone();
364
- return;
365
- }
366
- }
367
- catch {
368
- continue;
369
- }
370
- }
371
- };
372
- // Process stdout stream
373
- (async () => {
374
- if (!proc.stdout) {
375
- rejectReady(new Error('Failed to start SSE stream: no stdout'));
376
- rejectDone(new Error('Failed to start SSE stream: no stdout'));
377
- return;
378
- }
379
- try {
380
- for await (const chunk of proc.stdout) {
381
- processChunk(chunk);
382
- if (this.responseComplete)
383
- break;
384
- }
385
- // Stream ended - check if it was expected
386
- if (!this.responseComplete && !doneResolved) {
387
- // Stream ended without session.idle - could be connection loss
388
- console.warn('[opencode-server] SSE stream ended unexpectedly');
389
- this.streamError = new Error('Connection to OpenCode server closed unexpectedly');
390
- }
391
- }
392
- catch (err) {
393
- console.error('[opencode-server] SSE stream error:', err);
394
- this.streamError = err;
395
- }
396
- resolveDone();
397
- })();
398
- // Capture stderr for diagnostics
399
- (async () => {
400
- if (!proc.stderr)
401
- return;
402
- const stderrDecoder = new TextDecoder();
403
- let stderr = '';
404
- for await (const chunk of proc.stderr) {
405
- stderr += stderrDecoder.decode(chunk);
406
- }
407
- if (stderr && !this.responseComplete) {
408
- console.error('[opencode-server] SSE stderr:', stderr);
409
- }
410
- })();
411
- // Timeout for initial ready state
412
- setTimeout(() => {
413
- if (!hasReceivedData && !readyResolved) {
414
- rejectReady(new Error('Timeout connecting to OpenCode server event stream'));
415
- }
416
- }, SSE_READY_TIMEOUT_MS);
417
- // Overall stream timeout
418
- setTimeout(() => {
419
- if (!this.responseComplete && !doneResolved) {
420
- console.warn(`[opencode-server] SSE stream timeout after ${SSE_STREAM_TIMEOUT_MS}ms`);
421
- this.streamError = new Error('Request timed out. Please try again or check if OpenCode is responding.');
422
- proc.kill();
423
- resolveDone();
424
- }
425
- }, SSE_STREAM_TIMEOUT_MS);
426
- return { ready, done };
427
- }
428
- /**
429
- * Stop the heartbeat monitor timer
430
- */
431
- stopHeartbeatMonitor() {
432
- if (this.heartbeatTimer) {
433
- clearInterval(this.heartbeatTimer);
434
- this.heartbeatTimer = null;
435
- }
436
- }
437
- handleEvent(event) {
438
- const timestamp = new Date().toISOString();
439
- if (event.type === 'message.part.updated' && event.properties.part) {
440
- const part = event.properties.part;
441
- if (part.type === 'text' && event.properties.delta) {
442
- this.onMessage({
443
- type: 'assistant',
444
- content: event.properties.delta,
445
- timestamp,
446
- });
447
- }
448
- else if (part.type === 'tool' && part.tool) {
449
- const state = part.state;
450
- const partId = part.id;
451
- if (!this.seenToolUse.has(partId)) {
452
- this.seenToolUse.add(partId);
453
- this.onMessage({
454
- type: 'tool_use',
455
- content: JSON.stringify(state?.input, null, 2),
456
- toolName: state?.title || part.tool,
457
- toolId: partId,
458
- timestamp,
459
- });
460
- }
461
- if (state?.status === 'completed' && state?.output && !this.seenToolResult.has(partId)) {
462
- this.seenToolResult.add(partId);
463
- this.onMessage({
464
- type: 'tool_result',
465
- content: state.output,
466
- toolId: partId,
467
- timestamp,
468
- });
469
- }
470
- }
471
- }
472
- }
473
- async interrupt() {
474
- if (this.sseProcess || this.heartbeatTimer) {
475
- this.cleanup();
476
- this.onMessage({
477
- type: 'system',
478
- content: 'Chat interrupted',
479
- timestamp: new Date().toISOString(),
480
- });
481
- }
482
- }
483
- setModel(model) {
484
- if (this.model !== model) {
485
- this.model = model;
486
- if (this.sessionModel !== model) {
487
- this.sessionId = undefined;
488
- this.onMessage({
489
- type: 'system',
490
- content: `Switching to model: ${model}`,
491
- timestamp: new Date().toISOString(),
492
- });
493
- }
494
- }
495
- }
496
- getSessionId() {
497
- return this.sessionId;
498
- }
499
- }
500
- export function createOpenCodeServerSession(options, onMessage) {
501
- return new OpenCodeServerSession(options, onMessage);
502
- }
@@ -1,31 +0,0 @@
1
- import { BaseChatWebSocketServer, } from './base-chat-websocket';
2
- import { createHostOpencodeSession } from './host-opencode-handler';
3
- import { createOpenCodeServerSession } from './opencode-server';
4
- export class OpencodeWebSocketServer extends BaseChatWebSocketServer {
5
- agentType = 'opencode';
6
- getConfig;
7
- constructor(options) {
8
- super(options);
9
- this.getConfig = options.getConfig;
10
- }
11
- createConnection(ws, workspaceName) {
12
- return {
13
- ws,
14
- session: null,
15
- workspaceName,
16
- };
17
- }
18
- createHostSession(sessionId, onMessage, messageModel, _projectPath) {
19
- const model = messageModel || this.getConfig?.()?.agents?.opencode?.model;
20
- return createHostOpencodeSession({ sessionId, model }, onMessage);
21
- }
22
- createContainerSession(containerName, sessionId, onMessage, messageModel) {
23
- const model = messageModel || this.getConfig?.()?.agents?.opencode?.model;
24
- return createOpenCodeServerSession({
25
- containerName,
26
- workDir: '/home/workspace',
27
- sessionId,
28
- model,
29
- }, onMessage);
30
- }
31
- }