@gricha/perry 0.2.3 → 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.
@@ -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 {};
@@ -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);
@@ -15,7 +15,7 @@ export class OpencodeWebSocketServer extends BaseChatWebSocketServer {
15
15
  workspaceName,
16
16
  };
17
17
  }
18
- createHostSession(sessionId, onMessage, messageModel) {
18
+ createHostSession(sessionId, onMessage, messageModel, _projectPath) {
19
19
  const model = messageModel || this.getConfig?.()?.agents?.opencode?.model;
20
20
  return createHostOpencodeSession({ sessionId, model }, onMessage);
21
21
  }
@@ -15,10 +15,10 @@ export class ChatWebSocketServer extends BaseChatWebSocketServer {
15
15
  workspaceName,
16
16
  };
17
17
  }
18
- createHostSession(sessionId, onMessage, messageModel) {
18
+ createHostSession(sessionId, onMessage, messageModel, projectPath) {
19
19
  const config = this.getConfig();
20
20
  const model = messageModel || config.agents?.claude_code?.model;
21
- return createHostChatSession({ sessionId, model }, onMessage);
21
+ return createHostChatSession({ sessionId, model, workDir: projectPath }, onMessage);
22
22
  }
23
23
  createContainerSession(containerName, sessionId, onMessage, messageModel) {
24
24
  const config = this.getConfig();
@@ -98,6 +98,31 @@ export class ApiClient {
98
98
  throw this.wrapError(err);
99
99
  }
100
100
  }
101
+ async getPortForwards(name) {
102
+ try {
103
+ const result = await this.client.workspaces.getPortForwards({ name });
104
+ return result.forwards;
105
+ }
106
+ catch (err) {
107
+ throw this.wrapError(err);
108
+ }
109
+ }
110
+ async setPortForwards(name, forwards) {
111
+ try {
112
+ return await this.client.workspaces.setPortForwards({ name, forwards });
113
+ }
114
+ catch (err) {
115
+ throw this.wrapError(err);
116
+ }
117
+ }
118
+ async cloneWorkspace(sourceName, cloneName) {
119
+ try {
120
+ return await this.client.workspaces.clone({ sourceName, cloneName });
121
+ }
122
+ catch (err) {
123
+ throw this.wrapError(err);
124
+ }
125
+ }
101
126
  getTerminalUrl(name) {
102
127
  const wsUrl = this.baseUrl.replace(/^http/, 'ws');
103
128
  return `${wsUrl}/rpc/terminal/${encodeURIComponent(name)}`;
@@ -16,7 +16,10 @@ export function createDefaultAgentConfig() {
16
16
  env: {},
17
17
  files: {},
18
18
  },
19
- scripts: {},
19
+ scripts: {
20
+ post_start: ['~/.perry/userscripts'],
21
+ fail_on_error: false,
22
+ },
20
23
  agents: {},
21
24
  allowHostAccess: true,
22
25
  ssh: {
@@ -29,6 +32,18 @@ export function createDefaultAgentConfig() {
29
32
  },
30
33
  };
31
34
  }
35
+ function migratePostStart(value) {
36
+ if (!value) {
37
+ return ['~/.perry/userscripts'];
38
+ }
39
+ if (typeof value === 'string') {
40
+ return [value, '~/.perry/userscripts'];
41
+ }
42
+ if (Array.isArray(value)) {
43
+ return value.length > 0 ? value : ['~/.perry/userscripts'];
44
+ }
45
+ return ['~/.perry/userscripts'];
46
+ }
32
47
  export async function loadAgentConfig(configDir) {
33
48
  const dir = getConfigDir(configDir);
34
49
  const configPath = path.join(dir, CONFIG_FILE);
@@ -41,7 +56,10 @@ export async function loadAgentConfig(configDir) {
41
56
  env: config.credentials?.env || {},
42
57
  files: config.credentials?.files || {},
43
58
  },
44
- scripts: config.scripts || {},
59
+ scripts: {
60
+ post_start: migratePostStart(config.scripts?.post_start),
61
+ fail_on_error: config.scripts?.fail_on_error ?? false,
62
+ },
45
63
  agents: config.agents || {},
46
64
  allowHostAccess: config.allowHostAccess ?? true,
47
65
  ssh: {
@@ -5,6 +5,7 @@ const RETRY_INTERVAL_MS = 20000;
5
5
  const MAX_RETRIES = 10;
6
6
  let pullInProgress = false;
7
7
  let pullComplete = false;
8
+ let abortController = null;
8
9
  async function isDockerAvailable() {
9
10
  try {
10
11
  await getDockerVersion();
@@ -35,7 +36,13 @@ export async function startEagerImagePull() {
35
36
  return;
36
37
  }
37
38
  pullInProgress = true;
39
+ abortController = new AbortController();
40
+ const signal = abortController.signal;
38
41
  const attemptPull = async (attempt) => {
42
+ if (signal.aborted) {
43
+ pullInProgress = false;
44
+ return;
45
+ }
39
46
  if (attempt > MAX_RETRIES) {
40
47
  console.log('[agent] Max retries reached for image pull - giving up background pull');
41
48
  pullInProgress = false;
@@ -46,7 +53,8 @@ export async function startEagerImagePull() {
46
53
  if (attempt === 1) {
47
54
  console.log('[agent] Docker not available - will retry in background');
48
55
  }
49
- setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
56
+ const timer = setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
57
+ timer.unref();
50
58
  return;
51
59
  }
52
60
  const success = await pullWorkspaceImage();
@@ -54,12 +62,20 @@ export async function startEagerImagePull() {
54
62
  pullComplete = true;
55
63
  pullInProgress = false;
56
64
  }
57
- else {
58
- setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
65
+ else if (!signal.aborted) {
66
+ const timer = setTimeout(() => attemptPull(attempt + 1), RETRY_INTERVAL_MS);
67
+ timer.unref();
59
68
  }
60
69
  };
61
70
  attemptPull(1);
62
71
  }
72
+ export function stopEagerImagePull() {
73
+ if (abortController) {
74
+ abortController.abort();
75
+ abortController = null;
76
+ }
77
+ pullInProgress = false;
78
+ }
63
79
  export function isImagePullComplete() {
64
80
  return pullComplete;
65
81
  }
@@ -410,3 +410,30 @@ export async function getLogs(containerName, options = {}) {
410
410
  const { stdout, stderr } = await docker(args);
411
411
  return stdout + stderr;
412
412
  }
413
+ export async function cloneVolume(sourceVolume, destVolume) {
414
+ if (!(await volumeExists(sourceVolume))) {
415
+ throw new Error(`Source volume '${sourceVolume}' does not exist`);
416
+ }
417
+ if (await volumeExists(destVolume)) {
418
+ throw new Error(`Volume '${destVolume}' already exists`);
419
+ }
420
+ await createVolume(destVolume);
421
+ try {
422
+ await docker([
423
+ 'run',
424
+ '--rm',
425
+ '-v',
426
+ `${sourceVolume}:/source:ro`,
427
+ '-v',
428
+ `${destVolume}:/dest`,
429
+ 'alpine',
430
+ 'sh',
431
+ '-c',
432
+ 'cp -a /source/. /dest/',
433
+ ]);
434
+ }
435
+ catch (err) {
436
+ await removeVolume(destVolume, true).catch(() => { });
437
+ throw new Error(`Failed to clone volume: ${err.message}`);
438
+ }
439
+ }