@gricha/perry 0.3.0 → 0.3.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 +1 -0
- package/dist/client/docker-proxy.js +2 -16
- package/dist/client/port-forward.js +23 -0
- package/dist/client/proxy.js +2 -16
- package/dist/config/loader.js +2 -6
- package/dist/index.js +14 -15
- package/dist/perry-worker +0 -0
- package/dist/sessions/agents/utils.js +6 -2
- package/dist/sessions/parser.js +1 -11
- package/dist/shared/format-utils.js +15 -0
- package/dist/shared/path-utils.js +8 -0
- package/dist/ssh/sync.js +1 -8
- package/dist/update-checker.js +1 -4
- package/package.json +4 -7
- package/dist/chat/base-chat-websocket.js +0 -86
- package/dist/chat/base-claude-session.js +0 -215
- package/dist/chat/base-opencode-session.js +0 -181
- package/dist/chat/handler.js +0 -47
- package/dist/chat/host-handler.js +0 -41
- package/dist/chat/host-opencode-handler.js +0 -144
- package/dist/chat/index.js +0 -2
- package/dist/chat/opencode-handler.js +0 -100
- package/dist/chat/opencode-server.js +0 -502
- package/dist/chat/opencode-websocket.js +0 -31
- package/dist/chat/session-monitor.js +0 -186
- package/dist/chat/session-utils.js +0 -155
- package/dist/chat/websocket.js +0 -33
package/README.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<a href="https://gricha.github.io/perry/"><img src="https://img.shields.io/badge/docs-docusaurus-blue" alt="Documentation"></a>
|
|
9
|
+
<a href="https://discord.gg/s2KX8kTvGX"><img src="https://img.shields.io/discord/1459251359164666064?color=5865F2&label=discord" alt="Discord"></a>
|
|
9
10
|
<a href="https://github.com/gricha/perry/actions/workflows/test.yml"><img src="https://github.com/gricha/perry/actions/workflows/test.yml/badge.svg" alt="Tests"></a>
|
|
10
11
|
<a href="https://github.com/gricha/perry/releases"><img src="https://img.shields.io/github/v/release/gricha/perry" alt="Release"></a>
|
|
11
12
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parsePortForward, formatPortForwards } from './port-forward';
|
|
2
|
+
export { parsePortForward, formatPortForwards };
|
|
1
3
|
export async function startDockerProxy(options) {
|
|
2
4
|
const { containerIp, forwards, onConnect, onError } = options;
|
|
3
5
|
const servers = [];
|
|
@@ -85,19 +87,3 @@ export async function startDockerProxy(options) {
|
|
|
85
87
|
}
|
|
86
88
|
};
|
|
87
89
|
}
|
|
88
|
-
export function formatPortForwards(forwards) {
|
|
89
|
-
return forwards
|
|
90
|
-
.map((f) => f.localPort === f.remotePort ? String(f.localPort) : `${f.localPort}:${f.remotePort}`)
|
|
91
|
-
.join(', ');
|
|
92
|
-
}
|
|
93
|
-
export function parsePortForward(spec) {
|
|
94
|
-
if (spec.includes(':')) {
|
|
95
|
-
const [local, remote] = spec.split(':');
|
|
96
|
-
return {
|
|
97
|
-
localPort: parseInt(local, 10),
|
|
98
|
-
remotePort: parseInt(remote, 10),
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
const port = parseInt(spec, 10);
|
|
102
|
-
return { localPort: port, remotePort: port };
|
|
103
|
-
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function validatePort(port, label) {
|
|
2
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
3
|
+
throw new Error(`Invalid ${label}: must be a number between 1 and 65535`);
|
|
4
|
+
}
|
|
5
|
+
}
|
|
6
|
+
export function parsePortForward(spec) {
|
|
7
|
+
if (spec.includes(':')) {
|
|
8
|
+
const [local, remote] = spec.split(':');
|
|
9
|
+
const localPort = parseInt(local, 10);
|
|
10
|
+
const remotePort = parseInt(remote, 10);
|
|
11
|
+
validatePort(localPort, 'local port');
|
|
12
|
+
validatePort(remotePort, 'remote port');
|
|
13
|
+
return { localPort, remotePort };
|
|
14
|
+
}
|
|
15
|
+
const port = parseInt(spec, 10);
|
|
16
|
+
validatePort(port, 'port');
|
|
17
|
+
return { localPort: port, remotePort: port };
|
|
18
|
+
}
|
|
19
|
+
export function formatPortForwards(forwards) {
|
|
20
|
+
return forwards
|
|
21
|
+
.map((f) => f.localPort === f.remotePort ? String(f.localPort) : `${f.localPort}:${f.remotePort}`)
|
|
22
|
+
.join(', ');
|
|
23
|
+
}
|
package/dist/client/proxy.js
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const [local, remote] = spec.split(':');
|
|
5
|
-
return {
|
|
6
|
-
localPort: parseInt(local, 10),
|
|
7
|
-
remotePort: parseInt(remote, 10),
|
|
8
|
-
};
|
|
9
|
-
}
|
|
10
|
-
const port = parseInt(spec, 10);
|
|
11
|
-
return { localPort: port, remotePort: port };
|
|
12
|
-
}
|
|
2
|
+
import { parsePortForward, formatPortForwards } from './port-forward';
|
|
3
|
+
export { parsePortForward, formatPortForwards };
|
|
13
4
|
export async function startProxy(options) {
|
|
14
5
|
const { worker, sshPort, forwards, user = 'workspace', onConnect, onDisconnect, onError, } = options;
|
|
15
6
|
const workerHost = worker.includes(':') ? worker.split(':')[0] : worker;
|
|
@@ -89,8 +80,3 @@ export async function startProxy(options) {
|
|
|
89
80
|
});
|
|
90
81
|
});
|
|
91
82
|
}
|
|
92
|
-
export function formatPortForwards(forwards) {
|
|
93
|
-
return forwards
|
|
94
|
-
.map((f) => f.localPort === f.remotePort ? String(f.localPort) : `${f.localPort}:${f.remotePort}`)
|
|
95
|
-
.join(', ');
|
|
96
|
-
}
|
package/dist/config/loader.js
CHANGED
|
@@ -2,6 +2,8 @@ import { promises as fs } from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { DEFAULT_CONFIG_DIR, CONFIG_FILE } from '../shared/types';
|
|
4
4
|
import { DEFAULT_AGENT_PORT } from '../shared/constants';
|
|
5
|
+
import { expandPath } from '../shared/path-utils';
|
|
6
|
+
export { expandPath };
|
|
5
7
|
export function getConfigDir(configDir) {
|
|
6
8
|
return configDir || process.env.WS_CONFIG_DIR || DEFAULT_CONFIG_DIR;
|
|
7
9
|
}
|
|
@@ -85,9 +87,3 @@ export async function saveAgentConfig(config, configDir) {
|
|
|
85
87
|
const configPath = path.join(dir, CONFIG_FILE);
|
|
86
88
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
87
89
|
}
|
|
88
|
-
export function expandPath(filePath) {
|
|
89
|
-
if (filePath.startsWith('~/')) {
|
|
90
|
-
return path.join(process.env.HOME || '', filePath.slice(2));
|
|
91
|
-
}
|
|
92
|
-
return filePath;
|
|
93
|
-
}
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { buildImage } from './docker';
|
|
|
14
14
|
import { DEFAULT_AGENT_PORT, WORKSPACE_IMAGE_LOCAL } from './shared/constants';
|
|
15
15
|
import { checkForUpdates } from './update-checker';
|
|
16
16
|
import { discoverSSHKeys } from './ssh';
|
|
17
|
+
import { formatUptime } from './shared/format-utils';
|
|
17
18
|
const program = new Command();
|
|
18
19
|
program
|
|
19
20
|
.name('perry')
|
|
@@ -658,6 +659,19 @@ sshCmd
|
|
|
658
659
|
}
|
|
659
660
|
await saveAgentConfig(config, configDir);
|
|
660
661
|
});
|
|
662
|
+
program
|
|
663
|
+
.command('update')
|
|
664
|
+
.description('Update Perry to the latest version')
|
|
665
|
+
.action(async () => {
|
|
666
|
+
const { spawn } = await import('child_process');
|
|
667
|
+
console.log('Updating Perry...');
|
|
668
|
+
const child = spawn('bash', ['-c', 'curl -fsSL https://raw.githubusercontent.com/gricha/perry/main/install.sh | bash'], {
|
|
669
|
+
stdio: 'inherit',
|
|
670
|
+
});
|
|
671
|
+
child.on('close', (code) => {
|
|
672
|
+
process.exit(code ?? 0);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
661
675
|
program
|
|
662
676
|
.command('build')
|
|
663
677
|
.description('Build the workspace Docker image')
|
|
@@ -742,20 +756,5 @@ function handleError(err) {
|
|
|
742
756
|
}
|
|
743
757
|
process.exit(1);
|
|
744
758
|
}
|
|
745
|
-
function formatUptime(seconds) {
|
|
746
|
-
const days = Math.floor(seconds / 86400);
|
|
747
|
-
const hours = Math.floor((seconds % 86400) / 3600);
|
|
748
|
-
const minutes = Math.floor((seconds % 3600) / 60);
|
|
749
|
-
const parts = [];
|
|
750
|
-
if (days > 0)
|
|
751
|
-
parts.push(`${days}d`);
|
|
752
|
-
if (hours > 0)
|
|
753
|
-
parts.push(`${hours}h`);
|
|
754
|
-
if (minutes > 0)
|
|
755
|
-
parts.push(`${minutes}m`);
|
|
756
|
-
if (parts.length === 0)
|
|
757
|
-
parts.push(`${seconds}s`);
|
|
758
|
-
return parts.join(' ');
|
|
759
|
-
}
|
|
760
759
|
checkForUpdates(pkg.version);
|
|
761
760
|
program.parse();
|
package/dist/perry-worker
CHANGED
|
Binary file
|
|
@@ -21,11 +21,15 @@ export function extractClaudeSessionName(content) {
|
|
|
21
21
|
return null;
|
|
22
22
|
}
|
|
23
23
|
export function extractContent(content) {
|
|
24
|
+
if (!content)
|
|
25
|
+
return null;
|
|
24
26
|
if (typeof content === 'string')
|
|
25
27
|
return content;
|
|
26
28
|
if (Array.isArray(content)) {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
+
const textParts = content
|
|
30
|
+
.filter((c) => c.type === 'text' && c.text)
|
|
31
|
+
.map((c) => c.text);
|
|
32
|
+
return textParts.join('\n') || null;
|
|
29
33
|
}
|
|
30
34
|
return null;
|
|
31
35
|
}
|
package/dist/sessions/parser.js
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from 'fs/promises';
|
|
2
2
|
import { join, basename } from 'path';
|
|
3
|
+
import { extractContent } from './agents/utils';
|
|
3
4
|
function decodeProjectPath(encoded) {
|
|
4
5
|
return encoded.replace(/-/g, '/');
|
|
5
6
|
}
|
|
6
|
-
function extractContent(content) {
|
|
7
|
-
if (!content)
|
|
8
|
-
return null;
|
|
9
|
-
if (typeof content === 'string')
|
|
10
|
-
return content;
|
|
11
|
-
if (Array.isArray(content)) {
|
|
12
|
-
const textParts = content.filter((c) => c.type === 'text' && c.text).map((c) => c.text);
|
|
13
|
-
return textParts.join('\n') || null;
|
|
14
|
-
}
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
7
|
function extractInterleavedContent(content) {
|
|
18
8
|
const messages = [];
|
|
19
9
|
for (const part of content) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function formatUptime(seconds) {
|
|
2
|
+
const days = Math.floor(seconds / 86400);
|
|
3
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
4
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
5
|
+
const parts = [];
|
|
6
|
+
if (days > 0)
|
|
7
|
+
parts.push(`${days}d`);
|
|
8
|
+
if (hours > 0)
|
|
9
|
+
parts.push(`${hours}h`);
|
|
10
|
+
if (minutes > 0)
|
|
11
|
+
parts.push(`${minutes}m`);
|
|
12
|
+
if (parts.length === 0)
|
|
13
|
+
parts.push(`${seconds}s`);
|
|
14
|
+
return parts.join(' ');
|
|
15
|
+
}
|
package/dist/ssh/sync.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'fs/promises';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
import { homedir } from 'os';
|
|
4
2
|
import { discoverSSHKeys, readPublicKey } from './discovery';
|
|
3
|
+
import { expandPath } from '../shared/path-utils';
|
|
5
4
|
export function getEffectiveSSHConfig(settings, workspaceName) {
|
|
6
5
|
const global = settings.global;
|
|
7
6
|
if (!workspaceName) {
|
|
@@ -72,9 +71,3 @@ export async function collectCopyKeys(settings, workspaceName) {
|
|
|
72
71
|
}
|
|
73
72
|
return result;
|
|
74
73
|
}
|
|
75
|
-
function expandPath(filePath) {
|
|
76
|
-
if (filePath.startsWith('~/')) {
|
|
77
|
-
return join(homedir(), filePath.slice(2));
|
|
78
|
-
}
|
|
79
|
-
return filePath;
|
|
80
|
-
}
|
package/dist/update-checker.js
CHANGED
|
@@ -73,10 +73,7 @@ export async function checkForUpdates(currentVersion) {
|
|
|
73
73
|
}
|
|
74
74
|
if (latestVersion && compareVersions(currentVersion, latestVersion) > 0) {
|
|
75
75
|
console.log('');
|
|
76
|
-
console.log(`\x1b[
|
|
77
|
-
console.log(`\x1b[33m│\x1b[0m Update available: \x1b[90m${currentVersion}\x1b[0m → \x1b[32m${latestVersion}\x1b[0m \x1b[33m│\x1b[0m`);
|
|
78
|
-
console.log(`\x1b[33m│\x1b[0m Run: \x1b[36mcurl -fsSL https://raw.githubusercontent.com/${GITHUB_REPO}/main/install.sh | bash\x1b[0m \x1b[33m│\x1b[0m`);
|
|
79
|
-
console.log(`\x1b[33m╰──────────────────────────────────────────────────────────────────────────────────╯\x1b[0m`);
|
|
76
|
+
console.log(`\x1b[33mUpdate available: \x1b[90m${currentVersion}\x1b[0m → \x1b[32m${latestVersion}\x1b[0m \x1b[33mRun: \x1b[36mperry update\x1b[0m`);
|
|
80
77
|
console.log('');
|
|
81
78
|
}
|
|
82
79
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gricha/perry",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Self-contained CLI for spinning up Docker-in-Docker development environments with SSH and proxy helpers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,17 +10,14 @@
|
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
|
+
"dev": "trap 'kill 0' EXIT; (cd web && bunx vite build --watch) & bun --watch src/index.ts agent run --port 7391",
|
|
13
14
|
"build": "rm -rf ./dist && bun run build:ts && bun run build:worker && bun run build:web && bun link",
|
|
14
15
|
"build:ts": "tsc && chmod +x dist/index.js",
|
|
15
16
|
"build:worker": "bun build src/index.ts --compile --outfile dist/perry-worker --target=bun",
|
|
16
|
-
"build:web": "cd web && bun run build
|
|
17
|
+
"build:web": "cd web && bun run build",
|
|
17
18
|
"test": "vitest run",
|
|
18
19
|
"test:web": "playwright test",
|
|
19
|
-
"
|
|
20
|
-
"test:watch": "vitest",
|
|
21
|
-
"lint": "oxlint --deny-warnings src/ mobile/src/",
|
|
22
|
-
"lint:fix": "oxlint --fix src/ mobile/src/",
|
|
23
|
-
"format": "oxfmt --write src/ test/",
|
|
20
|
+
"lint": "oxlint src/ mobile/src/",
|
|
24
21
|
"format:check": "oxfmt --check src/ test/",
|
|
25
22
|
"check": "bun run lint && bun run format:check && bun x tsc --noEmit",
|
|
26
23
|
"lint:web": "cd web && bun run lint",
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { BaseWebSocketServer, safeSend } from '../shared/base-websocket';
|
|
2
|
-
import { getContainerName } from '../docker';
|
|
3
|
-
import { HOST_WORKSPACE_NAME } from '../shared/client-types';
|
|
4
|
-
export class BaseChatWebSocketServer extends BaseWebSocketServer {
|
|
5
|
-
isHostAccessAllowed;
|
|
6
|
-
constructor(options) {
|
|
7
|
-
super(options);
|
|
8
|
-
this.isHostAccessAllowed = options.isHostAccessAllowed || (() => false);
|
|
9
|
-
}
|
|
10
|
-
handleConnection(ws, workspaceName) {
|
|
11
|
-
const isHostMode = workspaceName === HOST_WORKSPACE_NAME;
|
|
12
|
-
if (isHostMode && !this.isHostAccessAllowed()) {
|
|
13
|
-
ws.close(4003, 'Host access is disabled');
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
const connection = this.createConnection(ws, workspaceName);
|
|
17
|
-
this.connections.set(ws, connection);
|
|
18
|
-
const connectedMessage = {
|
|
19
|
-
type: 'connected',
|
|
20
|
-
workspaceName,
|
|
21
|
-
timestamp: new Date().toISOString(),
|
|
22
|
-
};
|
|
23
|
-
if (this.agentType) {
|
|
24
|
-
connectedMessage.agentType = this.agentType;
|
|
25
|
-
}
|
|
26
|
-
safeSend(ws, JSON.stringify(connectedMessage));
|
|
27
|
-
ws.on('message', async (data) => {
|
|
28
|
-
const str = typeof data === 'string' ? data : data.toString();
|
|
29
|
-
try {
|
|
30
|
-
const message = JSON.parse(str);
|
|
31
|
-
if (message.type === 'interrupt') {
|
|
32
|
-
if (connection.session) {
|
|
33
|
-
await connection.session.interrupt();
|
|
34
|
-
connection.session = null;
|
|
35
|
-
}
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
if (message.type === 'message' && message.content) {
|
|
39
|
-
const onMessage = (chatMessage) => {
|
|
40
|
-
safeSend(ws, JSON.stringify(chatMessage));
|
|
41
|
-
};
|
|
42
|
-
if (!connection.session) {
|
|
43
|
-
if (isHostMode) {
|
|
44
|
-
connection.session = this.createHostSession(message.sessionId, onMessage, message.model, message.projectPath);
|
|
45
|
-
}
|
|
46
|
-
else {
|
|
47
|
-
const containerName = getContainerName(workspaceName);
|
|
48
|
-
connection.session = this.createContainerSession(containerName, message.sessionId, onMessage, message.model);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
else if (message.model && connection.session.setModel) {
|
|
52
|
-
connection.session.setModel(message.model);
|
|
53
|
-
}
|
|
54
|
-
await connection.session.sendMessage(message.content);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
catch (err) {
|
|
58
|
-
safeSend(ws, JSON.stringify({
|
|
59
|
-
type: 'error',
|
|
60
|
-
content: err.message,
|
|
61
|
-
timestamp: new Date().toISOString(),
|
|
62
|
-
}));
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
ws.on('close', () => {
|
|
66
|
-
const conn = this.connections.get(ws);
|
|
67
|
-
if (conn?.session) {
|
|
68
|
-
conn.session.interrupt().catch(() => { });
|
|
69
|
-
}
|
|
70
|
-
this.connections.delete(ws);
|
|
71
|
-
});
|
|
72
|
-
ws.on('error', (err) => {
|
|
73
|
-
console.error(`${this.agentType || 'Chat'} WebSocket error:`, err);
|
|
74
|
-
const conn = this.connections.get(ws);
|
|
75
|
-
if (conn?.session) {
|
|
76
|
-
conn.session.interrupt().catch(() => { });
|
|
77
|
-
}
|
|
78
|
-
this.connections.delete(ws);
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
cleanupConnection(connection) {
|
|
82
|
-
if (connection.session) {
|
|
83
|
-
connection.session.interrupt().catch(() => { });
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
import { DEFAULT_CLAUDE_MODEL } from '../shared/constants';
|
|
2
|
-
import { SessionMonitor, MONITOR_PRESETS, formatErrorMessage } from './session-monitor';
|
|
3
|
-
export class BaseClaudeSession {
|
|
4
|
-
process = null;
|
|
5
|
-
sessionId;
|
|
6
|
-
model;
|
|
7
|
-
sessionModel;
|
|
8
|
-
onMessage;
|
|
9
|
-
buffer = '';
|
|
10
|
-
monitor = null;
|
|
11
|
-
constructor(sessionId, model, onMessage) {
|
|
12
|
-
this.sessionId = sessionId;
|
|
13
|
-
this.model = model || DEFAULT_CLAUDE_MODEL;
|
|
14
|
-
this.sessionModel = this.model;
|
|
15
|
-
this.onMessage = onMessage;
|
|
16
|
-
}
|
|
17
|
-
async sendMessage(userMessage) {
|
|
18
|
-
const logPrefix = this.getLogPrefix();
|
|
19
|
-
const { command, options } = this.getSpawnConfig(userMessage);
|
|
20
|
-
console.log(`[${logPrefix}] Running:`, command.join(' '));
|
|
21
|
-
this.onMessage({
|
|
22
|
-
type: 'system',
|
|
23
|
-
content: 'Processing your message...',
|
|
24
|
-
timestamp: new Date().toISOString(),
|
|
25
|
-
});
|
|
26
|
-
// Create monitor with activity tracking to detect frozen subprocesses
|
|
27
|
-
this.monitor = new SessionMonitor({
|
|
28
|
-
...MONITOR_PRESETS.claudeCode,
|
|
29
|
-
activityTimeout: 60000, // Detect if no output for 60s
|
|
30
|
-
}, {
|
|
31
|
-
onError: this.onMessage,
|
|
32
|
-
onTimeout: () => {
|
|
33
|
-
if (this.process) {
|
|
34
|
-
console.warn(`[${logPrefix}] Killing process due to timeout`);
|
|
35
|
-
this.process.kill();
|
|
36
|
-
}
|
|
37
|
-
},
|
|
38
|
-
onActivityTimeout: () => {
|
|
39
|
-
if (this.process) {
|
|
40
|
-
console.warn(`[${logPrefix}] Killing process due to inactivity`);
|
|
41
|
-
this.process.kill();
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
this.monitor.start();
|
|
46
|
-
try {
|
|
47
|
-
const proc = Bun.spawn(command, {
|
|
48
|
-
stdin: 'ignore',
|
|
49
|
-
stdout: 'pipe',
|
|
50
|
-
stderr: 'pipe',
|
|
51
|
-
...options,
|
|
52
|
-
});
|
|
53
|
-
this.process = proc;
|
|
54
|
-
if (!proc.stdout || !proc.stderr) {
|
|
55
|
-
throw new Error('Failed to get process streams');
|
|
56
|
-
}
|
|
57
|
-
console.log(`[${logPrefix}] Process spawned, waiting for output...`);
|
|
58
|
-
const stderrPromise = new Response(proc.stderr).text();
|
|
59
|
-
const decoder = new TextDecoder();
|
|
60
|
-
let receivedAnyOutput = false;
|
|
61
|
-
for await (const chunk of proc.stdout) {
|
|
62
|
-
// Mark activity so monitor knows subprocess is alive
|
|
63
|
-
if (this.monitor) {
|
|
64
|
-
this.monitor.markActivity();
|
|
65
|
-
}
|
|
66
|
-
const text = decoder.decode(chunk);
|
|
67
|
-
console.log(`[${logPrefix}] Received chunk:`, text.length, 'bytes');
|
|
68
|
-
receivedAnyOutput = true;
|
|
69
|
-
this.buffer += text;
|
|
70
|
-
this.processBuffer();
|
|
71
|
-
// Check if monitor has timed out
|
|
72
|
-
if (this.monitor?.isCompleted()) {
|
|
73
|
-
console.warn(`[${logPrefix}] Monitor timeout, breaking from output loop`);
|
|
74
|
-
proc.kill();
|
|
75
|
-
break;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
const exitCode = await proc.exited;
|
|
79
|
-
console.log(`[${logPrefix}] Process exited with code:`, exitCode, 'receivedOutput:', receivedAnyOutput);
|
|
80
|
-
// Stop monitoring before handling results
|
|
81
|
-
if (this.monitor && !this.monitor.isCompleted()) {
|
|
82
|
-
this.monitor.complete();
|
|
83
|
-
}
|
|
84
|
-
const stderrText = await stderrPromise;
|
|
85
|
-
if (stderrText) {
|
|
86
|
-
console.error(`[${logPrefix}] stderr:`, stderrText);
|
|
87
|
-
}
|
|
88
|
-
// Don't send error if monitor already sent one
|
|
89
|
-
if (this.monitor?.isCompleted()) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
if (exitCode !== 0) {
|
|
93
|
-
this.onMessage({
|
|
94
|
-
type: 'error',
|
|
95
|
-
content: formatErrorMessage(new Error(stderrText || `Claude exited with code ${exitCode}`), 'Claude Code'),
|
|
96
|
-
timestamp: new Date().toISOString(),
|
|
97
|
-
});
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
if (!receivedAnyOutput) {
|
|
101
|
-
this.onMessage({
|
|
102
|
-
type: 'error',
|
|
103
|
-
content: this.getNoOutputErrorMessage(),
|
|
104
|
-
timestamp: new Date().toISOString(),
|
|
105
|
-
});
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
this.onMessage({
|
|
109
|
-
type: 'done',
|
|
110
|
-
content: 'Response complete',
|
|
111
|
-
timestamp: new Date().toISOString(),
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
catch (err) {
|
|
115
|
-
console.error(`[${logPrefix}] Error:`, err);
|
|
116
|
-
this.onMessage({
|
|
117
|
-
type: 'error',
|
|
118
|
-
content: formatErrorMessage(err, 'Claude Code'),
|
|
119
|
-
timestamp: new Date().toISOString(),
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
finally {
|
|
123
|
-
if (this.monitor) {
|
|
124
|
-
this.monitor.complete();
|
|
125
|
-
}
|
|
126
|
-
this.process = null;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
getNoOutputErrorMessage() {
|
|
130
|
-
return 'No response from Claude. Check if Claude is authenticated.';
|
|
131
|
-
}
|
|
132
|
-
processBuffer() {
|
|
133
|
-
const lines = this.buffer.split('\n');
|
|
134
|
-
this.buffer = lines.pop() || '';
|
|
135
|
-
for (const line of lines) {
|
|
136
|
-
if (!line.trim())
|
|
137
|
-
continue;
|
|
138
|
-
try {
|
|
139
|
-
const msg = JSON.parse(line);
|
|
140
|
-
this.handleStreamMessage(msg);
|
|
141
|
-
}
|
|
142
|
-
catch {
|
|
143
|
-
console.error(`[${this.getLogPrefix()}] Failed to parse:`, line);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
handleStreamMessage(msg) {
|
|
148
|
-
const timestamp = new Date().toISOString();
|
|
149
|
-
if (msg.type === 'system' && msg.subtype === 'init') {
|
|
150
|
-
this.sessionId = msg.session_id;
|
|
151
|
-
this.sessionModel = this.model;
|
|
152
|
-
this.onMessage({
|
|
153
|
-
type: 'system',
|
|
154
|
-
content: `Session started: ${msg.session_id?.slice(0, 8)}...`,
|
|
155
|
-
timestamp,
|
|
156
|
-
});
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (msg.type === 'assistant' && msg.message?.content) {
|
|
160
|
-
for (const block of msg.message.content) {
|
|
161
|
-
if (block.type === 'tool_use') {
|
|
162
|
-
this.onMessage({
|
|
163
|
-
type: 'tool_use',
|
|
164
|
-
content: JSON.stringify(block.input, null, 2),
|
|
165
|
-
toolName: block.name,
|
|
166
|
-
toolId: block.id,
|
|
167
|
-
timestamp,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
if (msg.type === 'stream_event' && msg.event?.type === 'content_block_delta') {
|
|
174
|
-
const delta = msg.event?.delta;
|
|
175
|
-
if (delta?.type === 'text_delta' && delta?.text) {
|
|
176
|
-
this.onMessage({
|
|
177
|
-
type: 'assistant',
|
|
178
|
-
content: delta.text,
|
|
179
|
-
timestamp,
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
async interrupt() {
|
|
186
|
-
if (this.monitor) {
|
|
187
|
-
this.monitor.complete();
|
|
188
|
-
}
|
|
189
|
-
if (this.process) {
|
|
190
|
-
this.process.kill();
|
|
191
|
-
this.process = null;
|
|
192
|
-
this.onMessage({
|
|
193
|
-
type: 'system',
|
|
194
|
-
content: 'Chat interrupted',
|
|
195
|
-
timestamp: new Date().toISOString(),
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
setModel(model) {
|
|
200
|
-
if (this.model !== model) {
|
|
201
|
-
this.model = model;
|
|
202
|
-
if (this.sessionModel !== model) {
|
|
203
|
-
this.sessionId = undefined;
|
|
204
|
-
this.onMessage({
|
|
205
|
-
type: 'system',
|
|
206
|
-
content: `Switching to model: ${model}`,
|
|
207
|
-
timestamp: new Date().toISOString(),
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
getSessionId() {
|
|
213
|
-
return this.sessionId;
|
|
214
|
-
}
|
|
215
|
-
}
|