@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.
@@ -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-CwCl9DVw.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-CGJDysKS.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>
package/dist/index.js CHANGED
@@ -201,6 +201,12 @@ program
201
201
  console.log(` Uptime: ${formatUptime(info.uptime)}`);
202
202
  console.log(` Workspaces: ${info.workspacesCount}`);
203
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
+ }
204
210
  }
205
211
  }
206
212
  catch (err) {
@@ -599,6 +605,42 @@ program
599
605
  process.exit(1);
600
606
  }
601
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
+ });
602
644
  function handleError(err) {
603
645
  if (err instanceof ApiClientError) {
604
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-';
@@ -0,0 +1,80 @@
1
+ export async function getTailscaleStatus() {
2
+ try {
3
+ const proc = Bun.spawn(['tailscale', 'status', '--json'], {
4
+ stdout: 'pipe',
5
+ stderr: 'pipe',
6
+ });
7
+ const output = await new Response(proc.stdout).text();
8
+ const exitCode = await proc.exited;
9
+ if (exitCode !== 0) {
10
+ return { running: false, httpsEnabled: false };
11
+ }
12
+ const status = JSON.parse(output);
13
+ if (status.BackendState !== 'Running') {
14
+ return { running: false, httpsEnabled: false };
15
+ }
16
+ const dnsName = status.Self?.DNSName?.replace(/\.$/, '');
17
+ const tailnetName = dnsName?.split('.').slice(1).join('.');
18
+ const ipv4 = status.Self?.TailscaleIPs?.find((ip) => !ip.includes(':'));
19
+ const httpsEnabled = (status.CertDomains?.length ?? 0) > 0;
20
+ return {
21
+ running: true,
22
+ dnsName,
23
+ tailnetName,
24
+ ipv4,
25
+ httpsEnabled,
26
+ };
27
+ }
28
+ catch {
29
+ return { running: false, httpsEnabled: false };
30
+ }
31
+ }
32
+ export async function startTailscaleServe(port) {
33
+ try {
34
+ const proc = Bun.spawn(['tailscale', 'serve', '--bg', String(port)], {
35
+ stdout: 'pipe',
36
+ stderr: 'pipe',
37
+ });
38
+ const stderr = await new Response(proc.stderr).text();
39
+ const exitCode = await proc.exited;
40
+ if (exitCode === 0) {
41
+ return { success: true };
42
+ }
43
+ if (stderr.includes('Access denied') || stderr.includes('serve config denied')) {
44
+ return {
45
+ success: false,
46
+ error: 'permission_denied',
47
+ message: 'Run: sudo tailscale set --operator=$USER',
48
+ };
49
+ }
50
+ return { success: false, error: 'unknown', message: stderr.trim() };
51
+ }
52
+ catch (err) {
53
+ return { success: false, error: 'unknown', message: String(err) };
54
+ }
55
+ }
56
+ export async function stopTailscaleServe() {
57
+ try {
58
+ const proc = Bun.spawn(['tailscale', 'serve', 'off'], {
59
+ stdout: 'pipe',
60
+ stderr: 'pipe',
61
+ });
62
+ const exitCode = await proc.exited;
63
+ return exitCode === 0;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ export function getTailscaleIdentity(req) {
70
+ const login = req.headers['tailscale-user-login'];
71
+ const name = req.headers['tailscale-user-name'];
72
+ const pic = req.headers['tailscale-user-profile-pic'];
73
+ if (!login)
74
+ return null;
75
+ return {
76
+ email: Array.isArray(login) ? login[0] : login,
77
+ name: Array.isArray(name) ? name[0] : name,
78
+ profilePic: Array.isArray(pic) ? pic[0] : pic,
79
+ };
80
+ }
@@ -282,10 +282,30 @@ export class WorkspaceManager {
282
282
  await this.setupClaudeCodeConfig(containerName);
283
283
  await this.copyCodexCredentials(containerName);
284
284
  await this.setupOpencodeConfig(containerName);
285
+ await this.copyPerryWorker(containerName);
285
286
  if (workspaceName) {
286
287
  await this.setupSSHKeys(containerName, workspaceName);
287
288
  }
288
289
  }
290
+ async copyPerryWorker(containerName) {
291
+ const distDir = path.dirname(new URL(import.meta.url).pathname);
292
+ const workerBinaryPath = path.join(distDir, '..', 'perry-worker');
293
+ try {
294
+ await fs.access(workerBinaryPath);
295
+ }
296
+ catch {
297
+ console.warn(`[sync] perry-worker binary not found at ${workerBinaryPath}, session discovery may not work`);
298
+ return;
299
+ }
300
+ const destPath = '/usr/local/bin/perry';
301
+ await docker.copyToContainer(containerName, workerBinaryPath, destPath);
302
+ await docker.execInContainer(containerName, ['chown', 'root:root', destPath], {
303
+ user: 'root',
304
+ });
305
+ await docker.execInContainer(containerName, ['chmod', '755', destPath], {
306
+ user: 'root',
307
+ });
308
+ }
289
309
  async runPostStartScript(containerName) {
290
310
  const scriptPath = this.config.scripts.post_start;
291
311
  if (!scriptPath) {
@@ -388,6 +408,10 @@ export class WorkspaceManager {
388
408
  if (clone) {
389
409
  containerEnv.WORKSPACE_REPO_URL = clone;
390
410
  }
411
+ const dockerVolumeName = `${VOLUME_PREFIX}${name}-docker`;
412
+ if (!(await docker.volumeExists(dockerVolumeName))) {
413
+ await docker.createVolume(dockerVolumeName);
414
+ }
391
415
  const containerId = await docker.createContainer({
392
416
  name: containerName,
393
417
  image: workspaceImage,
@@ -395,7 +419,10 @@ export class WorkspaceManager {
395
419
  privileged: true,
396
420
  restartPolicy: 'unless-stopped',
397
421
  env: containerEnv,
398
- volumes: [{ source: volumeName, target: '/home/workspace', readonly: false }],
422
+ volumes: [
423
+ { source: volumeName, target: '/home/workspace', readonly: false },
424
+ { source: dockerVolumeName, target: '/var/lib/docker', readonly: false },
425
+ ],
399
426
  ports: [{ hostPort: sshPort, containerPort: 22, protocol: 'tcp' }],
400
427
  labels: {
401
428
  'workspace.name': name,
@@ -451,6 +478,10 @@ export class WorkspaceManager {
451
478
  if (workspace.repo) {
452
479
  containerEnv.WORKSPACE_REPO_URL = workspace.repo;
453
480
  }
481
+ const dockerVolumeName = `${VOLUME_PREFIX}${name}-docker`;
482
+ if (!(await docker.volumeExists(dockerVolumeName))) {
483
+ await docker.createVolume(dockerVolumeName);
484
+ }
454
485
  const containerId = await docker.createContainer({
455
486
  name: containerName,
456
487
  image: workspaceImage,
@@ -458,7 +489,10 @@ export class WorkspaceManager {
458
489
  privileged: true,
459
490
  restartPolicy: 'unless-stopped',
460
491
  env: containerEnv,
461
- volumes: [{ source: volumeName, target: '/home/workspace', readonly: false }],
492
+ volumes: [
493
+ { source: volumeName, target: '/home/workspace', readonly: false },
494
+ { source: dockerVolumeName, target: '/var/lib/docker', readonly: false },
495
+ ],
462
496
  ports: [{ hostPort: sshPort, containerPort: 22, protocol: 'tcp' }],
463
497
  labels: {
464
498
  'workspace.name': name,
@@ -515,12 +549,16 @@ export class WorkspaceManager {
515
549
  }
516
550
  const containerName = getContainerName(name);
517
551
  const volumeName = `${VOLUME_PREFIX}${name}`;
552
+ const dockerVolumeName = `${VOLUME_PREFIX}${name}-docker`;
518
553
  if (await docker.containerExists(containerName)) {
519
554
  await docker.removeContainer(containerName, true);
520
555
  }
521
556
  if (await docker.volumeExists(volumeName)) {
522
557
  await docker.removeVolume(volumeName, true);
523
558
  }
559
+ if (await docker.volumeExists(dockerVolumeName)) {
560
+ await docker.removeVolume(dockerVolumeName, true);
561
+ }
524
562
  await this.state.deleteWorkspace(name);
525
563
  }
526
564
  async exec(name, command) {