@gricha/perry 0.2.2 → 0.2.4
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 +11 -0
- package/dist/agent/file-watcher.js +134 -0
- package/dist/agent/router.js +48 -2
- package/dist/agent/run.js +44 -4
- package/dist/agent/web/assets/index-BmFYrCoX.css +1 -0
- package/dist/agent/web/assets/index-IavvQP8G.js +104 -0
- package/dist/agent/web/index.html +2 -2
- package/dist/agents/__tests__/claude-code.test.js +125 -0
- package/dist/agents/__tests__/codex.test.js +64 -0
- package/dist/agents/__tests__/opencode.test.js +130 -0
- package/dist/agents/__tests__/sync.test.js +272 -0
- package/dist/agents/index.js +177 -0
- package/dist/agents/sync/claude-code.js +84 -0
- package/dist/agents/sync/codex.js +29 -0
- package/dist/agents/sync/copier.js +89 -0
- package/dist/agents/sync/opencode.js +51 -0
- package/dist/agents/sync/types.js +1 -0
- package/dist/agents/types.js +1 -0
- package/dist/chat/base-chat-websocket.js +2 -2
- package/dist/chat/base-claude-session.js +169 -0
- package/dist/chat/base-opencode-session.js +181 -0
- package/dist/chat/handler.js +14 -157
- package/dist/chat/host-handler.js +13 -142
- package/dist/chat/host-opencode-handler.js +28 -187
- package/dist/chat/opencode-handler.js +38 -197
- package/dist/chat/opencode-websocket.js +1 -1
- package/dist/chat/types.js +1 -0
- package/dist/chat/websocket.js +2 -2
- package/dist/client/api.js +25 -0
- package/dist/config/loader.js +20 -2
- package/dist/docker/eager-pull.js +19 -3
- package/dist/docker/index.js +28 -1
- package/dist/index.js +83 -12
- package/dist/perry-worker +0 -0
- package/dist/shared/constants.js +1 -0
- package/dist/shared/types.js +0 -1
- package/dist/terminal/websocket.js +1 -1
- package/dist/workspace/manager.js +178 -115
- package/package.json +4 -3
- package/dist/agent/web/assets/index-DIOWcVH-.css +0 -1
- package/dist/agent/web/assets/index-DN_QW9sL.js +0 -104
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { claudeProvider, opencodeProvider, codexProvider } from '../sessions/agents';
|
|
3
|
+
import { claudeCodeSync } from './sync/claude-code';
|
|
4
|
+
import { opencodeSync } from './sync/opencode';
|
|
5
|
+
import { codexSync } from './sync/codex';
|
|
6
|
+
import { createDockerFileCopier } from './sync/copier';
|
|
7
|
+
import { expandPath } from '../config/loader';
|
|
8
|
+
import * as docker from '../docker';
|
|
9
|
+
export const agents = {
|
|
10
|
+
'claude-code': {
|
|
11
|
+
agentType: 'claude-code',
|
|
12
|
+
sync: claudeCodeSync,
|
|
13
|
+
sessions: claudeProvider,
|
|
14
|
+
},
|
|
15
|
+
opencode: {
|
|
16
|
+
agentType: 'opencode',
|
|
17
|
+
sync: opencodeSync,
|
|
18
|
+
sessions: opencodeProvider,
|
|
19
|
+
},
|
|
20
|
+
codex: {
|
|
21
|
+
agentType: 'codex',
|
|
22
|
+
sync: codexSync,
|
|
23
|
+
sessions: codexProvider,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
export function createSyncContext(containerName, agentConfig) {
|
|
27
|
+
return {
|
|
28
|
+
containerName,
|
|
29
|
+
agentConfig,
|
|
30
|
+
async hostFileExists(filePath) {
|
|
31
|
+
try {
|
|
32
|
+
const expanded = expandPath(filePath);
|
|
33
|
+
const stat = await fs.stat(expanded);
|
|
34
|
+
return stat.isFile();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
async hostDirExists(dirPath) {
|
|
41
|
+
try {
|
|
42
|
+
const expanded = expandPath(dirPath);
|
|
43
|
+
const stat = await fs.stat(expanded);
|
|
44
|
+
return stat.isDirectory();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
async readHostFile(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
const expanded = expandPath(filePath);
|
|
53
|
+
return await fs.readFile(expanded, 'utf-8');
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
async readContainerFile(filePath) {
|
|
60
|
+
try {
|
|
61
|
+
const result = await docker.execInContainer(containerName, ['cat', filePath], {
|
|
62
|
+
user: 'workspace',
|
|
63
|
+
});
|
|
64
|
+
if (result.exitCode !== 0) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return result.stdout;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export async function syncAgent(provider, context, copier) {
|
|
76
|
+
const result = {
|
|
77
|
+
copied: [],
|
|
78
|
+
generated: [],
|
|
79
|
+
skipped: [],
|
|
80
|
+
errors: [],
|
|
81
|
+
};
|
|
82
|
+
for (const dir of provider.getRequiredDirs()) {
|
|
83
|
+
try {
|
|
84
|
+
await copier.ensureDir(context.containerName, dir);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
result.errors.push({ path: dir, error: String(err) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const files = await provider.getFilesToSync(context);
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const exists = await context.hostFileExists(file.source);
|
|
93
|
+
if (!exists) {
|
|
94
|
+
if (file.optional) {
|
|
95
|
+
result.skipped.push(file.source);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
result.errors.push({ path: file.source, error: 'File not found' });
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
await copier.copyFile(context.containerName, file);
|
|
104
|
+
result.copied.push(file.source);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
result.errors.push({ path: file.source, error: String(err) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const directories = await provider.getDirectoriesToSync(context);
|
|
111
|
+
for (const dir of directories) {
|
|
112
|
+
const exists = await context.hostDirExists(dir.source);
|
|
113
|
+
if (!exists) {
|
|
114
|
+
if (dir.optional) {
|
|
115
|
+
result.skipped.push(dir.source);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
result.errors.push({ path: dir.source, error: 'Directory not found' });
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
await copier.copyDirectory(context.containerName, dir);
|
|
124
|
+
result.copied.push(dir.source);
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
result.errors.push({ path: dir.source, error: String(err) });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const configs = await provider.getGeneratedConfigs(context);
|
|
131
|
+
for (const config of configs) {
|
|
132
|
+
try {
|
|
133
|
+
await copier.writeConfig(context.containerName, config);
|
|
134
|
+
result.generated.push(config.dest);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
result.errors.push({ path: config.dest, error: String(err) });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
export async function syncAllAgents(containerName, agentConfig, copier) {
|
|
143
|
+
const actualCopier = copier || createDockerFileCopier();
|
|
144
|
+
const context = createSyncContext(containerName, agentConfig);
|
|
145
|
+
const results = {
|
|
146
|
+
'claude-code': { copied: [], generated: [], skipped: [], errors: [] },
|
|
147
|
+
opencode: { copied: [], generated: [], skipped: [], errors: [] },
|
|
148
|
+
codex: { copied: [], generated: [], skipped: [], errors: [] },
|
|
149
|
+
};
|
|
150
|
+
for (const [agentType, agent] of Object.entries(agents)) {
|
|
151
|
+
results[agentType] = await syncAgent(agent.sync, context, actualCopier);
|
|
152
|
+
}
|
|
153
|
+
return results;
|
|
154
|
+
}
|
|
155
|
+
export function getCredentialFilePaths() {
|
|
156
|
+
const paths = [];
|
|
157
|
+
for (const agent of Object.values(agents)) {
|
|
158
|
+
const dummyContext = {
|
|
159
|
+
containerName: '',
|
|
160
|
+
agentConfig: { port: 0, credentials: { env: {}, files: {} }, scripts: {} },
|
|
161
|
+
hostFileExists: async () => false,
|
|
162
|
+
hostDirExists: async () => false,
|
|
163
|
+
readHostFile: async () => null,
|
|
164
|
+
readContainerFile: async () => null,
|
|
165
|
+
};
|
|
166
|
+
const filesPromise = agent.sync.getFilesToSync(dummyContext);
|
|
167
|
+
filesPromise.then((files) => {
|
|
168
|
+
for (const file of files) {
|
|
169
|
+
if (file.category === 'credential') {
|
|
170
|
+
paths.push(file.source);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return ['~/.claude/.credentials.json', '~/.codex/auth.json'];
|
|
176
|
+
}
|
|
177
|
+
export { createDockerFileCopier, createMockFileCopier } from './sync/copier';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const claudeCodeSync = {
|
|
2
|
+
getRequiredDirs() {
|
|
3
|
+
return ['/home/workspace/.claude'];
|
|
4
|
+
},
|
|
5
|
+
async getFilesToSync(_context) {
|
|
6
|
+
return [
|
|
7
|
+
{
|
|
8
|
+
source: '~/.claude/.credentials.json',
|
|
9
|
+
dest: '/home/workspace/.claude/.credentials.json',
|
|
10
|
+
category: 'credential',
|
|
11
|
+
permissions: '600',
|
|
12
|
+
optional: true,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
source: '~/.claude/settings.json',
|
|
16
|
+
dest: '/home/workspace/.claude/settings.json',
|
|
17
|
+
category: 'preference',
|
|
18
|
+
permissions: '644',
|
|
19
|
+
optional: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
source: '~/.claude/CLAUDE.md',
|
|
23
|
+
dest: '/home/workspace/.claude/CLAUDE.md',
|
|
24
|
+
category: 'preference',
|
|
25
|
+
permissions: '644',
|
|
26
|
+
optional: true,
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
},
|
|
30
|
+
async getDirectoriesToSync(context) {
|
|
31
|
+
const agentsDirExists = await context.hostDirExists('~/.claude/agents');
|
|
32
|
+
if (!agentsDirExists) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
source: '~/.claude/agents',
|
|
38
|
+
dest: '/home/workspace/.claude/agents',
|
|
39
|
+
category: 'preference',
|
|
40
|
+
optional: true,
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
},
|
|
44
|
+
async getGeneratedConfigs(context) {
|
|
45
|
+
const hostConfigContent = await context.readHostFile('~/.claude.json');
|
|
46
|
+
const containerConfigContent = await context.readContainerFile('/home/workspace/.claude.json');
|
|
47
|
+
let hostMcpServers = {};
|
|
48
|
+
if (hostConfigContent) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(hostConfigContent);
|
|
51
|
+
if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
|
|
52
|
+
hostMcpServers = parsed.mcpServers;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Invalid JSON, ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let containerConfig = {};
|
|
60
|
+
if (containerConfigContent) {
|
|
61
|
+
try {
|
|
62
|
+
containerConfig = JSON.parse(containerConfigContent);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Invalid JSON, start fresh
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
containerConfig.hasCompletedOnboarding = true;
|
|
69
|
+
if (Object.keys(hostMcpServers).length > 0) {
|
|
70
|
+
const existingMcp = containerConfig.mcpServers && typeof containerConfig.mcpServers === 'object'
|
|
71
|
+
? containerConfig.mcpServers
|
|
72
|
+
: {};
|
|
73
|
+
containerConfig.mcpServers = { ...existingMcp, ...hostMcpServers };
|
|
74
|
+
}
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
dest: '/home/workspace/.claude.json',
|
|
78
|
+
content: JSON.stringify(containerConfig, null, 2),
|
|
79
|
+
permissions: '644',
|
|
80
|
+
category: 'preference',
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const codexSync = {
|
|
2
|
+
getRequiredDirs() {
|
|
3
|
+
return ['/home/workspace/.codex'];
|
|
4
|
+
},
|
|
5
|
+
async getFilesToSync(_context) {
|
|
6
|
+
return [
|
|
7
|
+
{
|
|
8
|
+
source: '~/.codex/auth.json',
|
|
9
|
+
dest: '/home/workspace/.codex/auth.json',
|
|
10
|
+
category: 'credential',
|
|
11
|
+
permissions: '600',
|
|
12
|
+
optional: true,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
source: '~/.codex/config.toml',
|
|
16
|
+
dest: '/home/workspace/.codex/config.toml',
|
|
17
|
+
category: 'preference',
|
|
18
|
+
permissions: '600',
|
|
19
|
+
optional: true,
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
},
|
|
23
|
+
async getDirectoriesToSync(_context) {
|
|
24
|
+
return [];
|
|
25
|
+
},
|
|
26
|
+
async getGeneratedConfigs(_context) {
|
|
27
|
+
return [];
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import * as docker from '../../docker';
|
|
5
|
+
import { expandPath } from '../../config/loader';
|
|
6
|
+
export function createDockerFileCopier() {
|
|
7
|
+
return {
|
|
8
|
+
async ensureDir(containerName, dir) {
|
|
9
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', dir], {
|
|
10
|
+
user: 'workspace',
|
|
11
|
+
});
|
|
12
|
+
},
|
|
13
|
+
async copyFile(containerName, file) {
|
|
14
|
+
const expandedSource = expandPath(file.source);
|
|
15
|
+
const permissions = file.permissions || '644';
|
|
16
|
+
const destDir = path.dirname(file.dest);
|
|
17
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', destDir], {
|
|
18
|
+
user: 'workspace',
|
|
19
|
+
});
|
|
20
|
+
await docker.copyToContainer(containerName, expandedSource, file.dest);
|
|
21
|
+
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', file.dest], {
|
|
22
|
+
user: 'root',
|
|
23
|
+
});
|
|
24
|
+
await docker.execInContainer(containerName, ['chmod', permissions, file.dest], {
|
|
25
|
+
user: 'workspace',
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
async copyDirectory(containerName, dir) {
|
|
29
|
+
const expandedSource = expandPath(dir.source);
|
|
30
|
+
const tempTar = path.join(os.tmpdir(), `agent-sync-${Date.now()}.tar`);
|
|
31
|
+
try {
|
|
32
|
+
const { execSync } = await import('child_process');
|
|
33
|
+
execSync(`tar -cf "${tempTar}" -C "${expandedSource}" .`, { stdio: 'pipe' });
|
|
34
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', dir.dest], {
|
|
35
|
+
user: 'workspace',
|
|
36
|
+
});
|
|
37
|
+
await docker.copyToContainer(containerName, tempTar, '/tmp/agent-sync.tar');
|
|
38
|
+
await docker.execInContainer(containerName, ['tar', '-xf', '/tmp/agent-sync.tar', '-C', dir.dest], { user: 'workspace' });
|
|
39
|
+
await docker.execInContainer(containerName, ['rm', '/tmp/agent-sync.tar'], {
|
|
40
|
+
user: 'workspace',
|
|
41
|
+
});
|
|
42
|
+
await docker.execInContainer(containerName, ['find', dir.dest, '-type', 'f', '-exec', 'chmod', '644', '{}', '+'], { user: 'workspace' });
|
|
43
|
+
await docker.execInContainer(containerName, ['find', dir.dest, '-type', 'd', '-exec', 'chmod', '755', '{}', '+'], { user: 'workspace' });
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await fs.unlink(tempTar).catch(() => { });
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async writeConfig(containerName, config) {
|
|
50
|
+
const tempFile = path.join(os.tmpdir(), `agent-config-${Date.now()}.json`);
|
|
51
|
+
const permissions = config.permissions || '644';
|
|
52
|
+
await fs.writeFile(tempFile, config.content, 'utf-8');
|
|
53
|
+
try {
|
|
54
|
+
const destDir = path.dirname(config.dest);
|
|
55
|
+
await docker.execInContainer(containerName, ['mkdir', '-p', destDir], {
|
|
56
|
+
user: 'workspace',
|
|
57
|
+
});
|
|
58
|
+
await docker.copyToContainer(containerName, tempFile, config.dest);
|
|
59
|
+
await docker.execInContainer(containerName, ['chown', 'workspace:workspace', config.dest], {
|
|
60
|
+
user: 'root',
|
|
61
|
+
});
|
|
62
|
+
await docker.execInContainer(containerName, ['chmod', permissions, config.dest], {
|
|
63
|
+
user: 'workspace',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await fs.unlink(tempFile).catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function createMockFileCopier() {
|
|
73
|
+
const calls = [];
|
|
74
|
+
return {
|
|
75
|
+
calls,
|
|
76
|
+
async ensureDir(containerName, dir) {
|
|
77
|
+
calls.push({ method: 'ensureDir', args: [containerName, dir] });
|
|
78
|
+
},
|
|
79
|
+
async copyFile(containerName, file) {
|
|
80
|
+
calls.push({ method: 'copyFile', args: [containerName, file] });
|
|
81
|
+
},
|
|
82
|
+
async copyDirectory(containerName, dir) {
|
|
83
|
+
calls.push({ method: 'copyDirectory', args: [containerName, dir] });
|
|
84
|
+
},
|
|
85
|
+
async writeConfig(containerName, config) {
|
|
86
|
+
calls.push({ method: 'writeConfig', args: [containerName, config] });
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const opencodeSync = {
|
|
2
|
+
getRequiredDirs() {
|
|
3
|
+
return ['/home/workspace/.config/opencode'];
|
|
4
|
+
},
|
|
5
|
+
async getFilesToSync(_context) {
|
|
6
|
+
return [];
|
|
7
|
+
},
|
|
8
|
+
async getDirectoriesToSync(_context) {
|
|
9
|
+
return [];
|
|
10
|
+
},
|
|
11
|
+
async getGeneratedConfigs(context) {
|
|
12
|
+
const zenToken = context.agentConfig.agents?.opencode?.zen_token;
|
|
13
|
+
if (!zenToken) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
const hostConfigContent = await context.readHostFile('~/.config/opencode/opencode.json');
|
|
17
|
+
let mcpConfig = {};
|
|
18
|
+
if (hostConfigContent) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(hostConfigContent);
|
|
21
|
+
if (parsed.mcp && typeof parsed.mcp === 'object') {
|
|
22
|
+
mcpConfig = parsed.mcp;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Invalid JSON, ignore
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const config = {
|
|
30
|
+
provider: {
|
|
31
|
+
opencode: {
|
|
32
|
+
options: {
|
|
33
|
+
apiKey: zenToken,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
model: 'opencode/claude-sonnet-4',
|
|
38
|
+
};
|
|
39
|
+
if (Object.keys(mcpConfig).length > 0) {
|
|
40
|
+
config.mcp = mcpConfig;
|
|
41
|
+
}
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
dest: '/home/workspace/.config/opencode/opencode.json',
|
|
45
|
+
content: JSON.stringify(config, null, 2),
|
|
46
|
+
permissions: '600',
|
|
47
|
+
category: 'credential',
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BaseWebSocketServer, safeSend } from '../shared/base-websocket';
|
|
2
2
|
import { getContainerName } from '../docker';
|
|
3
|
-
import { HOST_WORKSPACE_NAME } from '../shared/types';
|
|
3
|
+
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
|
|
4
4
|
export class BaseChatWebSocketServer extends BaseWebSocketServer {
|
|
5
5
|
isHostAccessAllowed;
|
|
6
6
|
constructor(options) {
|
|
@@ -41,7 +41,7 @@ export class BaseChatWebSocketServer extends BaseWebSocketServer {
|
|
|
41
41
|
};
|
|
42
42
|
if (!connection.session) {
|
|
43
43
|
if (isHostMode) {
|
|
44
|
-
connection.session = this.createHostSession(message.sessionId, onMessage, message.model);
|
|
44
|
+
connection.session = this.createHostSession(message.sessionId, onMessage, message.model, message.projectPath);
|
|
45
45
|
}
|
|
46
46
|
else {
|
|
47
47
|
const containerName = getContainerName(workspaceName);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { DEFAULT_CLAUDE_MODEL } from '../shared/constants';
|
|
2
|
+
export class BaseClaudeSession {
|
|
3
|
+
process = null;
|
|
4
|
+
sessionId;
|
|
5
|
+
model;
|
|
6
|
+
sessionModel;
|
|
7
|
+
onMessage;
|
|
8
|
+
buffer = '';
|
|
9
|
+
constructor(sessionId, model, onMessage) {
|
|
10
|
+
this.sessionId = sessionId;
|
|
11
|
+
this.model = model || DEFAULT_CLAUDE_MODEL;
|
|
12
|
+
this.sessionModel = this.model;
|
|
13
|
+
this.onMessage = onMessage;
|
|
14
|
+
}
|
|
15
|
+
async sendMessage(userMessage) {
|
|
16
|
+
const logPrefix = this.getLogPrefix();
|
|
17
|
+
const { command, options } = this.getSpawnConfig(userMessage);
|
|
18
|
+
console.log(`[${logPrefix}] Running:`, command.join(' '));
|
|
19
|
+
this.onMessage({
|
|
20
|
+
type: 'system',
|
|
21
|
+
content: 'Processing your message...',
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
const proc = Bun.spawn(command, {
|
|
26
|
+
stdin: 'ignore',
|
|
27
|
+
stdout: 'pipe',
|
|
28
|
+
stderr: 'pipe',
|
|
29
|
+
...options,
|
|
30
|
+
});
|
|
31
|
+
this.process = proc;
|
|
32
|
+
if (!proc.stdout || !proc.stderr) {
|
|
33
|
+
throw new Error('Failed to get process streams');
|
|
34
|
+
}
|
|
35
|
+
console.log(`[${logPrefix}] Process spawned, waiting for output...`);
|
|
36
|
+
const stderrPromise = new Response(proc.stderr).text();
|
|
37
|
+
const decoder = new TextDecoder();
|
|
38
|
+
let receivedAnyOutput = false;
|
|
39
|
+
for await (const chunk of proc.stdout) {
|
|
40
|
+
const text = decoder.decode(chunk);
|
|
41
|
+
console.log(`[${logPrefix}] Received chunk:`, text.length, 'bytes');
|
|
42
|
+
receivedAnyOutput = true;
|
|
43
|
+
this.buffer += text;
|
|
44
|
+
this.processBuffer();
|
|
45
|
+
}
|
|
46
|
+
const exitCode = await proc.exited;
|
|
47
|
+
console.log(`[${logPrefix}] Process exited with code:`, exitCode, 'receivedOutput:', receivedAnyOutput);
|
|
48
|
+
const stderrText = await stderrPromise;
|
|
49
|
+
if (stderrText) {
|
|
50
|
+
console.error(`[${logPrefix}] stderr:`, stderrText);
|
|
51
|
+
}
|
|
52
|
+
if (exitCode !== 0) {
|
|
53
|
+
this.onMessage({
|
|
54
|
+
type: 'error',
|
|
55
|
+
content: stderrText || `Claude exited with code ${exitCode}`,
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!receivedAnyOutput) {
|
|
61
|
+
this.onMessage({
|
|
62
|
+
type: 'error',
|
|
63
|
+
content: this.getNoOutputErrorMessage(),
|
|
64
|
+
timestamp: new Date().toISOString(),
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.onMessage({
|
|
69
|
+
type: 'done',
|
|
70
|
+
content: 'Response complete',
|
|
71
|
+
timestamp: new Date().toISOString(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
console.error(`[${logPrefix}] Error:`, err);
|
|
76
|
+
this.onMessage({
|
|
77
|
+
type: 'error',
|
|
78
|
+
content: err.message,
|
|
79
|
+
timestamp: new Date().toISOString(),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
this.process = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
getNoOutputErrorMessage() {
|
|
87
|
+
return 'No response from Claude. Check if Claude is authenticated.';
|
|
88
|
+
}
|
|
89
|
+
processBuffer() {
|
|
90
|
+
const lines = this.buffer.split('\n');
|
|
91
|
+
this.buffer = lines.pop() || '';
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
if (!line.trim())
|
|
94
|
+
continue;
|
|
95
|
+
try {
|
|
96
|
+
const msg = JSON.parse(line);
|
|
97
|
+
this.handleStreamMessage(msg);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
console.error(`[${this.getLogPrefix()}] Failed to parse:`, line);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
handleStreamMessage(msg) {
|
|
105
|
+
const timestamp = new Date().toISOString();
|
|
106
|
+
if (msg.type === 'system' && msg.subtype === 'init') {
|
|
107
|
+
this.sessionId = msg.session_id;
|
|
108
|
+
this.sessionModel = this.model;
|
|
109
|
+
this.onMessage({
|
|
110
|
+
type: 'system',
|
|
111
|
+
content: `Session started: ${msg.session_id?.slice(0, 8)}...`,
|
|
112
|
+
timestamp,
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (msg.type === 'assistant' && msg.message?.content) {
|
|
117
|
+
for (const block of msg.message.content) {
|
|
118
|
+
if (block.type === 'tool_use') {
|
|
119
|
+
this.onMessage({
|
|
120
|
+
type: 'tool_use',
|
|
121
|
+
content: JSON.stringify(block.input, null, 2),
|
|
122
|
+
toolName: block.name,
|
|
123
|
+
toolId: block.id,
|
|
124
|
+
timestamp,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (msg.type === 'stream_event' && msg.event?.type === 'content_block_delta') {
|
|
131
|
+
const delta = msg.event?.delta;
|
|
132
|
+
if (delta?.type === 'text_delta' && delta?.text) {
|
|
133
|
+
this.onMessage({
|
|
134
|
+
type: 'assistant',
|
|
135
|
+
content: delta.text,
|
|
136
|
+
timestamp,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async interrupt() {
|
|
143
|
+
if (this.process) {
|
|
144
|
+
this.process.kill();
|
|
145
|
+
this.process = null;
|
|
146
|
+
this.onMessage({
|
|
147
|
+
type: 'system',
|
|
148
|
+
content: 'Chat interrupted',
|
|
149
|
+
timestamp: new Date().toISOString(),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
setModel(model) {
|
|
154
|
+
if (this.model !== model) {
|
|
155
|
+
this.model = model;
|
|
156
|
+
if (this.sessionModel !== model) {
|
|
157
|
+
this.sessionId = undefined;
|
|
158
|
+
this.onMessage({
|
|
159
|
+
type: 'system',
|
|
160
|
+
content: `Switching to model: ${model}`,
|
|
161
|
+
timestamp: new Date().toISOString(),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
getSessionId() {
|
|
167
|
+
return this.sessionId;
|
|
168
|
+
}
|
|
169
|
+
}
|