@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 +18 -2
- package/dist/agent/router.js +226 -87
- package/dist/agent/run.js +53 -4
- package/dist/agent/web/assets/index-DIOWcVH-.css +1 -0
- package/dist/agent/web/assets/index-DN_QW9sL.js +104 -0
- package/dist/agent/web/index.html +2 -2
- package/dist/index.js +42 -0
- package/dist/perry-worker +0 -0
- package/dist/sessions/agents/claude.js +19 -0
- package/dist/sessions/agents/codex.js +40 -0
- package/dist/sessions/agents/index.js +63 -0
- package/dist/sessions/agents/opencode-storage.js +218 -0
- package/dist/sessions/agents/opencode.js +17 -3
- package/dist/sessions/cache.js +5 -0
- package/dist/shared/constants.js +1 -1
- package/dist/tailscale/index.js +80 -0
- package/dist/workspace/manager.js +40 -2
- package/package.json +3 -2
- package/dist/agent/web/assets/index-CGJDysKS.css +0 -1
- package/dist/agent/web/assets/index-CwCl9DVw.js +0 -104
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
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
|
|
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
|
|
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
|
};
|
package/dist/sessions/cache.js
CHANGED
|
@@ -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
|
}
|
package/dist/shared/constants.js
CHANGED
|
@@ -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 = '
|
|
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: [
|
|
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: [
|
|
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) {
|