@gricha/perry 0.0.1 → 0.1.0
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 +25 -33
- package/dist/agent/router.js +39 -404
- package/dist/agent/web/assets/index-BGbqUzMS.js +104 -0
- package/dist/agent/web/assets/index-CHEQQv1U.css +1 -0
- package/dist/agent/web/favicon.ico +0 -0
- package/dist/agent/web/index.html +3 -3
- package/dist/agent/web/logo-192.png +0 -0
- package/dist/agent/web/logo-512.png +0 -0
- package/dist/agent/web/logo.png +0 -0
- package/dist/agent/web/logo.webp +0 -0
- package/dist/chat/base-chat-websocket.js +83 -0
- package/dist/chat/host-opencode-handler.js +115 -3
- package/dist/chat/opencode-handler.js +60 -0
- package/dist/chat/opencode-server.js +252 -0
- package/dist/chat/opencode-websocket.js +15 -87
- package/dist/chat/websocket.js +19 -86
- package/dist/client/ws-shell.js +15 -5
- package/dist/docker/index.js +41 -1
- package/dist/index.js +3 -3
- package/dist/sessions/agents/claude.js +86 -0
- package/dist/sessions/agents/codex.js +110 -0
- package/dist/sessions/agents/index.js +44 -0
- package/dist/sessions/agents/opencode.js +168 -0
- package/dist/sessions/agents/types.js +1 -0
- package/dist/sessions/agents/utils.js +31 -0
- package/dist/shared/base-websocket.js +13 -1
- package/dist/shared/constants.js +2 -1
- package/dist/terminal/base-handler.js +68 -0
- package/dist/terminal/handler.js +18 -75
- package/dist/terminal/host-handler.js +7 -61
- package/dist/terminal/websocket.js +2 -4
- package/dist/workspace/manager.js +33 -22
- package/dist/workspace/state.js +33 -2
- package/package.json +1 -1
- package/dist/agent/web/assets/index-9t2sFIJM.js +0 -101
- package/dist/agent/web/assets/index-CCFpTruF.css +0 -1
- package/dist/agent/web/vite.svg +0 -1
package/README.md
CHANGED
|
@@ -12,9 +12,13 @@
|
|
|
12
12
|
|
|
13
13
|
<p align="center">Isolated, self-hosted workspaces accessible over Tailscale. AI coding agents, web UI, and remote terminal access.</p>
|
|
14
14
|
|
|
15
|
+
## Overview
|
|
16
|
+
|
|
17
|
+
Perry is designed to run on a machine within a **secure private network** such as [Tailscale](https://tailscale.com). It provides isolated Docker-based development environments that you can access remotely via CLI, web UI, or SSH from any device on your network.
|
|
18
|
+
|
|
15
19
|
## Features
|
|
16
20
|
|
|
17
|
-
- **AI Coding Agents** - Claude Code, OpenCode,
|
|
21
|
+
- **AI Coding Agents** - Claude Code, OpenCode, Codex CLI pre-installed
|
|
18
22
|
- **Self-Hosted** - Run on your own hardware, full control
|
|
19
23
|
- **Remote Access** - Use from anywhere via Tailscale, CLI, web, or SSH
|
|
20
24
|
- **Web UI** - Manage workspaces from your browser
|
|
@@ -28,26 +32,13 @@
|
|
|
28
32
|
npm install -g @gricha/perry
|
|
29
33
|
```
|
|
30
34
|
|
|
31
|
-
### Build Base Image
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
perry build
|
|
35
|
-
```
|
|
36
|
-
|
|
37
35
|
### Start Agent
|
|
38
36
|
|
|
39
37
|
```bash
|
|
40
38
|
perry agent run
|
|
41
39
|
```
|
|
42
40
|
|
|
43
|
-
Web UI: **http://localhost:7391**
|
|
44
|
-
|
|
45
|
-
The agent runs on port 7391 by default. For remote access, install as a service:
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
perry agent install
|
|
49
|
-
systemctl --user start perry-agent
|
|
50
|
-
```
|
|
41
|
+
Web UI: **http://localhost:7391** (or your Tailscale host)
|
|
51
42
|
|
|
52
43
|
### Create & Use Workspaces
|
|
53
44
|
|
|
@@ -60,42 +51,47 @@ perry create myproject
|
|
|
60
51
|
# Or clone a repo
|
|
61
52
|
perry create myproject --clone git@github.com:user/repo.git
|
|
62
53
|
|
|
63
|
-
#
|
|
64
|
-
perry
|
|
65
|
-
ssh -p 2201 workspace@localhost
|
|
54
|
+
# Shell into workspace
|
|
55
|
+
perry shell myproject
|
|
66
56
|
|
|
67
57
|
# Manage workspaces
|
|
68
58
|
perry start myproject
|
|
69
59
|
perry stop myproject
|
|
70
60
|
perry delete myproject
|
|
61
|
+
perry list
|
|
71
62
|
```
|
|
72
63
|
|
|
73
64
|
**Via Web UI:**
|
|
74
65
|
|
|
75
|
-
Open http://localhost:7391 and click "+" to create a workspace.
|
|
66
|
+
Open http://localhost:7391 (or your Tailscale host) and click "+" to create a workspace.
|
|
76
67
|
|
|
77
68
|
## Security
|
|
78
69
|
|
|
79
|
-
Perry is designed for use within **secure networks** like [Tailscale](https://tailscale.com). The web UI and API have no authentication
|
|
80
|
-
|
|
81
|
-
For public internet exposure, place behind a reverse proxy with authentication.
|
|
70
|
+
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.
|
|
82
71
|
|
|
83
72
|
## Configuration
|
|
84
73
|
|
|
85
|
-
Configure credentials and
|
|
74
|
+
Configure credentials and agent settings via Web UI → Settings or edit `~/.config/perry/config.json`:
|
|
86
75
|
|
|
87
76
|
```json
|
|
88
77
|
{
|
|
89
78
|
"credentials": {
|
|
90
|
-
"env": {
|
|
91
|
-
"ANTHROPIC_API_KEY": "sk-ant-...",
|
|
92
|
-
"OPENAI_API_KEY": "sk-...",
|
|
93
|
-
"GITHUB_TOKEN": "ghp_..."
|
|
94
|
-
},
|
|
79
|
+
"env": {},
|
|
95
80
|
"files": {
|
|
96
81
|
"~/.ssh/id_ed25519": "~/.ssh/id_ed25519",
|
|
97
82
|
"~/.gitconfig": "~/.gitconfig"
|
|
98
83
|
}
|
|
84
|
+
},
|
|
85
|
+
"agents": {
|
|
86
|
+
"github": {
|
|
87
|
+
"token": "ghp_..."
|
|
88
|
+
},
|
|
89
|
+
"claude_code": {
|
|
90
|
+
"oauth_token": "..."
|
|
91
|
+
},
|
|
92
|
+
"opencode": {
|
|
93
|
+
"zen_token": "..."
|
|
94
|
+
}
|
|
99
95
|
}
|
|
100
96
|
}
|
|
101
97
|
```
|
|
@@ -116,8 +112,6 @@ Restart workspaces to apply changes.
|
|
|
116
112
|
```bash
|
|
117
113
|
# Agent
|
|
118
114
|
perry agent run [--port PORT]
|
|
119
|
-
perry agent install
|
|
120
|
-
perry agent uninstall
|
|
121
115
|
perry agent status
|
|
122
116
|
|
|
123
117
|
# Workspaces
|
|
@@ -126,10 +120,8 @@ perry start <name>
|
|
|
126
120
|
perry stop <name>
|
|
127
121
|
perry delete <name>
|
|
128
122
|
perry list
|
|
123
|
+
perry shell <name>
|
|
129
124
|
perry logs <name>
|
|
130
|
-
|
|
131
|
-
# Build
|
|
132
|
-
perry build [--no-cache]
|
|
133
125
|
```
|
|
134
126
|
|
|
135
127
|
## Development
|
package/dist/agent/router.js
CHANGED
|
@@ -8,6 +8,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 { parseClaudeSessionContent } from '../sessions/parser';
|
|
11
|
+
import { discoverAllSessions, getSessionDetails as getAgentSessionDetails, getSessionMessages, findSessionMessages, } from '../sessions/agents';
|
|
11
12
|
const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']);
|
|
12
13
|
const WorkspacePortsSchema = z.object({
|
|
13
14
|
ssh: z.number(),
|
|
@@ -56,37 +57,6 @@ function mapErrorToORPC(err, defaultMessage) {
|
|
|
56
57
|
}
|
|
57
58
|
throw new ORPCError('INTERNAL_SERVER_ERROR', { message });
|
|
58
59
|
}
|
|
59
|
-
function decodeClaudeProjectPath(encoded) {
|
|
60
|
-
return encoded.replace(/-/g, '/');
|
|
61
|
-
}
|
|
62
|
-
function extractFirstUserPrompt(messages) {
|
|
63
|
-
const firstPrompt = messages.find((msg) => msg.type === 'user' && msg.content && msg.content.trim().length > 0);
|
|
64
|
-
return firstPrompt?.content ? firstPrompt.content.slice(0, 200) : null;
|
|
65
|
-
}
|
|
66
|
-
function extractClaudeSessionName(content) {
|
|
67
|
-
const lines = content.split('\n').filter((line) => line.trim());
|
|
68
|
-
for (const line of lines) {
|
|
69
|
-
try {
|
|
70
|
-
const obj = JSON.parse(line);
|
|
71
|
-
if (obj.type === 'system' && obj.subtype === 'session_name') {
|
|
72
|
-
return obj.name || null;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
function extractContent(content) {
|
|
82
|
-
if (typeof content === 'string')
|
|
83
|
-
return content;
|
|
84
|
-
if (Array.isArray(content)) {
|
|
85
|
-
const text = content.find((c) => c.type === 'text')?.text;
|
|
86
|
-
return typeof text === 'string' ? text : null;
|
|
87
|
-
}
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
60
|
export function createRouter(ctx) {
|
|
91
61
|
const listWorkspaces = os.handler(async () => {
|
|
92
62
|
return ctx.workspaces.list();
|
|
@@ -479,211 +449,21 @@ export function createRouter(ctx) {
|
|
|
479
449
|
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
|
|
480
450
|
}
|
|
481
451
|
const containerName = `workspace-${input.workspaceName}`;
|
|
482
|
-
const rawSessions =
|
|
483
|
-
const claudeResult = await execInContainer(containerName, [
|
|
484
|
-
'bash',
|
|
485
|
-
'-c',
|
|
486
|
-
'find /home/workspace/.claude/projects -name "*.jsonl" -type f ! -name "agent-*.jsonl" -printf "%p\\t%T@\\t%s\\n" 2>/dev/null || true',
|
|
487
|
-
], { user: 'workspace' });
|
|
488
|
-
if (claudeResult.exitCode === 0 && claudeResult.stdout.trim()) {
|
|
489
|
-
const lines = claudeResult.stdout.trim().split('\n').filter(Boolean);
|
|
490
|
-
for (const line of lines) {
|
|
491
|
-
const parts = line.split('\t');
|
|
492
|
-
if (parts.length >= 3) {
|
|
493
|
-
const file = parts[0];
|
|
494
|
-
const mtime = Math.floor(parseFloat(parts[1]) || 0);
|
|
495
|
-
const size = parseInt(parts[2], 10) || 0;
|
|
496
|
-
if (size === 0)
|
|
497
|
-
continue;
|
|
498
|
-
const id = file.split('/').pop()?.replace('.jsonl', '') || '';
|
|
499
|
-
const projDir = file.split('/').slice(-2, -1)[0] || '';
|
|
500
|
-
const projectPath = decodeClaudeProjectPath(projDir);
|
|
501
|
-
if (!projectPath.startsWith('/workspace') && !projectPath.startsWith('/home/workspace'))
|
|
502
|
-
continue;
|
|
503
|
-
rawSessions.push({
|
|
504
|
-
id,
|
|
505
|
-
agentType: 'claude-code',
|
|
506
|
-
mtime,
|
|
507
|
-
projectPath,
|
|
508
|
-
filePath: file,
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
const opencodeResult = await execInContainer(containerName, [
|
|
514
|
-
'sh',
|
|
515
|
-
'-c',
|
|
516
|
-
'find /home/workspace/.local/share/opencode/storage/session -name "ses_*.json" -type f 2>/dev/null || true',
|
|
517
|
-
], { user: 'workspace' });
|
|
518
|
-
if (opencodeResult.exitCode === 0 && opencodeResult.stdout.trim()) {
|
|
519
|
-
const files = opencodeResult.stdout.trim().split('\n').filter(Boolean);
|
|
520
|
-
const catAll = await execInContainer(containerName, ['sh', '-c', `cat ${files.map((f) => `"${f}"`).join(' ')} 2>/dev/null | jq -s '.'`], { user: 'workspace' });
|
|
521
|
-
if (catAll.exitCode === 0) {
|
|
522
|
-
try {
|
|
523
|
-
const sessions = JSON.parse(catAll.stdout);
|
|
524
|
-
for (let i = 0; i < sessions.length; i++) {
|
|
525
|
-
const data = sessions[i];
|
|
526
|
-
const file = files[i];
|
|
527
|
-
const id = data.id || file.split('/').pop()?.replace('.json', '') || '';
|
|
528
|
-
const mtime = Math.floor((data.time?.updated || 0) / 1000);
|
|
529
|
-
rawSessions.push({
|
|
530
|
-
id,
|
|
531
|
-
agentType: 'opencode',
|
|
532
|
-
projectPath: data.directory || '',
|
|
533
|
-
mtime,
|
|
534
|
-
name: data.title || undefined,
|
|
535
|
-
filePath: file,
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
catch {
|
|
540
|
-
// Skip on parse error
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
const codexResult = await execInContainer(containerName, [
|
|
545
|
-
'sh',
|
|
546
|
-
'-c',
|
|
547
|
-
'find /home/workspace/.codex/sessions -name "rollout-*.jsonl" -type f -printf "%p\\t%T@\\t" -exec wc -l {} \\; 2>/dev/null || true',
|
|
548
|
-
], { user: 'workspace' });
|
|
549
|
-
if (codexResult.exitCode === 0 && codexResult.stdout.trim()) {
|
|
550
|
-
const lines = codexResult.stdout.trim().split('\n').filter(Boolean);
|
|
551
|
-
for (const line of lines) {
|
|
552
|
-
const parts = line.split('\t');
|
|
553
|
-
if (parts.length >= 2) {
|
|
554
|
-
const file = parts[0];
|
|
555
|
-
const mtime = Math.floor(parseFloat(parts[1]) || 0);
|
|
556
|
-
const id = file.split('/').pop()?.replace('.jsonl', '') || '';
|
|
557
|
-
const projPath = file
|
|
558
|
-
.replace('/home/workspace/.codex/sessions/', '')
|
|
559
|
-
.replace(/\/[^/]+$/, '');
|
|
560
|
-
rawSessions.push({
|
|
561
|
-
id,
|
|
562
|
-
agentType: 'codex',
|
|
563
|
-
projectPath: projPath,
|
|
564
|
-
mtime,
|
|
565
|
-
filePath: file,
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
}
|
|
452
|
+
const rawSessions = await discoverAllSessions(containerName, execInContainer);
|
|
570
453
|
const customNames = await getSessionNamesForWorkspace(ctx.stateDir, input.workspaceName);
|
|
571
454
|
const filteredSessions = rawSessions
|
|
572
455
|
.filter((s) => !input.agentType || s.agentType === input.agentType)
|
|
573
456
|
.sort((a, b) => b.mtime - a.mtime);
|
|
574
457
|
const paginatedRawSessions = filteredSessions.slice(offset, offset + limit);
|
|
575
458
|
const sessions = [];
|
|
576
|
-
for (const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
user: 'workspace',
|
|
580
|
-
});
|
|
581
|
-
if (catResult.exitCode !== 0) {
|
|
582
|
-
continue;
|
|
583
|
-
}
|
|
584
|
-
const messages = parseClaudeSessionContent(catResult.stdout).filter((msg) => msg.type !== 'system');
|
|
585
|
-
const firstPrompt = extractFirstUserPrompt(messages);
|
|
586
|
-
const name = extractClaudeSessionName(catResult.stdout);
|
|
587
|
-
if (messages.length === 0) {
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
459
|
+
for (const rawSession of paginatedRawSessions) {
|
|
460
|
+
const details = await getAgentSessionDetails(containerName, rawSession, execInContainer);
|
|
461
|
+
if (details) {
|
|
590
462
|
sessions.push({
|
|
591
|
-
|
|
592
|
-
name: customNames[
|
|
593
|
-
agentType: session.agentType,
|
|
594
|
-
projectPath: session.projectPath,
|
|
595
|
-
messageCount: messages.length,
|
|
596
|
-
lastActivity: new Date(session.mtime * 1000).toISOString(),
|
|
597
|
-
firstPrompt,
|
|
463
|
+
...details,
|
|
464
|
+
name: customNames[details.id] || details.name,
|
|
598
465
|
});
|
|
599
|
-
continue;
|
|
600
466
|
}
|
|
601
|
-
if (session.agentType === 'opencode') {
|
|
602
|
-
const msgDir = `/home/workspace/.local/share/opencode/storage/message/${session.id}`;
|
|
603
|
-
const listMsgsResult = await execInContainer(containerName, ['bash', '-c', `ls -1 "${msgDir}"/msg_*.json 2>/dev/null | sort`], { user: 'workspace' });
|
|
604
|
-
const messages = [];
|
|
605
|
-
if (listMsgsResult.exitCode === 0 && listMsgsResult.stdout.trim()) {
|
|
606
|
-
const msgFiles = listMsgsResult.stdout.trim().split('\n').filter(Boolean);
|
|
607
|
-
for (const msgFile of msgFiles) {
|
|
608
|
-
const msgResult = await execInContainer(containerName, ['cat', msgFile], {
|
|
609
|
-
user: 'workspace',
|
|
610
|
-
});
|
|
611
|
-
if (msgResult.exitCode !== 0)
|
|
612
|
-
continue;
|
|
613
|
-
try {
|
|
614
|
-
const msg = JSON.parse(msgResult.stdout);
|
|
615
|
-
if (msg.role === 'user' || msg.role === 'assistant') {
|
|
616
|
-
const content = extractContent(msg.content);
|
|
617
|
-
messages.push({ type: msg.role, content: content || undefined });
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
catch {
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
const firstPrompt = messages.find((msg) => msg.type === 'user' && msg.content && msg.content.trim().length > 0)?.content;
|
|
626
|
-
if (messages.length === 0) {
|
|
627
|
-
continue;
|
|
628
|
-
}
|
|
629
|
-
sessions.push({
|
|
630
|
-
id: session.id,
|
|
631
|
-
name: customNames[session.id] || session.name || null,
|
|
632
|
-
agentType: session.agentType,
|
|
633
|
-
projectPath: session.projectPath,
|
|
634
|
-
messageCount: messages.length,
|
|
635
|
-
lastActivity: new Date(session.mtime * 1000).toISOString(),
|
|
636
|
-
firstPrompt: firstPrompt ? firstPrompt.slice(0, 200) : null,
|
|
637
|
-
});
|
|
638
|
-
continue;
|
|
639
|
-
}
|
|
640
|
-
const catResult = await execInContainer(containerName, ['cat', session.filePath], {
|
|
641
|
-
user: 'workspace',
|
|
642
|
-
});
|
|
643
|
-
if (catResult.exitCode !== 0) {
|
|
644
|
-
continue;
|
|
645
|
-
}
|
|
646
|
-
const lines = catResult.stdout.split('\n').filter(Boolean);
|
|
647
|
-
let sessionId = session.id;
|
|
648
|
-
if (lines.length > 0) {
|
|
649
|
-
try {
|
|
650
|
-
const meta = JSON.parse(lines[0]);
|
|
651
|
-
if (meta.session_id) {
|
|
652
|
-
sessionId = meta.session_id;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
catch {
|
|
656
|
-
// ignore
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
const messages = [];
|
|
660
|
-
for (let i = 1; i < lines.length; i++) {
|
|
661
|
-
try {
|
|
662
|
-
const event = JSON.parse(lines[i]);
|
|
663
|
-
const role = event.payload?.role || event.payload?.message?.role;
|
|
664
|
-
const content = event.payload?.content || event.payload?.message?.content;
|
|
665
|
-
if (role === 'user' || role === 'assistant') {
|
|
666
|
-
const textContent = extractContent(content);
|
|
667
|
-
messages.push({ type: role, content: textContent || undefined });
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
catch {
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
const firstPrompt = messages.find((msg) => msg.type === 'user' && msg.content && msg.content.trim().length > 0)?.content;
|
|
675
|
-
if (messages.length === 0) {
|
|
676
|
-
continue;
|
|
677
|
-
}
|
|
678
|
-
sessions.push({
|
|
679
|
-
id: sessionId,
|
|
680
|
-
name: customNames[sessionId] || null,
|
|
681
|
-
agentType: session.agentType,
|
|
682
|
-
projectPath: session.projectPath,
|
|
683
|
-
messageCount: messages.length,
|
|
684
|
-
lastActivity: new Date(session.mtime * 1000).toISOString(),
|
|
685
|
-
firstPrompt: firstPrompt ? firstPrompt.slice(0, 200) : null,
|
|
686
|
-
});
|
|
687
467
|
}
|
|
688
468
|
return {
|
|
689
469
|
sessions,
|
|
@@ -706,195 +486,50 @@ export function createRouter(ctx) {
|
|
|
706
486
|
workspaceName: z.string(),
|
|
707
487
|
sessionId: z.string(),
|
|
708
488
|
agentType: z.enum(['claude-code', 'opencode', 'codex']).optional(),
|
|
489
|
+
limit: z.number().optional(),
|
|
490
|
+
offset: z.number().optional(),
|
|
709
491
|
}))
|
|
710
492
|
.handler(async ({ input }) => {
|
|
711
493
|
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
|
|
494
|
+
let result;
|
|
712
495
|
if (isHost) {
|
|
713
496
|
const config = ctx.config.get();
|
|
714
497
|
if (!config.allowHostAccess) {
|
|
715
498
|
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
|
|
716
499
|
}
|
|
717
|
-
|
|
500
|
+
result = await getHostSession(input.sessionId, input.agentType);
|
|
718
501
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
if (workspace.status !== 'running') {
|
|
724
|
-
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
|
|
725
|
-
}
|
|
726
|
-
const containerName = `workspace-${input.workspaceName}`;
|
|
727
|
-
const messages = [];
|
|
728
|
-
if (!input.agentType || input.agentType === 'claude-code') {
|
|
729
|
-
const safeSessionId = input.sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
730
|
-
const findResult = await execInContainer(containerName, [
|
|
731
|
-
'find',
|
|
732
|
-
'/home/workspace/.claude/projects',
|
|
733
|
-
'-name',
|
|
734
|
-
`${safeSessionId}.jsonl`,
|
|
735
|
-
'-type',
|
|
736
|
-
'f',
|
|
737
|
-
], { user: 'workspace' });
|
|
738
|
-
const foundPath = findResult.stdout.trim().split('\n')[0];
|
|
739
|
-
if (findResult.exitCode === 0 && foundPath) {
|
|
740
|
-
const filePath = foundPath;
|
|
741
|
-
const catResult = await execInContainer(containerName, ['cat', filePath], {
|
|
742
|
-
user: 'workspace',
|
|
743
|
-
});
|
|
744
|
-
if (catResult.exitCode === 0) {
|
|
745
|
-
const parsed = parseClaudeSessionContent(catResult.stdout)
|
|
746
|
-
.filter((msg) => msg.type !== 'system')
|
|
747
|
-
.filter((msg) => msg.type === 'tool_use' ||
|
|
748
|
-
msg.type === 'tool_result' ||
|
|
749
|
-
(msg.content && msg.content.trim().length > 0));
|
|
750
|
-
return { id: input.sessionId, agentType: 'claude-code', messages: parsed };
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
if (!input.agentType || input.agentType === 'opencode') {
|
|
755
|
-
const findResult = await execInContainer(containerName, [
|
|
756
|
-
'bash',
|
|
757
|
-
'-c',
|
|
758
|
-
`find /home/workspace/.local/share/opencode/storage/session -name "${input.sessionId}.json" -type f 2>/dev/null | head -1`,
|
|
759
|
-
], { user: 'workspace' });
|
|
760
|
-
if (findResult.exitCode === 0 && findResult.stdout.trim()) {
|
|
761
|
-
const filePath = findResult.stdout.trim();
|
|
762
|
-
const catResult = await execInContainer(containerName, ['cat', filePath], {
|
|
763
|
-
user: 'workspace',
|
|
764
|
-
});
|
|
765
|
-
if (catResult.exitCode === 0) {
|
|
766
|
-
try {
|
|
767
|
-
const session = JSON.parse(catResult.stdout);
|
|
768
|
-
const msgDir = `/home/workspace/.local/share/opencode/storage/message/${session.id}`;
|
|
769
|
-
const partDir = `/home/workspace/.local/share/opencode/storage/part`;
|
|
770
|
-
const listMsgsResult = await execInContainer(containerName, ['bash', '-c', `ls -1 "${msgDir}"/msg_*.json 2>/dev/null | sort`], { user: 'workspace' });
|
|
771
|
-
if (listMsgsResult.exitCode === 0 && listMsgsResult.stdout.trim()) {
|
|
772
|
-
const msgFiles = listMsgsResult.stdout.trim().split('\n').filter(Boolean);
|
|
773
|
-
for (const msgFile of msgFiles) {
|
|
774
|
-
const msgResult = await execInContainer(containerName, ['cat', msgFile], {
|
|
775
|
-
user: 'workspace',
|
|
776
|
-
});
|
|
777
|
-
if (msgResult.exitCode === 0) {
|
|
778
|
-
try {
|
|
779
|
-
const msg = JSON.parse(msgResult.stdout);
|
|
780
|
-
if (!msg.id || (msg.role !== 'user' && msg.role !== 'assistant'))
|
|
781
|
-
continue;
|
|
782
|
-
const timestamp = msg.time?.created
|
|
783
|
-
? new Date(msg.time.created).toISOString()
|
|
784
|
-
: undefined;
|
|
785
|
-
const listPartsResult = await execInContainer(containerName, [
|
|
786
|
-
'bash',
|
|
787
|
-
'-c',
|
|
788
|
-
`ls -1 "${partDir}/${msg.id}"/prt_*.json 2>/dev/null | sort`,
|
|
789
|
-
], { user: 'workspace' });
|
|
790
|
-
if (listPartsResult.exitCode === 0 && listPartsResult.stdout.trim()) {
|
|
791
|
-
const partFiles = listPartsResult.stdout.trim().split('\n').filter(Boolean);
|
|
792
|
-
for (const partFile of partFiles) {
|
|
793
|
-
const partResult = await execInContainer(containerName, ['cat', partFile], { user: 'workspace' });
|
|
794
|
-
if (partResult.exitCode === 0) {
|
|
795
|
-
try {
|
|
796
|
-
const part = JSON.parse(partResult.stdout);
|
|
797
|
-
if (part.type === 'text' && part.text) {
|
|
798
|
-
messages.push({
|
|
799
|
-
type: msg.role,
|
|
800
|
-
content: part.text,
|
|
801
|
-
timestamp,
|
|
802
|
-
});
|
|
803
|
-
}
|
|
804
|
-
else if (part.type === 'tool' && part.tool) {
|
|
805
|
-
messages.push({
|
|
806
|
-
type: 'tool_use',
|
|
807
|
-
content: undefined,
|
|
808
|
-
toolName: part.state?.title || part.tool,
|
|
809
|
-
toolId: part.callID || part.id,
|
|
810
|
-
toolInput: JSON.stringify(part.state?.input, null, 2),
|
|
811
|
-
timestamp,
|
|
812
|
-
});
|
|
813
|
-
if (part.state?.output) {
|
|
814
|
-
messages.push({
|
|
815
|
-
type: 'tool_result',
|
|
816
|
-
content: part.state.output,
|
|
817
|
-
toolId: part.callID || part.id,
|
|
818
|
-
timestamp,
|
|
819
|
-
});
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
catch {
|
|
824
|
-
continue;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
catch {
|
|
831
|
-
continue;
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
return { id: input.sessionId, agentType: 'opencode', messages };
|
|
837
|
-
}
|
|
838
|
-
catch {
|
|
839
|
-
// Session parse failed
|
|
840
|
-
}
|
|
841
|
-
}
|
|
502
|
+
else {
|
|
503
|
+
const workspace = await ctx.workspaces.get(input.workspaceName);
|
|
504
|
+
if (!workspace) {
|
|
505
|
+
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
|
|
842
506
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
const findResult = await execInContainer(containerName, [
|
|
846
|
-
'bash',
|
|
847
|
-
'-c',
|
|
848
|
-
`find /home/workspace/.codex/sessions -name "rollout-*.jsonl" -type f 2>/dev/null`,
|
|
849
|
-
], { user: 'workspace' });
|
|
850
|
-
if (findResult.exitCode === 0 && findResult.stdout.trim()) {
|
|
851
|
-
const files = findResult.stdout.trim().split('\n').filter(Boolean);
|
|
852
|
-
for (const filePath of files) {
|
|
853
|
-
const headResult = await execInContainer(containerName, ['bash', '-c', `head -1 "${filePath}"`], { user: 'workspace' });
|
|
854
|
-
let sessionId = filePath.split('/').pop()?.replace('.jsonl', '') || '';
|
|
855
|
-
if (headResult.exitCode === 0 && headResult.stdout.trim()) {
|
|
856
|
-
try {
|
|
857
|
-
const meta = JSON.parse(headResult.stdout.trim());
|
|
858
|
-
if (meta.session_id)
|
|
859
|
-
sessionId = meta.session_id;
|
|
860
|
-
}
|
|
861
|
-
catch {
|
|
862
|
-
// Use filename
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
if (sessionId === input.sessionId) {
|
|
866
|
-
const catResult = await execInContainer(containerName, ['cat', filePath], {
|
|
867
|
-
user: 'workspace',
|
|
868
|
-
});
|
|
869
|
-
if (catResult.exitCode === 0) {
|
|
870
|
-
const lines = catResult.stdout.split('\n').filter(Boolean);
|
|
871
|
-
for (let i = 1; i < lines.length; i++) {
|
|
872
|
-
try {
|
|
873
|
-
const event = JSON.parse(lines[i]);
|
|
874
|
-
const role = event.payload?.role || event.payload?.message?.role;
|
|
875
|
-
const content = event.payload?.content || event.payload?.message?.content;
|
|
876
|
-
if (role === 'user' || role === 'assistant') {
|
|
877
|
-
const parsedContent = extractContent(content);
|
|
878
|
-
messages.push({
|
|
879
|
-
type: role,
|
|
880
|
-
content: parsedContent || undefined,
|
|
881
|
-
timestamp: event.timestamp
|
|
882
|
-
? new Date(event.timestamp).toISOString()
|
|
883
|
-
: undefined,
|
|
884
|
-
});
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
catch {
|
|
888
|
-
continue;
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
return { id: input.sessionId, agentType: 'codex', messages };
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
}
|
|
507
|
+
if (workspace.status !== 'running') {
|
|
508
|
+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
|
|
895
509
|
}
|
|
510
|
+
const containerName = `workspace-${input.workspaceName}`;
|
|
511
|
+
result = input.agentType
|
|
512
|
+
? await getSessionMessages(containerName, input.sessionId, input.agentType, execInContainer)
|
|
513
|
+
: await findSessionMessages(containerName, input.sessionId, execInContainer);
|
|
514
|
+
}
|
|
515
|
+
if (!result) {
|
|
516
|
+
throw new ORPCError('NOT_FOUND', { message: 'Session not found' });
|
|
517
|
+
}
|
|
518
|
+
const allMessages = result.messages || [];
|
|
519
|
+
const total = allMessages.length;
|
|
520
|
+
if (input.limit !== undefined) {
|
|
521
|
+
const offset = input.offset ?? 0;
|
|
522
|
+
const startIndex = Math.max(0, total - offset - input.limit);
|
|
523
|
+
const endIndex = total - offset;
|
|
524
|
+
const paginatedMessages = allMessages.slice(startIndex, endIndex);
|
|
525
|
+
return {
|
|
526
|
+
...result,
|
|
527
|
+
messages: paginatedMessages,
|
|
528
|
+
total,
|
|
529
|
+
hasMore: startIndex > 0,
|
|
530
|
+
};
|
|
896
531
|
}
|
|
897
|
-
|
|
532
|
+
return { ...result, total, hasMore: false };
|
|
898
533
|
});
|
|
899
534
|
const renameSession = os
|
|
900
535
|
.input(z.object({
|