@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.
@@ -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-hNfXv8YX.js"></script>
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('opencode/claude-sonnet-4');
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: 'opencode/claude-sonnet-4',
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 = 120000;
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 "\\--port \\K[0-9]+" | head -1'], { user: 'workspace' });
19
- const port = parseInt(result.stdout.trim(), 10);
20
- if (port && (await isServerRunning(containerName, port))) {
21
- return port;
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, ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', `http://localhost:${port}/session`], { user: 'workspace' });
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}/message`, {
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
- '-f',
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}/message`,
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
- if (result.exitCode !== 0) {
285
- throw new Error(`Failed to send message: ${result.stderr || 'Connection failed'}`);
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) {
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gricha/perry",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "Self-contained CLI for spinning up Docker-in-Docker development environments with SSH and proxy helpers.",
5
5
  "type": "module",
6
6
  "bin": {