@gricha/perry 0.0.1 → 0.1.1

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.
Files changed (37) hide show
  1. package/README.md +25 -33
  2. package/dist/agent/router.js +39 -404
  3. package/dist/agent/web/assets/index-BGbqUzMS.js +104 -0
  4. package/dist/agent/web/assets/index-CHEQQv1U.css +1 -0
  5. package/dist/agent/web/favicon.ico +0 -0
  6. package/dist/agent/web/index.html +3 -3
  7. package/dist/agent/web/logo-192.png +0 -0
  8. package/dist/agent/web/logo-512.png +0 -0
  9. package/dist/agent/web/logo.png +0 -0
  10. package/dist/agent/web/logo.webp +0 -0
  11. package/dist/chat/base-chat-websocket.js +83 -0
  12. package/dist/chat/host-opencode-handler.js +115 -3
  13. package/dist/chat/opencode-handler.js +60 -0
  14. package/dist/chat/opencode-server.js +252 -0
  15. package/dist/chat/opencode-websocket.js +15 -87
  16. package/dist/chat/websocket.js +19 -86
  17. package/dist/client/ws-shell.js +15 -5
  18. package/dist/docker/index.js +41 -1
  19. package/dist/index.js +18 -3
  20. package/dist/sessions/agents/claude.js +86 -0
  21. package/dist/sessions/agents/codex.js +110 -0
  22. package/dist/sessions/agents/index.js +44 -0
  23. package/dist/sessions/agents/opencode.js +168 -0
  24. package/dist/sessions/agents/types.js +1 -0
  25. package/dist/sessions/agents/utils.js +31 -0
  26. package/dist/shared/base-websocket.js +13 -1
  27. package/dist/shared/constants.js +2 -1
  28. package/dist/terminal/base-handler.js +68 -0
  29. package/dist/terminal/handler.js +18 -75
  30. package/dist/terminal/host-handler.js +7 -61
  31. package/dist/terminal/websocket.js +2 -4
  32. package/dist/workspace/manager.js +33 -22
  33. package/dist/workspace/state.js +33 -2
  34. package/package.json +1 -1
  35. package/dist/agent/web/assets/index-9t2sFIJM.js +0 -101
  36. package/dist/agent/web/assets/index-CCFpTruF.css +0 -1
  37. 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, GitHub Copilot pre-installed
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
- # SSH into workspace
64
- perry list # Find SSH port
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, making them ideal for private networks where you can safely access workspaces remotely without additional security concerns.
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 environment variables via Web UI → Settings or edit `~/.config/perry/config.json`:
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
@@ -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 session of paginatedRawSessions) {
577
- if (session.agentType === 'claude-code') {
578
- const catResult = await execInContainer(containerName, ['cat', session.filePath], {
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
- id: session.id,
592
- name: customNames[session.id] || name || null,
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
- return getHostSession(input.sessionId, input.agentType);
500
+ result = await getHostSession(input.sessionId, input.agentType);
718
501
  }
719
- const workspace = await ctx.workspaces.get(input.workspaceName);
720
- if (!workspace) {
721
- throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
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
- if (!input.agentType || input.agentType === 'codex') {
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
- throw new ORPCError('NOT_FOUND', { message: 'Session not found' });
532
+ return { ...result, total, hasMore: false };
898
533
  });
899
534
  const renameSession = os
900
535
  .input(z.object({