@gricha/perry 0.2.1 → 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(),
@@ -207,6 +208,7 @@ export function createRouter(ctx) {
207
208
  workspacesCount: allWorkspaces.length,
208
209
  dockerVersion,
209
210
  terminalConnections: ctx.terminalServer.getConnectionCount(),
211
+ tailscale: ctx.tailscale,
210
212
  };
211
213
  });
212
214
  const getCredentials = os.output(CredentialsSchema).handler(async () => {
@@ -305,32 +307,16 @@ export function createRouter(ctx) {
305
307
  }
306
308
  }
307
309
  if (!input.agentType || input.agentType === 'opencode') {
308
- const opencodeDir = path.join(homeDir, '.opencode', 'sessions');
309
- try {
310
- const sessions = await fs.readdir(opencodeDir);
311
- for (const sessionDir of sessions) {
312
- const sessionPath = path.join(opencodeDir, sessionDir);
313
- const stat = await fs.stat(sessionPath);
314
- if (!stat.isDirectory())
315
- continue;
316
- const sessionFile = path.join(sessionPath, 'session.json');
317
- try {
318
- const sessionStat = await fs.stat(sessionFile);
319
- rawSessions.push({
320
- id: sessionDir,
321
- agentType: 'opencode',
322
- projectPath: homeDir,
323
- mtime: sessionStat.mtimeMs,
324
- filePath: sessionFile,
325
- });
326
- }
327
- catch {
328
- // session.json doesn't exist
329
- }
330
- }
331
- }
332
- catch {
333
- // 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
+ });
334
320
  }
335
321
  }
336
322
  rawSessions.sort((a, b) => b.mtime - a.mtime);
@@ -371,16 +357,17 @@ export function createRouter(ctx) {
371
357
  }
372
358
  }
373
359
  else if (raw.agentType === 'opencode') {
374
- try {
375
- const sessionContent = await fs.readFile(raw.filePath, 'utf-8');
376
- const sessionData = JSON.parse(sessionContent);
377
- messageCount = sessionData.messages?.length || 0;
378
- if (sessionData.title) {
379
- firstPrompt = sessionData.title;
380
- }
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;
381
365
  }
382
- catch {
383
- // 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
+ }
384
371
  }
385
372
  }
386
373
  return {
@@ -434,57 +421,17 @@ export function createRouter(ctx) {
434
421
  }
435
422
  }
436
423
  if (!agentType || agentType === 'opencode') {
437
- const sessionDir = path.join(homeDir, '.opencode', 'sessions', sessionId);
438
- const partsDir = path.join(sessionDir, 'part');
439
- try {
440
- const partFiles = await fs.readdir(partsDir);
441
- const sortedParts = partFiles.sort();
442
- for (const partFile of sortedParts) {
443
- const partPath = path.join(partsDir, partFile);
444
- try {
445
- const partContent = await fs.readFile(partPath, 'utf-8');
446
- const part = JSON.parse(partContent);
447
- if (part.role === 'user' && part.content) {
448
- const textContent = Array.isArray(part.content)
449
- ? part.content
450
- .filter((c) => c.type === 'text')
451
- .map((c) => c.text)
452
- .join('\n')
453
- : part.content;
454
- messages.push({
455
- type: 'user',
456
- content: textContent,
457
- timestamp: part.time || null,
458
- });
459
- }
460
- else if (part.role === 'assistant') {
461
- if (part.content) {
462
- const textContent = Array.isArray(part.content)
463
- ? part.content
464
- .filter((c) => c.type === 'text')
465
- .map((c) => c.text)
466
- .join('\n')
467
- : part.content;
468
- if (textContent) {
469
- messages.push({
470
- type: 'assistant',
471
- content: textContent,
472
- timestamp: part.time || null,
473
- });
474
- }
475
- }
476
- }
477
- }
478
- catch {
479
- // Can't parse part
480
- }
481
- }
482
- if (messages.length > 0) {
483
- return { id: sessionId, agentType: 'opencode', messages };
484
- }
485
- }
486
- catch {
487
- // 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 };
488
435
  }
489
436
  }
490
437
  return { id: sessionId, messages };
@@ -627,6 +574,196 @@ export function createRouter(ctx) {
627
574
  await ctx.sessionsCache.recordAccess(input.workspaceName, input.sessionId, input.agentType);
628
575
  return { success: true };
629
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
+ }
630
767
  const getHostInfo = os.handler(async () => {
631
768
  const config = ctx.config.get();
632
769
  return {
@@ -724,6 +861,8 @@ export function createRouter(ctx) {
724
861
  clearName: clearSessionName,
725
862
  getRecent: getRecentSessions,
726
863
  recordAccess: recordSessionAccess,
864
+ delete: deleteSession,
865
+ search: searchSessions,
727
866
  },
728
867
  models: {
729
868
  list: listModels,
package/dist/agent/run.js CHANGED
@@ -13,13 +13,14 @@ import { createRouter } from './router';
13
13
  import { serveStatic } from './static';
14
14
  import { SessionsCacheManager } from '../sessions/cache';
15
15
  import { ModelCacheManager } from '../models/cache';
16
+ import { getTailscaleStatus, getTailscaleIdentity, startTailscaleServe, stopTailscaleServe, } from '../tailscale';
16
17
  import pkg from '../../package.json';
17
18
  const startTime = Date.now();
18
19
  function sendJson(res, status, data) {
19
20
  res.writeHead(status, { 'Content-Type': 'application/json' });
20
21
  res.end(JSON.stringify(data));
21
22
  }
22
- function createAgentServer(configDir, config) {
23
+ function createAgentServer(configDir, config, tailscale) {
23
24
  let currentConfig = config;
24
25
  const workspaces = new WorkspaceManager(configDir, currentConfig);
25
26
  const sessionsCache = new SessionsCacheManager(configDir);
@@ -60,12 +61,14 @@ function createAgentServer(configDir, config) {
60
61
  terminalServer,
61
62
  sessionsCache,
62
63
  modelCache,
64
+ tailscale,
63
65
  });
64
66
  const rpcHandler = new RPCHandler(router);
65
67
  const server = createServer(async (req, res) => {
66
68
  const url = new URL(req.url || '/', 'http://localhost');
67
69
  const method = req.method;
68
70
  const pathname = url.pathname;
71
+ const identity = getTailscaleIdentity(req);
69
72
  res.setHeader('Access-Control-Allow-Origin', '*');
70
73
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
71
74
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
@@ -76,7 +79,11 @@ function createAgentServer(configDir, config) {
76
79
  }
77
80
  try {
78
81
  if (pathname === '/health' && method === 'GET') {
79
- 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);
80
87
  return;
81
88
  }
82
89
  if (pathname.startsWith('/rpc')) {
@@ -151,7 +158,38 @@ export async function startAgent(options = {}) {
151
158
  const port = options.port || parseInt(process.env.PERRY_PORT || '', 10) || config.port || DEFAULT_AGENT_PORT;
152
159
  console.log(`[agent] Config directory: ${configDir}`);
153
160
  console.log(`[agent] Starting on port ${port}...`);
154
- 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);
155
193
  server.on('error', async (err) => {
156
194
  if (err.code === 'EADDRINUSE') {
157
195
  console.error(`[agent] Error: Port ${port} is already in use.`);
@@ -169,14 +207,25 @@ export async function startAgent(options = {}) {
169
207
  });
170
208
  server.listen(port, '::', () => {
171
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
+ }
172
217
  console.log(`[agent] oRPC endpoint: http://localhost:${port}/rpc`);
173
218
  console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
174
219
  console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/chat/:name`);
175
220
  console.log(`[agent] WebSocket chat (OpenCode): ws://localhost:${port}/rpc/opencode/:name`);
176
221
  startEagerImagePull();
177
222
  });
178
- const shutdown = () => {
223
+ const shutdown = async () => {
179
224
  console.log('[agent] Shutting down...');
225
+ if (tailscaleServeActive) {
226
+ console.log('[agent] Stopping Tailscale Serve...');
227
+ await stopTailscaleServe();
228
+ }
180
229
  chatServer.close();
181
230
  opencodeServer.close();
182
231
  terminalServer.close();