@gricha/perry 0.2.6 → 0.3.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.
@@ -12,6 +12,7 @@ import { parseClaudeSessionContent } from '../sessions/parser';
12
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
14
  import { listOpencodeSessions, getOpencodeSessionMessages, deleteOpencodeSession, } from '../sessions/agents/opencode-storage';
15
+ import { sessionManager } from '../session-manager';
15
16
  const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']);
16
17
  const WorkspacePortsSchema = z.object({
17
18
  ssh: z.number(),
@@ -965,6 +966,122 @@ export function createRouter(ctx) {
965
966
  }
966
967
  return { models };
967
968
  });
969
+ const LiveAgentTypeSchema = z.enum(['claude', 'opencode', 'codex']);
970
+ const listLiveSessions = os
971
+ .input(z.object({
972
+ workspaceName: z.string().optional(),
973
+ }))
974
+ .handler(async ({ input }) => {
975
+ const sessions = sessionManager.listActiveSessions(input.workspaceName);
976
+ return sessions.map((s) => ({
977
+ ...s,
978
+ startedAt: s.startedAt.toISOString(),
979
+ lastActivity: s.lastActivity.toISOString(),
980
+ }));
981
+ });
982
+ const getLiveSession = os
983
+ .input(z.object({
984
+ sessionId: z.string(),
985
+ }))
986
+ .handler(async ({ input }) => {
987
+ const session = sessionManager.getSession(input.sessionId);
988
+ if (!session) {
989
+ throw new ORPCError('NOT_FOUND', { message: 'Live session not found' });
990
+ }
991
+ return {
992
+ ...session,
993
+ startedAt: session.startedAt.toISOString(),
994
+ lastActivity: session.lastActivity.toISOString(),
995
+ };
996
+ });
997
+ const getLiveSessionStatus = os
998
+ .input(z.object({
999
+ sessionId: z.string(),
1000
+ }))
1001
+ .handler(async ({ input }) => {
1002
+ const status = sessionManager.getSessionStatus(input.sessionId);
1003
+ if (!status) {
1004
+ throw new ORPCError('NOT_FOUND', { message: 'Live session not found' });
1005
+ }
1006
+ return { status };
1007
+ });
1008
+ const startLiveSession = os
1009
+ .input(z.object({
1010
+ workspaceName: z.string(),
1011
+ agentType: LiveAgentTypeSchema,
1012
+ sessionId: z.string().optional(),
1013
+ agentSessionId: z.string().optional(),
1014
+ model: z.string().optional(),
1015
+ projectPath: z.string().optional(),
1016
+ }))
1017
+ .handler(async ({ input }) => {
1018
+ if (input.workspaceName !== HOST_WORKSPACE_NAME) {
1019
+ const workspace = await ctx.workspaces.get(input.workspaceName);
1020
+ if (!workspace) {
1021
+ throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
1022
+ }
1023
+ if (workspace.status !== 'running') {
1024
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
1025
+ }
1026
+ }
1027
+ else {
1028
+ const config = ctx.config.get();
1029
+ if (!config.allowHostAccess) {
1030
+ throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
1031
+ }
1032
+ }
1033
+ const sessionId = await sessionManager.startSession({
1034
+ workspaceName: input.workspaceName,
1035
+ agentType: input.agentType,
1036
+ sessionId: input.sessionId,
1037
+ agentSessionId: input.agentSessionId,
1038
+ model: input.model,
1039
+ projectPath: input.projectPath,
1040
+ });
1041
+ return { sessionId };
1042
+ });
1043
+ const sendLiveMessage = os
1044
+ .input(z.object({
1045
+ sessionId: z.string(),
1046
+ message: z.string(),
1047
+ }))
1048
+ .handler(async ({ input }) => {
1049
+ const session = sessionManager.getSession(input.sessionId);
1050
+ if (!session) {
1051
+ throw new ORPCError('NOT_FOUND', { message: 'Live session not found' });
1052
+ }
1053
+ await sessionManager.sendMessage(input.sessionId, input.message);
1054
+ return { success: true };
1055
+ });
1056
+ const interruptLiveSession = os
1057
+ .input(z.object({
1058
+ sessionId: z.string(),
1059
+ }))
1060
+ .handler(async ({ input }) => {
1061
+ const session = sessionManager.getSession(input.sessionId);
1062
+ if (!session) {
1063
+ throw new ORPCError('NOT_FOUND', { message: 'Live session not found' });
1064
+ }
1065
+ await sessionManager.interrupt(input.sessionId);
1066
+ return { success: true };
1067
+ });
1068
+ const disposeLiveSession = os
1069
+ .input(z.object({
1070
+ sessionId: z.string(),
1071
+ }))
1072
+ .handler(async ({ input }) => {
1073
+ await sessionManager.disposeSession(input.sessionId);
1074
+ return { success: true };
1075
+ });
1076
+ const getLiveSessionMessages = os
1077
+ .input(z.object({
1078
+ sessionId: z.string(),
1079
+ sinceId: z.number().optional(),
1080
+ }))
1081
+ .handler(async ({ input }) => {
1082
+ const messages = sessionManager.getBufferedMessages(input.sessionId, input.sinceId);
1083
+ return { messages };
1084
+ });
968
1085
  return {
969
1086
  workspaces: {
970
1087
  list: listWorkspaces,
@@ -991,6 +1108,16 @@ export function createRouter(ctx) {
991
1108
  delete: deleteSession,
992
1109
  search: searchSessions,
993
1110
  },
1111
+ live: {
1112
+ list: listLiveSessions,
1113
+ get: getLiveSession,
1114
+ getStatus: getLiveSessionStatus,
1115
+ start: startLiveSession,
1116
+ sendMessage: sendLiveMessage,
1117
+ interrupt: interruptLiveSession,
1118
+ dispose: disposeLiveSession,
1119
+ getMessages: getLiveSessionMessages,
1120
+ },
994
1121
  models: {
995
1122
  list: listModels,
996
1123
  },
package/dist/agent/run.js CHANGED
@@ -1,27 +1,21 @@
1
- import { createServer } from 'http';
2
- import { RPCHandler } from '@orpc/server/node';
1
+ import { RPCHandler } from '@orpc/server/fetch';
3
2
  import { loadAgentConfig, getConfigDir, ensureConfigDir } from '../config/loader';
4
3
  import { HOST_WORKSPACE_NAME } from '../shared/client-types';
5
4
  import { DEFAULT_AGENT_PORT } from '../shared/constants';
6
5
  import { WorkspaceManager } from '../workspace/manager';
7
6
  import { containerRunning, getContainerName } from '../docker';
8
7
  import { startEagerImagePull, stopEagerImagePull } from '../docker/eager-pull';
9
- import { TerminalWebSocketServer } from '../terminal/websocket';
10
- import { ChatWebSocketServer } from '../chat/websocket';
11
- import { OpencodeWebSocketServer } from '../chat/opencode-websocket';
8
+ import { TerminalHandler } from '../terminal/bun-handler';
9
+ import { LiveChatHandler } from '../session-manager/bun-handler';
12
10
  import { createRouter } from './router';
13
- import { serveStatic } from './static';
11
+ import { serveStaticBun } from './static';
14
12
  import { SessionsCacheManager } from '../sessions/cache';
15
13
  import { ModelCacheManager } from '../models/cache';
16
14
  import { FileWatcher } from './file-watcher';
17
15
  import { getTailscaleStatus, getTailscaleIdentity, startTailscaleServe, stopTailscaleServe, } from '../tailscale';
18
16
  import pkg from '../../package.json';
19
17
  const startTime = Date.now();
20
- function sendJson(res, status, data) {
21
- res.writeHead(status, { 'Content-Type': 'application/json' });
22
- res.end(JSON.stringify(data));
23
- }
24
- function createAgentServer(configDir, config, tailscale) {
18
+ function createAgentServer(configDir, config, port, tailscale) {
25
19
  let currentConfig = config;
26
20
  const workspaces = new WorkspaceManager(configDir, currentConfig);
27
21
  const sessionsCache = new SessionsCacheManager(configDir);
@@ -52,21 +46,21 @@ function createAgentServer(configDir, config, tailscale) {
52
46
  const getPreferredShell = () => {
53
47
  return currentConfig.terminal?.preferredShell || process.env.SHELL;
54
48
  };
55
- const terminalServer = new TerminalWebSocketServer({
49
+ const terminalHandler = new TerminalHandler({
56
50
  getContainerName,
57
51
  isWorkspaceRunning,
58
52
  isHostAccessAllowed: () => currentConfig.allowHostAccess === true,
59
53
  getPreferredShell,
60
54
  });
61
- const chatServer = new ChatWebSocketServer({
55
+ const liveClaudeHandler = new LiveChatHandler({
62
56
  isWorkspaceRunning,
63
- getConfig: () => currentConfig,
64
57
  isHostAccessAllowed: () => currentConfig.allowHostAccess === true,
58
+ agentType: 'claude',
65
59
  });
66
- const opencodeServer = new OpencodeWebSocketServer({
60
+ const liveOpencodeHandler = new LiveChatHandler({
67
61
  isWorkspaceRunning,
68
62
  isHostAccessAllowed: () => currentConfig.allowHostAccess === true,
69
- getConfig: () => currentConfig,
63
+ agentType: 'opencode',
70
64
  });
71
65
  const triggerAutoSync = () => {
72
66
  syncAllRunning().catch((err) => {
@@ -86,75 +80,133 @@ function createAgentServer(configDir, config, tailscale) {
86
80
  configDir,
87
81
  stateDir: configDir,
88
82
  startTime,
89
- terminalServer,
83
+ terminalServer: terminalHandler,
90
84
  sessionsCache,
91
85
  modelCache,
92
86
  tailscale,
93
87
  triggerAutoSync,
94
88
  });
95
89
  const rpcHandler = new RPCHandler(router);
96
- const server = createServer(async (req, res) => {
97
- const url = new URL(req.url || '/', 'http://localhost');
98
- const method = req.method;
99
- const pathname = url.pathname;
100
- const identity = getTailscaleIdentity(req);
101
- res.setHeader('Access-Control-Allow-Origin', '*');
102
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
103
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
104
- if (method === 'OPTIONS') {
105
- res.writeHead(204);
106
- res.end();
107
- return;
108
- }
109
- try {
90
+ const server = Bun.serve({
91
+ port,
92
+ hostname: '::',
93
+ async fetch(req, server) {
94
+ const url = new URL(req.url);
95
+ const pathname = url.pathname;
96
+ const method = req.method;
97
+ const corsHeaders = {
98
+ 'Access-Control-Allow-Origin': '*',
99
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
100
+ 'Access-Control-Allow-Headers': 'Content-Type',
101
+ };
102
+ if (method === 'OPTIONS') {
103
+ return new Response(null, { status: 204, headers: corsHeaders });
104
+ }
105
+ const terminalMatch = pathname.match(/^\/rpc\/terminal\/([^/]+)$/);
106
+ const liveClaudeMatch = pathname.match(/^\/rpc\/live\/claude\/([^/]+)$/);
107
+ const liveOpencodeMatch = pathname.match(/^\/rpc\/live\/opencode\/([^/]+)$/);
108
+ if (terminalMatch || liveClaudeMatch || liveOpencodeMatch) {
109
+ let type;
110
+ let workspaceName;
111
+ if (terminalMatch) {
112
+ type = 'terminal';
113
+ workspaceName = decodeURIComponent(terminalMatch[1]);
114
+ }
115
+ else if (liveClaudeMatch) {
116
+ type = 'live-claude';
117
+ workspaceName = decodeURIComponent(liveClaudeMatch[1]);
118
+ }
119
+ else {
120
+ type = 'live-opencode';
121
+ workspaceName = decodeURIComponent(liveOpencodeMatch[1]);
122
+ }
123
+ const running = await isWorkspaceRunning(workspaceName);
124
+ if (!running) {
125
+ return new Response('Not Found', { status: 404 });
126
+ }
127
+ const upgraded = server.upgrade(req, {
128
+ data: { type, workspaceName },
129
+ });
130
+ if (upgraded) {
131
+ return undefined;
132
+ }
133
+ return new Response('WebSocket upgrade failed', { status: 400 });
134
+ }
110
135
  if (pathname === '/health' && method === 'GET') {
136
+ const identity = getTailscaleIdentity(req);
111
137
  const response = { status: 'ok', version: pkg.version };
112
138
  if (identity) {
113
139
  response.user = identity.email;
114
140
  }
115
- sendJson(res, 200, response);
116
- return;
141
+ return Response.json(response, { headers: corsHeaders });
117
142
  }
118
143
  if (pathname.startsWith('/rpc')) {
119
- const { matched } = await rpcHandler.handle(req, res, {
144
+ const { matched, response } = await rpcHandler.handle(req, {
120
145
  prefix: '/rpc',
121
146
  });
122
- if (matched)
123
- return;
147
+ if (matched && response) {
148
+ const newHeaders = new Headers(response.headers);
149
+ Object.entries(corsHeaders).forEach(([k, v]) => newHeaders.set(k, v));
150
+ return new Response(response.body, {
151
+ status: response.status,
152
+ statusText: response.statusText,
153
+ headers: newHeaders,
154
+ });
155
+ }
124
156
  }
125
- const served = await serveStatic(req, res, pathname);
126
- if (served)
127
- return;
128
- sendJson(res, 404, { error: 'Not found' });
129
- }
130
- catch (err) {
131
- console.error('Request error:', err);
132
- sendJson(res, 500, { error: 'Internal server error' });
133
- }
134
- });
135
- server.on('upgrade', async (request, socket, head) => {
136
- const url = new URL(request.url || '/', 'http://localhost');
137
- const terminalMatch = url.pathname.match(/^\/rpc\/terminal\/([^/]+)$/);
138
- const chatMatch = url.pathname.match(/^\/rpc\/chat\/([^/]+)$/);
139
- const opencodeMatch = url.pathname.match(/^\/rpc\/opencode\/([^/]+)$/);
140
- if (terminalMatch) {
141
- const workspaceName = decodeURIComponent(terminalMatch[1]);
142
- await terminalServer.handleUpgrade(request, socket, head, workspaceName);
143
- }
144
- else if (chatMatch) {
145
- const workspaceName = decodeURIComponent(chatMatch[1]);
146
- await chatServer.handleUpgrade(request, socket, head, workspaceName);
147
- }
148
- else if (opencodeMatch) {
149
- const workspaceName = decodeURIComponent(opencodeMatch[1]);
150
- await opencodeServer.handleUpgrade(request, socket, head, workspaceName);
151
- }
152
- else {
153
- socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
154
- socket.destroy();
155
- }
157
+ const staticResponse = await serveStaticBun(pathname);
158
+ if (staticResponse) {
159
+ return staticResponse;
160
+ }
161
+ return Response.json({ error: 'Not found' }, { status: 404, headers: corsHeaders });
162
+ },
163
+ websocket: {
164
+ open(ws) {
165
+ const { type, workspaceName } = ws.data;
166
+ if (type === 'terminal') {
167
+ terminalHandler.handleOpen(ws, workspaceName);
168
+ }
169
+ else if (type === 'live-claude') {
170
+ liveClaudeHandler.handleOpen(ws, workspaceName);
171
+ }
172
+ else if (type === 'live-opencode') {
173
+ liveOpencodeHandler.handleOpen(ws, workspaceName);
174
+ }
175
+ },
176
+ message(ws, message) {
177
+ const { type } = ws.data;
178
+ const data = typeof message === 'string' ? message : message.toString();
179
+ if (type === 'terminal') {
180
+ terminalHandler.handleMessage(ws, data);
181
+ }
182
+ else if (type === 'live-claude') {
183
+ liveClaudeHandler.handleMessage(ws, data);
184
+ }
185
+ else if (type === 'live-opencode') {
186
+ liveOpencodeHandler.handleMessage(ws, data);
187
+ }
188
+ },
189
+ close(ws, code, reason) {
190
+ const { type } = ws.data;
191
+ if (type === 'terminal') {
192
+ terminalHandler.handleClose(ws, code, reason);
193
+ }
194
+ else if (type === 'live-claude') {
195
+ liveClaudeHandler.handleClose(ws, code, reason);
196
+ }
197
+ else if (type === 'live-opencode') {
198
+ liveOpencodeHandler.handleClose(ws, code, reason);
199
+ }
200
+ },
201
+ },
156
202
  });
157
- return { server, terminalServer, chatServer, opencodeServer, fileWatcher };
203
+ return {
204
+ server,
205
+ terminalHandler,
206
+ liveClaudeHandler,
207
+ liveOpencodeHandler,
208
+ fileWatcher,
209
+ };
158
210
  }
159
211
  async function getProcessUsingPort(port) {
160
212
  try {
@@ -218,9 +270,22 @@ export async function startAgent(options = {}) {
218
270
  httpsUrl: tailscaleServeActive ? `https://${tailscale.dnsName}` : undefined,
219
271
  }
220
272
  : undefined;
221
- const { server, terminalServer, chatServer, opencodeServer, fileWatcher } = createAgentServer(configDir, config, tailscaleInfo);
222
- server.on('error', async (err) => {
223
- if (err.code === 'EADDRINUSE') {
273
+ let server;
274
+ let fileWatcher;
275
+ let terminalHandler;
276
+ let liveClaudeHandler;
277
+ let liveOpencodeHandler;
278
+ try {
279
+ const result = createAgentServer(configDir, config, port, tailscaleInfo);
280
+ server = result.server;
281
+ fileWatcher = result.fileWatcher;
282
+ terminalHandler = result.terminalHandler;
283
+ liveClaudeHandler = result.liveClaudeHandler;
284
+ liveOpencodeHandler = result.liveOpencodeHandler;
285
+ }
286
+ catch (err) {
287
+ const error = err;
288
+ if (error.code === 'EADDRINUSE') {
224
289
  console.error(`[agent] Error: Port ${port} is already in use.`);
225
290
  const processInfo = await getProcessUsingPort(port);
226
291
  if (processInfo) {
@@ -229,26 +294,21 @@ export async function startAgent(options = {}) {
229
294
  console.error(`[agent] Try using a different port with: perry agent run --port <port>`);
230
295
  process.exit(1);
231
296
  }
232
- else {
233
- console.error(`[agent] Server error: ${err.message}`);
234
- process.exit(1);
235
- }
236
- });
237
- server.listen(port, '::', () => {
238
- console.log(`[agent] Agent running at http://localhost:${port}`);
239
- if (tailscale.running && tailscale.dnsName) {
240
- const shortName = tailscale.dnsName.split('.')[0];
241
- console.log(`[agent] Tailnet: http://${shortName}:${port}`);
242
- if (tailscaleServeActive) {
243
- console.log(`[agent] Tailnet HTTPS: https://${tailscale.dnsName}`);
244
- }
297
+ throw err;
298
+ }
299
+ console.log(`[agent] Agent running at http://localhost:${port}`);
300
+ if (tailscale.running && tailscale.dnsName) {
301
+ const shortName = tailscale.dnsName.split('.')[0];
302
+ console.log(`[agent] Tailnet: http://${shortName}:${port}`);
303
+ if (tailscaleServeActive) {
304
+ console.log(`[agent] Tailnet HTTPS: https://${tailscale.dnsName}`);
245
305
  }
246
- console.log(`[agent] oRPC endpoint: http://localhost:${port}/rpc`);
247
- console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
248
- console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/chat/:name`);
249
- console.log(`[agent] WebSocket chat (OpenCode): ws://localhost:${port}/rpc/opencode/:name`);
250
- startEagerImagePull();
251
- });
306
+ }
307
+ console.log(`[agent] oRPC endpoint: http://localhost:${port}/rpc`);
308
+ console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
309
+ console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/live/claude/:name`);
310
+ console.log(`[agent] WebSocket chat (OpenCode): ws://localhost:${port}/rpc/live/opencode/:name`);
311
+ startEagerImagePull();
252
312
  let isShuttingDown = false;
253
313
  const shutdown = async () => {
254
314
  if (isShuttingDown) {
@@ -268,15 +328,13 @@ export async function startAgent(options = {}) {
268
328
  console.log('[agent] Stopping Tailscale Serve...');
269
329
  await stopTailscaleServe();
270
330
  }
271
- chatServer.close();
272
- opencodeServer.close();
273
- terminalServer.close();
274
- server.closeAllConnections();
275
- server.close(() => {
276
- clearTimeout(forceExitTimeout);
277
- console.log('[agent] Server closed');
278
- process.exit(0);
279
- });
331
+ liveClaudeHandler.close();
332
+ liveOpencodeHandler.close();
333
+ terminalHandler.close();
334
+ server.stop();
335
+ clearTimeout(forceExitTimeout);
336
+ console.log('[agent] Server closed');
337
+ process.exit(0);
280
338
  };
281
339
  process.on('SIGTERM', shutdown);
282
340
  process.on('SIGINT', shutdown);
@@ -65,3 +65,35 @@ export async function serveStatic(_req, res, pathname) {
65
65
  res.end(content);
66
66
  return true;
67
67
  }
68
+ export async function serveStaticBun(pathname) {
69
+ const webDir = getWebDir();
70
+ const indexPath = path.join(webDir, 'index.html');
71
+ try {
72
+ await fs.access(indexPath);
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ const ext = path.extname(pathname).toLowerCase();
78
+ const isAsset = ext && ext !== '.html';
79
+ if (isAsset) {
80
+ const filePath = path.join(webDir, pathname);
81
+ try {
82
+ const file = Bun.file(filePath);
83
+ if (await file.exists()) {
84
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
85
+ return new Response(file, {
86
+ headers: { 'Content-Type': contentType },
87
+ });
88
+ }
89
+ return null;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ const file = Bun.file(indexPath);
96
+ return new Response(file, {
97
+ headers: { 'Content-Type': 'text/html' },
98
+ });
99
+ }