@gricha/perry 0.1.7 → 0.1.8

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-BTiTEcB0.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-D2_-UqVf.css">
8
+ <script type="module" crossorigin src="/assets/index-DQmM39Em.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CaFOQOgc.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -41,13 +41,16 @@ export class BaseChatWebSocketServer extends BaseWebSocketServer {
41
41
  };
42
42
  if (!connection.session) {
43
43
  if (isHostMode) {
44
- connection.session = this.createHostSession(message.sessionId, onMessage);
44
+ connection.session = this.createHostSession(message.sessionId, onMessage, message.model);
45
45
  }
46
46
  else {
47
47
  const containerName = getContainerName(workspaceName);
48
- connection.session = this.createContainerSession(containerName, message.sessionId, onMessage);
48
+ connection.session = this.createContainerSession(containerName, message.sessionId, onMessage, message.model);
49
49
  }
50
50
  }
51
+ else if (message.model && connection.session.setModel) {
52
+ connection.session.setModel(message.model);
53
+ }
51
54
  await connection.session.sendMessage(message.content);
52
55
  }
53
56
  }
@@ -4,6 +4,7 @@ export class ChatSession {
4
4
  workDir;
5
5
  sessionId;
6
6
  model;
7
+ sessionModel;
7
8
  onMessage;
8
9
  buffer = '';
9
10
  constructor(options, onMessage) {
@@ -11,6 +12,7 @@ export class ChatSession {
11
12
  this.workDir = options.workDir || '/home/workspace';
12
13
  this.sessionId = options.sessionId;
13
14
  this.model = options.model || 'sonnet';
15
+ this.sessionModel = this.model;
14
16
  this.onMessage = onMessage;
15
17
  }
16
18
  async sendMessage(userMessage) {
@@ -121,6 +123,7 @@ export class ChatSession {
121
123
  const timestamp = new Date().toISOString();
122
124
  if (msg.type === 'system' && msg.subtype === 'init') {
123
125
  this.sessionId = msg.session_id;
126
+ this.sessionModel = this.model;
124
127
  this.onMessage({
125
128
  type: 'system',
126
129
  content: `Session started: ${msg.session_id?.slice(0, 8)}...`,
@@ -165,6 +168,19 @@ export class ChatSession {
165
168
  });
166
169
  }
167
170
  }
171
+ setModel(model) {
172
+ if (this.model !== model) {
173
+ this.model = model;
174
+ if (this.sessionModel !== model) {
175
+ this.sessionId = undefined;
176
+ this.onMessage({
177
+ type: 'system',
178
+ content: `Switching to model: ${model}`,
179
+ timestamp: new Date().toISOString(),
180
+ });
181
+ }
182
+ }
183
+ }
168
184
  getSessionId() {
169
185
  return this.sessionId;
170
186
  }
@@ -5,12 +5,16 @@ export class HostOpencodeSession {
5
5
  process = null;
6
6
  workDir;
7
7
  sessionId;
8
+ model;
9
+ sessionModel;
8
10
  onMessage;
9
11
  buffer = '';
10
12
  historyLoaded = false;
11
13
  constructor(options, onMessage) {
12
14
  this.workDir = options.workDir || homedir();
13
15
  this.sessionId = options.sessionId;
16
+ this.model = options.model;
17
+ this.sessionModel = options.model;
14
18
  this.onMessage = onMessage;
15
19
  }
16
20
  async loadHistory() {
@@ -126,6 +130,9 @@ export class HostOpencodeSession {
126
130
  if (this.sessionId) {
127
131
  args.push('--session', this.sessionId);
128
132
  }
133
+ if (this.model) {
134
+ args.push('--model', this.model);
135
+ }
129
136
  args.push(userMessage);
130
137
  console.log('[host-opencode] Running: stdbuf', args.join(' '));
131
138
  this.onMessage({
@@ -218,6 +225,7 @@ export class HostOpencodeSession {
218
225
  if (event.type === 'step_start' && event.sessionID) {
219
226
  if (!this.sessionId) {
220
227
  this.sessionId = event.sessionID;
228
+ this.sessionModel = this.model;
221
229
  this.historyLoaded = true;
222
230
  this.onMessage({
223
231
  type: 'system',
@@ -272,6 +280,20 @@ export class HostOpencodeSession {
272
280
  });
273
281
  }
274
282
  }
283
+ setModel(model) {
284
+ if (this.model !== model) {
285
+ this.model = model;
286
+ if (this.sessionModel !== model) {
287
+ this.sessionId = undefined;
288
+ this.historyLoaded = false;
289
+ this.onMessage({
290
+ type: 'system',
291
+ content: `Switching to model: ${model}`,
292
+ timestamp: new Date().toISOString(),
293
+ });
294
+ }
295
+ }
296
+ }
275
297
  getSessionId() {
276
298
  return this.sessionId;
277
299
  }
@@ -4,6 +4,8 @@ export class OpencodeSession {
4
4
  containerName;
5
5
  workDir;
6
6
  sessionId;
7
+ model;
8
+ sessionModel;
7
9
  onMessage;
8
10
  buffer = '';
9
11
  historyLoaded = false;
@@ -11,6 +13,8 @@ export class OpencodeSession {
11
13
  this.containerName = options.containerName;
12
14
  this.workDir = options.workDir || '/home/workspace';
13
15
  this.sessionId = options.sessionId;
16
+ this.model = options.model;
17
+ this.sessionModel = options.model;
14
18
  this.onMessage = onMessage;
15
19
  }
16
20
  async loadHistory() {
@@ -86,6 +90,9 @@ export class OpencodeSession {
86
90
  if (this.sessionId) {
87
91
  args.push('--session', this.sessionId);
88
92
  }
93
+ if (this.model) {
94
+ args.push('--model', this.model);
95
+ }
89
96
  args.push(userMessage);
90
97
  console.log('[opencode] Running:', 'docker', args.join(' '));
91
98
  this.onMessage({
@@ -174,6 +181,7 @@ export class OpencodeSession {
174
181
  if (event.type === 'step_start' && event.sessionID) {
175
182
  if (!this.sessionId) {
176
183
  this.sessionId = event.sessionID;
184
+ this.sessionModel = this.model;
177
185
  this.historyLoaded = true;
178
186
  this.onMessage({
179
187
  type: 'system',
@@ -228,6 +236,20 @@ export class OpencodeSession {
228
236
  });
229
237
  }
230
238
  }
239
+ setModel(model) {
240
+ if (this.model !== model) {
241
+ this.model = model;
242
+ if (this.sessionModel !== model) {
243
+ this.sessionId = undefined;
244
+ this.historyLoaded = false;
245
+ this.onMessage({
246
+ type: 'system',
247
+ content: `Switching to model: ${model}`,
248
+ timestamp: new Date().toISOString(),
249
+ });
250
+ }
251
+ }
252
+ }
231
253
  getSessionId() {
232
254
  return this.sessionId;
233
255
  }
@@ -53,6 +53,8 @@ export class OpenCodeServerSession {
53
53
  containerName;
54
54
  workDir;
55
55
  sessionId;
56
+ model;
57
+ sessionModel;
56
58
  onMessage;
57
59
  sseProcess = null;
58
60
  responseComplete = false;
@@ -62,6 +64,8 @@ export class OpenCodeServerSession {
62
64
  this.containerName = options.containerName;
63
65
  this.workDir = options.workDir || '/home/workspace';
64
66
  this.sessionId = options.sessionId;
67
+ this.model = options.model;
68
+ this.sessionModel = options.model;
65
69
  this.onMessage = onMessage;
66
70
  }
67
71
  async sendMessage(userMessage) {
@@ -74,6 +78,7 @@ export class OpenCodeServerSession {
74
78
  });
75
79
  try {
76
80
  if (!this.sessionId) {
81
+ const sessionPayload = this.model ? JSON.stringify({ model: this.model }) : '{}';
77
82
  const createResult = await execInContainer(this.containerName, [
78
83
  'curl',
79
84
  '-s',
@@ -83,10 +88,11 @@ export class OpenCodeServerSession {
83
88
  '-H',
84
89
  'Content-Type: application/json',
85
90
  '-d',
86
- '{}',
91
+ sessionPayload,
87
92
  ], { user: 'workspace' });
88
93
  const session = JSON.parse(createResult.stdout);
89
94
  this.sessionId = session.id;
95
+ this.sessionModel = this.model;
90
96
  this.onMessage({
91
97
  type: 'system',
92
98
  content: `Session started ${this.sessionId}`,
@@ -96,8 +102,8 @@ export class OpenCodeServerSession {
96
102
  this.responseComplete = false;
97
103
  this.seenToolUse.clear();
98
104
  this.seenToolResult.clear();
99
- const ssePromise = this.startSSEStream(port);
100
- await new Promise((resolve) => setTimeout(resolve, 100));
105
+ const { ready, done } = await this.startSSEStream(port);
106
+ await ready;
101
107
  const messagePayload = JSON.stringify({
102
108
  parts: [{ type: 'text', text: userMessage }],
103
109
  });
@@ -114,7 +120,7 @@ export class OpenCodeServerSession {
114
120
  ], { user: 'workspace' }).catch((err) => {
115
121
  console.error('[opencode-server] Send error:', err);
116
122
  });
117
- await ssePromise;
123
+ await done;
118
124
  this.onMessage({
119
125
  type: 'done',
120
126
  content: 'Response complete',
@@ -131,70 +137,84 @@ export class OpenCodeServerSession {
131
137
  }
132
138
  }
133
139
  async startSSEStream(port) {
134
- return new Promise((resolve) => {
135
- const proc = Bun.spawn([
136
- 'docker',
137
- 'exec',
138
- '-i',
139
- this.containerName,
140
- 'curl',
141
- '-s',
142
- '-N',
143
- '--max-time',
144
- '120',
145
- `http://localhost:${port}/event`,
146
- ], {
147
- stdin: 'ignore',
148
- stdout: 'pipe',
149
- stderr: 'pipe',
150
- });
151
- this.sseProcess = proc;
152
- const decoder = new TextDecoder();
153
- let buffer = '';
154
- const processChunk = (chunk) => {
155
- buffer += decoder.decode(chunk);
156
- const lines = buffer.split('\n');
157
- buffer = lines.pop() || '';
158
- for (const line of lines) {
159
- if (!line.startsWith('data: '))
160
- continue;
161
- const data = line.slice(6).trim();
162
- if (!data)
163
- continue;
164
- try {
165
- const event = JSON.parse(data);
166
- this.handleEvent(event);
167
- if (event.type === 'session.idle') {
168
- this.responseComplete = true;
169
- proc.kill();
170
- resolve();
171
- return;
172
- }
173
- }
174
- catch {
175
- continue;
140
+ let resolveReady;
141
+ let resolveDone;
142
+ const ready = new Promise((r) => (resolveReady = r));
143
+ const done = new Promise((r) => (resolveDone = r));
144
+ const proc = Bun.spawn([
145
+ 'docker',
146
+ 'exec',
147
+ '-i',
148
+ this.containerName,
149
+ 'curl',
150
+ '-s',
151
+ '-N',
152
+ '--max-time',
153
+ '120',
154
+ `http://localhost:${port}/event`,
155
+ ], {
156
+ stdin: 'ignore',
157
+ stdout: 'pipe',
158
+ stderr: 'pipe',
159
+ });
160
+ this.sseProcess = proc;
161
+ const decoder = new TextDecoder();
162
+ let buffer = '';
163
+ let hasReceivedData = false;
164
+ const processChunk = (chunk) => {
165
+ buffer += decoder.decode(chunk);
166
+ if (!hasReceivedData) {
167
+ hasReceivedData = true;
168
+ resolveReady();
169
+ }
170
+ const lines = buffer.split('\n');
171
+ buffer = lines.pop() || '';
172
+ for (const line of lines) {
173
+ if (!line.startsWith('data: '))
174
+ continue;
175
+ const data = line.slice(6).trim();
176
+ if (!data)
177
+ continue;
178
+ try {
179
+ const event = JSON.parse(data);
180
+ this.handleEvent(event);
181
+ if (event.type === 'session.idle') {
182
+ this.responseComplete = true;
183
+ proc.kill();
184
+ resolveDone();
185
+ return;
176
186
  }
177
187
  }
178
- };
179
- (async () => {
180
- if (!proc.stdout) {
181
- resolve();
182
- return;
188
+ catch {
189
+ continue;
183
190
  }
184
- for await (const chunk of proc.stdout) {
185
- processChunk(chunk);
186
- if (this.responseComplete)
187
- break;
188
- }
189
- resolve();
190
- })();
191
- setTimeout(() => {
192
- if (!this.responseComplete) {
193
- proc.kill();
194
- resolve();
195
- }
196
- }, 120000);
197
- });
191
+ }
192
+ };
193
+ (async () => {
194
+ if (!proc.stdout) {
195
+ resolveReady();
196
+ resolveDone();
197
+ return;
198
+ }
199
+ for await (const chunk of proc.stdout) {
200
+ processChunk(chunk);
201
+ if (this.responseComplete)
202
+ break;
203
+ }
204
+ resolveDone();
205
+ })();
206
+ setTimeout(() => {
207
+ if (!hasReceivedData) {
208
+ resolveReady();
209
+ }
210
+ }, 500);
211
+ setTimeout(() => {
212
+ if (!this.responseComplete) {
213
+ proc.kill();
214
+ resolveDone();
215
+ }
216
+ }, 120000);
217
+ return { ready, done };
198
218
  }
199
219
  handleEvent(event) {
200
220
  const timestamp = new Date().toISOString();
@@ -243,6 +263,19 @@ export class OpenCodeServerSession {
243
263
  });
244
264
  }
245
265
  }
266
+ setModel(model) {
267
+ if (this.model !== model) {
268
+ this.model = model;
269
+ if (this.sessionModel !== model) {
270
+ this.sessionId = undefined;
271
+ this.onMessage({
272
+ type: 'system',
273
+ content: `Switching to model: ${model}`,
274
+ timestamp: new Date().toISOString(),
275
+ });
276
+ }
277
+ }
278
+ }
246
279
  getSessionId() {
247
280
  return this.sessionId;
248
281
  }
@@ -3,6 +3,11 @@ import { createHostOpencodeSession } from './host-opencode-handler';
3
3
  import { createOpenCodeServerSession } from './opencode-server';
4
4
  export class OpencodeWebSocketServer extends BaseChatWebSocketServer {
5
5
  agentType = 'opencode';
6
+ getConfig;
7
+ constructor(options) {
8
+ super(options);
9
+ this.getConfig = options.getConfig;
10
+ }
6
11
  createConnection(ws, workspaceName) {
7
12
  return {
8
13
  ws,
@@ -10,14 +15,17 @@ export class OpencodeWebSocketServer extends BaseChatWebSocketServer {
10
15
  workspaceName,
11
16
  };
12
17
  }
13
- createHostSession(sessionId, onMessage) {
14
- return createHostOpencodeSession({ sessionId }, onMessage);
18
+ createHostSession(sessionId, onMessage, messageModel) {
19
+ const model = messageModel || this.getConfig?.()?.agents?.opencode?.model;
20
+ return createHostOpencodeSession({ sessionId, model }, onMessage);
15
21
  }
16
- createContainerSession(containerName, sessionId, onMessage) {
22
+ createContainerSession(containerName, sessionId, onMessage, messageModel) {
23
+ const model = messageModel || this.getConfig?.()?.agents?.opencode?.model;
17
24
  return createOpenCodeServerSession({
18
25
  containerName,
19
26
  workDir: '/home/workspace',
20
27
  sessionId,
28
+ model,
21
29
  }, onMessage);
22
30
  }
23
31
  }
@@ -15,14 +15,14 @@ export class ChatWebSocketServer extends BaseChatWebSocketServer {
15
15
  workspaceName,
16
16
  };
17
17
  }
18
- createHostSession(sessionId, onMessage) {
18
+ createHostSession(sessionId, onMessage, messageModel) {
19
19
  const config = this.getConfig();
20
- const model = config.agents?.claude_code?.model;
20
+ const model = messageModel || config.agents?.claude_code?.model;
21
21
  return createHostChatSession({ sessionId, model }, onMessage);
22
22
  }
23
- createContainerSession(containerName, sessionId, onMessage) {
23
+ createContainerSession(containerName, sessionId, onMessage, messageModel) {
24
24
  const config = this.getConfig();
25
- const model = config.agents?.claude_code?.model;
25
+ const model = messageModel || config.agents?.claude_code?.model;
26
26
  return createChatSession({
27
27
  containerName,
28
28
  workDir: '/home/workspace',
@@ -0,0 +1,70 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ const CACHE_FILE = 'model-cache.json';
4
+ const TTL_MS = 60 * 60 * 1000;
5
+ export class ModelCacheManager {
6
+ cachePath;
7
+ cache = null;
8
+ constructor(configDir) {
9
+ this.cachePath = path.join(configDir, CACHE_FILE);
10
+ }
11
+ async load() {
12
+ if (this.cache) {
13
+ return this.cache;
14
+ }
15
+ try {
16
+ const content = await fs.readFile(this.cachePath, 'utf-8');
17
+ this.cache = JSON.parse(content);
18
+ return this.cache;
19
+ }
20
+ catch {
21
+ this.cache = {};
22
+ return this.cache;
23
+ }
24
+ }
25
+ async save() {
26
+ if (!this.cache)
27
+ return;
28
+ await fs.mkdir(path.dirname(this.cachePath), { recursive: true });
29
+ await fs.writeFile(this.cachePath, JSON.stringify(this.cache, null, 2), 'utf-8');
30
+ }
31
+ isExpired(entry) {
32
+ if (!entry)
33
+ return true;
34
+ return Date.now() - entry.cachedAt > TTL_MS;
35
+ }
36
+ async getClaudeCodeModels() {
37
+ const cache = await this.load();
38
+ if (this.isExpired(cache.claudeCode)) {
39
+ return null;
40
+ }
41
+ return cache.claudeCode.models;
42
+ }
43
+ async setClaudeCodeModels(models) {
44
+ const cache = await this.load();
45
+ cache.claudeCode = {
46
+ models,
47
+ cachedAt: Date.now(),
48
+ };
49
+ await this.save();
50
+ }
51
+ async getOpencodeModels() {
52
+ const cache = await this.load();
53
+ if (this.isExpired(cache.opencode)) {
54
+ return null;
55
+ }
56
+ return cache.opencode.models;
57
+ }
58
+ async setOpencodeModels(models) {
59
+ const cache = await this.load();
60
+ cache.opencode = {
61
+ models,
62
+ cachedAt: Date.now(),
63
+ };
64
+ await this.save();
65
+ }
66
+ async clearCache() {
67
+ this.cache = {};
68
+ await this.save();
69
+ }
70
+ }
@@ -0,0 +1,108 @@
1
+ import { spawn } from 'child_process';
2
+ const ANTHROPIC_API_URL = 'https://api.anthropic.com/v1/models';
3
+ const ANTHROPIC_API_VERSION = '2023-06-01';
4
+ const FALLBACK_CLAUDE_MODELS = [
5
+ { id: 'sonnet', name: 'Sonnet', description: 'Fast and capable' },
6
+ { id: 'opus', name: 'Opus', description: 'Most capable' },
7
+ { id: 'haiku', name: 'Haiku', description: 'Fastest' },
8
+ ];
9
+ export async function discoverClaudeCodeModels(config) {
10
+ const oauthToken = config.agents?.claude_code?.oauth_token;
11
+ if (!oauthToken) {
12
+ return FALLBACK_CLAUDE_MODELS;
13
+ }
14
+ try {
15
+ const response = await fetch(ANTHROPIC_API_URL, {
16
+ method: 'GET',
17
+ headers: {
18
+ 'x-api-key': oauthToken,
19
+ 'anthropic-version': ANTHROPIC_API_VERSION,
20
+ },
21
+ signal: AbortSignal.timeout(5000),
22
+ });
23
+ if (!response.ok) {
24
+ return FALLBACK_CLAUDE_MODELS;
25
+ }
26
+ const data = (await response.json());
27
+ const models = data.data
28
+ .filter((m) => m.type === 'model')
29
+ .map((m) => ({
30
+ id: m.id,
31
+ name: m.display_name || m.id,
32
+ }));
33
+ if (models.length === 0) {
34
+ return FALLBACK_CLAUDE_MODELS;
35
+ }
36
+ return models;
37
+ }
38
+ catch {
39
+ return FALLBACK_CLAUDE_MODELS;
40
+ }
41
+ }
42
+ async function runCommand(command, args) {
43
+ return new Promise((resolve, reject) => {
44
+ const child = spawn(command, args, {
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ });
47
+ let stdout = '';
48
+ let stderr = '';
49
+ child.stdout.on('data', (chunk) => {
50
+ stdout += chunk;
51
+ });
52
+ child.stderr.on('data', (chunk) => {
53
+ stderr += chunk;
54
+ });
55
+ child.on('error', reject);
56
+ child.on('close', (code) => {
57
+ if (code === 0) {
58
+ resolve(stdout.trim());
59
+ }
60
+ else {
61
+ reject(new Error(`Command failed: ${stderr}`));
62
+ }
63
+ });
64
+ });
65
+ }
66
+ function parseOpencodeModels(output) {
67
+ const models = [];
68
+ const lines = output.split('\n').filter((line) => line.trim());
69
+ for (const line of lines) {
70
+ const trimmed = line.trim();
71
+ if (!trimmed || trimmed.startsWith('{'))
72
+ continue;
73
+ const parts = trimmed.split('/');
74
+ const id = trimmed;
75
+ const name = parts.length > 1 ? parts[1] : parts[0];
76
+ const displayName = name
77
+ .split('-')
78
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
79
+ .join(' ');
80
+ models.push({
81
+ id,
82
+ name: displayName,
83
+ });
84
+ }
85
+ return models;
86
+ }
87
+ export async function discoverHostOpencodeModels() {
88
+ try {
89
+ const output = await runCommand('opencode', ['models']);
90
+ return parseOpencodeModels(output);
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ }
96
+ export async function discoverContainerOpencodeModels(containerName, execInContainer) {
97
+ try {
98
+ const result = await execInContainer(containerName, ['opencode', 'models']);
99
+ if (result.exitCode !== 0) {
100
+ return [];
101
+ }
102
+ return parseOpencodeModels(result.stdout);
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ }
108
+ export { FALLBACK_CLAUDE_MODELS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gricha/perry",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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": {