@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.
@@ -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-DQmM39Em.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CaFOQOgc.css">
8
+ <script type="module" crossorigin src="/assets/index-DN_QW9sL.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DIOWcVH-.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -66,9 +66,9 @@ export class ApiClient {
66
66
  throw this.wrapError(err);
67
67
  }
68
68
  }
69
- async startWorkspace(name) {
69
+ async startWorkspace(name, options) {
70
70
  try {
71
- return await this.client.workspaces.start({ name });
71
+ return await this.client.workspaces.start({ name, clone: options?.clone, env: options?.env });
72
72
  }
73
73
  catch (err) {
74
74
  throw this.wrapError(err);
@@ -0,0 +1,65 @@
1
+ import { imageExists, tryPullImage, getDockerVersion } from './index';
2
+ import { WORKSPACE_IMAGE_REGISTRY } from '../shared/constants';
3
+ import pkg from '../../package.json';
4
+ const RETRY_INTERVAL_MS = 20000;
5
+ const MAX_RETRIES = 10;
6
+ let pullInProgress = false;
7
+ let pullComplete = false;
8
+ async function isDockerAvailable() {
9
+ try {
10
+ await getDockerVersion();
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ async function pullWorkspaceImage() {
18
+ const registryImage = `${WORKSPACE_IMAGE_REGISTRY}:${pkg.version}`;
19
+ const exists = await imageExists(registryImage);
20
+ if (exists) {
21
+ console.log(`[agent] Workspace image ${registryImage} already available`);
22
+ return true;
23
+ }
24
+ console.log(`[agent] Pulling workspace image ${registryImage}...`);
25
+ const pulled = await tryPullImage(registryImage);
26
+ if (pulled) {
27
+ console.log('[agent] Workspace image pulled successfully');
28
+ return true;
29
+ }
30
+ console.log('[agent] Failed to pull image - will retry later');
31
+ return false;
32
+ }
33
+ export async function startEagerImagePull() {
34
+ if (pullInProgress || pullComplete) {
35
+ return;
36
+ }
37
+ pullInProgress = true;
38
+ const attemptPull = async (attempt) => {
39
+ if (attempt > MAX_RETRIES) {
40
+ console.log('[agent] Max retries reached for image pull - giving up background pull');
41
+ pullInProgress = false;
42
+ return;
43
+ }
44
+ const dockerAvailable = await isDockerAvailable();
45
+ if (!dockerAvailable) {
46
+ if (attempt === 1) {
47
+ console.log('[agent] Docker not available - will retry in background');
48
+ }
49
+ setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
50
+ return;
51
+ }
52
+ const success = await pullWorkspaceImage();
53
+ if (success) {
54
+ pullComplete = true;
55
+ pullInProgress = false;
56
+ }
57
+ else {
58
+ setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
59
+ }
60
+ };
61
+ attemptPull(1);
62
+ }
63
+ export function isImagePullComplete() {
64
+ return pullComplete;
65
+ }
package/dist/index.js CHANGED
@@ -131,36 +131,18 @@ program
131
131
  handleError(err);
132
132
  }
133
133
  });
134
- program
135
- .command('create <name>')
136
- .description('Create a new workspace')
137
- .option('--clone <url>', 'Git repository URL to clone')
138
- .action(async (name, options) => {
139
- try {
140
- const client = await getClient();
141
- console.log(`Creating workspace '${name}'...`);
142
- const workspace = await client.createWorkspace({
143
- name,
144
- clone: options.clone,
145
- });
146
- console.log(`Workspace '${workspace.name}' created.`);
147
- console.log(` Status: ${workspace.status}`);
148
- console.log(` SSH Port: ${workspace.ports.ssh}`);
149
- }
150
- catch (err) {
151
- handleError(err);
152
- }
153
- });
154
134
  program
155
135
  .command('start <name>')
156
- .description('Start a stopped workspace')
157
- .action(async (name) => {
136
+ .description('Start a workspace (creates it if it does not exist)')
137
+ .option('--clone <url>', 'Git repository URL to clone (when creating)')
138
+ .action(async (name, options) => {
158
139
  try {
159
140
  const client = await getClient();
160
141
  console.log(`Starting workspace '${name}'...`);
161
- const workspace = await client.startWorkspace(name);
142
+ const workspace = await client.startWorkspace(name, { clone: options.clone });
162
143
  console.log(`Workspace '${workspace.name}' started.`);
163
144
  console.log(` Status: ${workspace.status}`);
145
+ console.log(` SSH Port: ${workspace.ports.ssh}`);
164
146
  }
165
147
  catch (err) {
166
148
  handleError(err);
@@ -219,6 +201,12 @@ program
219
201
  console.log(` Uptime: ${formatUptime(info.uptime)}`);
220
202
  console.log(` Workspaces: ${info.workspacesCount}`);
221
203
  console.log(` Docker: ${info.dockerVersion}`);
204
+ if (info.tailscale?.running) {
205
+ console.log(` Tailscale: ${info.tailscale.dnsName}`);
206
+ if (info.tailscale.httpsUrl) {
207
+ console.log(` HTTPS URL: ${info.tailscale.httpsUrl}`);
208
+ }
209
+ }
222
210
  }
223
211
  }
224
212
  catch (err) {
@@ -617,6 +605,42 @@ program
617
605
  process.exit(1);
618
606
  }
619
607
  });
608
+ const workerCmd = program
609
+ .command('worker')
610
+ .description('Worker mode commands (for use inside containers)');
611
+ workerCmd
612
+ .command('sessions')
613
+ .argument('<subcommand>', 'Subcommand: list, messages, or delete')
614
+ .argument('[sessionId]', 'Session ID (required for messages and delete)')
615
+ .description('Manage OpenCode sessions')
616
+ .action(async (subcommand, sessionId) => {
617
+ const { listOpencodeSessions, getOpencodeSessionMessages, deleteOpencodeSession } = await import('./sessions/agents/opencode-storage');
618
+ if (subcommand === 'list') {
619
+ const sessions = await listOpencodeSessions();
620
+ console.log(JSON.stringify(sessions));
621
+ }
622
+ else if (subcommand === 'messages') {
623
+ if (!sessionId) {
624
+ console.error('Usage: perry worker sessions messages <session_id>');
625
+ process.exit(1);
626
+ }
627
+ const result = await getOpencodeSessionMessages(sessionId);
628
+ console.log(JSON.stringify(result));
629
+ }
630
+ else if (subcommand === 'delete') {
631
+ if (!sessionId) {
632
+ console.error('Usage: perry worker sessions delete <session_id>');
633
+ process.exit(1);
634
+ }
635
+ const result = await deleteOpencodeSession(sessionId);
636
+ console.log(JSON.stringify(result));
637
+ }
638
+ else {
639
+ console.error(`Unknown subcommand: ${subcommand}`);
640
+ console.error('Available: list, messages, delete');
641
+ process.exit(1);
642
+ }
643
+ });
620
644
  function handleError(err) {
621
645
  if (err instanceof ApiClientError) {
622
646
  console.error(`Error: ${err.message}`);
Binary file
@@ -83,4 +83,23 @@ export const claudeProvider = {
83
83
  (msg.content && msg.content.trim().length > 0));
84
84
  return { id: sessionId, messages };
85
85
  },
86
+ async deleteSession(containerName, sessionId, exec) {
87
+ const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
88
+ const findResult = await exec(containerName, [
89
+ 'bash',
90
+ '-c',
91
+ `find /home/workspace/.claude/projects -name "${safeSessionId}.jsonl" -type f 2>/dev/null | head -1`,
92
+ ], { user: 'workspace' });
93
+ if (findResult.exitCode !== 0 || !findResult.stdout.trim()) {
94
+ return { success: false, error: 'Session not found' };
95
+ }
96
+ const filePath = findResult.stdout.trim();
97
+ const rmResult = await exec(containerName, ['rm', '-f', filePath], {
98
+ user: 'workspace',
99
+ });
100
+ if (rmResult.exitCode !== 0) {
101
+ return { success: false, error: rmResult.stderr || 'Failed to delete session file' };
102
+ }
103
+ return { success: true };
104
+ },
86
105
  };
@@ -107,4 +107,44 @@ export const codexProvider = {
107
107
  }
108
108
  return null;
109
109
  },
110
+ async deleteSession(containerName, sessionId, exec) {
111
+ const findResult = await exec(containerName, ['bash', '-c', `find /home/workspace/.codex/sessions -name "*.jsonl" -type f 2>/dev/null`], { user: 'workspace' });
112
+ if (findResult.exitCode !== 0 || !findResult.stdout.trim()) {
113
+ return { success: false, error: 'No session files found' };
114
+ }
115
+ const files = findResult.stdout.trim().split('\n').filter(Boolean);
116
+ for (const file of files) {
117
+ const fileId = file.split('/').pop()?.replace('.jsonl', '') || '';
118
+ if (fileId === sessionId) {
119
+ const rmResult = await exec(containerName, ['rm', '-f', file], {
120
+ user: 'workspace',
121
+ });
122
+ if (rmResult.exitCode !== 0) {
123
+ return { success: false, error: rmResult.stderr || 'Failed to delete session file' };
124
+ }
125
+ return { success: true };
126
+ }
127
+ const headResult = await exec(containerName, ['head', '-1', file], {
128
+ user: 'workspace',
129
+ });
130
+ if (headResult.exitCode === 0) {
131
+ try {
132
+ const meta = JSON.parse(headResult.stdout);
133
+ if (meta.session_id === sessionId) {
134
+ const rmResult = await exec(containerName, ['rm', '-f', file], {
135
+ user: 'workspace',
136
+ });
137
+ if (rmResult.exitCode !== 0) {
138
+ return { success: false, error: rmResult.stderr || 'Failed to delete session file' };
139
+ }
140
+ return { success: true };
141
+ }
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ }
147
+ }
148
+ return { success: false, error: 'Session not found' };
149
+ },
110
150
  };
@@ -42,3 +42,66 @@ export async function findSessionMessages(containerName, sessionId, exec) {
42
42
  }
43
43
  return null;
44
44
  }
45
+ export async function deleteSession(containerName, sessionId, agentType, exec) {
46
+ const provider = providers[agentType];
47
+ if (!provider) {
48
+ return { success: false, error: 'Unknown agent type' };
49
+ }
50
+ return provider.deleteSession(containerName, sessionId, exec);
51
+ }
52
+ export async function searchSessions(containerName, query, exec) {
53
+ const safeQuery = query.replace(/['"\\]/g, '\\$&');
54
+ const searchPaths = [
55
+ '/home/workspace/.claude/projects',
56
+ '/home/workspace/.local/share/opencode/storage',
57
+ '/home/workspace/.codex/sessions',
58
+ ];
59
+ const rgCommand = `rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`;
60
+ const result = await exec(containerName, ['bash', '-c', rgCommand], {
61
+ user: 'workspace',
62
+ });
63
+ if (result.exitCode !== 0 || !result.stdout.trim()) {
64
+ return [];
65
+ }
66
+ const files = result.stdout.trim().split('\n').filter(Boolean);
67
+ const results = [];
68
+ for (const file of files) {
69
+ let sessionId = null;
70
+ let agentType = null;
71
+ if (file.includes('/.claude/projects/')) {
72
+ const match = file.match(/\/([^/]+)\.jsonl$/);
73
+ if (match && !match[1].startsWith('agent-')) {
74
+ sessionId = match[1];
75
+ agentType = 'claude-code';
76
+ }
77
+ }
78
+ else if (file.includes('/.local/share/opencode/storage/')) {
79
+ if (file.includes('/session/') && file.endsWith('.json')) {
80
+ const match = file.match(/\/(ses_[^/]+)\.json$/);
81
+ if (match) {
82
+ sessionId = match[1];
83
+ agentType = 'opencode';
84
+ }
85
+ }
86
+ else if (file.includes('/part/') || file.includes('/message/')) {
87
+ continue;
88
+ }
89
+ }
90
+ else if (file.includes('/.codex/sessions/')) {
91
+ const match = file.match(/\/([^/]+)\.jsonl$/);
92
+ if (match) {
93
+ sessionId = match[1];
94
+ agentType = 'codex';
95
+ }
96
+ }
97
+ if (sessionId && agentType) {
98
+ results.push({
99
+ sessionId,
100
+ agentType,
101
+ filePath: file,
102
+ matchCount: 1,
103
+ });
104
+ }
105
+ }
106
+ return results;
107
+ }
@@ -0,0 +1,218 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ function getStorageBase(homeDir) {
5
+ const home = homeDir || os.homedir();
6
+ return path.join(home, '.local', 'share', 'opencode', 'storage');
7
+ }
8
+ export async function listOpencodeSessions(homeDir) {
9
+ const storageBase = getStorageBase(homeDir);
10
+ const sessionDir = path.join(storageBase, 'session');
11
+ const sessions = [];
12
+ try {
13
+ const projectDirs = await fs.readdir(sessionDir, { withFileTypes: true });
14
+ for (const projectDir of projectDirs) {
15
+ if (!projectDir.isDirectory())
16
+ continue;
17
+ const projectPath = path.join(sessionDir, projectDir.name);
18
+ const sessionFiles = await fs.readdir(projectPath);
19
+ for (const sessionFile of sessionFiles) {
20
+ if (!sessionFile.startsWith('ses_') || !sessionFile.endsWith('.json'))
21
+ continue;
22
+ const filePath = path.join(projectPath, sessionFile);
23
+ try {
24
+ const stat = await fs.stat(filePath);
25
+ const content = await fs.readFile(filePath, 'utf-8');
26
+ const data = JSON.parse(content);
27
+ if (!data.id)
28
+ continue;
29
+ sessions.push({
30
+ id: data.id,
31
+ title: data.title || '',
32
+ directory: data.directory || '',
33
+ mtime: data.time?.updated || Math.floor(stat.mtimeMs),
34
+ file: filePath,
35
+ });
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ }
41
+ }
42
+ }
43
+ catch {
44
+ // Storage doesn't exist
45
+ }
46
+ return sessions;
47
+ }
48
+ export async function getOpencodeSessionMessages(sessionId, homeDir) {
49
+ const storageBase = getStorageBase(homeDir);
50
+ const sessionDir = path.join(storageBase, 'session');
51
+ const messageDir = path.join(storageBase, 'message');
52
+ const partDir = path.join(storageBase, 'part');
53
+ const sessionFile = await findSessionFile(sessionDir, sessionId);
54
+ if (!sessionFile) {
55
+ return { id: sessionId, messages: [] };
56
+ }
57
+ let internalId;
58
+ try {
59
+ const content = await fs.readFile(sessionFile, 'utf-8');
60
+ const data = JSON.parse(content);
61
+ internalId = data.id;
62
+ if (!internalId) {
63
+ return { id: sessionId, messages: [] };
64
+ }
65
+ }
66
+ catch {
67
+ return { id: sessionId, messages: [] };
68
+ }
69
+ const msgDir = path.join(messageDir, internalId);
70
+ const messages = [];
71
+ try {
72
+ const msgFiles = (await fs.readdir(msgDir))
73
+ .filter((f) => f.startsWith('msg_') && f.endsWith('.json'))
74
+ .sort();
75
+ for (const msgFile of msgFiles) {
76
+ const msgPath = path.join(msgDir, msgFile);
77
+ try {
78
+ const content = await fs.readFile(msgPath, 'utf-8');
79
+ const msg = JSON.parse(content);
80
+ if (!msg.id || (msg.role !== 'user' && msg.role !== 'assistant'))
81
+ continue;
82
+ const partMsgDir = path.join(partDir, msg.id);
83
+ try {
84
+ const partFiles = (await fs.readdir(partMsgDir))
85
+ .filter((f) => f.startsWith('prt_') && f.endsWith('.json'))
86
+ .sort();
87
+ for (const partFile of partFiles) {
88
+ const partPath = path.join(partMsgDir, partFile);
89
+ try {
90
+ const partContent = await fs.readFile(partPath, 'utf-8');
91
+ const part = JSON.parse(partContent);
92
+ const timestamp = msg.time?.created
93
+ ? new Date(msg.time.created).toISOString()
94
+ : undefined;
95
+ if (part.type === 'text' && part.text) {
96
+ messages.push({
97
+ type: msg.role,
98
+ content: part.text,
99
+ timestamp,
100
+ });
101
+ }
102
+ else if (part.type === 'tool') {
103
+ const toolName = part.state?.title || part.tool || '';
104
+ const callId = part.callID || part.id || '';
105
+ messages.push({
106
+ type: 'tool_use',
107
+ toolName,
108
+ toolId: callId,
109
+ toolInput: part.state?.input ? JSON.stringify(part.state.input) : '',
110
+ timestamp,
111
+ });
112
+ if (part.state?.output) {
113
+ messages.push({
114
+ type: 'tool_result',
115
+ content: part.state.output,
116
+ toolId: callId,
117
+ timestamp,
118
+ });
119
+ }
120
+ }
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ }
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ }
135
+ }
136
+ catch {
137
+ // No messages
138
+ }
139
+ return { id: sessionId, messages };
140
+ }
141
+ async function findSessionFile(sessionDir, sessionId) {
142
+ try {
143
+ const projectDirs = await fs.readdir(sessionDir, { withFileTypes: true });
144
+ for (const projectDir of projectDirs) {
145
+ if (!projectDir.isDirectory())
146
+ continue;
147
+ const filePath = path.join(sessionDir, projectDir.name, `${sessionId}.json`);
148
+ try {
149
+ await fs.access(filePath);
150
+ return filePath;
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ }
156
+ }
157
+ catch {
158
+ // Directory doesn't exist
159
+ }
160
+ return null;
161
+ }
162
+ export async function deleteOpencodeSession(sessionId, homeDir) {
163
+ const storageBase = getStorageBase(homeDir);
164
+ const sessionDir = path.join(storageBase, 'session');
165
+ const messageDir = path.join(storageBase, 'message');
166
+ const partDir = path.join(storageBase, 'part');
167
+ const sessionFile = await findSessionFile(sessionDir, sessionId);
168
+ if (!sessionFile) {
169
+ return { success: false, error: 'Session not found' };
170
+ }
171
+ let internalId = null;
172
+ try {
173
+ const content = await fs.readFile(sessionFile, 'utf-8');
174
+ const data = JSON.parse(content);
175
+ internalId = data.id;
176
+ }
177
+ catch {
178
+ // Continue with session file deletion only
179
+ }
180
+ try {
181
+ await fs.unlink(sessionFile);
182
+ }
183
+ catch (err) {
184
+ return { success: false, error: `Failed to delete session file: ${err}` };
185
+ }
186
+ if (internalId) {
187
+ const msgDir = path.join(messageDir, internalId);
188
+ try {
189
+ const msgFiles = await fs.readdir(msgDir);
190
+ for (const msgFile of msgFiles) {
191
+ if (!msgFile.startsWith('msg_') || !msgFile.endsWith('.json'))
192
+ continue;
193
+ const msgPath = path.join(msgDir, msgFile);
194
+ try {
195
+ const content = await fs.readFile(msgPath, 'utf-8');
196
+ const msg = JSON.parse(content);
197
+ if (msg.id) {
198
+ const partMsgDir = path.join(partDir, msg.id);
199
+ try {
200
+ await fs.rm(partMsgDir, { recursive: true });
201
+ }
202
+ catch {
203
+ // Parts may not exist
204
+ }
205
+ }
206
+ }
207
+ catch {
208
+ // Skip malformed messages
209
+ }
210
+ }
211
+ await fs.rm(msgDir, { recursive: true });
212
+ }
213
+ catch {
214
+ // Messages directory may not exist
215
+ }
216
+ }
217
+ return { success: true };
218
+ }
@@ -1,6 +1,6 @@
1
1
  export const opencodeProvider = {
2
2
  async discoverSessions(containerName, exec) {
3
- const result = await exec(containerName, ['perry-session-reader', 'list'], {
3
+ const result = await exec(containerName, ['perry', 'worker', 'sessions', 'list'], {
4
4
  user: 'workspace',
5
5
  });
6
6
  if (result.exitCode !== 0) {
@@ -22,7 +22,7 @@ export const opencodeProvider = {
22
22
  }
23
23
  },
24
24
  async getSessionDetails(containerName, rawSession, exec) {
25
- const result = await exec(containerName, ['perry-session-reader', 'messages', rawSession.id], {
25
+ const result = await exec(containerName, ['perry', 'worker', 'sessions', 'messages', rawSession.id], {
26
26
  user: 'workspace',
27
27
  });
28
28
  if (result.exitCode !== 0) {
@@ -50,7 +50,7 @@ export const opencodeProvider = {
50
50
  }
51
51
  },
52
52
  async getSessionMessages(containerName, sessionId, exec) {
53
- const result = await exec(containerName, ['perry-session-reader', 'messages', sessionId], {
53
+ const result = await exec(containerName, ['perry', 'worker', 'sessions', 'messages', sessionId], {
54
54
  user: 'workspace',
55
55
  });
56
56
  if (result.exitCode !== 0) {
@@ -67,4 +67,18 @@ export const opencodeProvider = {
67
67
  return null;
68
68
  }
69
69
  },
70
+ async deleteSession(containerName, sessionId, exec) {
71
+ const result = await exec(containerName, ['perry', 'worker', 'sessions', 'delete', sessionId], {
72
+ user: 'workspace',
73
+ });
74
+ if (result.exitCode !== 0) {
75
+ return { success: false, error: result.stderr || 'Failed to delete session' };
76
+ }
77
+ try {
78
+ return JSON.parse(result.stdout);
79
+ }
80
+ catch {
81
+ return { success: false, error: 'Invalid response from worker' };
82
+ }
83
+ },
70
84
  };
@@ -49,4 +49,9 @@ export class SessionsCacheManager {
49
49
  cache.recent = cache.recent.filter((s) => s.workspaceName !== workspaceName);
50
50
  await this.save();
51
51
  }
52
+ async removeSession(workspaceName, sessionId) {
53
+ const cache = await this.load();
54
+ cache.recent = cache.recent.filter((s) => !(s.workspaceName === workspaceName && s.sessionId === sessionId));
55
+ await this.save();
56
+ }
52
57
  }
@@ -1,7 +1,7 @@
1
1
  export const DEFAULT_AGENT_PORT = 7391;
2
2
  export const SSH_PORT_RANGE_START = 2200;
3
3
  export const SSH_PORT_RANGE_END = 2400;
4
- export const WORKSPACE_IMAGE_LOCAL = 'workspace:latest';
4
+ export const WORKSPACE_IMAGE_LOCAL = 'perry:latest';
5
5
  export const WORKSPACE_IMAGE_REGISTRY = 'ghcr.io/gricha/perry';
6
6
  export const VOLUME_PREFIX = 'workspace-';
7
7
  export const CONTAINER_PREFIX = 'workspace-';