@gricha/perry 0.2.6 → 0.3.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.
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Perry</title>
8
- <script type="module" crossorigin src="/assets/index-DhU_amC3.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BwItLEFi.css">
8
+ <script type="module" crossorigin src="/assets/index-CZjSxNrg.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CYo-1I5o.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_CLAUDE_MODEL } from '../shared/constants';
2
+ import { SessionMonitor, MONITOR_PRESETS, formatErrorMessage } from './session-monitor';
2
3
  export class BaseClaudeSession {
3
4
  process = null;
4
5
  sessionId;
@@ -6,6 +7,7 @@ export class BaseClaudeSession {
6
7
  sessionModel;
7
8
  onMessage;
8
9
  buffer = '';
10
+ monitor = null;
9
11
  constructor(sessionId, model, onMessage) {
10
12
  this.sessionId = sessionId;
11
13
  this.model = model || DEFAULT_CLAUDE_MODEL;
@@ -21,6 +23,26 @@ export class BaseClaudeSession {
21
23
  content: 'Processing your message...',
22
24
  timestamp: new Date().toISOString(),
23
25
  });
26
+ // Create monitor with activity tracking to detect frozen subprocesses
27
+ this.monitor = new SessionMonitor({
28
+ ...MONITOR_PRESETS.claudeCode,
29
+ activityTimeout: 60000, // Detect if no output for 60s
30
+ }, {
31
+ onError: this.onMessage,
32
+ onTimeout: () => {
33
+ if (this.process) {
34
+ console.warn(`[${logPrefix}] Killing process due to timeout`);
35
+ this.process.kill();
36
+ }
37
+ },
38
+ onActivityTimeout: () => {
39
+ if (this.process) {
40
+ console.warn(`[${logPrefix}] Killing process due to inactivity`);
41
+ this.process.kill();
42
+ }
43
+ },
44
+ });
45
+ this.monitor.start();
24
46
  try {
25
47
  const proc = Bun.spawn(command, {
26
48
  stdin: 'ignore',
@@ -37,22 +59,40 @@ export class BaseClaudeSession {
37
59
  const decoder = new TextDecoder();
38
60
  let receivedAnyOutput = false;
39
61
  for await (const chunk of proc.stdout) {
62
+ // Mark activity so monitor knows subprocess is alive
63
+ if (this.monitor) {
64
+ this.monitor.markActivity();
65
+ }
40
66
  const text = decoder.decode(chunk);
41
67
  console.log(`[${logPrefix}] Received chunk:`, text.length, 'bytes');
42
68
  receivedAnyOutput = true;
43
69
  this.buffer += text;
44
70
  this.processBuffer();
71
+ // Check if monitor has timed out
72
+ if (this.monitor?.isCompleted()) {
73
+ console.warn(`[${logPrefix}] Monitor timeout, breaking from output loop`);
74
+ proc.kill();
75
+ break;
76
+ }
45
77
  }
46
78
  const exitCode = await proc.exited;
47
79
  console.log(`[${logPrefix}] Process exited with code:`, exitCode, 'receivedOutput:', receivedAnyOutput);
80
+ // Stop monitoring before handling results
81
+ if (this.monitor && !this.monitor.isCompleted()) {
82
+ this.monitor.complete();
83
+ }
48
84
  const stderrText = await stderrPromise;
49
85
  if (stderrText) {
50
86
  console.error(`[${logPrefix}] stderr:`, stderrText);
51
87
  }
88
+ // Don't send error if monitor already sent one
89
+ if (this.monitor?.isCompleted()) {
90
+ return;
91
+ }
52
92
  if (exitCode !== 0) {
53
93
  this.onMessage({
54
94
  type: 'error',
55
- content: stderrText || `Claude exited with code ${exitCode}`,
95
+ content: formatErrorMessage(new Error(stderrText || `Claude exited with code ${exitCode}`), 'Claude Code'),
56
96
  timestamp: new Date().toISOString(),
57
97
  });
58
98
  return;
@@ -75,11 +115,14 @@ export class BaseClaudeSession {
75
115
  console.error(`[${logPrefix}] Error:`, err);
76
116
  this.onMessage({
77
117
  type: 'error',
78
- content: err.message,
118
+ content: formatErrorMessage(err, 'Claude Code'),
79
119
  timestamp: new Date().toISOString(),
80
120
  });
81
121
  }
82
122
  finally {
123
+ if (this.monitor) {
124
+ this.monitor.complete();
125
+ }
83
126
  this.process = null;
84
127
  }
85
128
  }
@@ -140,6 +183,9 @@ export class BaseClaudeSession {
140
183
  }
141
184
  }
142
185
  async interrupt() {
186
+ if (this.monitor) {
187
+ this.monitor.complete();
188
+ }
143
189
  if (this.process) {
144
190
  this.process.kill();
145
191
  this.process = null;
@@ -1,4 +1,9 @@
1
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
2
7
  const serverPorts = new Map();
3
8
  const serverStarting = new Map();
4
9
  async function findAvailablePort(containerName) {
@@ -60,6 +65,9 @@ export class OpenCodeServerSession {
60
65
  responseComplete = false;
61
66
  seenToolUse = new Set();
62
67
  seenToolResult = new Set();
68
+ lastHeartbeat = 0;
69
+ heartbeatTimer = null;
70
+ streamError = null;
63
71
  constructor(options, onMessage) {
64
72
  this.containerName = options.containerName;
65
73
  this.workDir = options.workDir || '/home/workspace';
@@ -68,20 +76,89 @@ export class OpenCodeServerSession {
68
76
  this.sessionModel = options.model;
69
77
  this.onMessage = onMessage;
70
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
+ }
71
120
  async sendMessage(userMessage) {
72
121
  const port = await startServer(this.containerName);
73
122
  const baseUrl = `http://localhost:${port}`;
123
+ // Reset error state for new message
124
+ this.streamError = null;
74
125
  this.onMessage({
75
126
  type: 'system',
76
127
  content: 'Processing your message...',
77
128
  timestamp: new Date().toISOString(),
78
129
  });
79
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
+ }
80
155
  if (!this.sessionId) {
81
156
  const sessionPayload = this.model ? JSON.stringify({ model: this.model }) : '{}';
82
157
  const createResult = await execInContainer(this.containerName, [
83
158
  'curl',
84
159
  '-s',
160
+ '--max-time',
161
+ String(MESSAGE_SEND_TIMEOUT_MS / 1000),
85
162
  '-X',
86
163
  'POST',
87
164
  `${baseUrl}/session`,
@@ -90,8 +167,16 @@ export class OpenCodeServerSession {
90
167
  '-d',
91
168
  sessionPayload,
92
169
  ], { user: 'workspace' });
93
- const session = JSON.parse(createResult.stdout);
94
- this.sessionId = session.id;
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
+ }
95
180
  this.sessionModel = this.model;
96
181
  this.onMessage({
97
182
  type: 'system',
@@ -102,14 +187,30 @@ export class OpenCodeServerSession {
102
187
  this.responseComplete = false;
103
188
  this.seenToolUse.clear();
104
189
  this.seenToolResult.clear();
190
+ // Start SSE stream with timeout
105
191
  const { ready, done } = await this.startSSEStream(port);
106
- await ready;
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!
107
204
  const messagePayload = JSON.stringify({
108
205
  parts: [{ type: 'text', text: userMessage }],
109
206
  });
110
- execInContainer(this.containerName, [
207
+ const sendResult = await execInContainer(this.containerName, [
111
208
  'curl',
112
209
  '-s',
210
+ '--max-time',
211
+ String(MESSAGE_SEND_TIMEOUT_MS / 1000),
212
+ '-w',
213
+ '\n%{http_code}',
113
214
  '-X',
114
215
  'POST',
115
216
  `${baseUrl}/session/${this.sessionId}/message`,
@@ -117,10 +218,25 @@ export class OpenCodeServerSession {
117
218
  'Content-Type: application/json',
118
219
  '-d',
119
220
  messagePayload,
120
- ], { user: 'workspace' }).catch((err) => {
121
- console.error('[opencode-server] Send error:', err);
122
- });
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
123
235
  await done;
236
+ // Check if there was a stream error during processing
237
+ if (this.streamError) {
238
+ throw this.streamError;
239
+ }
124
240
  this.onMessage({
125
241
  type: 'done',
126
242
  content: 'Response complete',
@@ -129,6 +245,7 @@ export class OpenCodeServerSession {
129
245
  }
130
246
  catch (err) {
131
247
  console.error('[opencode-server] Error:', err);
248
+ this.cleanup();
132
249
  this.onMessage({
133
250
  type: 'error',
134
251
  content: err.message,
@@ -136,11 +253,53 @@ export class OpenCodeServerSession {
136
253
  });
137
254
  }
138
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
+ }
139
266
  async startSSEStream(port) {
140
267
  let resolveReady;
268
+ let rejectReady;
141
269
  let resolveDone;
142
- const ready = new Promise((r) => (resolveReady = r));
143
- const done = new Promise((r) => (resolveDone = r));
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
+ });
144
303
  const proc = Bun.spawn([
145
304
  'docker',
146
305
  'exec',
@@ -150,7 +309,7 @@ export class OpenCodeServerSession {
150
309
  '-s',
151
310
  '-N',
152
311
  '--max-time',
153
- '120',
312
+ String(SSE_STREAM_TIMEOUT_MS / 1000),
154
313
  `http://localhost:${port}/event`,
155
314
  ], {
156
315
  stdin: 'ignore',
@@ -161,10 +320,24 @@ export class OpenCodeServerSession {
161
320
  const decoder = new TextDecoder();
162
321
  let buffer = '';
163
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
+ };
164
336
  const processChunk = (chunk) => {
165
337
  buffer += decoder.decode(chunk);
166
338
  if (!hasReceivedData) {
167
339
  hasReceivedData = true;
340
+ startHeartbeatMonitor();
168
341
  resolveReady();
169
342
  }
170
343
  const lines = buffer.split('\n');
@@ -177,6 +350,12 @@ export class OpenCodeServerSession {
177
350
  continue;
178
351
  try {
179
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
+ }
180
359
  this.handleEvent(event);
181
360
  if (event.type === 'session.idle') {
182
361
  this.responseComplete = true;
@@ -190,32 +369,71 @@ export class OpenCodeServerSession {
190
369
  }
191
370
  }
192
371
  };
372
+ // Process stdout stream
193
373
  (async () => {
194
374
  if (!proc.stdout) {
195
- resolveReady();
196
- resolveDone();
375
+ rejectReady(new Error('Failed to start SSE stream: no stdout'));
376
+ rejectDone(new Error('Failed to start SSE stream: no stdout'));
197
377
  return;
198
378
  }
199
- for await (const chunk of proc.stdout) {
200
- processChunk(chunk);
201
- if (this.responseComplete)
202
- break;
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;
203
395
  }
204
396
  resolveDone();
205
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
206
412
  setTimeout(() => {
207
- if (!hasReceivedData) {
208
- resolveReady();
413
+ if (!hasReceivedData && !readyResolved) {
414
+ rejectReady(new Error('Timeout connecting to OpenCode server event stream'));
209
415
  }
210
- }, 500);
416
+ }, SSE_READY_TIMEOUT_MS);
417
+ // Overall stream timeout
211
418
  setTimeout(() => {
212
- if (!this.responseComplete) {
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.');
213
422
  proc.kill();
214
423
  resolveDone();
215
424
  }
216
- }, 120000);
425
+ }, SSE_STREAM_TIMEOUT_MS);
217
426
  return { ready, done };
218
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
+ }
219
437
  handleEvent(event) {
220
438
  const timestamp = new Date().toISOString();
221
439
  if (event.type === 'message.part.updated' && event.properties.part) {
@@ -253,9 +471,8 @@ export class OpenCodeServerSession {
253
471
  }
254
472
  }
255
473
  async interrupt() {
256
- if (this.sseProcess) {
257
- this.sseProcess.kill();
258
- this.sseProcess = null;
474
+ if (this.sseProcess || this.heartbeatTimer) {
475
+ this.cleanup();
259
476
  this.onMessage({
260
477
  type: 'system',
261
478
  content: 'Chat interrupted',