@gricha/perry 0.3.12 → 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;
@@ -13,9 +13,39 @@ async function findAvailablePort(containerName) {
13
13
  });
14
14
  return parseInt(result.stdout.trim(), 10);
15
15
  }
16
+ async function findExistingServer(containerName) {
17
+ try {
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
+ }
29
+ }
30
+ }
31
+ catch {
32
+ // No existing server
33
+ }
34
+ return null;
35
+ }
16
36
  async function isServerRunning(containerName, port) {
17
37
  try {
18
- 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' });
19
49
  return result.stdout.trim() === '200';
20
50
  }
21
51
  catch {
@@ -34,8 +64,14 @@ async function getServerLogs(containerName) {
34
64
  }
35
65
  }
36
66
  async function startServer(containerName) {
37
- const existing = serverPorts.get(containerName);
38
- if (existing && (await isServerRunning(containerName, existing))) {
67
+ const cached = serverPorts.get(containerName);
68
+ if (cached && (await isServerRunning(containerName, cached))) {
69
+ return cached;
70
+ }
71
+ const existing = await findExistingServer(containerName);
72
+ if (existing) {
73
+ console.log(`[opencode] Found existing server on port ${existing} in ${containerName}`);
74
+ serverPorts.set(containerName, existing);
39
75
  return existing;
40
76
  }
41
77
  const starting = serverStarting.get(containerName);
@@ -173,9 +209,17 @@ export class OpenCodeAdapter {
173
209
  }
174
210
  const baseUrl = `http://localhost:${this.port}`;
175
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
+ }
176
219
  if (!this.agentSessionId) {
177
220
  this.agentSessionId = await this.createSession(baseUrl);
178
221
  this.emit({ type: 'system', content: `Session: ${this.agentSessionId.slice(0, 8)}...` });
222
+ this.statusCallback?.(this.status);
179
223
  }
180
224
  this.setStatus('running');
181
225
  this.emit({ type: 'system', content: 'Processing...' });
@@ -189,10 +233,35 @@ export class OpenCodeAdapter {
189
233
  this.currentMessageId = undefined;
190
234
  this.setStatus('error');
191
235
  this.emitError(err);
192
- this.emit({ type: 'error', content: err.message });
193
236
  throw err;
194
237
  }
195
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
+ }
196
265
  async createSession(baseUrl) {
197
266
  const payload = this.model ? { model: this.model } : {};
198
267
  if (this.isHost) {
@@ -229,17 +298,20 @@ export class OpenCodeAdapter {
229
298
  return session.id;
230
299
  }
231
300
  async sendAndStream(baseUrl, message) {
232
- const sseReady = this.startSSEStream();
301
+ let sseError = null;
302
+ const sseReady = this.startSSEStream().catch((err) => {
303
+ sseError = err;
304
+ });
233
305
  await new Promise((resolve) => setTimeout(resolve, 100));
234
306
  const payload = { parts: [{ type: 'text', text: message }] };
235
307
  if (this.isHost) {
236
- const response = await fetch(`${baseUrl}/session/${this.agentSessionId}/message`, {
308
+ const response = await fetch(`${baseUrl}/session/${this.agentSessionId}/prompt_async`, {
237
309
  method: 'POST',
238
310
  headers: { 'Content-Type': 'application/json' },
239
311
  body: JSON.stringify(payload),
240
312
  signal: AbortSignal.timeout(MESSAGE_TIMEOUT_MS),
241
313
  });
242
- if (!response.ok) {
314
+ if (!response.ok && response.status !== 204) {
243
315
  throw new Error(`Failed to send message: ${response.statusText}`);
244
316
  }
245
317
  }
@@ -247,22 +319,29 @@ export class OpenCodeAdapter {
247
319
  const result = await execInContainer(this.containerName, [
248
320
  'curl',
249
321
  '-s',
250
- '-f',
322
+ '-w',
323
+ '%{http_code}',
324
+ '-o',
325
+ '/dev/null',
251
326
  '--max-time',
252
327
  String(MESSAGE_TIMEOUT_MS / 1000),
253
328
  '-X',
254
329
  'POST',
255
- `${baseUrl}/session/${this.agentSessionId}/message`,
330
+ `${baseUrl}/session/${this.agentSessionId}/prompt_async`,
256
331
  '-H',
257
332
  'Content-Type: application/json',
258
333
  '-d',
259
334
  JSON.stringify(payload),
260
335
  ], { user: 'workspace' });
261
- if (result.exitCode !== 0) {
262
- 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}`}`);
263
339
  }
264
340
  }
265
341
  await sseReady;
342
+ if (sseError) {
343
+ throw sseError;
344
+ }
266
345
  }
267
346
  startSSEStream() {
268
347
  return new Promise((resolve, reject) => {
@@ -284,6 +363,7 @@ export class OpenCodeAdapter {
284
363
  this.sseProcess = proc;
285
364
  const decoder = new TextDecoder();
286
365
  let buffer = '';
366
+ let eventCount = 0;
287
367
  const finish = () => {
288
368
  if (!resolved) {
289
369
  resolved = true;
@@ -316,7 +396,13 @@ export class OpenCodeAdapter {
316
396
  continue;
317
397
  try {
318
398
  const event = JSON.parse(data);
399
+ eventCount++;
319
400
  if (event.type === 'session.idle') {
401
+ const idleSessionId = event.properties?.sessionID;
402
+ if (!idleSessionId || idleSessionId !== this.agentSessionId) {
403
+ continue;
404
+ }
405
+ console.log(`[opencode] SSE received session.idle after ${eventCount} events`);
320
406
  receivedIdle = true;
321
407
  clearTimeout(timeout);
322
408
  proc.kill();
@@ -325,6 +411,9 @@ export class OpenCodeAdapter {
325
411
  }
326
412
  if (event.type === 'message.part.updated' && event.properties.part) {
327
413
  const part = event.properties.part;
414
+ if (!part.sessionID || part.sessionID !== this.agentSessionId) {
415
+ continue;
416
+ }
328
417
  if (part.messageID) {
329
418
  this.currentMessageId = part.messageID;
330
419
  }
@@ -375,7 +464,7 @@ export class OpenCodeAdapter {
375
464
  }
376
465
  else if (!resolved) {
377
466
  resolved = true;
378
- reject(new Error('SSE stream ended unexpectedly without session.idle'));
467
+ reject(new Error(`SSE stream ended unexpectedly without session.idle (received ${eventCount} events)`));
379
468
  }
380
469
  })().catch((err) => {
381
470
  clearTimeout(timeout);
@@ -90,7 +90,7 @@ export class LiveChatHandler {
90
90
  const agentType = message.agentType || this.agentType;
91
91
  if (message.sessionId) {
92
92
  // Look up by internal sessionId or agentSessionId (Claude session ID)
93
- const found = sessionManager.findSession(message.sessionId);
93
+ const found = await sessionManager.findSession(message.sessionId);
94
94
  if (found) {
95
95
  connection.sessionId = found.sessionId;
96
96
  const sendFn = (msg) => {
@@ -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) {
@@ -241,18 +241,41 @@ export class SessionManager {
241
241
  const session = this.sessions.get(sessionId);
242
242
  return session?.info ?? null;
243
243
  }
244
- findSession(id) {
245
- // First try direct lookup by internal sessionId
244
+ async findSession(id) {
245
+ // First try direct lookup by internal sessionId (in-memory cache)
246
246
  const direct = this.sessions.get(id);
247
247
  if (direct) {
248
248
  return { sessionId: id, info: direct.info };
249
249
  }
250
- // Then search by agentSessionId (Claude session ID)
250
+ // Then search by agentSessionId in memory
251
251
  for (const [sessionId, session] of this.sessions) {
252
252
  if (session.info.agentSessionId === id) {
253
253
  return { sessionId, info: session.info };
254
254
  }
255
255
  }
256
+ // Not in memory - check disk registry
257
+ if (this.stateDir) {
258
+ // Try lookup by perrySessionId first
259
+ let record = await registry.getSession(this.stateDir, id);
260
+ // Then try by agentSessionId
261
+ if (!record) {
262
+ record = await registry.findByAgentSessionId(this.stateDir, id);
263
+ }
264
+ // Found on disk - restore the session
265
+ if (record) {
266
+ const restoredId = await this.startSession({
267
+ sessionId: record.perrySessionId,
268
+ workspaceName: record.workspaceName,
269
+ agentType: record.agentType,
270
+ agentSessionId: record.agentSessionId ?? undefined,
271
+ projectPath: record.projectPath ?? undefined,
272
+ });
273
+ const restored = this.sessions.get(restoredId);
274
+ if (restored) {
275
+ return { sessionId: restoredId, info: restored.info };
276
+ }
277
+ }
278
+ }
256
279
  return null;
257
280
  }
258
281
  getSessionStatus(sessionId) {
@@ -98,6 +98,25 @@ export async function getSessionsForWorkspace(stateDir, workspaceName) {
98
98
  .filter((record) => record.workspaceName === workspaceName)
99
99
  .sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
100
100
  }
101
+ /**
102
+ * Get a specific session by perrySessionId.
103
+ */
104
+ export async function getSession(stateDir, perrySessionId) {
105
+ const registry = await loadRegistry(stateDir);
106
+ return registry.sessions[perrySessionId] ?? null;
107
+ }
108
+ /**
109
+ * Find a session by agentSessionId.
110
+ */
111
+ export async function findByAgentSessionId(stateDir, agentSessionId) {
112
+ const registry = await loadRegistry(stateDir);
113
+ for (const record of Object.values(registry.sessions)) {
114
+ if (record.agentSessionId === agentSessionId) {
115
+ return record;
116
+ }
117
+ }
118
+ return null;
119
+ }
101
120
  /**
102
121
  * Import an external session (discovered from agent storage).
103
122
  * Creates a Perry session record for a session that wasn't started through Perry.
@@ -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.12",
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": {