@gricha/perry 0.2.0 → 0.2.2
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 +18 -2
- package/dist/agent/router.js +235 -89
- package/dist/agent/run.js +55 -4
- package/dist/agent/web/assets/index-DIOWcVH-.css +1 -0
- package/dist/agent/web/assets/index-DN_QW9sL.js +104 -0
- package/dist/agent/web/index.html +2 -2
- package/dist/client/api.js +2 -2
- package/dist/docker/eager-pull.js +65 -0
- package/dist/index.js +47 -23
- package/dist/perry-worker +0 -0
- package/dist/sessions/agents/claude.js +19 -0
- package/dist/sessions/agents/codex.js +40 -0
- package/dist/sessions/agents/index.js +63 -0
- package/dist/sessions/agents/opencode-storage.js +218 -0
- package/dist/sessions/agents/opencode.js +17 -3
- package/dist/sessions/cache.js +5 -0
- package/dist/shared/constants.js +1 -1
- package/dist/tailscale/index.js +80 -0
- package/dist/workspace/manager.js +104 -53
- package/package.json +3 -2
- package/dist/agent/web/assets/index-CaFOQOgc.css +0 -1
- package/dist/agent/web/assets/index-DQmM39Em.js +0 -104
package/README.md
CHANGED
|
@@ -10,11 +10,23 @@
|
|
|
10
10
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
|
-
<p align="center">
|
|
13
|
+
<p align="center">
|
|
14
|
+
Continue your coding sessions on the go. Self-hosted workspaces, accessible over Tailscale.
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img src="assets/demo-terminal-mobile.gif" alt="Terminal" width="280">
|
|
19
|
+
|
|
20
|
+
<img src="assets/demo-chat-mobile.gif" alt="Chat" width="280">
|
|
21
|
+
</p>
|
|
14
22
|
|
|
15
23
|
## Overview
|
|
16
24
|
|
|
17
|
-
Perry is designed to run
|
|
25
|
+
Perry is an agent (agent P) designed to run as a daemon on your machine. It allows your clients - other machines through CLI, web, or mobile app - to connect directly to your workspaces over the Tailscale network.
|
|
26
|
+
|
|
27
|
+
It can be connected directly to your host, or it can create docker containers so that your work can be fully isolated.
|
|
28
|
+
|
|
29
|
+
Continue your sessions on the go!
|
|
18
30
|
|
|
19
31
|
## Features
|
|
20
32
|
|
|
@@ -65,6 +77,10 @@ perry list
|
|
|
65
77
|
|
|
66
78
|
Open http://localhost:7391 (or your Tailscale host) and click "+" to create a workspace.
|
|
67
79
|
|
|
80
|
+
<p align="center">
|
|
81
|
+
<img src="assets/demo.gif" alt="Web UI Demo" width="800">
|
|
82
|
+
</p>
|
|
83
|
+
|
|
68
84
|
## Security
|
|
69
85
|
|
|
70
86
|
Perry is designed for use within **secure private networks** like [Tailscale](https://tailscale.com). The web UI and API currently have no authentication - this is intentional for private network use where all devices are trusted.
|
package/dist/agent/router.js
CHANGED
|
@@ -9,8 +9,9 @@ import { saveAgentConfig } from '../config/loader';
|
|
|
9
9
|
import { setSessionName, getSessionNamesForWorkspace, deleteSessionName, } from '../sessions/metadata';
|
|
10
10
|
import { discoverSSHKeys } from '../ssh/discovery';
|
|
11
11
|
import { parseClaudeSessionContent } from '../sessions/parser';
|
|
12
|
-
import { discoverAllSessions, getSessionDetails as getAgentSessionDetails, getSessionMessages, findSessionMessages, } from '../sessions/agents';
|
|
12
|
+
import { discoverAllSessions, getSessionDetails as getAgentSessionDetails, getSessionMessages, findSessionMessages, deleteSession as deleteSessionFromProvider, searchSessions as searchSessionsInContainer, } from '../sessions/agents';
|
|
13
13
|
import { discoverClaudeCodeModels, discoverHostOpencodeModels, discoverContainerOpencodeModels, } from '../models/discovery';
|
|
14
|
+
import { listOpencodeSessions, getOpencodeSessionMessages, deleteOpencodeSession, } from '../sessions/agents/opencode-storage';
|
|
14
15
|
const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']);
|
|
15
16
|
const WorkspacePortsSchema = z.object({
|
|
16
17
|
ssh: z.number(),
|
|
@@ -115,11 +116,18 @@ export function createRouter(ctx) {
|
|
|
115
116
|
}
|
|
116
117
|
});
|
|
117
118
|
const startWorkspace = os
|
|
118
|
-
.input(z.object({
|
|
119
|
+
.input(z.object({
|
|
120
|
+
name: z.string(),
|
|
121
|
+
clone: z.string().optional(),
|
|
122
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
123
|
+
}))
|
|
119
124
|
.output(WorkspaceInfoSchema)
|
|
120
125
|
.handler(async ({ input }) => {
|
|
121
126
|
try {
|
|
122
|
-
return await ctx.workspaces.start(input.name
|
|
127
|
+
return await ctx.workspaces.start(input.name, {
|
|
128
|
+
clone: input.clone,
|
|
129
|
+
env: input.env,
|
|
130
|
+
});
|
|
123
131
|
}
|
|
124
132
|
catch (err) {
|
|
125
133
|
mapErrorToORPC(err, 'Failed to start workspace');
|
|
@@ -200,6 +208,7 @@ export function createRouter(ctx) {
|
|
|
200
208
|
workspacesCount: allWorkspaces.length,
|
|
201
209
|
dockerVersion,
|
|
202
210
|
terminalConnections: ctx.terminalServer.getConnectionCount(),
|
|
211
|
+
tailscale: ctx.tailscale,
|
|
203
212
|
};
|
|
204
213
|
});
|
|
205
214
|
const getCredentials = os.output(CredentialsSchema).handler(async () => {
|
|
@@ -298,32 +307,16 @@ export function createRouter(ctx) {
|
|
|
298
307
|
}
|
|
299
308
|
}
|
|
300
309
|
if (!input.agentType || input.agentType === 'opencode') {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const sessionStat = await fs.stat(sessionFile);
|
|
312
|
-
rawSessions.push({
|
|
313
|
-
id: sessionDir,
|
|
314
|
-
agentType: 'opencode',
|
|
315
|
-
projectPath: homeDir,
|
|
316
|
-
mtime: sessionStat.mtimeMs,
|
|
317
|
-
filePath: sessionFile,
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
catch {
|
|
321
|
-
// session.json doesn't exist
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
catch {
|
|
326
|
-
// Directory doesn't exist
|
|
310
|
+
const opencodeSessions = await listOpencodeSessions();
|
|
311
|
+
for (const session of opencodeSessions) {
|
|
312
|
+
rawSessions.push({
|
|
313
|
+
id: session.id,
|
|
314
|
+
agentType: 'opencode',
|
|
315
|
+
projectPath: session.directory || homeDir,
|
|
316
|
+
mtime: session.mtime,
|
|
317
|
+
filePath: session.file,
|
|
318
|
+
name: session.title || undefined,
|
|
319
|
+
});
|
|
327
320
|
}
|
|
328
321
|
}
|
|
329
322
|
rawSessions.sort((a, b) => b.mtime - a.mtime);
|
|
@@ -364,16 +357,17 @@ export function createRouter(ctx) {
|
|
|
364
357
|
}
|
|
365
358
|
}
|
|
366
359
|
else if (raw.agentType === 'opencode') {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
firstPrompt = sessionData.title;
|
|
373
|
-
}
|
|
360
|
+
const sessionMessages = await getOpencodeSessionMessages(raw.id);
|
|
361
|
+
const userAssistantMessages = sessionMessages.messages.filter((m) => m.type === 'user' || m.type === 'assistant');
|
|
362
|
+
messageCount = userAssistantMessages.length;
|
|
363
|
+
if (raw.name) {
|
|
364
|
+
firstPrompt = raw.name;
|
|
374
365
|
}
|
|
375
|
-
|
|
376
|
-
|
|
366
|
+
else {
|
|
367
|
+
const firstUserMsg = userAssistantMessages.find((m) => m.type === 'user' && m.content);
|
|
368
|
+
if (firstUserMsg?.content) {
|
|
369
|
+
firstPrompt = firstUserMsg.content.slice(0, 200);
|
|
370
|
+
}
|
|
377
371
|
}
|
|
378
372
|
}
|
|
379
373
|
return {
|
|
@@ -427,57 +421,17 @@ export function createRouter(ctx) {
|
|
|
427
421
|
}
|
|
428
422
|
}
|
|
429
423
|
if (!agentType || agentType === 'opencode') {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const textContent = Array.isArray(part.content)
|
|
442
|
-
? part.content
|
|
443
|
-
.filter((c) => c.type === 'text')
|
|
444
|
-
.map((c) => c.text)
|
|
445
|
-
.join('\n')
|
|
446
|
-
: part.content;
|
|
447
|
-
messages.push({
|
|
448
|
-
type: 'user',
|
|
449
|
-
content: textContent,
|
|
450
|
-
timestamp: part.time || null,
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
else if (part.role === 'assistant') {
|
|
454
|
-
if (part.content) {
|
|
455
|
-
const textContent = Array.isArray(part.content)
|
|
456
|
-
? part.content
|
|
457
|
-
.filter((c) => c.type === 'text')
|
|
458
|
-
.map((c) => c.text)
|
|
459
|
-
.join('\n')
|
|
460
|
-
: part.content;
|
|
461
|
-
if (textContent) {
|
|
462
|
-
messages.push({
|
|
463
|
-
type: 'assistant',
|
|
464
|
-
content: textContent,
|
|
465
|
-
timestamp: part.time || null,
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
catch {
|
|
472
|
-
// Can't parse part
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
if (messages.length > 0) {
|
|
476
|
-
return { id: sessionId, agentType: 'opencode', messages };
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
catch {
|
|
480
|
-
// Directory doesn't exist
|
|
424
|
+
const sessionData = await getOpencodeSessionMessages(sessionId);
|
|
425
|
+
if (sessionData.messages.length > 0) {
|
|
426
|
+
const opencodeMessages = sessionData.messages.map((m) => ({
|
|
427
|
+
type: m.type,
|
|
428
|
+
content: m.content,
|
|
429
|
+
toolName: m.toolName,
|
|
430
|
+
toolId: m.toolId,
|
|
431
|
+
toolInput: m.toolInput,
|
|
432
|
+
timestamp: m.timestamp,
|
|
433
|
+
}));
|
|
434
|
+
return { id: sessionId, agentType: 'opencode', messages: opencodeMessages };
|
|
481
435
|
}
|
|
482
436
|
}
|
|
483
437
|
return { id: sessionId, messages };
|
|
@@ -620,6 +574,196 @@ export function createRouter(ctx) {
|
|
|
620
574
|
await ctx.sessionsCache.recordAccess(input.workspaceName, input.sessionId, input.agentType);
|
|
621
575
|
return { success: true };
|
|
622
576
|
});
|
|
577
|
+
const deleteSession = os
|
|
578
|
+
.input(z.object({
|
|
579
|
+
workspaceName: z.string(),
|
|
580
|
+
sessionId: z.string(),
|
|
581
|
+
agentType: z.enum(['claude-code', 'opencode', 'codex']),
|
|
582
|
+
}))
|
|
583
|
+
.handler(async ({ input }) => {
|
|
584
|
+
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
|
|
585
|
+
if (isHost) {
|
|
586
|
+
const config = ctx.config.get();
|
|
587
|
+
if (!config.allowHostAccess) {
|
|
588
|
+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
|
|
589
|
+
}
|
|
590
|
+
const result = await deleteHostSession(input.sessionId, input.agentType);
|
|
591
|
+
if (!result.success) {
|
|
592
|
+
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
|
593
|
+
message: result.error || 'Failed to delete session',
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
|
|
597
|
+
await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
|
|
598
|
+
return { success: true };
|
|
599
|
+
}
|
|
600
|
+
const workspace = await ctx.workspaces.get(input.workspaceName);
|
|
601
|
+
if (!workspace) {
|
|
602
|
+
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
|
|
603
|
+
}
|
|
604
|
+
if (workspace.status !== 'running') {
|
|
605
|
+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
|
|
606
|
+
}
|
|
607
|
+
const containerName = `workspace-${input.workspaceName}`;
|
|
608
|
+
const result = await deleteSessionFromProvider(containerName, input.sessionId, input.agentType, execInContainer);
|
|
609
|
+
if (!result.success) {
|
|
610
|
+
throw new ORPCError('INTERNAL_SERVER_ERROR', {
|
|
611
|
+
message: result.error || 'Failed to delete session',
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
|
|
615
|
+
await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
|
|
616
|
+
return { success: true };
|
|
617
|
+
});
|
|
618
|
+
const searchSessions = os
|
|
619
|
+
.input(z.object({
|
|
620
|
+
workspaceName: z.string(),
|
|
621
|
+
query: z.string().min(1).max(500),
|
|
622
|
+
}))
|
|
623
|
+
.handler(async ({ input }) => {
|
|
624
|
+
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
|
|
625
|
+
if (isHost) {
|
|
626
|
+
const config = ctx.config.get();
|
|
627
|
+
if (!config.allowHostAccess) {
|
|
628
|
+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
|
|
629
|
+
}
|
|
630
|
+
const results = await searchHostSessions(input.query);
|
|
631
|
+
return { results };
|
|
632
|
+
}
|
|
633
|
+
const workspace = await ctx.workspaces.get(input.workspaceName);
|
|
634
|
+
if (!workspace) {
|
|
635
|
+
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
|
|
636
|
+
}
|
|
637
|
+
if (workspace.status !== 'running') {
|
|
638
|
+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
|
|
639
|
+
}
|
|
640
|
+
const containerName = `workspace-${input.workspaceName}`;
|
|
641
|
+
const results = await searchSessionsInContainer(containerName, input.query, execInContainer);
|
|
642
|
+
return { results };
|
|
643
|
+
});
|
|
644
|
+
async function searchHostSessions(query) {
|
|
645
|
+
const homeDir = os_module.homedir();
|
|
646
|
+
const safeQuery = query.replace(/['"\\]/g, '\\$&');
|
|
647
|
+
const searchPaths = [
|
|
648
|
+
path.join(homeDir, '.claude', 'projects'),
|
|
649
|
+
path.join(homeDir, '.local', 'share', 'opencode', 'storage'),
|
|
650
|
+
path.join(homeDir, '.codex', 'sessions'),
|
|
651
|
+
].filter((p) => {
|
|
652
|
+
try {
|
|
653
|
+
require('fs').accessSync(p);
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
if (searchPaths.length === 0) {
|
|
661
|
+
return [];
|
|
662
|
+
}
|
|
663
|
+
const { execSync } = await import('child_process');
|
|
664
|
+
try {
|
|
665
|
+
const output = execSync(`rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`, {
|
|
666
|
+
encoding: 'utf-8',
|
|
667
|
+
timeout: 30000,
|
|
668
|
+
});
|
|
669
|
+
const files = output.trim().split('\n').filter(Boolean);
|
|
670
|
+
const results = [];
|
|
671
|
+
for (const file of files) {
|
|
672
|
+
let sessionId = null;
|
|
673
|
+
let agentType = null;
|
|
674
|
+
if (file.includes('/.claude/projects/')) {
|
|
675
|
+
const match = file.match(/\/([^/]+)\.jsonl$/);
|
|
676
|
+
if (match && !match[1].startsWith('agent-')) {
|
|
677
|
+
sessionId = match[1];
|
|
678
|
+
agentType = 'claude-code';
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
else if (file.includes('/.local/share/opencode/storage/')) {
|
|
682
|
+
if (file.includes('/session/') && file.endsWith('.json')) {
|
|
683
|
+
const match = file.match(/\/(ses_[^/]+)\.json$/);
|
|
684
|
+
if (match) {
|
|
685
|
+
sessionId = match[1];
|
|
686
|
+
agentType = 'opencode';
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
else if (file.includes('/.codex/sessions/')) {
|
|
691
|
+
const match = file.match(/\/([^/]+)\.jsonl$/);
|
|
692
|
+
if (match) {
|
|
693
|
+
sessionId = match[1];
|
|
694
|
+
agentType = 'codex';
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (sessionId && agentType) {
|
|
698
|
+
results.push({ sessionId, agentType, matchCount: 1 });
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return results;
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
return [];
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
async function deleteHostSession(sessionId, agentType) {
|
|
708
|
+
const homeDir = os_module.homedir();
|
|
709
|
+
if (agentType === 'claude-code') {
|
|
710
|
+
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
711
|
+
const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');
|
|
712
|
+
try {
|
|
713
|
+
const projectDirs = await fs.readdir(claudeProjectsDir);
|
|
714
|
+
for (const projectDir of projectDirs) {
|
|
715
|
+
const sessionFile = path.join(claudeProjectsDir, projectDir, `${safeSessionId}.jsonl`);
|
|
716
|
+
try {
|
|
717
|
+
await fs.unlink(sessionFile);
|
|
718
|
+
return { success: true };
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
return { success: false, error: 'Session not found' };
|
|
727
|
+
}
|
|
728
|
+
return { success: false, error: 'Session not found' };
|
|
729
|
+
}
|
|
730
|
+
if (agentType === 'opencode') {
|
|
731
|
+
return deleteOpencodeSession(sessionId);
|
|
732
|
+
}
|
|
733
|
+
if (agentType === 'codex') {
|
|
734
|
+
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
735
|
+
try {
|
|
736
|
+
const files = await fs.readdir(codexSessionsDir);
|
|
737
|
+
for (const file of files) {
|
|
738
|
+
if (!file.endsWith('.jsonl'))
|
|
739
|
+
continue;
|
|
740
|
+
const filePath = path.join(codexSessionsDir, file);
|
|
741
|
+
const fileId = file.replace('.jsonl', '');
|
|
742
|
+
if (fileId === sessionId) {
|
|
743
|
+
await fs.unlink(filePath);
|
|
744
|
+
return { success: true };
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
748
|
+
const firstLine = content.split('\n')[0];
|
|
749
|
+
const meta = JSON.parse(firstLine);
|
|
750
|
+
if (meta.session_id === sessionId) {
|
|
751
|
+
await fs.unlink(filePath);
|
|
752
|
+
return { success: true };
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
return { success: false, error: 'Session not found' };
|
|
762
|
+
}
|
|
763
|
+
return { success: false, error: 'Session not found' };
|
|
764
|
+
}
|
|
765
|
+
return { success: false, error: 'Unsupported agent type' };
|
|
766
|
+
}
|
|
623
767
|
const getHostInfo = os.handler(async () => {
|
|
624
768
|
const config = ctx.config.get();
|
|
625
769
|
return {
|
|
@@ -717,6 +861,8 @@ export function createRouter(ctx) {
|
|
|
717
861
|
clearName: clearSessionName,
|
|
718
862
|
getRecent: getRecentSessions,
|
|
719
863
|
recordAccess: recordSessionAccess,
|
|
864
|
+
delete: deleteSession,
|
|
865
|
+
search: searchSessions,
|
|
720
866
|
},
|
|
721
867
|
models: {
|
|
722
868
|
list: listModels,
|
package/dist/agent/run.js
CHANGED
|
@@ -5,6 +5,7 @@ import { HOST_WORKSPACE_NAME } from '../shared/types';
|
|
|
5
5
|
import { DEFAULT_AGENT_PORT } from '../shared/constants';
|
|
6
6
|
import { WorkspaceManager } from '../workspace/manager';
|
|
7
7
|
import { containerRunning, getContainerName } from '../docker';
|
|
8
|
+
import { startEagerImagePull } from '../docker/eager-pull';
|
|
8
9
|
import { TerminalWebSocketServer } from '../terminal/websocket';
|
|
9
10
|
import { ChatWebSocketServer } from '../chat/websocket';
|
|
10
11
|
import { OpencodeWebSocketServer } from '../chat/opencode-websocket';
|
|
@@ -12,13 +13,14 @@ import { createRouter } from './router';
|
|
|
12
13
|
import { serveStatic } from './static';
|
|
13
14
|
import { SessionsCacheManager } from '../sessions/cache';
|
|
14
15
|
import { ModelCacheManager } from '../models/cache';
|
|
16
|
+
import { getTailscaleStatus, getTailscaleIdentity, startTailscaleServe, stopTailscaleServe, } from '../tailscale';
|
|
15
17
|
import pkg from '../../package.json';
|
|
16
18
|
const startTime = Date.now();
|
|
17
19
|
function sendJson(res, status, data) {
|
|
18
20
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
19
21
|
res.end(JSON.stringify(data));
|
|
20
22
|
}
|
|
21
|
-
function createAgentServer(configDir, config) {
|
|
23
|
+
function createAgentServer(configDir, config, tailscale) {
|
|
22
24
|
let currentConfig = config;
|
|
23
25
|
const workspaces = new WorkspaceManager(configDir, currentConfig);
|
|
24
26
|
const sessionsCache = new SessionsCacheManager(configDir);
|
|
@@ -59,12 +61,14 @@ function createAgentServer(configDir, config) {
|
|
|
59
61
|
terminalServer,
|
|
60
62
|
sessionsCache,
|
|
61
63
|
modelCache,
|
|
64
|
+
tailscale,
|
|
62
65
|
});
|
|
63
66
|
const rpcHandler = new RPCHandler(router);
|
|
64
67
|
const server = createServer(async (req, res) => {
|
|
65
68
|
const url = new URL(req.url || '/', 'http://localhost');
|
|
66
69
|
const method = req.method;
|
|
67
70
|
const pathname = url.pathname;
|
|
71
|
+
const identity = getTailscaleIdentity(req);
|
|
68
72
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
69
73
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
70
74
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
@@ -75,7 +79,11 @@ function createAgentServer(configDir, config) {
|
|
|
75
79
|
}
|
|
76
80
|
try {
|
|
77
81
|
if (pathname === '/health' && method === 'GET') {
|
|
78
|
-
|
|
82
|
+
const response = { status: 'ok', version: pkg.version };
|
|
83
|
+
if (identity) {
|
|
84
|
+
response.user = identity.email;
|
|
85
|
+
}
|
|
86
|
+
sendJson(res, 200, response);
|
|
79
87
|
return;
|
|
80
88
|
}
|
|
81
89
|
if (pathname.startsWith('/rpc')) {
|
|
@@ -150,7 +158,38 @@ export async function startAgent(options = {}) {
|
|
|
150
158
|
const port = options.port || parseInt(process.env.PERRY_PORT || '', 10) || config.port || DEFAULT_AGENT_PORT;
|
|
151
159
|
console.log(`[agent] Config directory: ${configDir}`);
|
|
152
160
|
console.log(`[agent] Starting on port ${port}...`);
|
|
153
|
-
const
|
|
161
|
+
const tailscale = await getTailscaleStatus();
|
|
162
|
+
let tailscaleServeActive = false;
|
|
163
|
+
if (tailscale.running && tailscale.dnsName) {
|
|
164
|
+
console.log(`[agent] Tailscale detected: ${tailscale.dnsName}`);
|
|
165
|
+
if (!tailscale.httpsEnabled) {
|
|
166
|
+
console.log(`[agent] Tailscale HTTPS not enabled in tailnet, skipping Serve`);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const result = await startTailscaleServe(port);
|
|
170
|
+
if (result.success) {
|
|
171
|
+
tailscaleServeActive = true;
|
|
172
|
+
console.log(`[agent] Tailscale Serve enabled`);
|
|
173
|
+
}
|
|
174
|
+
else if (result.error === 'permission_denied') {
|
|
175
|
+
console.log(`[agent] Tailscale Serve requires operator permissions`);
|
|
176
|
+
console.log(`[agent] To enable: ${result.message}`);
|
|
177
|
+
console.log(`[agent] Continuing without HTTPS...`);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
console.log(`[agent] Tailscale Serve failed: ${result.message || 'unknown error'}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const tailscaleInfo = tailscale.running && tailscale.dnsName
|
|
185
|
+
? {
|
|
186
|
+
running: true,
|
|
187
|
+
dnsName: tailscale.dnsName,
|
|
188
|
+
serveActive: tailscaleServeActive,
|
|
189
|
+
httpsUrl: tailscaleServeActive ? `https://${tailscale.dnsName}` : undefined,
|
|
190
|
+
}
|
|
191
|
+
: undefined;
|
|
192
|
+
const { server, terminalServer, chatServer, opencodeServer } = createAgentServer(configDir, config, tailscaleInfo);
|
|
154
193
|
server.on('error', async (err) => {
|
|
155
194
|
if (err.code === 'EADDRINUSE') {
|
|
156
195
|
console.error(`[agent] Error: Port ${port} is already in use.`);
|
|
@@ -168,13 +207,25 @@ export async function startAgent(options = {}) {
|
|
|
168
207
|
});
|
|
169
208
|
server.listen(port, '::', () => {
|
|
170
209
|
console.log(`[agent] Agent running at http://localhost:${port}`);
|
|
210
|
+
if (tailscale.running && tailscale.dnsName) {
|
|
211
|
+
const shortName = tailscale.dnsName.split('.')[0];
|
|
212
|
+
console.log(`[agent] Tailnet: http://${shortName}:${port}`);
|
|
213
|
+
if (tailscaleServeActive) {
|
|
214
|
+
console.log(`[agent] Tailnet HTTPS: https://${tailscale.dnsName}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
171
217
|
console.log(`[agent] oRPC endpoint: http://localhost:${port}/rpc`);
|
|
172
218
|
console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
|
|
173
219
|
console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/chat/:name`);
|
|
174
220
|
console.log(`[agent] WebSocket chat (OpenCode): ws://localhost:${port}/rpc/opencode/:name`);
|
|
221
|
+
startEagerImagePull();
|
|
175
222
|
});
|
|
176
|
-
const shutdown = () => {
|
|
223
|
+
const shutdown = async () => {
|
|
177
224
|
console.log('[agent] Shutting down...');
|
|
225
|
+
if (tailscaleServeActive) {
|
|
226
|
+
console.log('[agent] Stopping Tailscale Serve...');
|
|
227
|
+
await stopTailscaleServe();
|
|
228
|
+
}
|
|
178
229
|
chatServer.close();
|
|
179
230
|
opencodeServer.close();
|
|
180
231
|
terminalServer.close();
|