@gricha/perry 0.3.4 → 0.3.6

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-CZjSxNrg.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-C-xi0Vax.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-CYo-1I5o.css">
10
10
  </head>
11
11
  <body>
package/dist/index.js CHANGED
@@ -662,9 +662,24 @@ sshCmd
662
662
  program
663
663
  .command('update')
664
664
  .description('Update Perry to the latest version')
665
- .action(async () => {
665
+ .option('-f, --force', 'Force update even if already on latest version')
666
+ .action(async (options) => {
667
+ const { fetchLatestVersion, compareVersions } = await import('./update-checker.js');
668
+ const currentVersion = pkg.version;
669
+ console.log(`Current version: ${currentVersion}`);
670
+ console.log('Checking for updates...');
671
+ const latestVersion = await fetchLatestVersion();
672
+ if (!latestVersion) {
673
+ console.error('Failed to fetch latest version. Please try again later.');
674
+ process.exit(1);
675
+ }
676
+ console.log(`Latest version: ${latestVersion}`);
677
+ if (compareVersions(currentVersion, latestVersion) <= 0 && !options.force) {
678
+ console.log('Already up to date.');
679
+ process.exit(0);
680
+ }
681
+ console.log(`Updating Perry from ${currentVersion} to ${latestVersion}...`);
666
682
  const { spawn } = await import('child_process');
667
- console.log('Updating Perry...');
668
683
  const child = spawn('bash', ['-c', 'curl -fsSL https://raw.githubusercontent.com/gricha/perry/main/install.sh | bash'], {
669
684
  stdio: 'inherit',
670
685
  });
@@ -756,5 +771,8 @@ function handleError(err) {
756
771
  }
757
772
  process.exit(1);
758
773
  }
759
- checkForUpdates(pkg.version);
774
+ const isWorkerCommand = process.argv[2] === 'worker';
775
+ if (!isWorkerCommand) {
776
+ checkForUpdates(pkg.version);
777
+ }
760
778
  program.parse();
package/dist/perry-worker CHANGED
Binary file
@@ -15,6 +15,7 @@ export class ClaudeCodeAdapter {
15
15
  errorCallback;
16
16
  pendingMessage = null;
17
17
  messageResolver = null;
18
+ currentMessageId;
18
19
  onMessage(callback) {
19
20
  this.messageCallback = callback;
20
21
  }
@@ -154,6 +155,11 @@ export class ClaudeCodeAdapter {
154
155
  return;
155
156
  }
156
157
  if (msg.type === 'assistant' && msg.message?.content) {
158
+ // Capture message ID from upstream when assistant message starts
159
+ const messageId = msg.message.id || msg.id;
160
+ if (messageId) {
161
+ this.currentMessageId = messageId;
162
+ }
157
163
  for (const block of msg.message.content) {
158
164
  if (block.type === 'tool_use') {
159
165
  this.emitMessage({
@@ -161,6 +167,7 @@ export class ClaudeCodeAdapter {
161
167
  content: JSON.stringify(block.input, null, 2),
162
168
  toolName: block.name,
163
169
  toolId: block.id,
170
+ messageId: this.currentMessageId,
164
171
  timestamp,
165
172
  });
166
173
  }
@@ -173,6 +180,7 @@ export class ClaudeCodeAdapter {
173
180
  this.emitMessage({
174
181
  type: 'assistant',
175
182
  content: delta.text,
183
+ messageId: this.currentMessageId,
176
184
  timestamp,
177
185
  });
178
186
  }
@@ -182,13 +190,16 @@ export class ClaudeCodeAdapter {
182
190
  this.emitMessage({
183
191
  type: 'done',
184
192
  content: 'Response complete',
193
+ messageId: this.currentMessageId,
185
194
  timestamp,
186
195
  });
196
+ this.currentMessageId = undefined;
187
197
  }
188
198
  }
189
199
  handleProcessExit(code) {
190
200
  this.process = null;
191
201
  this.terminal = null;
202
+ this.currentMessageId = undefined;
192
203
  if (this.status === 'interrupted') {
193
204
  this.emitMessage({
194
205
  type: 'system',
@@ -3,6 +3,9 @@ const MESSAGE_TIMEOUT_MS = 30000;
3
3
  const SSE_TIMEOUT_MS = 120000;
4
4
  const serverPorts = new Map();
5
5
  const serverStarting = new Map();
6
+ let hostServerPort = null;
7
+ let hostServerStarting = null;
8
+ let hostServerProcess = null;
6
9
  async function findAvailablePort(containerName) {
7
10
  const script = `import socket; s=socket.socket(); s.bind(('', 0)); print(s.getsockname()[1]); s.close()`;
8
11
  const result = await execInContainer(containerName, ['python3', '-c', script], {
@@ -70,7 +73,9 @@ export class OpenCodeAdapter {
70
73
  model;
71
74
  status = 'idle';
72
75
  port;
76
+ isHost = false;
73
77
  sseProcess = null;
78
+ currentMessageId;
74
79
  messageCallback;
75
80
  statusCallback;
76
81
  errorCallback;
@@ -84,14 +89,17 @@ export class OpenCodeAdapter {
84
89
  this.errorCallback = callback;
85
90
  }
86
91
  async start(options) {
87
- if (options.isHost) {
88
- throw new Error('OpenCode adapter does not support host mode');
89
- }
92
+ this.isHost = options.isHost;
90
93
  this.containerName = options.containerName;
91
94
  this.agentSessionId = options.agentSessionId;
92
95
  this.model = options.model;
93
96
  try {
94
- this.port = await startServer(this.containerName);
97
+ if (this.isHost) {
98
+ this.port = await this.startServerHost();
99
+ }
100
+ else {
101
+ this.port = await startServer(this.containerName);
102
+ }
95
103
  this.setStatus('idle');
96
104
  }
97
105
  catch (err) {
@@ -99,8 +107,61 @@ export class OpenCodeAdapter {
99
107
  throw err;
100
108
  }
101
109
  }
110
+ async startServerHost() {
111
+ if (hostServerPort && (await this.isServerRunningHost(hostServerPort))) {
112
+ return hostServerPort;
113
+ }
114
+ if (hostServerStarting) {
115
+ return hostServerStarting;
116
+ }
117
+ const startPromise = (async () => {
118
+ const port = await this.findAvailablePortHost();
119
+ console.log(`[opencode] Starting server on port ${port} on host`);
120
+ hostServerProcess = Bun.spawn(['opencode', 'serve', '--port', String(port), '--hostname', '127.0.0.1'], {
121
+ stdin: 'ignore',
122
+ stdout: 'pipe',
123
+ stderr: 'pipe',
124
+ });
125
+ for (let i = 0; i < 30; i++) {
126
+ await new Promise((resolve) => setTimeout(resolve, 500));
127
+ if (await this.isServerRunningHost(port)) {
128
+ console.log(`[opencode] Server ready on port ${port}`);
129
+ hostServerPort = port;
130
+ hostServerStarting = null;
131
+ return port;
132
+ }
133
+ }
134
+ hostServerStarting = null;
135
+ if (hostServerProcess) {
136
+ hostServerProcess.kill();
137
+ await hostServerProcess.exited;
138
+ hostServerProcess = null;
139
+ }
140
+ throw new Error('Failed to start OpenCode server on host');
141
+ })();
142
+ hostServerStarting = startPromise;
143
+ return startPromise;
144
+ }
145
+ async findAvailablePortHost() {
146
+ const server = Bun.serve({
147
+ port: 0,
148
+ fetch: () => new Response(''),
149
+ });
150
+ const port = server.port;
151
+ server.stop();
152
+ return port;
153
+ }
154
+ async isServerRunningHost(port) {
155
+ try {
156
+ const response = await fetch(`http://localhost:${port}/session`, { method: 'GET' });
157
+ return response.ok;
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ }
102
163
  async sendMessage(message) {
103
- if (!this.containerName || !this.port) {
164
+ if (!this.port) {
104
165
  const err = new Error('Adapter not started');
105
166
  this.emitError(err);
106
167
  throw err;
@@ -120,10 +181,12 @@ export class OpenCodeAdapter {
120
181
  this.emit({ type: 'system', content: 'Processing...' });
121
182
  await this.sendAndStream(baseUrl, message);
122
183
  this.setStatus('idle');
123
- this.emit({ type: 'done', content: 'Response complete' });
184
+ this.emit({ type: 'done', content: 'Response complete', messageId: this.currentMessageId });
185
+ this.currentMessageId = undefined;
124
186
  }
125
187
  catch (err) {
126
188
  this.cleanup();
189
+ this.currentMessageId = undefined;
127
190
  this.setStatus('error');
128
191
  this.emitError(err);
129
192
  this.emit({ type: 'error', content: err.message });
@@ -131,7 +194,20 @@ export class OpenCodeAdapter {
131
194
  }
132
195
  }
133
196
  async createSession(baseUrl) {
134
- const payload = this.model ? JSON.stringify({ model: this.model }) : '{}';
197
+ const payload = this.model ? { model: this.model } : {};
198
+ if (this.isHost) {
199
+ const response = await fetch(`${baseUrl}/session`, {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify(payload),
203
+ signal: AbortSignal.timeout(MESSAGE_TIMEOUT_MS),
204
+ });
205
+ if (!response.ok) {
206
+ throw new Error(`Failed to create session: ${response.statusText}`);
207
+ }
208
+ const session = await response.json();
209
+ return session.id;
210
+ }
135
211
  const result = await execInContainer(this.containerName, [
136
212
  'curl',
137
213
  '-s',
@@ -144,7 +220,7 @@ export class OpenCodeAdapter {
144
220
  '-H',
145
221
  'Content-Type: application/json',
146
222
  '-d',
147
- payload,
223
+ JSON.stringify(payload),
148
224
  ], { user: 'workspace' });
149
225
  if (result.exitCode !== 0) {
150
226
  throw new Error(`Failed to create session: ${result.stderr || 'Unknown error'}`);
@@ -155,23 +231,36 @@ export class OpenCodeAdapter {
155
231
  async sendAndStream(baseUrl, message) {
156
232
  const sseReady = this.startSSEStream();
157
233
  await new Promise((resolve) => setTimeout(resolve, 100));
158
- const payload = JSON.stringify({ parts: [{ type: 'text', text: message }] });
159
- const result = await execInContainer(this.containerName, [
160
- 'curl',
161
- '-s',
162
- '-f',
163
- '--max-time',
164
- String(MESSAGE_TIMEOUT_MS / 1000),
165
- '-X',
166
- 'POST',
167
- `${baseUrl}/session/${this.agentSessionId}/message`,
168
- '-H',
169
- 'Content-Type: application/json',
170
- '-d',
171
- payload,
172
- ], { user: 'workspace' });
173
- if (result.exitCode !== 0) {
174
- throw new Error(`Failed to send message: ${result.stderr || 'Connection failed'}`);
234
+ const payload = { parts: [{ type: 'text', text: message }] };
235
+ if (this.isHost) {
236
+ const response = await fetch(`${baseUrl}/session/${this.agentSessionId}/message`, {
237
+ method: 'POST',
238
+ headers: { 'Content-Type': 'application/json' },
239
+ body: JSON.stringify(payload),
240
+ signal: AbortSignal.timeout(MESSAGE_TIMEOUT_MS),
241
+ });
242
+ if (!response.ok) {
243
+ throw new Error(`Failed to send message: ${response.statusText}`);
244
+ }
245
+ }
246
+ else {
247
+ const result = await execInContainer(this.containerName, [
248
+ 'curl',
249
+ '-s',
250
+ '-f',
251
+ '--max-time',
252
+ String(MESSAGE_TIMEOUT_MS / 1000),
253
+ '-X',
254
+ 'POST',
255
+ `${baseUrl}/session/${this.agentSessionId}/message`,
256
+ '-H',
257
+ 'Content-Type: application/json',
258
+ '-d',
259
+ JSON.stringify(payload),
260
+ ], { user: 'workspace' });
261
+ if (result.exitCode !== 0) {
262
+ throw new Error(`Failed to send message: ${result.stderr || 'Connection failed'}`);
263
+ }
175
264
  }
176
265
  await sseReady;
177
266
  }
@@ -180,18 +269,18 @@ export class OpenCodeAdapter {
180
269
  const seenTools = new Set();
181
270
  let resolved = false;
182
271
  let receivedIdle = false;
183
- const proc = Bun.spawn([
184
- 'docker',
185
- 'exec',
186
- '-i',
187
- this.containerName,
272
+ const curlArgs = [
188
273
  'curl',
189
274
  '-s',
190
275
  '-N',
191
276
  '--max-time',
192
277
  String(SSE_TIMEOUT_MS / 1000),
193
278
  `http://localhost:${this.port}/event`,
194
- ], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
279
+ ];
280
+ const spawnArgs = this.isHost
281
+ ? curlArgs
282
+ : ['docker', 'exec', '-i', this.containerName, ...curlArgs];
283
+ const proc = Bun.spawn(spawnArgs, { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
195
284
  this.sseProcess = proc;
196
285
  const decoder = new TextDecoder();
197
286
  let buffer = '';
@@ -236,8 +325,15 @@ export class OpenCodeAdapter {
236
325
  }
237
326
  if (event.type === 'message.part.updated' && event.properties.part) {
238
327
  const part = event.properties.part;
328
+ if (part.messageID) {
329
+ this.currentMessageId = part.messageID;
330
+ }
239
331
  if (part.type === 'text' && event.properties.delta) {
240
- this.emit({ type: 'assistant', content: event.properties.delta });
332
+ this.emit({
333
+ type: 'assistant',
334
+ content: event.properties.delta,
335
+ messageId: this.currentMessageId,
336
+ });
241
337
  }
242
338
  else if (part.type === 'tool' && part.tool && !seenTools.has(part.id)) {
243
339
  seenTools.add(part.id);
@@ -246,12 +342,14 @@ export class OpenCodeAdapter {
246
342
  content: JSON.stringify(part.state?.input, null, 2),
247
343
  toolName: part.state?.title || part.tool,
248
344
  toolId: part.id,
345
+ messageId: this.currentMessageId,
249
346
  });
250
347
  if (part.state?.status === 'completed' && part.state?.output) {
251
348
  this.emit({
252
349
  type: 'tool_result',
253
350
  content: part.state.output,
254
351
  toolId: part.id,
352
+ messageId: this.currentMessageId,
255
353
  });
256
354
  }
257
355
  }
@@ -290,6 +388,7 @@ export class OpenCodeAdapter {
290
388
  }
291
389
  async interrupt() {
292
390
  this.cleanup();
391
+ this.currentMessageId = undefined;
293
392
  if (this.status === 'running') {
294
393
  this.setStatus('interrupted');
295
394
  this.emit({ type: 'system', content: 'Interrupted' });
@@ -3,10 +3,15 @@ import { ClaudeCodeAdapter } from './adapters/claude';
3
3
  import { OpenCodeAdapter } from './adapters/opencode';
4
4
  import { getContainerName } from '../docker';
5
5
  import { HOST_WORKSPACE_NAME } from '../shared/client-types';
6
+ import * as registry from '../sessions/registry';
6
7
  const DEFAULT_BUFFER_SIZE = 1000;
7
8
  export class SessionManager {
8
9
  sessions = new Map();
9
10
  clientIdCounter = 0;
11
+ stateDir = null;
12
+ init(stateDir) {
13
+ this.stateDir = stateDir;
14
+ }
10
15
  generateSessionId() {
11
16
  return `session-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
12
17
  }
@@ -69,6 +74,15 @@ export class SessionManager {
69
74
  isHost,
70
75
  });
71
76
  this.sessions.set(sessionId, session);
77
+ if (this.stateDir) {
78
+ await registry.createSession(this.stateDir, {
79
+ perrySessionId: sessionId,
80
+ workspaceName: options.workspaceName,
81
+ agentType: options.agentType,
82
+ agentSessionId: options.agentSessionId ?? null,
83
+ projectPath: options.projectPath ?? null,
84
+ });
85
+ }
72
86
  return sessionId;
73
87
  }
74
88
  handleAdapterMessage(sessionId, message) {
@@ -101,6 +115,11 @@ export class SessionManager {
101
115
  const currentAgentSessionId = session.adapter.getAgentSessionId();
102
116
  if (currentAgentSessionId !== undefined && previousAgentSessionId !== currentAgentSessionId) {
103
117
  session.info.agentSessionId = currentAgentSessionId;
118
+ if (this.stateDir) {
119
+ registry.linkAgentSession(this.stateDir, sessionId, currentAgentSessionId).catch((err) => {
120
+ this.handleAdapterError(sessionId, new Error(`Failed to link agent session: ${err.message}`));
121
+ });
122
+ }
104
123
  const updateMessage = {
105
124
  type: 'system',
106
125
  content: JSON.stringify({ agentSessionId: currentAgentSessionId }),
@@ -0,0 +1,129 @@
1
+ import { readFile, writeFile, mkdir } from 'fs/promises';
2
+ import { join, dirname } from 'path';
3
+ import lockfile from 'proper-lockfile';
4
+ function getStorePath(stateDir) {
5
+ return join(stateDir, 'session-registry.json');
6
+ }
7
+ function getLockPath(stateDir) {
8
+ return join(stateDir, '.session-registry.lock');
9
+ }
10
+ async function ensureLockfile(stateDir) {
11
+ const lockPath = getLockPath(stateDir);
12
+ try {
13
+ await mkdir(dirname(lockPath), { recursive: true });
14
+ await writeFile(lockPath, '', { flag: 'wx' });
15
+ }
16
+ catch (err) {
17
+ if (err.code !== 'EEXIST') {
18
+ throw err;
19
+ }
20
+ }
21
+ }
22
+ async function withLock(stateDir, fn) {
23
+ await ensureLockfile(stateDir);
24
+ const lockPath = getLockPath(stateDir);
25
+ let release;
26
+ try {
27
+ release = await lockfile.lock(lockPath, {
28
+ retries: { retries: 10, minTimeout: 50, maxTimeout: 500 },
29
+ });
30
+ return await fn();
31
+ }
32
+ finally {
33
+ if (release) {
34
+ await release();
35
+ }
36
+ }
37
+ }
38
+ async function loadRegistry(stateDir) {
39
+ const storePath = getStorePath(stateDir);
40
+ try {
41
+ const content = await readFile(storePath, 'utf-8');
42
+ return JSON.parse(content);
43
+ }
44
+ catch {
45
+ return { version: 1, sessions: {} };
46
+ }
47
+ }
48
+ async function saveRegistry(stateDir, registry) {
49
+ const storePath = getStorePath(stateDir);
50
+ await mkdir(dirname(storePath), { recursive: true });
51
+ await writeFile(storePath, JSON.stringify(registry, null, 2));
52
+ }
53
+ /**
54
+ * Create a new session record. Called when first message is sent.
55
+ * agentSessionId will be null until agent responds.
56
+ */
57
+ export async function createSession(stateDir, session) {
58
+ return withLock(stateDir, async () => {
59
+ const registry = await loadRegistry(stateDir);
60
+ const now = new Date().toISOString();
61
+ const record = {
62
+ perrySessionId: session.perrySessionId,
63
+ workspaceName: session.workspaceName,
64
+ agentType: session.agentType,
65
+ agentSessionId: session.agentSessionId ?? null,
66
+ projectPath: session.projectPath ?? null,
67
+ createdAt: now,
68
+ lastActivity: now,
69
+ };
70
+ registry.sessions[session.perrySessionId] = record;
71
+ await saveRegistry(stateDir, registry);
72
+ return record;
73
+ });
74
+ }
75
+ /**
76
+ * Link an agent session ID to an existing Perry session.
77
+ * Called when agent responds and provides its session ID.
78
+ */
79
+ export async function linkAgentSession(stateDir, perrySessionId, agentSessionId) {
80
+ return withLock(stateDir, async () => {
81
+ const registry = await loadRegistry(stateDir);
82
+ const record = registry.sessions[perrySessionId];
83
+ if (!record) {
84
+ return null;
85
+ }
86
+ record.agentSessionId = agentSessionId;
87
+ record.lastActivity = new Date().toISOString();
88
+ await saveRegistry(stateDir, registry);
89
+ return record;
90
+ });
91
+ }
92
+ /**
93
+ * Get all sessions for a workspace.
94
+ */
95
+ export async function getSessionsForWorkspace(stateDir, workspaceName) {
96
+ const registry = await loadRegistry(stateDir);
97
+ return Object.values(registry.sessions)
98
+ .filter((record) => record.workspaceName === workspaceName)
99
+ .sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
100
+ }
101
+ /**
102
+ * Import an external session (discovered from agent storage).
103
+ * Creates a Perry session record for a session that wasn't started through Perry.
104
+ */
105
+ export async function importExternalSession(stateDir, session) {
106
+ return withLock(stateDir, async () => {
107
+ const registry = await loadRegistry(stateDir);
108
+ // Check if already imported (inside lock to prevent race)
109
+ for (const record of Object.values(registry.sessions)) {
110
+ if (record.agentType === session.agentType &&
111
+ record.agentSessionId === session.agentSessionId) {
112
+ return record;
113
+ }
114
+ }
115
+ const now = new Date().toISOString();
116
+ const record = {
117
+ perrySessionId: session.perrySessionId,
118
+ workspaceName: session.workspaceName,
119
+ agentType: session.agentType,
120
+ agentSessionId: session.agentSessionId,
121
+ projectPath: session.projectPath ?? null,
122
+ createdAt: session.createdAt ?? now,
123
+ lastActivity: session.lastActivity ?? now,
124
+ };
125
+ registry.sessions[session.perrySessionId] = record;
126
+ await saveRegistry(stateDir, registry);
127
+ return record;
128
+ });
129
+ }
@@ -27,7 +27,7 @@ async function writeCache(cache) {
27
27
  // Ignore cache write errors
28
28
  }
29
29
  }
30
- async function fetchLatestVersion() {
30
+ export async function fetchLatestVersion() {
31
31
  try {
32
32
  const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
33
33
  signal: AbortSignal.timeout(3000),
@@ -46,7 +46,7 @@ async function fetchLatestVersion() {
46
46
  return null;
47
47
  }
48
48
  }
49
- function compareVersions(current, latest) {
49
+ export function compareVersions(current, latest) {
50
50
  const currentParts = current.split('.').map(Number);
51
51
  const latestParts = latest.split('.').map(Number);
52
52
  for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
@@ -72,9 +72,9 @@ export async function checkForUpdates(currentVersion) {
72
72
  await writeCache({ lastCheck: now, latestVersion });
73
73
  }
74
74
  if (latestVersion && compareVersions(currentVersion, latestVersion) > 0) {
75
- console.log('');
76
- console.log(`\x1b[33mUpdate available: \x1b[90m${currentVersion}\x1b[0m → \x1b[32m${latestVersion}\x1b[0m \x1b[33mRun: \x1b[36mperry update\x1b[0m`);
77
- console.log('');
75
+ console.error('');
76
+ console.error(`\x1b[33mUpdate available: \x1b[90m${currentVersion}\x1b[0m → \x1b[32m${latestVersion}\x1b[0m \x1b[33mRun: \x1b[36mperry update\x1b[0m`);
77
+ console.error('');
78
78
  }
79
79
  }
80
80
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gricha/perry",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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": {