@gricha/perry 0.3.13 → 0.3.14
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 +142 -40
- package/dist/agent/web/assets/index-ChUt0xgV.js +104 -0
- package/dist/agent/web/index.html +1 -1
- package/dist/agents/__tests__/opencode.test.js +37 -1
- package/dist/agents/sync/opencode.js +8 -1
- package/dist/perry-worker +0 -0
- package/dist/session-manager/adapters/opencode.js +73 -13
- package/dist/session-manager/manager.js +9 -9
- package/dist/shared/constants.js +1 -0
- package/package.json +1 -1
- package/dist/agent/web/assets/index-hNfXv8YX.js +0 -104
|
@@ -5,7 +5,7 @@
|
|
|
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-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-ChUt0xgV.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-B-qVBi35.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { opencodeSync } from '../sync/opencode';
|
|
3
|
+
import { DEFAULT_OPENCODE_MODEL } from '../../shared/constants';
|
|
3
4
|
function createMockContext(overrides = {}) {
|
|
4
5
|
return {
|
|
5
6
|
containerName: 'test-container',
|
|
@@ -62,7 +63,42 @@ describe('opencodeSync', () => {
|
|
|
62
63
|
expect(configs[0].permissions).toBe('600');
|
|
63
64
|
const parsed = JSON.parse(configs[0].content);
|
|
64
65
|
expect(parsed.provider.opencode.options.apiKey).toBe('test-token-123');
|
|
65
|
-
expect(parsed.model).toBe(
|
|
66
|
+
expect(parsed.model).toBe(DEFAULT_OPENCODE_MODEL);
|
|
67
|
+
});
|
|
68
|
+
it('uses host model when configured model missing', async () => {
|
|
69
|
+
const hostConfig = { model: 'opencode/claude-opus-4-5' };
|
|
70
|
+
const context = createMockContext({
|
|
71
|
+
agentConfig: {
|
|
72
|
+
port: 7777,
|
|
73
|
+
credentials: { env: {}, files: {} },
|
|
74
|
+
scripts: {},
|
|
75
|
+
agents: { opencode: { zen_token: 'test-token' } },
|
|
76
|
+
},
|
|
77
|
+
readHostFile: async (path) => path === '~/.config/opencode/opencode.json' ? JSON.stringify(hostConfig) : null,
|
|
78
|
+
});
|
|
79
|
+
const configs = await opencodeSync.getGeneratedConfigs(context);
|
|
80
|
+
const parsed = JSON.parse(configs[0].content);
|
|
81
|
+
expect(parsed.model).toBe('opencode/claude-opus-4-5');
|
|
82
|
+
});
|
|
83
|
+
it('prefers configured model over host model', async () => {
|
|
84
|
+
const hostConfig = { model: 'opencode/claude-sonnet-4' };
|
|
85
|
+
const context = createMockContext({
|
|
86
|
+
agentConfig: {
|
|
87
|
+
port: 7777,
|
|
88
|
+
credentials: { env: {}, files: {} },
|
|
89
|
+
scripts: {},
|
|
90
|
+
agents: {
|
|
91
|
+
opencode: {
|
|
92
|
+
zen_token: 'test-token',
|
|
93
|
+
model: 'opencode/claude-opus-4-5',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
readHostFile: async (path) => path === '~/.config/opencode/opencode.json' ? JSON.stringify(hostConfig) : null,
|
|
98
|
+
});
|
|
99
|
+
const configs = await opencodeSync.getGeneratedConfigs(context);
|
|
100
|
+
const parsed = JSON.parse(configs[0].content);
|
|
101
|
+
expect(parsed.model).toBe('opencode/claude-opus-4-5');
|
|
66
102
|
});
|
|
67
103
|
it('does not include mcp when host has none', async () => {
|
|
68
104
|
const context = createMockContext({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DEFAULT_OPENCODE_MODEL } from '../../shared/constants';
|
|
1
2
|
export const opencodeSync = {
|
|
2
3
|
getRequiredDirs() {
|
|
3
4
|
return ['/home/workspace/.config/opencode'];
|
|
@@ -15,17 +16,23 @@ export const opencodeSync = {
|
|
|
15
16
|
}
|
|
16
17
|
const hostConfigContent = await context.readHostFile('~/.config/opencode/opencode.json');
|
|
17
18
|
let mcpConfig = {};
|
|
19
|
+
let hostModel;
|
|
18
20
|
if (hostConfigContent) {
|
|
19
21
|
try {
|
|
20
22
|
const parsed = JSON.parse(hostConfigContent);
|
|
21
23
|
if (parsed.mcp && typeof parsed.mcp === 'object') {
|
|
22
24
|
mcpConfig = parsed.mcp;
|
|
23
25
|
}
|
|
26
|
+
if (typeof parsed.model === 'string' && parsed.model.trim().length > 0) {
|
|
27
|
+
hostModel = parsed.model.trim();
|
|
28
|
+
}
|
|
24
29
|
}
|
|
25
30
|
catch {
|
|
26
31
|
// Invalid JSON, ignore
|
|
27
32
|
}
|
|
28
33
|
}
|
|
34
|
+
const configuredModel = context.agentConfig.agents?.opencode?.model?.trim();
|
|
35
|
+
const model = configuredModel || hostModel || DEFAULT_OPENCODE_MODEL;
|
|
29
36
|
const config = {
|
|
30
37
|
provider: {
|
|
31
38
|
opencode: {
|
|
@@ -34,7 +41,7 @@ export const opencodeSync = {
|
|
|
34
41
|
},
|
|
35
42
|
},
|
|
36
43
|
},
|
|
37
|
-
model
|
|
44
|
+
model,
|
|
38
45
|
};
|
|
39
46
|
if (Object.keys(mcpConfig).length > 0) {
|
|
40
47
|
config.mcp = mcpConfig;
|
package/dist/perry-worker
CHANGED
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execInContainer } from '../../docker';
|
|
2
2
|
const MESSAGE_TIMEOUT_MS = 30000;
|
|
3
|
-
const SSE_TIMEOUT_MS =
|
|
3
|
+
const SSE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes for long-running operations
|
|
4
4
|
const serverPorts = new Map();
|
|
5
5
|
const serverStarting = new Map();
|
|
6
6
|
let hostServerPort = null;
|
|
@@ -15,10 +15,17 @@ async function findAvailablePort(containerName) {
|
|
|
15
15
|
}
|
|
16
16
|
async function findExistingServer(containerName) {
|
|
17
17
|
try {
|
|
18
|
-
const result = await execInContainer(containerName, ['sh', '-c', 'pgrep -a -f "opencode serve" | grep -oP "
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
const result = await execInContainer(containerName, ['sh', '-c', 'pgrep -a -f "opencode serve" | grep -oP "\\\\--port \\\\K[0-9]+"'], { user: 'workspace' });
|
|
19
|
+
const ports = result.stdout
|
|
20
|
+
.trim()
|
|
21
|
+
.split('\n')
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((p) => parseInt(p, 10))
|
|
24
|
+
.filter((p) => !isNaN(p));
|
|
25
|
+
for (const port of ports) {
|
|
26
|
+
if (await isServerRunning(containerName, port)) {
|
|
27
|
+
return port;
|
|
28
|
+
}
|
|
22
29
|
}
|
|
23
30
|
}
|
|
24
31
|
catch {
|
|
@@ -28,7 +35,17 @@ async function findExistingServer(containerName) {
|
|
|
28
35
|
}
|
|
29
36
|
async function isServerRunning(containerName, port) {
|
|
30
37
|
try {
|
|
31
|
-
const result = await execInContainer(containerName, [
|
|
38
|
+
const result = await execInContainer(containerName, [
|
|
39
|
+
'curl',
|
|
40
|
+
'-s',
|
|
41
|
+
'-o',
|
|
42
|
+
'/dev/null',
|
|
43
|
+
'-w',
|
|
44
|
+
'%{http_code}',
|
|
45
|
+
'--max-time',
|
|
46
|
+
'3',
|
|
47
|
+
`http://localhost:${port}/session`,
|
|
48
|
+
], { user: 'workspace' });
|
|
32
49
|
return result.stdout.trim() === '200';
|
|
33
50
|
}
|
|
34
51
|
catch {
|
|
@@ -192,6 +209,13 @@ export class OpenCodeAdapter {
|
|
|
192
209
|
}
|
|
193
210
|
const baseUrl = `http://localhost:${this.port}`;
|
|
194
211
|
try {
|
|
212
|
+
if (this.agentSessionId) {
|
|
213
|
+
const exists = await this.sessionExists(baseUrl, this.agentSessionId);
|
|
214
|
+
if (!exists) {
|
|
215
|
+
console.log(`[opencode] Session ${this.agentSessionId} not found on server, creating new`);
|
|
216
|
+
this.agentSessionId = undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
195
219
|
if (!this.agentSessionId) {
|
|
196
220
|
this.agentSessionId = await this.createSession(baseUrl);
|
|
197
221
|
this.emit({ type: 'system', content: `Session: ${this.agentSessionId.slice(0, 8)}...` });
|
|
@@ -209,10 +233,35 @@ export class OpenCodeAdapter {
|
|
|
209
233
|
this.currentMessageId = undefined;
|
|
210
234
|
this.setStatus('error');
|
|
211
235
|
this.emitError(err);
|
|
212
|
-
this.emit({ type: 'error', content: err.message });
|
|
213
236
|
throw err;
|
|
214
237
|
}
|
|
215
238
|
}
|
|
239
|
+
async sessionExists(baseUrl, sessionId) {
|
|
240
|
+
try {
|
|
241
|
+
if (this.isHost) {
|
|
242
|
+
const response = await fetch(`${baseUrl}/session/${sessionId}`, {
|
|
243
|
+
method: 'GET',
|
|
244
|
+
signal: AbortSignal.timeout(5000),
|
|
245
|
+
});
|
|
246
|
+
return response.ok;
|
|
247
|
+
}
|
|
248
|
+
const result = await execInContainer(this.containerName, [
|
|
249
|
+
'curl',
|
|
250
|
+
'-s',
|
|
251
|
+
'-o',
|
|
252
|
+
'/dev/null',
|
|
253
|
+
'-w',
|
|
254
|
+
'%{http_code}',
|
|
255
|
+
'--max-time',
|
|
256
|
+
'5',
|
|
257
|
+
`${baseUrl}/session/${sessionId}`,
|
|
258
|
+
], { user: 'workspace' });
|
|
259
|
+
return result.stdout.trim() === '200';
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
216
265
|
async createSession(baseUrl) {
|
|
217
266
|
const payload = this.model ? { model: this.model } : {};
|
|
218
267
|
if (this.isHost) {
|
|
@@ -256,13 +305,13 @@ export class OpenCodeAdapter {
|
|
|
256
305
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
257
306
|
const payload = { parts: [{ type: 'text', text: message }] };
|
|
258
307
|
if (this.isHost) {
|
|
259
|
-
const response = await fetch(`${baseUrl}/session/${this.agentSessionId}/
|
|
308
|
+
const response = await fetch(`${baseUrl}/session/${this.agentSessionId}/prompt_async`, {
|
|
260
309
|
method: 'POST',
|
|
261
310
|
headers: { 'Content-Type': 'application/json' },
|
|
262
311
|
body: JSON.stringify(payload),
|
|
263
312
|
signal: AbortSignal.timeout(MESSAGE_TIMEOUT_MS),
|
|
264
313
|
});
|
|
265
|
-
if (!response.ok) {
|
|
314
|
+
if (!response.ok && response.status !== 204) {
|
|
266
315
|
throw new Error(`Failed to send message: ${response.statusText}`);
|
|
267
316
|
}
|
|
268
317
|
}
|
|
@@ -270,19 +319,23 @@ export class OpenCodeAdapter {
|
|
|
270
319
|
const result = await execInContainer(this.containerName, [
|
|
271
320
|
'curl',
|
|
272
321
|
'-s',
|
|
273
|
-
'-
|
|
322
|
+
'-w',
|
|
323
|
+
'%{http_code}',
|
|
324
|
+
'-o',
|
|
325
|
+
'/dev/null',
|
|
274
326
|
'--max-time',
|
|
275
327
|
String(MESSAGE_TIMEOUT_MS / 1000),
|
|
276
328
|
'-X',
|
|
277
329
|
'POST',
|
|
278
|
-
`${baseUrl}/session/${this.agentSessionId}/
|
|
330
|
+
`${baseUrl}/session/${this.agentSessionId}/prompt_async`,
|
|
279
331
|
'-H',
|
|
280
332
|
'Content-Type: application/json',
|
|
281
333
|
'-d',
|
|
282
334
|
JSON.stringify(payload),
|
|
283
335
|
], { user: 'workspace' });
|
|
284
|
-
|
|
285
|
-
|
|
336
|
+
const httpCode = result.stdout.trim();
|
|
337
|
+
if (result.exitCode !== 0 || (httpCode !== '204' && httpCode !== '200')) {
|
|
338
|
+
throw new Error(`Failed to send message: ${result.stderr || `HTTP ${httpCode}`}`);
|
|
286
339
|
}
|
|
287
340
|
}
|
|
288
341
|
await sseReady;
|
|
@@ -345,6 +398,10 @@ export class OpenCodeAdapter {
|
|
|
345
398
|
const event = JSON.parse(data);
|
|
346
399
|
eventCount++;
|
|
347
400
|
if (event.type === 'session.idle') {
|
|
401
|
+
const idleSessionId = event.properties?.sessionID;
|
|
402
|
+
if (!idleSessionId || idleSessionId !== this.agentSessionId) {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
348
405
|
console.log(`[opencode] SSE received session.idle after ${eventCount} events`);
|
|
349
406
|
receivedIdle = true;
|
|
350
407
|
clearTimeout(timeout);
|
|
@@ -354,6 +411,9 @@ export class OpenCodeAdapter {
|
|
|
354
411
|
}
|
|
355
412
|
if (event.type === 'message.part.updated' && event.properties.part) {
|
|
356
413
|
const part = event.properties.part;
|
|
414
|
+
if (!part.sessionID || part.sessionID !== this.agentSessionId) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
357
417
|
if (part.messageID) {
|
|
358
418
|
this.currentMessageId = part.messageID;
|
|
359
419
|
}
|
|
@@ -65,15 +65,6 @@ export class SessionManager {
|
|
|
65
65
|
});
|
|
66
66
|
const isHost = options.workspaceName === HOST_WORKSPACE_NAME;
|
|
67
67
|
const containerName = isHost ? undefined : getContainerName(options.workspaceName);
|
|
68
|
-
await adapter.start({
|
|
69
|
-
workspaceName: options.workspaceName,
|
|
70
|
-
containerName,
|
|
71
|
-
agentSessionId: options.agentSessionId,
|
|
72
|
-
model: options.model,
|
|
73
|
-
projectPath: options.projectPath,
|
|
74
|
-
isHost,
|
|
75
|
-
});
|
|
76
|
-
this.sessions.set(sessionId, session);
|
|
77
68
|
if (this.stateDir) {
|
|
78
69
|
await registry.createSession(this.stateDir, {
|
|
79
70
|
perrySessionId: sessionId,
|
|
@@ -83,6 +74,15 @@ export class SessionManager {
|
|
|
83
74
|
projectPath: options.projectPath ?? null,
|
|
84
75
|
});
|
|
85
76
|
}
|
|
77
|
+
await adapter.start({
|
|
78
|
+
workspaceName: options.workspaceName,
|
|
79
|
+
containerName,
|
|
80
|
+
agentSessionId: options.agentSessionId,
|
|
81
|
+
model: options.model,
|
|
82
|
+
projectPath: options.projectPath,
|
|
83
|
+
isHost,
|
|
84
|
+
});
|
|
85
|
+
this.sessions.set(sessionId, session);
|
|
86
86
|
return sessionId;
|
|
87
87
|
}
|
|
88
88
|
handleAdapterMessage(sessionId, message) {
|
package/dist/shared/constants.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const DEFAULT_AGENT_PORT = 7391;
|
|
2
2
|
export const DEFAULT_CLAUDE_MODEL = 'sonnet';
|
|
3
|
+
export const DEFAULT_OPENCODE_MODEL = 'opencode/claude-sonnet-4';
|
|
3
4
|
export const SSH_PORT_RANGE_START = 2200;
|
|
4
5
|
export const SSH_PORT_RANGE_END = 2400;
|
|
5
6
|
export const WORKSPACE_IMAGE_LOCAL = 'perry:latest';
|