@gricha/perry 0.2.0 → 0.2.2

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.
package/README.md CHANGED
@@ -10,11 +10,23 @@
10
10
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
11
11
  </p>
12
12
 
13
- <p align="center">Isolated, self-hosted workspaces accessible over Tailscale. AI coding agents, web UI, and remote terminal access.</p>
13
+ <p align="center">
14
+ Continue your coding sessions on the go. Self-hosted workspaces, accessible over Tailscale.
15
+ </p>
16
+
17
+ <p align="center">
18
+ <img src="assets/demo-terminal-mobile.gif" alt="Terminal" width="280">
19
+ &nbsp;&nbsp;&nbsp;
20
+ <img src="assets/demo-chat-mobile.gif" alt="Chat" width="280">
21
+ </p>
14
22
 
15
23
  ## Overview
16
24
 
17
- Perry is designed to run on a machine within a **secure private network** such as [Tailscale](https://tailscale.com). It provides isolated Docker-based development environments that you can access remotely via CLI, web UI, or SSH from any device on your network.
25
+ Perry is an agent (agent P) designed to run as a daemon on your machine. It allows your clients - other machines through CLI, web, or mobile app - to connect directly to your workspaces over the Tailscale network.
26
+
27
+ It can be connected directly to your host, or it can create docker containers so that your work can be fully isolated.
28
+
29
+ Continue your sessions on the go!
18
30
 
19
31
  ## Features
20
32
 
@@ -65,6 +77,10 @@ perry list
65
77
 
66
78
  Open http://localhost:7391 (or your Tailscale host) and click "+" to create a workspace.
67
79
 
80
+ <p align="center">
81
+ <img src="assets/demo.gif" alt="Web UI Demo" width="800">
82
+ </p>
83
+
68
84
  ## Security
69
85
 
70
86
  Perry is designed for use within **secure private networks** like [Tailscale](https://tailscale.com). The web UI and API currently have no authentication - this is intentional for private network use where all devices are trusted.
@@ -9,8 +9,9 @@ import { saveAgentConfig } from '../config/loader';
9
9
  import { setSessionName, getSessionNamesForWorkspace, deleteSessionName, } from '../sessions/metadata';
10
10
  import { discoverSSHKeys } from '../ssh/discovery';
11
11
  import { parseClaudeSessionContent } from '../sessions/parser';
12
- import { discoverAllSessions, getSessionDetails as getAgentSessionDetails, getSessionMessages, findSessionMessages, } from '../sessions/agents';
12
+ import { discoverAllSessions, getSessionDetails as getAgentSessionDetails, getSessionMessages, findSessionMessages, deleteSession as deleteSessionFromProvider, searchSessions as searchSessionsInContainer, } from '../sessions/agents';
13
13
  import { discoverClaudeCodeModels, discoverHostOpencodeModels, discoverContainerOpencodeModels, } from '../models/discovery';
14
+ import { listOpencodeSessions, getOpencodeSessionMessages, deleteOpencodeSession, } from '../sessions/agents/opencode-storage';
14
15
  const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']);
15
16
  const WorkspacePortsSchema = z.object({
16
17
  ssh: z.number(),
@@ -115,11 +116,18 @@ export function createRouter(ctx) {
115
116
  }
116
117
  });
117
118
  const startWorkspace = os
118
- .input(z.object({ name: z.string() }))
119
+ .input(z.object({
120
+ name: z.string(),
121
+ clone: z.string().optional(),
122
+ env: z.record(z.string(), z.string()).optional(),
123
+ }))
119
124
  .output(WorkspaceInfoSchema)
120
125
  .handler(async ({ input }) => {
121
126
  try {
122
- return await ctx.workspaces.start(input.name);
127
+ return await ctx.workspaces.start(input.name, {
128
+ clone: input.clone,
129
+ env: input.env,
130
+ });
123
131
  }
124
132
  catch (err) {
125
133
  mapErrorToORPC(err, 'Failed to start workspace');
@@ -200,6 +208,7 @@ export function createRouter(ctx) {
200
208
  workspacesCount: allWorkspaces.length,
201
209
  dockerVersion,
202
210
  terminalConnections: ctx.terminalServer.getConnectionCount(),
211
+ tailscale: ctx.tailscale,
203
212
  };
204
213
  });
205
214
  const getCredentials = os.output(CredentialsSchema).handler(async () => {
@@ -298,32 +307,16 @@ export function createRouter(ctx) {
298
307
  }
299
308
  }
300
309
  if (!input.agentType || input.agentType === 'opencode') {
301
- const opencodeDir = path.join(homeDir, '.opencode', 'sessions');
302
- try {
303
- const sessions = await fs.readdir(opencodeDir);
304
- for (const sessionDir of sessions) {
305
- const sessionPath = path.join(opencodeDir, sessionDir);
306
- const stat = await fs.stat(sessionPath);
307
- if (!stat.isDirectory())
308
- continue;
309
- const sessionFile = path.join(sessionPath, 'session.json');
310
- try {
311
- const sessionStat = await fs.stat(sessionFile);
312
- rawSessions.push({
313
- id: sessionDir,
314
- agentType: 'opencode',
315
- projectPath: homeDir,
316
- mtime: sessionStat.mtimeMs,
317
- filePath: sessionFile,
318
- });
319
- }
320
- catch {
321
- // session.json doesn't exist
322
- }
323
- }
324
- }
325
- catch {
326
- // Directory doesn't exist
310
+ const opencodeSessions = await listOpencodeSessions();
311
+ for (const session of opencodeSessions) {
312
+ rawSessions.push({
313
+ id: session.id,
314
+ agentType: 'opencode',
315
+ projectPath: session.directory || homeDir,
316
+ mtime: session.mtime,
317
+ filePath: session.file,
318
+ name: session.title || undefined,
319
+ });
327
320
  }
328
321
  }
329
322
  rawSessions.sort((a, b) => b.mtime - a.mtime);
@@ -364,16 +357,17 @@ export function createRouter(ctx) {
364
357
  }
365
358
  }
366
359
  else if (raw.agentType === 'opencode') {
367
- try {
368
- const sessionContent = await fs.readFile(raw.filePath, 'utf-8');
369
- const sessionData = JSON.parse(sessionContent);
370
- messageCount = sessionData.messages?.length || 0;
371
- if (sessionData.title) {
372
- firstPrompt = sessionData.title;
373
- }
360
+ const sessionMessages = await getOpencodeSessionMessages(raw.id);
361
+ const userAssistantMessages = sessionMessages.messages.filter((m) => m.type === 'user' || m.type === 'assistant');
362
+ messageCount = userAssistantMessages.length;
363
+ if (raw.name) {
364
+ firstPrompt = raw.name;
374
365
  }
375
- catch {
376
- // Can't read file
366
+ else {
367
+ const firstUserMsg = userAssistantMessages.find((m) => m.type === 'user' && m.content);
368
+ if (firstUserMsg?.content) {
369
+ firstPrompt = firstUserMsg.content.slice(0, 200);
370
+ }
377
371
  }
378
372
  }
379
373
  return {
@@ -427,57 +421,17 @@ export function createRouter(ctx) {
427
421
  }
428
422
  }
429
423
  if (!agentType || agentType === 'opencode') {
430
- const sessionDir = path.join(homeDir, '.opencode', 'sessions', sessionId);
431
- const partsDir = path.join(sessionDir, 'part');
432
- try {
433
- const partFiles = await fs.readdir(partsDir);
434
- const sortedParts = partFiles.sort();
435
- for (const partFile of sortedParts) {
436
- const partPath = path.join(partsDir, partFile);
437
- try {
438
- const partContent = await fs.readFile(partPath, 'utf-8');
439
- const part = JSON.parse(partContent);
440
- if (part.role === 'user' && part.content) {
441
- const textContent = Array.isArray(part.content)
442
- ? part.content
443
- .filter((c) => c.type === 'text')
444
- .map((c) => c.text)
445
- .join('\n')
446
- : part.content;
447
- messages.push({
448
- type: 'user',
449
- content: textContent,
450
- timestamp: part.time || null,
451
- });
452
- }
453
- else if (part.role === 'assistant') {
454
- if (part.content) {
455
- const textContent = Array.isArray(part.content)
456
- ? part.content
457
- .filter((c) => c.type === 'text')
458
- .map((c) => c.text)
459
- .join('\n')
460
- : part.content;
461
- if (textContent) {
462
- messages.push({
463
- type: 'assistant',
464
- content: textContent,
465
- timestamp: part.time || null,
466
- });
467
- }
468
- }
469
- }
470
- }
471
- catch {
472
- // Can't parse part
473
- }
474
- }
475
- if (messages.length > 0) {
476
- return { id: sessionId, agentType: 'opencode', messages };
477
- }
478
- }
479
- catch {
480
- // Directory doesn't exist
424
+ const sessionData = await getOpencodeSessionMessages(sessionId);
425
+ if (sessionData.messages.length > 0) {
426
+ const opencodeMessages = sessionData.messages.map((m) => ({
427
+ type: m.type,
428
+ content: m.content,
429
+ toolName: m.toolName,
430
+ toolId: m.toolId,
431
+ toolInput: m.toolInput,
432
+ timestamp: m.timestamp,
433
+ }));
434
+ return { id: sessionId, agentType: 'opencode', messages: opencodeMessages };
481
435
  }
482
436
  }
483
437
  return { id: sessionId, messages };
@@ -620,6 +574,196 @@ export function createRouter(ctx) {
620
574
  await ctx.sessionsCache.recordAccess(input.workspaceName, input.sessionId, input.agentType);
621
575
  return { success: true };
622
576
  });
577
+ const deleteSession = os
578
+ .input(z.object({
579
+ workspaceName: z.string(),
580
+ sessionId: z.string(),
581
+ agentType: z.enum(['claude-code', 'opencode', 'codex']),
582
+ }))
583
+ .handler(async ({ input }) => {
584
+ const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
585
+ if (isHost) {
586
+ const config = ctx.config.get();
587
+ if (!config.allowHostAccess) {
588
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
589
+ }
590
+ const result = await deleteHostSession(input.sessionId, input.agentType);
591
+ if (!result.success) {
592
+ throw new ORPCError('INTERNAL_SERVER_ERROR', {
593
+ message: result.error || 'Failed to delete session',
594
+ });
595
+ }
596
+ await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
597
+ await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
598
+ return { success: true };
599
+ }
600
+ const workspace = await ctx.workspaces.get(input.workspaceName);
601
+ if (!workspace) {
602
+ throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
603
+ }
604
+ if (workspace.status !== 'running') {
605
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
606
+ }
607
+ const containerName = `workspace-${input.workspaceName}`;
608
+ const result = await deleteSessionFromProvider(containerName, input.sessionId, input.agentType, execInContainer);
609
+ if (!result.success) {
610
+ throw new ORPCError('INTERNAL_SERVER_ERROR', {
611
+ message: result.error || 'Failed to delete session',
612
+ });
613
+ }
614
+ await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
615
+ await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
616
+ return { success: true };
617
+ });
618
+ const searchSessions = os
619
+ .input(z.object({
620
+ workspaceName: z.string(),
621
+ query: z.string().min(1).max(500),
622
+ }))
623
+ .handler(async ({ input }) => {
624
+ const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
625
+ if (isHost) {
626
+ const config = ctx.config.get();
627
+ if (!config.allowHostAccess) {
628
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
629
+ }
630
+ const results = await searchHostSessions(input.query);
631
+ return { results };
632
+ }
633
+ const workspace = await ctx.workspaces.get(input.workspaceName);
634
+ if (!workspace) {
635
+ throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
636
+ }
637
+ if (workspace.status !== 'running') {
638
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
639
+ }
640
+ const containerName = `workspace-${input.workspaceName}`;
641
+ const results = await searchSessionsInContainer(containerName, input.query, execInContainer);
642
+ return { results };
643
+ });
644
+ async function searchHostSessions(query) {
645
+ const homeDir = os_module.homedir();
646
+ const safeQuery = query.replace(/['"\\]/g, '\\$&');
647
+ const searchPaths = [
648
+ path.join(homeDir, '.claude', 'projects'),
649
+ path.join(homeDir, '.local', 'share', 'opencode', 'storage'),
650
+ path.join(homeDir, '.codex', 'sessions'),
651
+ ].filter((p) => {
652
+ try {
653
+ require('fs').accessSync(p);
654
+ return true;
655
+ }
656
+ catch {
657
+ return false;
658
+ }
659
+ });
660
+ if (searchPaths.length === 0) {
661
+ return [];
662
+ }
663
+ const { execSync } = await import('child_process');
664
+ try {
665
+ const output = execSync(`rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`, {
666
+ encoding: 'utf-8',
667
+ timeout: 30000,
668
+ });
669
+ const files = output.trim().split('\n').filter(Boolean);
670
+ const results = [];
671
+ for (const file of files) {
672
+ let sessionId = null;
673
+ let agentType = null;
674
+ if (file.includes('/.claude/projects/')) {
675
+ const match = file.match(/\/([^/]+)\.jsonl$/);
676
+ if (match && !match[1].startsWith('agent-')) {
677
+ sessionId = match[1];
678
+ agentType = 'claude-code';
679
+ }
680
+ }
681
+ else if (file.includes('/.local/share/opencode/storage/')) {
682
+ if (file.includes('/session/') && file.endsWith('.json')) {
683
+ const match = file.match(/\/(ses_[^/]+)\.json$/);
684
+ if (match) {
685
+ sessionId = match[1];
686
+ agentType = 'opencode';
687
+ }
688
+ }
689
+ }
690
+ else if (file.includes('/.codex/sessions/')) {
691
+ const match = file.match(/\/([^/]+)\.jsonl$/);
692
+ if (match) {
693
+ sessionId = match[1];
694
+ agentType = 'codex';
695
+ }
696
+ }
697
+ if (sessionId && agentType) {
698
+ results.push({ sessionId, agentType, matchCount: 1 });
699
+ }
700
+ }
701
+ return results;
702
+ }
703
+ catch {
704
+ return [];
705
+ }
706
+ }
707
+ async function deleteHostSession(sessionId, agentType) {
708
+ const homeDir = os_module.homedir();
709
+ if (agentType === 'claude-code') {
710
+ const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
711
+ const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');
712
+ try {
713
+ const projectDirs = await fs.readdir(claudeProjectsDir);
714
+ for (const projectDir of projectDirs) {
715
+ const sessionFile = path.join(claudeProjectsDir, projectDir, `${safeSessionId}.jsonl`);
716
+ try {
717
+ await fs.unlink(sessionFile);
718
+ return { success: true };
719
+ }
720
+ catch {
721
+ continue;
722
+ }
723
+ }
724
+ }
725
+ catch {
726
+ return { success: false, error: 'Session not found' };
727
+ }
728
+ return { success: false, error: 'Session not found' };
729
+ }
730
+ if (agentType === 'opencode') {
731
+ return deleteOpencodeSession(sessionId);
732
+ }
733
+ if (agentType === 'codex') {
734
+ const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
735
+ try {
736
+ const files = await fs.readdir(codexSessionsDir);
737
+ for (const file of files) {
738
+ if (!file.endsWith('.jsonl'))
739
+ continue;
740
+ const filePath = path.join(codexSessionsDir, file);
741
+ const fileId = file.replace('.jsonl', '');
742
+ if (fileId === sessionId) {
743
+ await fs.unlink(filePath);
744
+ return { success: true };
745
+ }
746
+ try {
747
+ const content = await fs.readFile(filePath, 'utf-8');
748
+ const firstLine = content.split('\n')[0];
749
+ const meta = JSON.parse(firstLine);
750
+ if (meta.session_id === sessionId) {
751
+ await fs.unlink(filePath);
752
+ return { success: true };
753
+ }
754
+ }
755
+ catch {
756
+ continue;
757
+ }
758
+ }
759
+ }
760
+ catch {
761
+ return { success: false, error: 'Session not found' };
762
+ }
763
+ return { success: false, error: 'Session not found' };
764
+ }
765
+ return { success: false, error: 'Unsupported agent type' };
766
+ }
623
767
  const getHostInfo = os.handler(async () => {
624
768
  const config = ctx.config.get();
625
769
  return {
@@ -717,6 +861,8 @@ export function createRouter(ctx) {
717
861
  clearName: clearSessionName,
718
862
  getRecent: getRecentSessions,
719
863
  recordAccess: recordSessionAccess,
864
+ delete: deleteSession,
865
+ search: searchSessions,
720
866
  },
721
867
  models: {
722
868
  list: listModels,
package/dist/agent/run.js CHANGED
@@ -5,6 +5,7 @@ import { HOST_WORKSPACE_NAME } from '../shared/types';
5
5
  import { DEFAULT_AGENT_PORT } from '../shared/constants';
6
6
  import { WorkspaceManager } from '../workspace/manager';
7
7
  import { containerRunning, getContainerName } from '../docker';
8
+ import { startEagerImagePull } from '../docker/eager-pull';
8
9
  import { TerminalWebSocketServer } from '../terminal/websocket';
9
10
  import { ChatWebSocketServer } from '../chat/websocket';
10
11
  import { OpencodeWebSocketServer } from '../chat/opencode-websocket';
@@ -12,13 +13,14 @@ import { createRouter } from './router';
12
13
  import { serveStatic } from './static';
13
14
  import { SessionsCacheManager } from '../sessions/cache';
14
15
  import { ModelCacheManager } from '../models/cache';
16
+ import { getTailscaleStatus, getTailscaleIdentity, startTailscaleServe, stopTailscaleServe, } from '../tailscale';
15
17
  import pkg from '../../package.json';
16
18
  const startTime = Date.now();
17
19
  function sendJson(res, status, data) {
18
20
  res.writeHead(status, { 'Content-Type': 'application/json' });
19
21
  res.end(JSON.stringify(data));
20
22
  }
21
- function createAgentServer(configDir, config) {
23
+ function createAgentServer(configDir, config, tailscale) {
22
24
  let currentConfig = config;
23
25
  const workspaces = new WorkspaceManager(configDir, currentConfig);
24
26
  const sessionsCache = new SessionsCacheManager(configDir);
@@ -59,12 +61,14 @@ function createAgentServer(configDir, config) {
59
61
  terminalServer,
60
62
  sessionsCache,
61
63
  modelCache,
64
+ tailscale,
62
65
  });
63
66
  const rpcHandler = new RPCHandler(router);
64
67
  const server = createServer(async (req, res) => {
65
68
  const url = new URL(req.url || '/', 'http://localhost');
66
69
  const method = req.method;
67
70
  const pathname = url.pathname;
71
+ const identity = getTailscaleIdentity(req);
68
72
  res.setHeader('Access-Control-Allow-Origin', '*');
69
73
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
70
74
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
@@ -75,7 +79,11 @@ function createAgentServer(configDir, config) {
75
79
  }
76
80
  try {
77
81
  if (pathname === '/health' && method === 'GET') {
78
- sendJson(res, 200, { status: 'ok', version: pkg.version });
82
+ const response = { status: 'ok', version: pkg.version };
83
+ if (identity) {
84
+ response.user = identity.email;
85
+ }
86
+ sendJson(res, 200, response);
79
87
  return;
80
88
  }
81
89
  if (pathname.startsWith('/rpc')) {
@@ -150,7 +158,38 @@ export async function startAgent(options = {}) {
150
158
  const port = options.port || parseInt(process.env.PERRY_PORT || '', 10) || config.port || DEFAULT_AGENT_PORT;
151
159
  console.log(`[agent] Config directory: ${configDir}`);
152
160
  console.log(`[agent] Starting on port ${port}...`);
153
- const { server, terminalServer, chatServer, opencodeServer } = createAgentServer(configDir, config);
161
+ const tailscale = await getTailscaleStatus();
162
+ let tailscaleServeActive = false;
163
+ if (tailscale.running && tailscale.dnsName) {
164
+ console.log(`[agent] Tailscale detected: ${tailscale.dnsName}`);
165
+ if (!tailscale.httpsEnabled) {
166
+ console.log(`[agent] Tailscale HTTPS not enabled in tailnet, skipping Serve`);
167
+ }
168
+ else {
169
+ const result = await startTailscaleServe(port);
170
+ if (result.success) {
171
+ tailscaleServeActive = true;
172
+ console.log(`[agent] Tailscale Serve enabled`);
173
+ }
174
+ else if (result.error === 'permission_denied') {
175
+ console.log(`[agent] Tailscale Serve requires operator permissions`);
176
+ console.log(`[agent] To enable: ${result.message}`);
177
+ console.log(`[agent] Continuing without HTTPS...`);
178
+ }
179
+ else {
180
+ console.log(`[agent] Tailscale Serve failed: ${result.message || 'unknown error'}`);
181
+ }
182
+ }
183
+ }
184
+ const tailscaleInfo = tailscale.running && tailscale.dnsName
185
+ ? {
186
+ running: true,
187
+ dnsName: tailscale.dnsName,
188
+ serveActive: tailscaleServeActive,
189
+ httpsUrl: tailscaleServeActive ? `https://${tailscale.dnsName}` : undefined,
190
+ }
191
+ : undefined;
192
+ const { server, terminalServer, chatServer, opencodeServer } = createAgentServer(configDir, config, tailscaleInfo);
154
193
  server.on('error', async (err) => {
155
194
  if (err.code === 'EADDRINUSE') {
156
195
  console.error(`[agent] Error: Port ${port} is already in use.`);
@@ -168,13 +207,25 @@ export async function startAgent(options = {}) {
168
207
  });
169
208
  server.listen(port, '::', () => {
170
209
  console.log(`[agent] Agent running at http://localhost:${port}`);
210
+ if (tailscale.running && tailscale.dnsName) {
211
+ const shortName = tailscale.dnsName.split('.')[0];
212
+ console.log(`[agent] Tailnet: http://${shortName}:${port}`);
213
+ if (tailscaleServeActive) {
214
+ console.log(`[agent] Tailnet HTTPS: https://${tailscale.dnsName}`);
215
+ }
216
+ }
171
217
  console.log(`[agent] oRPC endpoint: http://localhost:${port}/rpc`);
172
218
  console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
173
219
  console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/chat/:name`);
174
220
  console.log(`[agent] WebSocket chat (OpenCode): ws://localhost:${port}/rpc/opencode/:name`);
221
+ startEagerImagePull();
175
222
  });
176
- const shutdown = () => {
223
+ const shutdown = async () => {
177
224
  console.log('[agent] Shutting down...');
225
+ if (tailscaleServeActive) {
226
+ console.log('[agent] Stopping Tailscale Serve...');
227
+ await stopTailscaleServe();
228
+ }
178
229
  chatServer.close();
179
230
  opencodeServer.close();
180
231
  terminalServer.close();