@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.
@@ -1,21 +1,47 @@
1
+ import { BaseOpencodeSession } from './base-opencode-session';
1
2
  import { opencodeProvider } from '../sessions/agents/opencode';
2
- export class OpencodeSession {
3
- process = null;
3
+ export class OpencodeSession extends BaseOpencodeSession {
4
4
  containerName;
5
5
  workDir;
6
- sessionId;
7
- model;
8
- sessionModel;
9
- onMessage;
10
- buffer = '';
11
- historyLoaded = false;
12
6
  constructor(options, onMessage) {
7
+ super(options.sessionId, options.model, onMessage);
13
8
  this.containerName = options.containerName;
14
9
  this.workDir = options.workDir || '/home/workspace';
15
- this.sessionId = options.sessionId;
16
- this.model = options.model;
17
- this.sessionModel = options.model;
18
- this.onMessage = onMessage;
10
+ }
11
+ getLogPrefix() {
12
+ return 'opencode';
13
+ }
14
+ getNoOutputErrorMessage() {
15
+ return 'No response from OpenCode. Check if OpenCode is configured in the workspace.';
16
+ }
17
+ getSpawnConfig(userMessage) {
18
+ const args = [
19
+ 'docker',
20
+ 'exec',
21
+ '-i',
22
+ '-u',
23
+ 'workspace',
24
+ '-w',
25
+ this.workDir,
26
+ this.containerName,
27
+ 'stdbuf',
28
+ '-oL',
29
+ 'opencode',
30
+ 'run',
31
+ '--format',
32
+ 'json',
33
+ ];
34
+ if (this.sessionId) {
35
+ args.push('--session', this.sessionId);
36
+ }
37
+ if (this.model) {
38
+ args.push('--model', this.model);
39
+ }
40
+ args.push(userMessage);
41
+ return {
42
+ command: args,
43
+ options: {},
44
+ };
19
45
  }
20
46
  async loadHistory() {
21
47
  if (this.historyLoaded || !this.sessionId) {
@@ -68,191 +94,6 @@ export class OpencodeSession {
68
94
  console.error('[opencode] Failed to load history:', err);
69
95
  }
70
96
  }
71
- async sendMessage(userMessage) {
72
- if (this.sessionId && !this.historyLoaded) {
73
- await this.loadHistory();
74
- }
75
- const args = [
76
- 'exec',
77
- '-i',
78
- '-u',
79
- 'workspace',
80
- '-w',
81
- this.workDir,
82
- this.containerName,
83
- 'stdbuf',
84
- '-oL',
85
- 'opencode',
86
- 'run',
87
- '--format',
88
- 'json',
89
- ];
90
- if (this.sessionId) {
91
- args.push('--session', this.sessionId);
92
- }
93
- if (this.model) {
94
- args.push('--model', this.model);
95
- }
96
- args.push(userMessage);
97
- console.log('[opencode] Running:', 'docker', args.join(' '));
98
- this.onMessage({
99
- type: 'system',
100
- content: 'Processing your message...',
101
- timestamp: new Date().toISOString(),
102
- });
103
- try {
104
- const proc = Bun.spawn(['docker', ...args], {
105
- stdin: 'ignore',
106
- stdout: 'pipe',
107
- stderr: 'pipe',
108
- });
109
- this.process = proc;
110
- if (!proc.stdout || !proc.stderr) {
111
- throw new Error('Failed to get process streams');
112
- }
113
- console.log('[opencode] Process spawned, waiting for output...');
114
- const stderrPromise = new Response(proc.stderr).text();
115
- const decoder = new TextDecoder();
116
- let receivedAnyOutput = false;
117
- for await (const chunk of proc.stdout) {
118
- const text = decoder.decode(chunk);
119
- console.log('[opencode] Received chunk:', text.length, 'bytes');
120
- receivedAnyOutput = true;
121
- this.buffer += text;
122
- this.processBuffer();
123
- }
124
- const exitCode = await proc.exited;
125
- console.log('[opencode] Process exited with code:', exitCode, 'receivedOutput:', receivedAnyOutput);
126
- const stderrText = await stderrPromise;
127
- if (stderrText) {
128
- console.error('[opencode] stderr:', stderrText);
129
- }
130
- if (exitCode !== 0) {
131
- this.onMessage({
132
- type: 'error',
133
- content: stderrText || `OpenCode exited with code ${exitCode}`,
134
- timestamp: new Date().toISOString(),
135
- });
136
- return;
137
- }
138
- if (!receivedAnyOutput) {
139
- this.onMessage({
140
- type: 'error',
141
- content: 'No response from OpenCode. Check if OpenCode is configured in the workspace.',
142
- timestamp: new Date().toISOString(),
143
- });
144
- return;
145
- }
146
- this.onMessage({
147
- type: 'done',
148
- content: 'Response complete',
149
- timestamp: new Date().toISOString(),
150
- });
151
- }
152
- catch (err) {
153
- console.error('[opencode] Error:', err);
154
- this.onMessage({
155
- type: 'error',
156
- content: err.message,
157
- timestamp: new Date().toISOString(),
158
- });
159
- }
160
- finally {
161
- this.process = null;
162
- }
163
- }
164
- processBuffer() {
165
- const lines = this.buffer.split('\n');
166
- this.buffer = lines.pop() || '';
167
- for (const line of lines) {
168
- if (!line.trim())
169
- continue;
170
- try {
171
- const event = JSON.parse(line);
172
- this.handleStreamEvent(event);
173
- }
174
- catch {
175
- console.error('[opencode] Failed to parse:', line);
176
- }
177
- }
178
- }
179
- handleStreamEvent(event) {
180
- const timestamp = new Date().toISOString();
181
- if (event.type === 'step_start' && event.sessionID) {
182
- if (!this.sessionId) {
183
- this.sessionId = event.sessionID;
184
- this.sessionModel = this.model;
185
- this.historyLoaded = true;
186
- this.onMessage({
187
- type: 'system',
188
- content: `Session started ${this.sessionId}`,
189
- timestamp,
190
- });
191
- }
192
- return;
193
- }
194
- if (event.type === 'text' && event.part?.text) {
195
- this.onMessage({
196
- type: 'assistant',
197
- content: event.part.text,
198
- timestamp,
199
- });
200
- return;
201
- }
202
- if (event.type === 'tool_use' && event.part) {
203
- const toolName = event.part.tool || 'unknown';
204
- const toolId = event.part.callID || event.part.id;
205
- const input = event.part.state?.input;
206
- const output = event.part.state?.output;
207
- const title = event.part.state?.title || input?.description || toolName;
208
- console.log('[opencode] Tool use:', toolName, title);
209
- this.onMessage({
210
- type: 'tool_use',
211
- content: JSON.stringify(input, null, 2),
212
- toolName: title || toolName,
213
- toolId,
214
- timestamp,
215
- });
216
- if (output) {
217
- this.onMessage({
218
- type: 'tool_result',
219
- content: output,
220
- toolName,
221
- toolId,
222
- timestamp,
223
- });
224
- }
225
- return;
226
- }
227
- }
228
- async interrupt() {
229
- if (this.process) {
230
- this.process.kill();
231
- this.process = null;
232
- this.onMessage({
233
- type: 'system',
234
- content: 'Chat interrupted',
235
- timestamp: new Date().toISOString(),
236
- });
237
- }
238
- }
239
- setModel(model) {
240
- if (this.model !== model) {
241
- this.model = model;
242
- if (this.sessionModel !== model) {
243
- this.sessionId = undefined;
244
- this.historyLoaded = false;
245
- this.onMessage({
246
- type: 'system',
247
- content: `Switching to model: ${model}`,
248
- timestamp: new Date().toISOString(),
249
- });
250
- }
251
- }
252
- }
253
- getSessionId() {
254
- return this.sessionId;
255
- }
256
97
  }
257
98
  export function createOpencodeSession(options, onMessage) {
258
99
  return new OpencodeSession(options, onMessage);
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'child_process';
2
+ import { CONTAINER_PREFIX } from '../shared/constants';
2
3
  export * from './types';
3
- const CONTAINER_PREFIX = 'workspace-';
4
4
  export function getContainerName(name) {
5
5
  return `${CONTAINER_PREFIX}${name}`;
6
6
  }
package/dist/index.js CHANGED
@@ -201,6 +201,12 @@ program
201
201
  console.log(` Uptime: ${formatUptime(info.uptime)}`);
202
202
  console.log(` Workspaces: ${info.workspacesCount}`);
203
203
  console.log(` Docker: ${info.dockerVersion}`);
204
+ if (info.tailscale?.running) {
205
+ console.log(` Tailscale: ${info.tailscale.dnsName}`);
206
+ if (info.tailscale.httpsUrl) {
207
+ console.log(` HTTPS URL: ${info.tailscale.httpsUrl}`);
208
+ }
209
+ }
204
210
  }
205
211
  }
206
212
  catch (err) {
@@ -599,6 +605,42 @@ program
599
605
  process.exit(1);
600
606
  }
601
607
  });
608
+ const workerCmd = program
609
+ .command('worker')
610
+ .description('Worker mode commands (for use inside containers)');
611
+ workerCmd
612
+ .command('sessions')
613
+ .argument('<subcommand>', 'Subcommand: list, messages, or delete')
614
+ .argument('[sessionId]', 'Session ID (required for messages and delete)')
615
+ .description('Manage OpenCode sessions')
616
+ .action(async (subcommand, sessionId) => {
617
+ const { listOpencodeSessions, getOpencodeSessionMessages, deleteOpencodeSession } = await import('./sessions/agents/opencode-storage');
618
+ if (subcommand === 'list') {
619
+ const sessions = await listOpencodeSessions();
620
+ console.log(JSON.stringify(sessions));
621
+ }
622
+ else if (subcommand === 'messages') {
623
+ if (!sessionId) {
624
+ console.error('Usage: perry worker sessions messages <session_id>');
625
+ process.exit(1);
626
+ }
627
+ const result = await getOpencodeSessionMessages(sessionId);
628
+ console.log(JSON.stringify(result));
629
+ }
630
+ else if (subcommand === 'delete') {
631
+ if (!sessionId) {
632
+ console.error('Usage: perry worker sessions delete <session_id>');
633
+ process.exit(1);
634
+ }
635
+ const result = await deleteOpencodeSession(sessionId);
636
+ console.log(JSON.stringify(result));
637
+ }
638
+ else {
639
+ console.error(`Unknown subcommand: ${subcommand}`);
640
+ console.error('Available: list, messages, delete');
641
+ process.exit(1);
642
+ }
643
+ });
602
644
  function handleError(err) {
603
645
  if (err instanceof ApiClientError) {
604
646
  console.error(`Error: ${err.message}`);
Binary file
@@ -83,4 +83,23 @@ export const claudeProvider = {
83
83
  (msg.content && msg.content.trim().length > 0));
84
84
  return { id: sessionId, messages };
85
85
  },
86
+ async deleteSession(containerName, sessionId, exec) {
87
+ const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
88
+ const findResult = await exec(containerName, [
89
+ 'bash',
90
+ '-c',
91
+ `find /home/workspace/.claude/projects -name "${safeSessionId}.jsonl" -type f 2>/dev/null | head -1`,
92
+ ], { user: 'workspace' });
93
+ if (findResult.exitCode !== 0 || !findResult.stdout.trim()) {
94
+ return { success: false, error: 'Session not found' };
95
+ }
96
+ const filePath = findResult.stdout.trim();
97
+ const rmResult = await exec(containerName, ['rm', '-f', filePath], {
98
+ user: 'workspace',
99
+ });
100
+ if (rmResult.exitCode !== 0) {
101
+ return { success: false, error: rmResult.stderr || 'Failed to delete session file' };
102
+ }
103
+ return { success: true };
104
+ },
86
105
  };
@@ -107,4 +107,44 @@ export const codexProvider = {
107
107
  }
108
108
  return null;
109
109
  },
110
+ async deleteSession(containerName, sessionId, exec) {
111
+ const findResult = await exec(containerName, ['bash', '-c', `find /home/workspace/.codex/sessions -name "*.jsonl" -type f 2>/dev/null`], { user: 'workspace' });
112
+ if (findResult.exitCode !== 0 || !findResult.stdout.trim()) {
113
+ return { success: false, error: 'No session files found' };
114
+ }
115
+ const files = findResult.stdout.trim().split('\n').filter(Boolean);
116
+ for (const file of files) {
117
+ const fileId = file.split('/').pop()?.replace('.jsonl', '') || '';
118
+ if (fileId === sessionId) {
119
+ const rmResult = await exec(containerName, ['rm', '-f', file], {
120
+ user: 'workspace',
121
+ });
122
+ if (rmResult.exitCode !== 0) {
123
+ return { success: false, error: rmResult.stderr || 'Failed to delete session file' };
124
+ }
125
+ return { success: true };
126
+ }
127
+ const headResult = await exec(containerName, ['head', '-1', file], {
128
+ user: 'workspace',
129
+ });
130
+ if (headResult.exitCode === 0) {
131
+ try {
132
+ const meta = JSON.parse(headResult.stdout);
133
+ if (meta.session_id === sessionId) {
134
+ const rmResult = await exec(containerName, ['rm', '-f', file], {
135
+ user: 'workspace',
136
+ });
137
+ if (rmResult.exitCode !== 0) {
138
+ return { success: false, error: rmResult.stderr || 'Failed to delete session file' };
139
+ }
140
+ return { success: true };
141
+ }
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ }
147
+ }
148
+ return { success: false, error: 'Session not found' };
149
+ },
110
150
  };
@@ -42,3 +42,66 @@ export async function findSessionMessages(containerName, sessionId, exec) {
42
42
  }
43
43
  return null;
44
44
  }
45
+ export async function deleteSession(containerName, sessionId, agentType, exec) {
46
+ const provider = providers[agentType];
47
+ if (!provider) {
48
+ return { success: false, error: 'Unknown agent type' };
49
+ }
50
+ return provider.deleteSession(containerName, sessionId, exec);
51
+ }
52
+ export async function searchSessions(containerName, query, exec) {
53
+ const safeQuery = query.replace(/['"\\]/g, '\\$&');
54
+ const searchPaths = [
55
+ '/home/workspace/.claude/projects',
56
+ '/home/workspace/.local/share/opencode/storage',
57
+ '/home/workspace/.codex/sessions',
58
+ ];
59
+ const rgCommand = `rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`;
60
+ const result = await exec(containerName, ['bash', '-c', rgCommand], {
61
+ user: 'workspace',
62
+ });
63
+ if (result.exitCode !== 0 || !result.stdout.trim()) {
64
+ return [];
65
+ }
66
+ const files = result.stdout.trim().split('\n').filter(Boolean);
67
+ const results = [];
68
+ for (const file of files) {
69
+ let sessionId = null;
70
+ let agentType = null;
71
+ if (file.includes('/.claude/projects/')) {
72
+ const match = file.match(/\/([^/]+)\.jsonl$/);
73
+ if (match && !match[1].startsWith('agent-')) {
74
+ sessionId = match[1];
75
+ agentType = 'claude-code';
76
+ }
77
+ }
78
+ else if (file.includes('/.local/share/opencode/storage/')) {
79
+ if (file.includes('/session/') && file.endsWith('.json')) {
80
+ const match = file.match(/\/(ses_[^/]+)\.json$/);
81
+ if (match) {
82
+ sessionId = match[1];
83
+ agentType = 'opencode';
84
+ }
85
+ }
86
+ else if (file.includes('/part/') || file.includes('/message/')) {
87
+ continue;
88
+ }
89
+ }
90
+ else if (file.includes('/.codex/sessions/')) {
91
+ const match = file.match(/\/([^/]+)\.jsonl$/);
92
+ if (match) {
93
+ sessionId = match[1];
94
+ agentType = 'codex';
95
+ }
96
+ }
97
+ if (sessionId && agentType) {
98
+ results.push({
99
+ sessionId,
100
+ agentType,
101
+ filePath: file,
102
+ matchCount: 1,
103
+ });
104
+ }
105
+ }
106
+ return results;
107
+ }
@@ -0,0 +1,218 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ function getStorageBase(homeDir) {
5
+ const home = homeDir || os.homedir();
6
+ return path.join(home, '.local', 'share', 'opencode', 'storage');
7
+ }
8
+ export async function listOpencodeSessions(homeDir) {
9
+ const storageBase = getStorageBase(homeDir);
10
+ const sessionDir = path.join(storageBase, 'session');
11
+ const sessions = [];
12
+ try {
13
+ const projectDirs = await fs.readdir(sessionDir, { withFileTypes: true });
14
+ for (const projectDir of projectDirs) {
15
+ if (!projectDir.isDirectory())
16
+ continue;
17
+ const projectPath = path.join(sessionDir, projectDir.name);
18
+ const sessionFiles = await fs.readdir(projectPath);
19
+ for (const sessionFile of sessionFiles) {
20
+ if (!sessionFile.startsWith('ses_') || !sessionFile.endsWith('.json'))
21
+ continue;
22
+ const filePath = path.join(projectPath, sessionFile);
23
+ try {
24
+ const stat = await fs.stat(filePath);
25
+ const content = await fs.readFile(filePath, 'utf-8');
26
+ const data = JSON.parse(content);
27
+ if (!data.id)
28
+ continue;
29
+ sessions.push({
30
+ id: data.id,
31
+ title: data.title || '',
32
+ directory: data.directory || '',
33
+ mtime: data.time?.updated || Math.floor(stat.mtimeMs),
34
+ file: filePath,
35
+ });
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ }
41
+ }
42
+ }
43
+ catch {
44
+ // Storage doesn't exist
45
+ }
46
+ return sessions;
47
+ }
48
+ export async function getOpencodeSessionMessages(sessionId, homeDir) {
49
+ const storageBase = getStorageBase(homeDir);
50
+ const sessionDir = path.join(storageBase, 'session');
51
+ const messageDir = path.join(storageBase, 'message');
52
+ const partDir = path.join(storageBase, 'part');
53
+ const sessionFile = await findSessionFile(sessionDir, sessionId);
54
+ if (!sessionFile) {
55
+ return { id: sessionId, messages: [] };
56
+ }
57
+ let internalId;
58
+ try {
59
+ const content = await fs.readFile(sessionFile, 'utf-8');
60
+ const data = JSON.parse(content);
61
+ internalId = data.id;
62
+ if (!internalId) {
63
+ return { id: sessionId, messages: [] };
64
+ }
65
+ }
66
+ catch {
67
+ return { id: sessionId, messages: [] };
68
+ }
69
+ const msgDir = path.join(messageDir, internalId);
70
+ const messages = [];
71
+ try {
72
+ const msgFiles = (await fs.readdir(msgDir))
73
+ .filter((f) => f.startsWith('msg_') && f.endsWith('.json'))
74
+ .sort();
75
+ for (const msgFile of msgFiles) {
76
+ const msgPath = path.join(msgDir, msgFile);
77
+ try {
78
+ const content = await fs.readFile(msgPath, 'utf-8');
79
+ const msg = JSON.parse(content);
80
+ if (!msg.id || (msg.role !== 'user' && msg.role !== 'assistant'))
81
+ continue;
82
+ const partMsgDir = path.join(partDir, msg.id);
83
+ try {
84
+ const partFiles = (await fs.readdir(partMsgDir))
85
+ .filter((f) => f.startsWith('prt_') && f.endsWith('.json'))
86
+ .sort();
87
+ for (const partFile of partFiles) {
88
+ const partPath = path.join(partMsgDir, partFile);
89
+ try {
90
+ const partContent = await fs.readFile(partPath, 'utf-8');
91
+ const part = JSON.parse(partContent);
92
+ const timestamp = msg.time?.created
93
+ ? new Date(msg.time.created).toISOString()
94
+ : undefined;
95
+ if (part.type === 'text' && part.text) {
96
+ messages.push({
97
+ type: msg.role,
98
+ content: part.text,
99
+ timestamp,
100
+ });
101
+ }
102
+ else if (part.type === 'tool') {
103
+ const toolName = part.state?.title || part.tool || '';
104
+ const callId = part.callID || part.id || '';
105
+ messages.push({
106
+ type: 'tool_use',
107
+ toolName,
108
+ toolId: callId,
109
+ toolInput: part.state?.input ? JSON.stringify(part.state.input) : '',
110
+ timestamp,
111
+ });
112
+ if (part.state?.output) {
113
+ messages.push({
114
+ type: 'tool_result',
115
+ content: part.state.output,
116
+ toolId: callId,
117
+ timestamp,
118
+ });
119
+ }
120
+ }
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ }
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ }
131
+ catch {
132
+ continue;
133
+ }
134
+ }
135
+ }
136
+ catch {
137
+ // No messages
138
+ }
139
+ return { id: sessionId, messages };
140
+ }
141
+ async function findSessionFile(sessionDir, sessionId) {
142
+ try {
143
+ const projectDirs = await fs.readdir(sessionDir, { withFileTypes: true });
144
+ for (const projectDir of projectDirs) {
145
+ if (!projectDir.isDirectory())
146
+ continue;
147
+ const filePath = path.join(sessionDir, projectDir.name, `${sessionId}.json`);
148
+ try {
149
+ await fs.access(filePath);
150
+ return filePath;
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ }
156
+ }
157
+ catch {
158
+ // Directory doesn't exist
159
+ }
160
+ return null;
161
+ }
162
+ export async function deleteOpencodeSession(sessionId, homeDir) {
163
+ const storageBase = getStorageBase(homeDir);
164
+ const sessionDir = path.join(storageBase, 'session');
165
+ const messageDir = path.join(storageBase, 'message');
166
+ const partDir = path.join(storageBase, 'part');
167
+ const sessionFile = await findSessionFile(sessionDir, sessionId);
168
+ if (!sessionFile) {
169
+ return { success: false, error: 'Session not found' };
170
+ }
171
+ let internalId = null;
172
+ try {
173
+ const content = await fs.readFile(sessionFile, 'utf-8');
174
+ const data = JSON.parse(content);
175
+ internalId = data.id;
176
+ }
177
+ catch {
178
+ // Continue with session file deletion only
179
+ }
180
+ try {
181
+ await fs.unlink(sessionFile);
182
+ }
183
+ catch (err) {
184
+ return { success: false, error: `Failed to delete session file: ${err}` };
185
+ }
186
+ if (internalId) {
187
+ const msgDir = path.join(messageDir, internalId);
188
+ try {
189
+ const msgFiles = await fs.readdir(msgDir);
190
+ for (const msgFile of msgFiles) {
191
+ if (!msgFile.startsWith('msg_') || !msgFile.endsWith('.json'))
192
+ continue;
193
+ const msgPath = path.join(msgDir, msgFile);
194
+ try {
195
+ const content = await fs.readFile(msgPath, 'utf-8');
196
+ const msg = JSON.parse(content);
197
+ if (msg.id) {
198
+ const partMsgDir = path.join(partDir, msg.id);
199
+ try {
200
+ await fs.rm(partMsgDir, { recursive: true });
201
+ }
202
+ catch {
203
+ // Parts may not exist
204
+ }
205
+ }
206
+ }
207
+ catch {
208
+ // Skip malformed messages
209
+ }
210
+ }
211
+ await fs.rm(msgDir, { recursive: true });
212
+ }
213
+ catch {
214
+ // Messages directory may not exist
215
+ }
216
+ }
217
+ return { success: true };
218
+ }