@gricha/perry 0.3.10 → 0.3.13

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.
@@ -14,7 +14,9 @@ export class FileWatcher {
14
14
  this.config = options.config;
15
15
  this.syncCallback = options.syncCallback;
16
16
  this.debounceMs = options.debounceMs ?? 500;
17
- this.setupWatchers();
17
+ this.setupWatchers().catch((err) => {
18
+ console.error('[file-watcher] Failed to setup watchers:', err);
19
+ });
18
20
  }
19
21
  updateConfig(config) {
20
22
  this.config = config;
@@ -97,18 +99,18 @@ export class FileWatcher {
97
99
  clearTimeout(this.debounceTimer);
98
100
  }
99
101
  this.pendingSync = true;
100
- this.debounceTimer = setTimeout(async () => {
102
+ this.debounceTimer = setTimeout(() => {
101
103
  this.debounceTimer = null;
102
104
  if (this.pendingSync) {
103
105
  this.pendingSync = false;
104
- try {
105
- console.log('[file-watcher] Triggering sync...');
106
- await this.syncCallback();
106
+ console.log('[file-watcher] Triggering sync...');
107
+ this.syncCallback()
108
+ .then(() => {
107
109
  console.log('[file-watcher] Sync completed');
108
- }
109
- catch (err) {
110
+ })
111
+ .catch((err) => {
110
112
  console.error('[file-watcher] Sync failed:', err);
111
- }
113
+ });
112
114
  }
113
115
  }, this.debounceMs);
114
116
  }
@@ -127,7 +129,9 @@ export class FileWatcher {
127
129
  }
128
130
  for (const filePath of newPaths) {
129
131
  if (!currentPaths.has(filePath)) {
130
- this.watchFile(filePath);
132
+ this.watchFile(filePath).catch((err) => {
133
+ console.error(`[file-watcher] Failed to watch ${filePath}:`, err);
134
+ });
131
135
  }
132
136
  }
133
137
  }
package/dist/agent/run.js CHANGED
@@ -182,10 +182,14 @@ function createAgentServer(configDir, config, port, tailscale) {
182
182
  terminalHandler.handleMessage(ws, data);
183
183
  }
184
184
  else if (type === 'live-claude') {
185
- liveClaudeHandler.handleMessage(ws, data);
185
+ liveClaudeHandler.handleMessage(ws, data).catch((err) => {
186
+ console.error('[ws] Error handling claude message:', err);
187
+ });
186
188
  }
187
189
  else if (type === 'live-opencode') {
188
- liveOpencodeHandler.handleMessage(ws, data);
190
+ liveOpencodeHandler.handleMessage(ws, data).catch((err) => {
191
+ console.error('[ws] Error handling opencode message:', err);
192
+ });
189
193
  }
190
194
  },
191
195
  close(ws, code, reason) {
@@ -310,9 +314,11 @@ export async function startAgent(options = {}) {
310
314
  console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
311
315
  console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/live/claude/:name`);
312
316
  console.log(`[agent] WebSocket chat (OpenCode): ws://localhost:${port}/rpc/live/opencode/:name`);
313
- startEagerImagePull();
317
+ startEagerImagePull().catch((err) => {
318
+ console.error('[agent] Error during image pull:', err);
319
+ });
314
320
  let isShuttingDown = false;
315
- const shutdown = async () => {
321
+ const shutdown = () => {
316
322
  if (isShuttingDown) {
317
323
  console.log('[agent] Force exit');
318
324
  process.exit(0);
@@ -326,17 +332,23 @@ export async function startAgent(options = {}) {
326
332
  forceExitTimeout.unref();
327
333
  stopEagerImagePull();
328
334
  fileWatcher.stop();
329
- if (tailscaleServeActive) {
330
- console.log('[agent] Stopping Tailscale Serve...');
331
- await stopTailscaleServe();
332
- }
333
- liveClaudeHandler.close();
334
- liveOpencodeHandler.close();
335
- terminalHandler.close();
336
- server.stop();
337
- clearTimeout(forceExitTimeout);
338
- console.log('[agent] Server closed');
339
- process.exit(0);
335
+ const cleanup = async () => {
336
+ if (tailscaleServeActive) {
337
+ console.log('[agent] Stopping Tailscale Serve...');
338
+ await stopTailscaleServe();
339
+ }
340
+ liveClaudeHandler.close();
341
+ liveOpencodeHandler.close();
342
+ terminalHandler.close();
343
+ await server.stop();
344
+ clearTimeout(forceExitTimeout);
345
+ console.log('[agent] Server closed');
346
+ process.exit(0);
347
+ };
348
+ cleanup().catch((err) => {
349
+ console.error('[agent] Shutdown error:', err);
350
+ process.exit(1);
351
+ });
340
352
  };
341
353
  process.on('SIGTERM', shutdown);
342
354
  process.on('SIGINT', shutdown);
@@ -153,25 +153,6 @@ export async function syncAllAgents(containerName, agentConfig, copier) {
153
153
  return results;
154
154
  }
155
155
  export function getCredentialFilePaths() {
156
- const paths = [];
157
- for (const agent of Object.values(agents)) {
158
- const dummyContext = {
159
- containerName: '',
160
- agentConfig: { port: 0, credentials: { env: {}, files: {} }, scripts: {} },
161
- hostFileExists: async () => false,
162
- hostDirExists: async () => false,
163
- readHostFile: async () => null,
164
- readContainerFile: async () => null,
165
- };
166
- const filesPromise = agent.sync.getFilesToSync(dummyContext);
167
- filesPromise.then((files) => {
168
- for (const file of files) {
169
- if (file.category === 'credential') {
170
- paths.push(file.source);
171
- }
172
- }
173
- });
174
- }
175
156
  return ['~/.claude/.credentials.json', '~/.codex/auth.json'];
176
157
  }
177
158
  export { createDockerFileCopier, createMockFileCopier } from './sync/copier';
package/dist/index.js CHANGED
@@ -841,6 +841,6 @@ function handleError(err) {
841
841
  }
842
842
  const isWorkerCommand = process.argv[2] === 'worker';
843
843
  if (!isWorkerCommand) {
844
- checkForUpdates(pkg.version);
844
+ checkForUpdates(pkg.version).catch(() => { });
845
845
  }
846
846
  program.parse();
package/dist/perry-worker CHANGED
Binary file
@@ -13,6 +13,19 @@ 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]+" | head -1'], { user: 'workspace' });
19
+ const port = parseInt(result.stdout.trim(), 10);
20
+ if (port && (await isServerRunning(containerName, port))) {
21
+ return port;
22
+ }
23
+ }
24
+ catch {
25
+ // No existing server
26
+ }
27
+ return null;
28
+ }
16
29
  async function isServerRunning(containerName, port) {
17
30
  try {
18
31
  const result = await execInContainer(containerName, ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', `http://localhost:${port}/session`], { user: 'workspace' });
@@ -34,8 +47,14 @@ async function getServerLogs(containerName) {
34
47
  }
35
48
  }
36
49
  async function startServer(containerName) {
37
- const existing = serverPorts.get(containerName);
38
- if (existing && (await isServerRunning(containerName, existing))) {
50
+ const cached = serverPorts.get(containerName);
51
+ if (cached && (await isServerRunning(containerName, cached))) {
52
+ return cached;
53
+ }
54
+ const existing = await findExistingServer(containerName);
55
+ if (existing) {
56
+ console.log(`[opencode] Found existing server on port ${existing} in ${containerName}`);
57
+ serverPorts.set(containerName, existing);
39
58
  return existing;
40
59
  }
41
60
  const starting = serverStarting.get(containerName);
@@ -148,7 +167,7 @@ export class OpenCodeAdapter {
148
167
  fetch: () => new Response(''),
149
168
  });
150
169
  const port = server.port;
151
- server.stop();
170
+ await server.stop();
152
171
  return port;
153
172
  }
154
173
  async isServerRunningHost(port) {
@@ -176,6 +195,7 @@ export class OpenCodeAdapter {
176
195
  if (!this.agentSessionId) {
177
196
  this.agentSessionId = await this.createSession(baseUrl);
178
197
  this.emit({ type: 'system', content: `Session: ${this.agentSessionId.slice(0, 8)}...` });
198
+ this.statusCallback?.(this.status);
179
199
  }
180
200
  this.setStatus('running');
181
201
  this.emit({ type: 'system', content: 'Processing...' });
@@ -229,7 +249,10 @@ export class OpenCodeAdapter {
229
249
  return session.id;
230
250
  }
231
251
  async sendAndStream(baseUrl, message) {
232
- const sseReady = this.startSSEStream();
252
+ let sseError = null;
253
+ const sseReady = this.startSSEStream().catch((err) => {
254
+ sseError = err;
255
+ });
233
256
  await new Promise((resolve) => setTimeout(resolve, 100));
234
257
  const payload = { parts: [{ type: 'text', text: message }] };
235
258
  if (this.isHost) {
@@ -263,6 +286,9 @@ export class OpenCodeAdapter {
263
286
  }
264
287
  }
265
288
  await sseReady;
289
+ if (sseError) {
290
+ throw sseError;
291
+ }
266
292
  }
267
293
  startSSEStream() {
268
294
  return new Promise((resolve, reject) => {
@@ -284,6 +310,7 @@ export class OpenCodeAdapter {
284
310
  this.sseProcess = proc;
285
311
  const decoder = new TextDecoder();
286
312
  let buffer = '';
313
+ let eventCount = 0;
287
314
  const finish = () => {
288
315
  if (!resolved) {
289
316
  resolved = true;
@@ -316,7 +343,9 @@ export class OpenCodeAdapter {
316
343
  continue;
317
344
  try {
318
345
  const event = JSON.parse(data);
346
+ eventCount++;
319
347
  if (event.type === 'session.idle') {
348
+ console.log(`[opencode] SSE received session.idle after ${eventCount} events`);
320
349
  receivedIdle = true;
321
350
  clearTimeout(timeout);
322
351
  proc.kill();
@@ -375,7 +404,7 @@ export class OpenCodeAdapter {
375
404
  }
376
405
  else if (!resolved) {
377
406
  resolved = true;
378
- reject(new Error('SSE stream ended unexpectedly without session.idle'));
407
+ reject(new Error(`SSE stream ended unexpectedly without session.idle (received ${eventCount} events)`));
379
408
  }
380
409
  })().catch((err) => {
381
410
  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) => {
@@ -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) {
@@ -29,9 +29,9 @@ export class LiveChatWebSocketServer extends BaseWebSocketServer {
29
29
  agentType: this.agentType,
30
30
  timestamp: new Date().toISOString(),
31
31
  }));
32
- ws.on('message', async (data) => {
32
+ ws.on('message', (data) => {
33
33
  const str = typeof data === 'string' ? data : data.toString();
34
- try {
34
+ const handleMessage = async () => {
35
35
  const message = JSON.parse(str);
36
36
  if (message.type === 'connect') {
37
37
  await this.handleConnect(connection, ws, workspaceName, message);
@@ -50,14 +50,14 @@ export class LiveChatWebSocketServer extends BaseWebSocketServer {
50
50
  if (message.type === 'message' && message.content) {
51
51
  await this.handleMessage(connection, ws, workspaceName, message);
52
52
  }
53
- }
54
- catch (err) {
53
+ };
54
+ handleMessage().catch((err) => {
55
55
  safeSend(ws, JSON.stringify({
56
56
  type: 'error',
57
57
  content: err.message,
58
58
  timestamp: new Date().toISOString(),
59
59
  }));
60
- }
60
+ });
61
61
  });
62
62
  ws.on('close', () => {
63
63
  this.handleDisconnect(connection);
@@ -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.
@@ -56,14 +56,18 @@ export async function startWorkerServer(options = {}) {
56
56
  },
57
57
  });
58
58
  console.error(`Worker server listening on port ${server.port}`);
59
- process.on('SIGINT', () => {
59
+ const shutdown = () => {
60
60
  sessionIndex.stopWatchers();
61
- server.stop();
62
- process.exit(0);
63
- });
64
- process.on('SIGTERM', () => {
65
- sessionIndex.stopWatchers();
66
- server.stop();
67
- process.exit(0);
68
- });
61
+ server
62
+ .stop()
63
+ .then(() => {
64
+ process.exit(0);
65
+ })
66
+ .catch((err) => {
67
+ console.error('[worker] Shutdown error:', err);
68
+ process.exit(1);
69
+ });
70
+ };
71
+ process.on('SIGINT', shutdown);
72
+ process.on('SIGTERM', shutdown);
69
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gricha/perry",
3
- "version": "0.3.10",
3
+ "version": "0.3.13",
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": {
@@ -17,7 +17,7 @@
17
17
  "build:web": "cd web && bun run build",
18
18
  "test": "vitest run",
19
19
  "test:web": "playwright test",
20
- "lint": "oxlint src/ mobile/src/",
20
+ "lint": "oxlint --type-aware --tsconfig=tsconfig.json src/ && oxlint mobile/src/",
21
21
  "format:check": "oxfmt --check src/ test/",
22
22
  "check": "bun run lint && bun run format:check && bun x tsc --noEmit",
23
23
  "lint:web": "cd web && bun run lint",
@@ -45,6 +45,7 @@
45
45
  "@types/ws": "^8.18.1",
46
46
  "oxfmt": "^0.21.0",
47
47
  "oxlint": "^1.36.0",
48
+ "oxlint-tsgolint": "^0.11.0",
48
49
  "typescript": "^5.6.3",
49
50
  "vitest": "^4.0.6"
50
51
  },