@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.
- package/dist/agent/router.js +127 -0
- package/dist/agent/run.js +157 -99
- package/dist/agent/static.js +32 -0
- package/dist/agent/web/assets/index-CYo-1I5o.css +1 -0
- package/dist/agent/web/assets/index-CZjSxNrg.js +104 -0
- package/dist/agent/web/index.html +2 -2
- package/dist/chat/base-claude-session.js +48 -2
- package/dist/chat/opencode-server.js +241 -24
- package/dist/chat/session-monitor.js +186 -0
- package/dist/chat/session-utils.js +155 -0
- package/dist/client/api.js +19 -0
- package/dist/perry-worker +0 -0
- package/dist/session-manager/adapters/claude.js +256 -0
- package/dist/session-manager/adapters/index.js +2 -0
- package/dist/session-manager/adapters/opencode.js +317 -0
- package/dist/session-manager/bun-handler.js +175 -0
- package/dist/session-manager/index.js +3 -0
- package/dist/session-manager/manager.js +302 -0
- package/dist/session-manager/ring-buffer.js +66 -0
- package/dist/session-manager/types.js +1 -0
- package/dist/session-manager/websocket.js +153 -0
- package/dist/shared/base-websocket.js +39 -7
- package/dist/tailscale/index.js +20 -6
- package/dist/terminal/bun-handler.js +151 -0
- package/package.json +3 -3
- package/dist/agent/web/assets/index-BwItLEFi.css +0 -1
- package/dist/agent/web/assets/index-DhU_amC3.js +0 -104
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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' })
|
|
121
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
413
|
+
if (!hasReceivedData && !readyResolved) {
|
|
414
|
+
rejectReady(new Error('Timeout connecting to OpenCode server event stream'));
|
|
209
415
|
}
|
|
210
|
-
},
|
|
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
|
-
},
|
|
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.
|
|
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',
|