@gricha/perry 0.2.1 → 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 +26 -2
- package/dist/agent/file-watcher.js +138 -0
- package/dist/agent/router.js +231 -88
- package/dist/agent/run.js +81 -6
- package/dist/agent/web/assets/index-BF-4SpMu.js +104 -0
- package/dist/agent/web/assets/index-DIOWcVH-.css +1 -0
- package/dist/agent/web/index.html +2 -2
- 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/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 +2 -1
- package/dist/shared/types.js +0 -1
- package/dist/tailscale/index.js +80 -0
- package/dist/terminal/websocket.js +1 -1
- package/dist/workspace/manager.js +40 -2
- package/package.json +6 -4
- package/dist/agent/web/assets/index-CGJDysKS.css +0 -1
- package/dist/agent/web/assets/index-CwCl9DVw.js +0 -104
package/README.md
CHANGED
|
@@ -10,11 +10,23 @@
|
|
|
10
10
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
|
-
<p align="center">
|
|
13
|
+
<p align="center">
|
|
14
|
+
Continue your coding sessions on the go. Self-hosted workspaces, accessible over Tailscale.
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img src="assets/demo-terminal-mobile.gif" alt="Terminal" width="280">
|
|
19
|
+
|
|
20
|
+
<img src="assets/demo-chat-mobile.gif" alt="Chat" width="280">
|
|
21
|
+
</p>
|
|
14
22
|
|
|
15
23
|
## Overview
|
|
16
24
|
|
|
17
|
-
Perry is designed to run
|
|
25
|
+
Perry is an agent (agent P) designed to run as a daemon on your machine. It allows your clients - other machines through CLI, web, or mobile app - to connect directly to your workspaces over the Tailscale network.
|
|
26
|
+
|
|
27
|
+
It can be connected directly to your host, or it can create docker containers so that your work can be fully isolated.
|
|
28
|
+
|
|
29
|
+
Continue your sessions on the go!
|
|
18
30
|
|
|
19
31
|
## Features
|
|
20
32
|
|
|
@@ -65,10 +77,22 @@ perry list
|
|
|
65
77
|
|
|
66
78
|
Open http://localhost:7391 (or your Tailscale host) and click "+" to create a workspace.
|
|
67
79
|
|
|
80
|
+
<p align="center">
|
|
81
|
+
<img src="assets/demo.gif" alt="Web UI Demo" width="800">
|
|
82
|
+
</p>
|
|
83
|
+
|
|
68
84
|
## Security
|
|
69
85
|
|
|
70
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.
|
|
71
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
|
+
|
|
72
96
|
## Configuration
|
|
73
97
|
|
|
74
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,14 +3,15 @@ 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';
|
|
10
10
|
import { discoverSSHKeys } from '../ssh/discovery';
|
|
11
11
|
import { parseClaudeSessionContent } from '../sessions/parser';
|
|
12
|
-
import { discoverAllSessions, getSessionDetails as getAgentSessionDetails, getSessionMessages, findSessionMessages, } from '../sessions/agents';
|
|
12
|
+
import { discoverAllSessions, getSessionDetails as getAgentSessionDetails, getSessionMessages, findSessionMessages, deleteSession as deleteSessionFromProvider, searchSessions as searchSessionsInContainer, } from '../sessions/agents';
|
|
13
13
|
import { discoverClaudeCodeModels, discoverHostOpencodeModels, discoverContainerOpencodeModels, } from '../models/discovery';
|
|
14
|
+
import { listOpencodeSessions, getOpencodeSessionMessages, deleteOpencodeSession, } from '../sessions/agents/opencode-storage';
|
|
14
15
|
const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']);
|
|
15
16
|
const WorkspacePortsSchema = z.object({
|
|
16
17
|
ssh: z.number(),
|
|
@@ -207,6 +208,7 @@ export function createRouter(ctx) {
|
|
|
207
208
|
workspacesCount: allWorkspaces.length,
|
|
208
209
|
dockerVersion,
|
|
209
210
|
terminalConnections: ctx.terminalServer.getConnectionCount(),
|
|
211
|
+
tailscale: ctx.tailscale,
|
|
210
212
|
};
|
|
211
213
|
});
|
|
212
214
|
const getCredentials = os.output(CredentialsSchema).handler(async () => {
|
|
@@ -220,6 +222,7 @@ export function createRouter(ctx) {
|
|
|
220
222
|
const newConfig = { ...currentConfig, credentials: input };
|
|
221
223
|
ctx.config.set(newConfig);
|
|
222
224
|
await saveAgentConfig(newConfig, ctx.configDir);
|
|
225
|
+
ctx.triggerAutoSync();
|
|
223
226
|
return input;
|
|
224
227
|
});
|
|
225
228
|
const getScripts = os.output(ScriptsSchema).handler(async () => {
|
|
@@ -233,6 +236,7 @@ export function createRouter(ctx) {
|
|
|
233
236
|
const newConfig = { ...currentConfig, scripts: input };
|
|
234
237
|
ctx.config.set(newConfig);
|
|
235
238
|
await saveAgentConfig(newConfig, ctx.configDir);
|
|
239
|
+
ctx.triggerAutoSync();
|
|
236
240
|
return input;
|
|
237
241
|
});
|
|
238
242
|
const getAgents = os.output(CodingAgentsSchema).handler(async () => {
|
|
@@ -246,6 +250,7 @@ export function createRouter(ctx) {
|
|
|
246
250
|
const newConfig = { ...currentConfig, agents: input };
|
|
247
251
|
ctx.config.set(newConfig);
|
|
248
252
|
await saveAgentConfig(newConfig, ctx.configDir);
|
|
253
|
+
ctx.triggerAutoSync();
|
|
249
254
|
return input;
|
|
250
255
|
});
|
|
251
256
|
const getSSHSettings = os.output(SSHSettingsSchema).handler(async () => {
|
|
@@ -264,6 +269,7 @@ export function createRouter(ctx) {
|
|
|
264
269
|
const newConfig = { ...currentConfig, ssh: input };
|
|
265
270
|
ctx.config.set(newConfig);
|
|
266
271
|
await saveAgentConfig(newConfig, ctx.configDir);
|
|
272
|
+
ctx.triggerAutoSync();
|
|
267
273
|
return input;
|
|
268
274
|
});
|
|
269
275
|
const listSSHKeys = os.output(z.array(SSHKeyInfoSchema)).handler(async () => {
|
|
@@ -305,32 +311,16 @@ export function createRouter(ctx) {
|
|
|
305
311
|
}
|
|
306
312
|
}
|
|
307
313
|
if (!input.agentType || input.agentType === 'opencode') {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const sessionStat = await fs.stat(sessionFile);
|
|
319
|
-
rawSessions.push({
|
|
320
|
-
id: sessionDir,
|
|
321
|
-
agentType: 'opencode',
|
|
322
|
-
projectPath: homeDir,
|
|
323
|
-
mtime: sessionStat.mtimeMs,
|
|
324
|
-
filePath: sessionFile,
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
catch {
|
|
328
|
-
// session.json doesn't exist
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
catch {
|
|
333
|
-
// Directory doesn't exist
|
|
314
|
+
const opencodeSessions = await listOpencodeSessions();
|
|
315
|
+
for (const session of opencodeSessions) {
|
|
316
|
+
rawSessions.push({
|
|
317
|
+
id: session.id,
|
|
318
|
+
agentType: 'opencode',
|
|
319
|
+
projectPath: session.directory || homeDir,
|
|
320
|
+
mtime: session.mtime,
|
|
321
|
+
filePath: session.file,
|
|
322
|
+
name: session.title || undefined,
|
|
323
|
+
});
|
|
334
324
|
}
|
|
335
325
|
}
|
|
336
326
|
rawSessions.sort((a, b) => b.mtime - a.mtime);
|
|
@@ -371,16 +361,17 @@ export function createRouter(ctx) {
|
|
|
371
361
|
}
|
|
372
362
|
}
|
|
373
363
|
else if (raw.agentType === 'opencode') {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
firstPrompt = sessionData.title;
|
|
380
|
-
}
|
|
364
|
+
const sessionMessages = await getOpencodeSessionMessages(raw.id);
|
|
365
|
+
const userAssistantMessages = sessionMessages.messages.filter((m) => m.type === 'user' || m.type === 'assistant');
|
|
366
|
+
messageCount = userAssistantMessages.length;
|
|
367
|
+
if (raw.name) {
|
|
368
|
+
firstPrompt = raw.name;
|
|
381
369
|
}
|
|
382
|
-
|
|
383
|
-
|
|
370
|
+
else {
|
|
371
|
+
const firstUserMsg = userAssistantMessages.find((m) => m.type === 'user' && m.content);
|
|
372
|
+
if (firstUserMsg?.content) {
|
|
373
|
+
firstPrompt = firstUserMsg.content.slice(0, 200);
|
|
374
|
+
}
|
|
384
375
|
}
|
|
385
376
|
}
|
|
386
377
|
return {
|
|
@@ -434,57 +425,17 @@ export function createRouter(ctx) {
|
|
|
434
425
|
}
|
|
435
426
|
}
|
|
436
427
|
if (!agentType || agentType === 'opencode') {
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const textContent = Array.isArray(part.content)
|
|
449
|
-
? part.content
|
|
450
|
-
.filter((c) => c.type === 'text')
|
|
451
|
-
.map((c) => c.text)
|
|
452
|
-
.join('\n')
|
|
453
|
-
: part.content;
|
|
454
|
-
messages.push({
|
|
455
|
-
type: 'user',
|
|
456
|
-
content: textContent,
|
|
457
|
-
timestamp: part.time || null,
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
else if (part.role === 'assistant') {
|
|
461
|
-
if (part.content) {
|
|
462
|
-
const textContent = Array.isArray(part.content)
|
|
463
|
-
? part.content
|
|
464
|
-
.filter((c) => c.type === 'text')
|
|
465
|
-
.map((c) => c.text)
|
|
466
|
-
.join('\n')
|
|
467
|
-
: part.content;
|
|
468
|
-
if (textContent) {
|
|
469
|
-
messages.push({
|
|
470
|
-
type: 'assistant',
|
|
471
|
-
content: textContent,
|
|
472
|
-
timestamp: part.time || null,
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
catch {
|
|
479
|
-
// Can't parse part
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
if (messages.length > 0) {
|
|
483
|
-
return { id: sessionId, agentType: 'opencode', messages };
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
catch {
|
|
487
|
-
// Directory doesn't exist
|
|
428
|
+
const sessionData = await getOpencodeSessionMessages(sessionId);
|
|
429
|
+
if (sessionData.messages.length > 0) {
|
|
430
|
+
const opencodeMessages = sessionData.messages.map((m) => ({
|
|
431
|
+
type: m.type,
|
|
432
|
+
content: m.content,
|
|
433
|
+
toolName: m.toolName,
|
|
434
|
+
toolId: m.toolId,
|
|
435
|
+
toolInput: m.toolInput,
|
|
436
|
+
timestamp: m.timestamp,
|
|
437
|
+
}));
|
|
438
|
+
return { id: sessionId, agentType: 'opencode', messages: opencodeMessages };
|
|
488
439
|
}
|
|
489
440
|
}
|
|
490
441
|
return { id: sessionId, messages };
|
|
@@ -627,6 +578,196 @@ export function createRouter(ctx) {
|
|
|
627
578
|
await ctx.sessionsCache.recordAccess(input.workspaceName, input.sessionId, input.agentType);
|
|
628
579
|
return { success: true };
|
|
629
580
|
});
|
|
581
|
+
const deleteSession = os
|
|
582
|
+
.input(z.object({
|
|
583
|
+
workspaceName: z.string(),
|
|
584
|
+
sessionId: z.string(),
|
|
585
|
+
agentType: z.enum(['claude-code', 'opencode', 'codex']),
|
|
586
|
+
}))
|
|
587
|
+
.handler(async ({ input }) => {
|
|
588
|
+
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
|
|
589
|
+
if (isHost) {
|
|
590
|
+
const config = ctx.config.get();
|
|
591
|
+
if (!config.allowHostAccess) {
|
|
592
|
+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
|
|
593
|
+
}
|
|
594
|
+
const result = await deleteHostSession(input.sessionId, input.agentType);
|
|
595
|
+
if (!result.success) {
|
|
596
|
+
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
|
597
|
+
message: result.error || 'Failed to delete session',
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
|
|
601
|
+
await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
|
|
602
|
+
return { success: true };
|
|
603
|
+
}
|
|
604
|
+
const workspace = await ctx.workspaces.get(input.workspaceName);
|
|
605
|
+
if (!workspace) {
|
|
606
|
+
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
|
|
607
|
+
}
|
|
608
|
+
if (workspace.status !== 'running') {
|
|
609
|
+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
|
|
610
|
+
}
|
|
611
|
+
const containerName = `workspace-${input.workspaceName}`;
|
|
612
|
+
const result = await deleteSessionFromProvider(containerName, input.sessionId, input.agentType, execInContainer);
|
|
613
|
+
if (!result.success) {
|
|
614
|
+
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
|
615
|
+
message: result.error || 'Failed to delete session',
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
|
|
619
|
+
await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
|
|
620
|
+
return { success: true };
|
|
621
|
+
});
|
|
622
|
+
const searchSessions = os
|
|
623
|
+
.input(z.object({
|
|
624
|
+
workspaceName: z.string(),
|
|
625
|
+
query: z.string().min(1).max(500),
|
|
626
|
+
}))
|
|
627
|
+
.handler(async ({ input }) => {
|
|
628
|
+
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
|
|
629
|
+
if (isHost) {
|
|
630
|
+
const config = ctx.config.get();
|
|
631
|
+
if (!config.allowHostAccess) {
|
|
632
|
+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
|
|
633
|
+
}
|
|
634
|
+
const results = await searchHostSessions(input.query);
|
|
635
|
+
return { results };
|
|
636
|
+
}
|
|
637
|
+
const workspace = await ctx.workspaces.get(input.workspaceName);
|
|
638
|
+
if (!workspace) {
|
|
639
|
+
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
|
|
640
|
+
}
|
|
641
|
+
if (workspace.status !== 'running') {
|
|
642
|
+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
|
|
643
|
+
}
|
|
644
|
+
const containerName = `workspace-${input.workspaceName}`;
|
|
645
|
+
const results = await searchSessionsInContainer(containerName, input.query, execInContainer);
|
|
646
|
+
return { results };
|
|
647
|
+
});
|
|
648
|
+
async function searchHostSessions(query) {
|
|
649
|
+
const homeDir = os_module.homedir();
|
|
650
|
+
const safeQuery = query.replace(/['"\\]/g, '\\$&');
|
|
651
|
+
const searchPaths = [
|
|
652
|
+
path.join(homeDir, '.claude', 'projects'),
|
|
653
|
+
path.join(homeDir, '.local', 'share', 'opencode', 'storage'),
|
|
654
|
+
path.join(homeDir, '.codex', 'sessions'),
|
|
655
|
+
].filter((p) => {
|
|
656
|
+
try {
|
|
657
|
+
require('fs').accessSync(p);
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
});
|
|
664
|
+
if (searchPaths.length === 0) {
|
|
665
|
+
return [];
|
|
666
|
+
}
|
|
667
|
+
const { execSync } = await import('child_process');
|
|
668
|
+
try {
|
|
669
|
+
const output = execSync(`rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`, {
|
|
670
|
+
encoding: 'utf-8',
|
|
671
|
+
timeout: 30000,
|
|
672
|
+
});
|
|
673
|
+
const files = output.trim().split('\n').filter(Boolean);
|
|
674
|
+
const results = [];
|
|
675
|
+
for (const file of files) {
|
|
676
|
+
let sessionId = null;
|
|
677
|
+
let agentType = null;
|
|
678
|
+
if (file.includes('/.claude/projects/')) {
|
|
679
|
+
const match = file.match(/\/([^/]+)\.jsonl$/);
|
|
680
|
+
if (match && !match[1].startsWith('agent-')) {
|
|
681
|
+
sessionId = match[1];
|
|
682
|
+
agentType = 'claude-code';
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
else if (file.includes('/.local/share/opencode/storage/')) {
|
|
686
|
+
if (file.includes('/session/') && file.endsWith('.json')) {
|
|
687
|
+
const match = file.match(/\/(ses_[^/]+)\.json$/);
|
|
688
|
+
if (match) {
|
|
689
|
+
sessionId = match[1];
|
|
690
|
+
agentType = 'opencode';
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else if (file.includes('/.codex/sessions/')) {
|
|
695
|
+
const match = file.match(/\/([^/]+)\.jsonl$/);
|
|
696
|
+
if (match) {
|
|
697
|
+
sessionId = match[1];
|
|
698
|
+
agentType = 'codex';
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (sessionId && agentType) {
|
|
702
|
+
results.push({ sessionId, agentType, matchCount: 1 });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return results;
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
return [];
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
async function deleteHostSession(sessionId, agentType) {
|
|
712
|
+
const homeDir = os_module.homedir();
|
|
713
|
+
if (agentType === 'claude-code') {
|
|
714
|
+
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
715
|
+
const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');
|
|
716
|
+
try {
|
|
717
|
+
const projectDirs = await fs.readdir(claudeProjectsDir);
|
|
718
|
+
for (const projectDir of projectDirs) {
|
|
719
|
+
const sessionFile = path.join(claudeProjectsDir, projectDir, `${safeSessionId}.jsonl`);
|
|
720
|
+
try {
|
|
721
|
+
await fs.unlink(sessionFile);
|
|
722
|
+
return { success: true };
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
return { success: false, error: 'Session not found' };
|
|
731
|
+
}
|
|
732
|
+
return { success: false, error: 'Session not found' };
|
|
733
|
+
}
|
|
734
|
+
if (agentType === 'opencode') {
|
|
735
|
+
return deleteOpencodeSession(sessionId);
|
|
736
|
+
}
|
|
737
|
+
if (agentType === 'codex') {
|
|
738
|
+
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
739
|
+
try {
|
|
740
|
+
const files = await fs.readdir(codexSessionsDir);
|
|
741
|
+
for (const file of files) {
|
|
742
|
+
if (!file.endsWith('.jsonl'))
|
|
743
|
+
continue;
|
|
744
|
+
const filePath = path.join(codexSessionsDir, file);
|
|
745
|
+
const fileId = file.replace('.jsonl', '');
|
|
746
|
+
if (fileId === sessionId) {
|
|
747
|
+
await fs.unlink(filePath);
|
|
748
|
+
return { success: true };
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
752
|
+
const firstLine = content.split('\n')[0];
|
|
753
|
+
const meta = JSON.parse(firstLine);
|
|
754
|
+
if (meta.session_id === sessionId) {
|
|
755
|
+
await fs.unlink(filePath);
|
|
756
|
+
return { success: true };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
return { success: false, error: 'Session not found' };
|
|
766
|
+
}
|
|
767
|
+
return { success: false, error: 'Session not found' };
|
|
768
|
+
}
|
|
769
|
+
return { success: false, error: 'Unsupported agent type' };
|
|
770
|
+
}
|
|
630
771
|
const getHostInfo = os.handler(async () => {
|
|
631
772
|
const config = ctx.config.get();
|
|
632
773
|
return {
|
|
@@ -724,6 +865,8 @@ export function createRouter(ctx) {
|
|
|
724
865
|
clearName: clearSessionName,
|
|
725
866
|
getRecent: getRecentSessions,
|
|
726
867
|
recordAccess: recordSessionAccess,
|
|
868
|
+
delete: deleteSession,
|
|
869
|
+
search: searchSessions,
|
|
727
870
|
},
|
|
728
871
|
models: {
|
|
729
872
|
list: listModels,
|