@gricha/perry 0.2.1 → 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 +226 -87
- package/dist/agent/run.js +53 -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/index.js +42 -0
- 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 +40 -2
- package/package.json +3 -2
- package/dist/agent/web/assets/index-CGJDysKS.css +0 -1
- package/dist/agent/web/assets/index-CwCl9DVw.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(),
|
|
@@ -207,6 +208,7 @@ export function createRouter(ctx) {
|
|
|
207
208
|
workspacesCount: allWorkspaces.length,
|
|
208
209
|
dockerVersion,
|
|
209
210
|
terminalConnections: ctx.terminalServer.getConnectionCount(),
|
|
211
|
+
tailscale: ctx.tailscale,
|
|
210
212
|
};
|
|
211
213
|
});
|
|
212
214
|
const getCredentials = os.output(CredentialsSchema).handler(async () => {
|
|
@@ -305,32 +307,16 @@ export function createRouter(ctx) {
|
|
|
305
307
|
}
|
|
306
308
|
}
|
|
307
309
|
if (!input.agentType || input.agentType === 'opencode') {
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const sessionStat = await fs.stat(sessionFile);
|
|
319
|
-
rawSessions.push({
|
|
320
|
-
id: sessionDir,
|
|
321
|
-
agentType: 'opencode',
|
|
322
|
-
projectPath: homeDir,
|
|
323
|
-
mtime: sessionStat.mtimeMs,
|
|
324
|
-
filePath: sessionFile,
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
catch {
|
|
328
|
-
// session.json doesn't exist
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
catch {
|
|
333
|
-
// 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
|
+
});
|
|
334
320
|
}
|
|
335
321
|
}
|
|
336
322
|
rawSessions.sort((a, b) => b.mtime - a.mtime);
|
|
@@ -371,16 +357,17 @@ export function createRouter(ctx) {
|
|
|
371
357
|
}
|
|
372
358
|
}
|
|
373
359
|
else if (raw.agentType === 'opencode') {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
firstPrompt = sessionData.title;
|
|
380
|
-
}
|
|
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;
|
|
381
365
|
}
|
|
382
|
-
|
|
383
|
-
|
|
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
|
+
}
|
|
384
371
|
}
|
|
385
372
|
}
|
|
386
373
|
return {
|
|
@@ -434,57 +421,17 @@ export function createRouter(ctx) {
|
|
|
434
421
|
}
|
|
435
422
|
}
|
|
436
423
|
if (!agentType || agentType === 'opencode') {
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const textContent = Array.isArray(part.content)
|
|
449
|
-
? part.content
|
|
450
|
-
.filter((c) => c.type === 'text')
|
|
451
|
-
.map((c) => c.text)
|
|
452
|
-
.join('\n')
|
|
453
|
-
: part.content;
|
|
454
|
-
messages.push({
|
|
455
|
-
type: 'user',
|
|
456
|
-
content: textContent,
|
|
457
|
-
timestamp: part.time || null,
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
else if (part.role === 'assistant') {
|
|
461
|
-
if (part.content) {
|
|
462
|
-
const textContent = Array.isArray(part.content)
|
|
463
|
-
? part.content
|
|
464
|
-
.filter((c) => c.type === 'text')
|
|
465
|
-
.map((c) => c.text)
|
|
466
|
-
.join('\n')
|
|
467
|
-
: part.content;
|
|
468
|
-
if (textContent) {
|
|
469
|
-
messages.push({
|
|
470
|
-
type: 'assistant',
|
|
471
|
-
content: textContent,
|
|
472
|
-
timestamp: part.time || null,
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
catch {
|
|
479
|
-
// Can't parse part
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
if (messages.length > 0) {
|
|
483
|
-
return { id: sessionId, agentType: 'opencode', messages };
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
catch {
|
|
487
|
-
// 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 };
|
|
488
435
|
}
|
|
489
436
|
}
|
|
490
437
|
return { id: sessionId, messages };
|
|
@@ -627,6 +574,196 @@ export function createRouter(ctx) {
|
|
|
627
574
|
await ctx.sessionsCache.recordAccess(input.workspaceName, input.sessionId, input.agentType);
|
|
628
575
|
return { success: true };
|
|
629
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
|
+
}
|
|
630
767
|
const getHostInfo = os.handler(async () => {
|
|
631
768
|
const config = ctx.config.get();
|
|
632
769
|
return {
|
|
@@ -724,6 +861,8 @@ export function createRouter(ctx) {
|
|
|
724
861
|
clearName: clearSessionName,
|
|
725
862
|
getRecent: getRecentSessions,
|
|
726
863
|
recordAccess: recordSessionAccess,
|
|
864
|
+
delete: deleteSession,
|
|
865
|
+
search: searchSessions,
|
|
727
866
|
},
|
|
728
867
|
models: {
|
|
729
868
|
list: listModels,
|
package/dist/agent/run.js
CHANGED
|
@@ -13,13 +13,14 @@ import { createRouter } from './router';
|
|
|
13
13
|
import { serveStatic } from './static';
|
|
14
14
|
import { SessionsCacheManager } from '../sessions/cache';
|
|
15
15
|
import { ModelCacheManager } from '../models/cache';
|
|
16
|
+
import { getTailscaleStatus, getTailscaleIdentity, startTailscaleServe, stopTailscaleServe, } from '../tailscale';
|
|
16
17
|
import pkg from '../../package.json';
|
|
17
18
|
const startTime = Date.now();
|
|
18
19
|
function sendJson(res, status, data) {
|
|
19
20
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
20
21
|
res.end(JSON.stringify(data));
|
|
21
22
|
}
|
|
22
|
-
function createAgentServer(configDir, config) {
|
|
23
|
+
function createAgentServer(configDir, config, tailscale) {
|
|
23
24
|
let currentConfig = config;
|
|
24
25
|
const workspaces = new WorkspaceManager(configDir, currentConfig);
|
|
25
26
|
const sessionsCache = new SessionsCacheManager(configDir);
|
|
@@ -60,12 +61,14 @@ function createAgentServer(configDir, config) {
|
|
|
60
61
|
terminalServer,
|
|
61
62
|
sessionsCache,
|
|
62
63
|
modelCache,
|
|
64
|
+
tailscale,
|
|
63
65
|
});
|
|
64
66
|
const rpcHandler = new RPCHandler(router);
|
|
65
67
|
const server = createServer(async (req, res) => {
|
|
66
68
|
const url = new URL(req.url || '/', 'http://localhost');
|
|
67
69
|
const method = req.method;
|
|
68
70
|
const pathname = url.pathname;
|
|
71
|
+
const identity = getTailscaleIdentity(req);
|
|
69
72
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
70
73
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
71
74
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
@@ -76,7 +79,11 @@ function createAgentServer(configDir, config) {
|
|
|
76
79
|
}
|
|
77
80
|
try {
|
|
78
81
|
if (pathname === '/health' && method === 'GET') {
|
|
79
|
-
|
|
82
|
+
const response = { status: 'ok', version: pkg.version };
|
|
83
|
+
if (identity) {
|
|
84
|
+
response.user = identity.email;
|
|
85
|
+
}
|
|
86
|
+
sendJson(res, 200, response);
|
|
80
87
|
return;
|
|
81
88
|
}
|
|
82
89
|
if (pathname.startsWith('/rpc')) {
|
|
@@ -151,7 +158,38 @@ export async function startAgent(options = {}) {
|
|
|
151
158
|
const port = options.port || parseInt(process.env.PERRY_PORT || '', 10) || config.port || DEFAULT_AGENT_PORT;
|
|
152
159
|
console.log(`[agent] Config directory: ${configDir}`);
|
|
153
160
|
console.log(`[agent] Starting on port ${port}...`);
|
|
154
|
-
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);
|
|
155
193
|
server.on('error', async (err) => {
|
|
156
194
|
if (err.code === 'EADDRINUSE') {
|
|
157
195
|
console.error(`[agent] Error: Port ${port} is already in use.`);
|
|
@@ -169,14 +207,25 @@ export async function startAgent(options = {}) {
|
|
|
169
207
|
});
|
|
170
208
|
server.listen(port, '::', () => {
|
|
171
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
|
+
}
|
|
172
217
|
console.log(`[agent] oRPC endpoint: http://localhost:${port}/rpc`);
|
|
173
218
|
console.log(`[agent] WebSocket terminal: ws://localhost:${port}/rpc/terminal/:name`);
|
|
174
219
|
console.log(`[agent] WebSocket chat (Claude): ws://localhost:${port}/rpc/chat/:name`);
|
|
175
220
|
console.log(`[agent] WebSocket chat (OpenCode): ws://localhost:${port}/rpc/opencode/:name`);
|
|
176
221
|
startEagerImagePull();
|
|
177
222
|
});
|
|
178
|
-
const shutdown = () => {
|
|
223
|
+
const shutdown = async () => {
|
|
179
224
|
console.log('[agent] Shutting down...');
|
|
225
|
+
if (tailscaleServeActive) {
|
|
226
|
+
console.log('[agent] Stopping Tailscale Serve...');
|
|
227
|
+
await stopTailscaleServe();
|
|
228
|
+
}
|
|
180
229
|
chatServer.close();
|
|
181
230
|
opencodeServer.close();
|
|
182
231
|
terminalServer.close();
|