@gricha/perry 0.2.2 → 0.2.3
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 +8 -0
- package/dist/agent/file-watcher.js +138 -0
- package/dist/agent/router.js +5 -1
- package/dist/agent/run.js +29 -3
- package/dist/agent/web/assets/{index-DN_QW9sL.js → index-BF-4SpMu.js} +21 -21
- package/dist/agent/web/index.html +1 -1
- package/dist/chat/base-chat-websocket.js +1 -1
- 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/types.js +1 -0
- package/dist/docker/index.js +1 -1
- 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/package.json +4 -3
package/README.md
CHANGED
|
@@ -85,6 +85,14 @@ Open http://localhost:7391 (or your Tailscale host) and click "+" to create a wo
|
|
|
85
85
|
|
|
86
86
|
Perry is designed for use within **secure private networks** like [Tailscale](https://tailscale.com). The web UI and API currently have no authentication - this is intentional for private network use where all devices are trusted.
|
|
87
87
|
|
|
88
|
+
NOTE: Using this software can be dangerous, don't expose it on the network. Any user that can access perry's web server may be able to do serious damage to your system. Keep it closed in Tailscale network.
|
|
89
|
+
|
|
90
|
+
Perry by default allows the API to interact with the host machine as well - while it's intended purpose is to manage docker containers, sometimes, for simplicity I run some of my jobs directly on my machine. This can be disabled. When you start perry, you can pass a `--no-host-access` flag.
|
|
91
|
+
|
|
92
|
+
`perry agent run --no-host-access`
|
|
93
|
+
|
|
94
|
+
This will ensure that perry can only stand up/tear down docker containers. While this reduces the attack surface, it is only as good as docker is as sandbox (and it may very well not be).
|
|
95
|
+
|
|
88
96
|
## Configuration
|
|
89
97
|
|
|
90
98
|
Configure credentials and agent settings via Web UI → Settings or edit `~/.config/perry/config.json`:
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { watch } from 'fs';
|
|
2
|
+
import { access } from 'fs/promises';
|
|
3
|
+
import { expandPath } from '../config/loader';
|
|
4
|
+
const STANDARD_CREDENTIAL_FILES = [
|
|
5
|
+
'~/.gitconfig',
|
|
6
|
+
'~/.claude/.credentials.json',
|
|
7
|
+
'~/.codex/auth.json',
|
|
8
|
+
'~/.codex/config.toml',
|
|
9
|
+
];
|
|
10
|
+
export class FileWatcher {
|
|
11
|
+
watchers = new Map();
|
|
12
|
+
config;
|
|
13
|
+
syncCallback;
|
|
14
|
+
debounceMs;
|
|
15
|
+
debounceTimer = null;
|
|
16
|
+
pendingSync = false;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.config = options.config;
|
|
19
|
+
this.syncCallback = options.syncCallback;
|
|
20
|
+
this.debounceMs = options.debounceMs ?? 500;
|
|
21
|
+
this.setupWatchers();
|
|
22
|
+
}
|
|
23
|
+
updateConfig(config) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.rebuildWatchers();
|
|
26
|
+
}
|
|
27
|
+
stop() {
|
|
28
|
+
if (this.debounceTimer) {
|
|
29
|
+
clearTimeout(this.debounceTimer);
|
|
30
|
+
this.debounceTimer = null;
|
|
31
|
+
}
|
|
32
|
+
for (const [filePath, watcher] of this.watchers) {
|
|
33
|
+
watcher.close();
|
|
34
|
+
console.log(`[file-watcher] Stopped watching: ${filePath}`);
|
|
35
|
+
}
|
|
36
|
+
this.watchers.clear();
|
|
37
|
+
}
|
|
38
|
+
collectWatchPaths() {
|
|
39
|
+
const paths = new Set();
|
|
40
|
+
for (const sourcePath of Object.values(this.config.credentials.files)) {
|
|
41
|
+
paths.add(expandPath(sourcePath));
|
|
42
|
+
}
|
|
43
|
+
for (const stdPath of STANDARD_CREDENTIAL_FILES) {
|
|
44
|
+
paths.add(expandPath(stdPath));
|
|
45
|
+
}
|
|
46
|
+
if (this.config.ssh?.global?.copy) {
|
|
47
|
+
for (const keyPath of this.config.ssh.global.copy) {
|
|
48
|
+
paths.add(expandPath(keyPath));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (this.config.ssh?.workspaces) {
|
|
52
|
+
for (const wsConfig of Object.values(this.config.ssh.workspaces)) {
|
|
53
|
+
if (wsConfig.copy) {
|
|
54
|
+
for (const keyPath of wsConfig.copy) {
|
|
55
|
+
paths.add(expandPath(keyPath));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return Array.from(paths);
|
|
61
|
+
}
|
|
62
|
+
async setupWatchers() {
|
|
63
|
+
const paths = this.collectWatchPaths();
|
|
64
|
+
for (const filePath of paths) {
|
|
65
|
+
await this.watchFile(filePath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async watchFile(filePath) {
|
|
69
|
+
if (this.watchers.has(filePath)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
await access(filePath);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const watcher = watch(filePath, (eventType) => {
|
|
80
|
+
if (eventType === 'change' || eventType === 'rename') {
|
|
81
|
+
this.handleFileChange(filePath);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
watcher.on('error', (err) => {
|
|
85
|
+
console.error(`[file-watcher] Error watching ${filePath}:`, err);
|
|
86
|
+
this.watchers.delete(filePath);
|
|
87
|
+
});
|
|
88
|
+
this.watchers.set(filePath, watcher);
|
|
89
|
+
console.log(`[file-watcher] Watching: ${filePath}`);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error(`[file-watcher] Failed to watch ${filePath}:`, err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
handleFileChange(filePath) {
|
|
96
|
+
console.log(`[file-watcher] Change detected: ${filePath}`);
|
|
97
|
+
this.scheduleSync();
|
|
98
|
+
}
|
|
99
|
+
scheduleSync() {
|
|
100
|
+
if (this.debounceTimer) {
|
|
101
|
+
clearTimeout(this.debounceTimer);
|
|
102
|
+
}
|
|
103
|
+
this.pendingSync = true;
|
|
104
|
+
this.debounceTimer = setTimeout(async () => {
|
|
105
|
+
this.debounceTimer = null;
|
|
106
|
+
if (this.pendingSync) {
|
|
107
|
+
this.pendingSync = false;
|
|
108
|
+
try {
|
|
109
|
+
console.log('[file-watcher] Triggering sync...');
|
|
110
|
+
await this.syncCallback();
|
|
111
|
+
console.log('[file-watcher] Sync completed');
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
console.error('[file-watcher] Sync failed:', err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}, this.debounceMs);
|
|
118
|
+
}
|
|
119
|
+
rebuildWatchers() {
|
|
120
|
+
const newPaths = new Set(this.collectWatchPaths());
|
|
121
|
+
const currentPaths = new Set(this.watchers.keys());
|
|
122
|
+
for (const filePath of currentPaths) {
|
|
123
|
+
if (!newPaths.has(filePath)) {
|
|
124
|
+
const watcher = this.watchers.get(filePath);
|
|
125
|
+
if (watcher) {
|
|
126
|
+
watcher.close();
|
|
127
|
+
this.watchers.delete(filePath);
|
|
128
|
+
console.log(`[file-watcher] Stopped watching: ${filePath}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const filePath of newPaths) {
|
|
133
|
+
if (!currentPaths.has(filePath)) {
|
|
134
|
+
this.watchFile(filePath);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
package/dist/agent/router.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as z from 'zod';
|
|
|
3
3
|
import os_module from 'os';
|
|
4
4
|
import { promises as fs } from 'fs';
|
|
5
5
|
import path from 'path';
|
|
6
|
-
import { HOST_WORKSPACE_NAME } from '../shared/types';
|
|
6
|
+
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
|
|
7
7
|
import { getDockerVersion, execInContainer } from '../docker';
|
|
8
8
|
import { saveAgentConfig } from '../config/loader';
|
|
9
9
|
import { setSessionName, getSessionNamesForWorkspace, deleteSessionName, } from '../sessions/metadata';
|
|
@@ -222,6 +222,7 @@ export function createRouter(ctx) {
|
|
|
222
222
|
const newConfig = { ...currentConfig, credentials: input };
|
|
223
223
|
ctx.config.set(newConfig);
|
|
224
224
|
await saveAgentConfig(newConfig, ctx.configDir);
|
|
225
|
+
ctx.triggerAutoSync();
|
|
225
226
|
return input;
|
|
226
227
|
});
|
|
227
228
|
const getScripts = os.output(ScriptsSchema).handler(async () => {
|
|
@@ -235,6 +236,7 @@ export function createRouter(ctx) {
|
|
|
235
236
|
const newConfig = { ...currentConfig, scripts: input };
|
|
236
237
|
ctx.config.set(newConfig);
|
|
237
238
|
await saveAgentConfig(newConfig, ctx.configDir);
|
|
239
|
+
ctx.triggerAutoSync();
|
|
238
240
|
return input;
|
|
239
241
|
});
|
|
240
242
|
const getAgents = os.output(CodingAgentsSchema).handler(async () => {
|
|
@@ -248,6 +250,7 @@ export function createRouter(ctx) {
|
|
|
248
250
|
const newConfig = { ...currentConfig, agents: input };
|
|
249
251
|
ctx.config.set(newConfig);
|
|
250
252
|
await saveAgentConfig(newConfig, ctx.configDir);
|
|
253
|
+
ctx.triggerAutoSync();
|
|
251
254
|
return input;
|
|
252
255
|
});
|
|
253
256
|
const getSSHSettings = os.output(SSHSettingsSchema).handler(async () => {
|
|
@@ -266,6 +269,7 @@ export function createRouter(ctx) {
|
|
|
266
269
|
const newConfig = { ...currentConfig, ssh: input };
|
|
267
270
|
ctx.config.set(newConfig);
|
|
268
271
|
await saveAgentConfig(newConfig, ctx.configDir);
|
|
272
|
+
ctx.triggerAutoSync();
|
|
269
273
|
return input;
|
|
270
274
|
});
|
|
271
275
|
const listSSHKeys = os.output(z.array(SSHKeyInfoSchema)).handler(async () => {
|
package/dist/agent/run.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'http';
|
|
2
2
|
import { RPCHandler } from '@orpc/server/node';
|
|
3
3
|
import { loadAgentConfig, getConfigDir, ensureConfigDir } from '../config/loader';
|
|
4
|
-
import { HOST_WORKSPACE_NAME } from '../shared/types';
|
|
4
|
+
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
|
|
5
5
|
import { DEFAULT_AGENT_PORT } from '../shared/constants';
|
|
6
6
|
import { WorkspaceManager } from '../workspace/manager';
|
|
7
7
|
import { containerRunning, getContainerName } from '../docker';
|
|
@@ -13,6 +13,7 @@ import { createRouter } from './router';
|
|
|
13
13
|
import { serveStatic } from './static';
|
|
14
14
|
import { SessionsCacheManager } from '../sessions/cache';
|
|
15
15
|
import { ModelCacheManager } from '../models/cache';
|
|
16
|
+
import { FileWatcher } from './file-watcher';
|
|
16
17
|
import { getTailscaleStatus, getTailscaleIdentity, startTailscaleServe, stopTailscaleServe, } from '../tailscale';
|
|
17
18
|
import pkg from '../../package.json';
|
|
18
19
|
const startTime = Date.now();
|
|
@@ -25,6 +26,23 @@ function createAgentServer(configDir, config, tailscale) {
|
|
|
25
26
|
const workspaces = new WorkspaceManager(configDir, currentConfig);
|
|
26
27
|
const sessionsCache = new SessionsCacheManager(configDir);
|
|
27
28
|
const modelCache = new ModelCacheManager(configDir);
|
|
29
|
+
const syncAllRunning = async () => {
|
|
30
|
+
const allWorkspaces = await workspaces.list();
|
|
31
|
+
const running = allWorkspaces.filter((ws) => ws.status === 'running');
|
|
32
|
+
for (const ws of running) {
|
|
33
|
+
try {
|
|
34
|
+
await workspaces.sync(ws.name);
|
|
35
|
+
console.log(`[sync] Synced workspace: ${ws.name}`);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
console.error(`[sync] Failed to sync ${ws.name}:`, err);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const fileWatcher = new FileWatcher({
|
|
43
|
+
config: currentConfig,
|
|
44
|
+
syncCallback: syncAllRunning,
|
|
45
|
+
});
|
|
28
46
|
const isWorkspaceRunning = async (name) => {
|
|
29
47
|
if (name === HOST_WORKSPACE_NAME) {
|
|
30
48
|
return currentConfig.allowHostAccess === true;
|
|
@@ -46,6 +64,11 @@ function createAgentServer(configDir, config, tailscale) {
|
|
|
46
64
|
isHostAccessAllowed: () => currentConfig.allowHostAccess === true,
|
|
47
65
|
getConfig: () => currentConfig,
|
|
48
66
|
});
|
|
67
|
+
const triggerAutoSync = () => {
|
|
68
|
+
syncAllRunning().catch((err) => {
|
|
69
|
+
console.error('[sync] Auto-sync failed:', err);
|
|
70
|
+
});
|
|
71
|
+
};
|
|
49
72
|
const router = createRouter({
|
|
50
73
|
workspaces,
|
|
51
74
|
config: {
|
|
@@ -53,6 +76,7 @@ function createAgentServer(configDir, config, tailscale) {
|
|
|
53
76
|
set: (newConfig) => {
|
|
54
77
|
currentConfig = newConfig;
|
|
55
78
|
workspaces.updateConfig(newConfig);
|
|
79
|
+
fileWatcher.updateConfig(newConfig);
|
|
56
80
|
},
|
|
57
81
|
},
|
|
58
82
|
configDir,
|
|
@@ -62,6 +86,7 @@ function createAgentServer(configDir, config, tailscale) {
|
|
|
62
86
|
sessionsCache,
|
|
63
87
|
modelCache,
|
|
64
88
|
tailscale,
|
|
89
|
+
triggerAutoSync,
|
|
65
90
|
});
|
|
66
91
|
const rpcHandler = new RPCHandler(router);
|
|
67
92
|
const server = createServer(async (req, res) => {
|
|
@@ -125,7 +150,7 @@ function createAgentServer(configDir, config, tailscale) {
|
|
|
125
150
|
socket.destroy();
|
|
126
151
|
}
|
|
127
152
|
});
|
|
128
|
-
return { server, terminalServer, chatServer, opencodeServer };
|
|
153
|
+
return { server, terminalServer, chatServer, opencodeServer, fileWatcher };
|
|
129
154
|
}
|
|
130
155
|
async function getProcessUsingPort(port) {
|
|
131
156
|
try {
|
|
@@ -189,7 +214,7 @@ export async function startAgent(options = {}) {
|
|
|
189
214
|
httpsUrl: tailscaleServeActive ? `https://${tailscale.dnsName}` : undefined,
|
|
190
215
|
}
|
|
191
216
|
: undefined;
|
|
192
|
-
const { server, terminalServer, chatServer, opencodeServer } = createAgentServer(configDir, config, tailscaleInfo);
|
|
217
|
+
const { server, terminalServer, chatServer, opencodeServer, fileWatcher } = createAgentServer(configDir, config, tailscaleInfo);
|
|
193
218
|
server.on('error', async (err) => {
|
|
194
219
|
if (err.code === 'EADDRINUSE') {
|
|
195
220
|
console.error(`[agent] Error: Port ${port} is already in use.`);
|
|
@@ -222,6 +247,7 @@ export async function startAgent(options = {}) {
|
|
|
222
247
|
});
|
|
223
248
|
const shutdown = async () => {
|
|
224
249
|
console.log('[agent] Shutting down...');
|
|
250
|
+
fileWatcher.stop();
|
|
225
251
|
if (tailscaleServeActive) {
|
|
226
252
|
console.log('[agent] Stopping Tailscale Serve...');
|
|
227
253
|
await stopTailscaleServe();
|