claude-remote-agent 0.1.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.
- package/agent.js +470 -0
- package/cli.js +51 -0
- package/package.json +33 -0
- package/pty-bridge.py +114 -0
package/agent.js
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const WebSocket = require('ws');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const SERVER_URL = process.env.SERVER_URL || 'wss://claude.pishchykau.eu';
|
|
10
|
+
const AGENT_NAME = process.env.AGENT_NAME || os.hostname();
|
|
11
|
+
const AGENT_KEY = process.env.AGENT_KEY || crypto.randomBytes(24).toString('base64url');
|
|
12
|
+
const PTY_BRIDGE = path.join(__dirname, 'pty-bridge.py');
|
|
13
|
+
|
|
14
|
+
const sessions = new Map();
|
|
15
|
+
let ws = null;
|
|
16
|
+
let reconnectTimer = null;
|
|
17
|
+
let reconnectDelay = 1000;
|
|
18
|
+
|
|
19
|
+
console.log('');
|
|
20
|
+
console.log(' ╔══════════════════════════════════════╗');
|
|
21
|
+
console.log(' ║ Claude Remote Agent ║');
|
|
22
|
+
console.log(' ╠══════════════════════════════════════╣');
|
|
23
|
+
console.log(` ║ Name: ${pad(AGENT_NAME, 28)}║`);
|
|
24
|
+
console.log(` ║ Host: ${pad(os.hostname(), 28)}║`);
|
|
25
|
+
console.log(` ║ Key: ${pad(AGENT_KEY, 28)}║`);
|
|
26
|
+
console.log(' ╠══════════════════════════════════════╣');
|
|
27
|
+
console.log(` ║ Server: ${pad(SERVER_URL, 27)}║`);
|
|
28
|
+
console.log(' ╚══════════════════════════════════════╝');
|
|
29
|
+
console.log('');
|
|
30
|
+
|
|
31
|
+
function pad(str, len) {
|
|
32
|
+
if (str.length > len) return str.substring(0, len - 1) + '…';
|
|
33
|
+
return str + ' '.repeat(len - str.length);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function connect() {
|
|
37
|
+
const params = new URLSearchParams({
|
|
38
|
+
role: 'agent',
|
|
39
|
+
key: AGENT_KEY,
|
|
40
|
+
name: AGENT_NAME,
|
|
41
|
+
hostname: os.hostname(),
|
|
42
|
+
platform: `${os.platform()}/${os.arch()}`,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
ws = new WebSocket(`${SERVER_URL}?${params}`);
|
|
46
|
+
|
|
47
|
+
ws.on('open', () => {
|
|
48
|
+
console.log(`[${ts()}] Connected to server`);
|
|
49
|
+
reconnectDelay = 1000;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
ws.on('message', (raw) => {
|
|
53
|
+
try {
|
|
54
|
+
const msg = JSON.parse(raw);
|
|
55
|
+
|
|
56
|
+
switch (msg.type) {
|
|
57
|
+
case 'registered':
|
|
58
|
+
console.log(`[${ts()}] Registered as agent ${msg.id}`);
|
|
59
|
+
break;
|
|
60
|
+
case 'create-session':
|
|
61
|
+
createSession(msg.id, msg.cwd, msg.cols, msg.rows, msg.resumeId);
|
|
62
|
+
break;
|
|
63
|
+
case 'kill-session':
|
|
64
|
+
killSession(msg.id);
|
|
65
|
+
break;
|
|
66
|
+
case 'request':
|
|
67
|
+
handleRequest(msg);
|
|
68
|
+
break;
|
|
69
|
+
case 'input': {
|
|
70
|
+
const s = sessions.get(msg.sessionId);
|
|
71
|
+
if (s && s.proc && s.proc.stdin.writable) s.proc.stdin.write(msg.data);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'resize': {
|
|
75
|
+
const s = sessions.get(msg.sessionId);
|
|
76
|
+
if (s && s.proc && s.proc.stdin.writable) {
|
|
77
|
+
s.proc.stdin.write(`\x1b_RESIZE:${msg.cols},${msg.rows}\x1b\\`);
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case 'create-chat-session':
|
|
82
|
+
createChatSession(msg.id, msg.cwd, msg.resumeId);
|
|
83
|
+
break;
|
|
84
|
+
case 'chat-message':
|
|
85
|
+
handleChatMessage(msg.sessionId, msg.text, msg.images);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`[${ts()}] Message error:`, err.message);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
ws.on('close', (code) => {
|
|
94
|
+
ws = null;
|
|
95
|
+
if (code === 4009) {
|
|
96
|
+
console.log(`[${ts()}] Replaced by another agent with the same key`);
|
|
97
|
+
}
|
|
98
|
+
console.log(`[${ts()}] Disconnected. Reconnecting in ${reconnectDelay / 1000}s...`);
|
|
99
|
+
reconnectTimer = setTimeout(connect, reconnectDelay);
|
|
100
|
+
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
ws.on('error', (err) => {
|
|
104
|
+
if (err.code !== 'ECONNREFUSED') {
|
|
105
|
+
console.error(`[${ts()}] Error:`, err.message);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildEnv(cwd) {
|
|
111
|
+
const env = { ...process.env, TERM: 'xterm-256color', PTY_CWD: cwd };
|
|
112
|
+
const localBin = path.join(process.env.HOME, '.local', 'bin');
|
|
113
|
+
if (!env.PATH?.includes(localBin)) {
|
|
114
|
+
env.PATH = localBin + ':' + (env.PATH || '');
|
|
115
|
+
}
|
|
116
|
+
return env;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveDir(cwd) {
|
|
120
|
+
let workDir = cwd || process.env.HOME;
|
|
121
|
+
if (workDir.startsWith('~')) {
|
|
122
|
+
workDir = workDir.replace(/^~/, process.env.HOME);
|
|
123
|
+
}
|
|
124
|
+
return workDir;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createSession(id, cwd, cols, rows, resumeId) {
|
|
128
|
+
const workDir = resolveDir(cwd);
|
|
129
|
+
const env = buildEnv(workDir);
|
|
130
|
+
const cmd = resumeId ? ['claude', '--resume', resumeId] : ['claude'];
|
|
131
|
+
|
|
132
|
+
let proc;
|
|
133
|
+
try {
|
|
134
|
+
proc = spawn('python3', [PTY_BRIDGE, String(cols), String(rows), ...cmd], {
|
|
135
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
136
|
+
env,
|
|
137
|
+
});
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error(`[${ts()}] Session ${id} spawn error:`, err.message);
|
|
140
|
+
send({ type: 'exit', sessionId: id, exitCode: -1 });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
sessions.set(id, { id, proc, cwd: workDir });
|
|
145
|
+
console.log(`[${ts()}] Session ${id.slice(0, 8)} started in ${workDir}`);
|
|
146
|
+
|
|
147
|
+
proc.stdout.on('data', (data) => {
|
|
148
|
+
send({ type: 'output', sessionId: id, data: data.toString('utf-8') });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
proc.stderr.on('data', (data) => {
|
|
152
|
+
send({ type: 'output', sessionId: id, data: data.toString('utf-8') });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
proc.on('exit', (exitCode) => {
|
|
156
|
+
console.log(`[${ts()}] Session ${id.slice(0, 8)} exited (${exitCode})`);
|
|
157
|
+
sessions.delete(id);
|
|
158
|
+
send({ type: 'exit', sessionId: id, exitCode });
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function killSession(id) {
|
|
163
|
+
const session = sessions.get(id);
|
|
164
|
+
if (session && session.proc) {
|
|
165
|
+
session.proc.kill('SIGTERM');
|
|
166
|
+
}
|
|
167
|
+
sessions.delete(id);
|
|
168
|
+
console.log(`[${ts()}] Session ${id.slice(0, 8)} killed`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function send(msg) {
|
|
172
|
+
if (ws && ws.readyState === 1) {
|
|
173
|
+
ws.send(JSON.stringify(msg));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function ts() {
|
|
178
|
+
return new Date().toLocaleTimeString();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Conversation history ──
|
|
182
|
+
|
|
183
|
+
const CLAUDE_PROJECTS = path.join(process.env.HOME, '.claude', 'projects');
|
|
184
|
+
|
|
185
|
+
function resolveProjectDir(dir) {
|
|
186
|
+
const parts = dir.slice(1).split('-');
|
|
187
|
+
let resolved = '';
|
|
188
|
+
let i = 0;
|
|
189
|
+
while (i < parts.length) {
|
|
190
|
+
let found = false;
|
|
191
|
+
for (let j = parts.length; j > i; j--) {
|
|
192
|
+
const segment = parts.slice(i, j).join('-');
|
|
193
|
+
const test = resolved + '/' + segment;
|
|
194
|
+
if (fs.existsSync(test)) {
|
|
195
|
+
resolved = test;
|
|
196
|
+
i = j;
|
|
197
|
+
found = true;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!found) {
|
|
202
|
+
resolved += '/' + parts[i];
|
|
203
|
+
i++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return resolved;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function handleRequest(msg) {
|
|
210
|
+
const { reqId, action, params } = msg;
|
|
211
|
+
try {
|
|
212
|
+
let data;
|
|
213
|
+
if (action === 'list-conversations') data = await listConversations();
|
|
214
|
+
else if (action === 'get-conversation') data = await getConversation(params.file);
|
|
215
|
+
else { sendResponse(reqId, null, 'Unknown action'); return; }
|
|
216
|
+
sendResponse(reqId, data);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
sendResponse(reqId, null, err.message);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sendResponse(reqId, data, error) {
|
|
223
|
+
send({ type: 'response', reqId, data, error });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function listConversations() {
|
|
227
|
+
const projects = [];
|
|
228
|
+
if (!fs.existsSync(CLAUDE_PROJECTS)) return projects;
|
|
229
|
+
|
|
230
|
+
for (const dir of fs.readdirSync(CLAUDE_PROJECTS)) {
|
|
231
|
+
const dirPath = path.join(CLAUDE_PROJECTS, dir);
|
|
232
|
+
if (!fs.statSync(dirPath).isDirectory()) continue;
|
|
233
|
+
|
|
234
|
+
const jsonls = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
|
|
235
|
+
if (jsonls.length === 0) continue;
|
|
236
|
+
|
|
237
|
+
const projectPath = resolveProjectDir(dir);
|
|
238
|
+
const projectName = projectPath
|
|
239
|
+
.replace(/^\/Users\/[^/]+\//, '')
|
|
240
|
+
.replace(/^\/home\/[^/]+\//, '');
|
|
241
|
+
|
|
242
|
+
const convos = [];
|
|
243
|
+
for (const jf of jsonls) {
|
|
244
|
+
const filePath = path.join(dirPath, jf);
|
|
245
|
+
const sessionId = jf.replace('.jsonl', '');
|
|
246
|
+
const stat = fs.statSync(filePath);
|
|
247
|
+
let aiTitle = '';
|
|
248
|
+
let lastPrompt = '';
|
|
249
|
+
let msgCount = 0;
|
|
250
|
+
|
|
251
|
+
const rl = readline.createInterface({
|
|
252
|
+
input: fs.createReadStream(filePath),
|
|
253
|
+
crlfDelay: Infinity,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
for await (const line of rl) {
|
|
257
|
+
try {
|
|
258
|
+
const obj = JSON.parse(line);
|
|
259
|
+
if (obj.type === 'ai-title') aiTitle = obj.aiTitle || '';
|
|
260
|
+
if (obj.type === 'last-prompt') lastPrompt = obj.lastPrompt || '';
|
|
261
|
+
if (obj.type === 'user' || obj.type === 'assistant') msgCount++;
|
|
262
|
+
} catch {}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
convos.push({
|
|
266
|
+
sessionId,
|
|
267
|
+
file: path.join(dir, jf),
|
|
268
|
+
cwd: projectPath,
|
|
269
|
+
title: aiTitle || lastPrompt.slice(0, 80) || sessionId.slice(0, 8),
|
|
270
|
+
lastPrompt: lastPrompt.slice(0, 100),
|
|
271
|
+
msgCount,
|
|
272
|
+
updatedAt: stat.mtime.toISOString(),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
convos.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
277
|
+
projects.push({ name: projectName, dir, conversations: convos });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
projects.sort((a, b) => {
|
|
281
|
+
const aMax = a.conversations[0]?.updatedAt || '';
|
|
282
|
+
const bMax = b.conversations[0]?.updatedAt || '';
|
|
283
|
+
return bMax.localeCompare(aMax);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return projects;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function getConversation(file) {
|
|
290
|
+
const filePath = path.join(CLAUDE_PROJECTS, file);
|
|
291
|
+
if (!filePath.startsWith(CLAUDE_PROJECTS)) throw new Error('Invalid path');
|
|
292
|
+
if (!fs.existsSync(filePath)) throw new Error('Not found');
|
|
293
|
+
|
|
294
|
+
const messages = [];
|
|
295
|
+
let aiTitle = '';
|
|
296
|
+
|
|
297
|
+
const rl = readline.createInterface({
|
|
298
|
+
input: fs.createReadStream(filePath),
|
|
299
|
+
crlfDelay: Infinity,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
for await (const line of rl) {
|
|
303
|
+
try {
|
|
304
|
+
const obj = JSON.parse(line);
|
|
305
|
+
if (obj.type === 'ai-title') {
|
|
306
|
+
aiTitle = obj.aiTitle || '';
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (obj.type !== 'user' && obj.type !== 'assistant') continue;
|
|
310
|
+
if (!obj.message) continue;
|
|
311
|
+
|
|
312
|
+
const content = obj.message.content;
|
|
313
|
+
|
|
314
|
+
if (typeof content === 'string') {
|
|
315
|
+
if (content.trim()) messages.push({ role: obj.type, text: content });
|
|
316
|
+
} else if (Array.isArray(content)) {
|
|
317
|
+
for (const block of content) {
|
|
318
|
+
if (block.type === 'thinking') continue;
|
|
319
|
+
if (block.type === 'text') {
|
|
320
|
+
const t = block.text?.trim();
|
|
321
|
+
if (t) messages.push({ role: obj.type === 'user' ? 'user' : 'assistant', text: t });
|
|
322
|
+
} else if (block.type === 'tool_use') {
|
|
323
|
+
let input = '';
|
|
324
|
+
if (block.name === 'Bash') input = block.input?.command || '';
|
|
325
|
+
else if (block.name === 'Read') input = block.input?.file_path || '';
|
|
326
|
+
else if (block.name === 'Edit' || block.name === 'Write') input = block.input?.file_path || '';
|
|
327
|
+
else input = JSON.stringify(block.input || {}).slice(0, 200);
|
|
328
|
+
messages.push({
|
|
329
|
+
role: 'tool',
|
|
330
|
+
tool: block.name,
|
|
331
|
+
toolId: block.id,
|
|
332
|
+
description: block.input?.description || '',
|
|
333
|
+
input: input.slice(0, 500),
|
|
334
|
+
});
|
|
335
|
+
} else if (block.type === 'tool_result') {
|
|
336
|
+
const prev = messages[messages.length - 1];
|
|
337
|
+
if (prev && prev.role === 'tool' && prev.toolId === block.tool_use_id) {
|
|
338
|
+
let out = '';
|
|
339
|
+
if (typeof block.content === 'string') out = block.content;
|
|
340
|
+
else if (Array.isArray(block.content)) {
|
|
341
|
+
out = block.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
|
|
342
|
+
}
|
|
343
|
+
prev.output = out.slice(0, 1000);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch {}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { title: aiTitle, messages };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Chat sessions (structured JSON mode) ──
|
|
355
|
+
|
|
356
|
+
const chatSessions = new Map();
|
|
357
|
+
|
|
358
|
+
function createChatSession(id, cwd, resumeId) {
|
|
359
|
+
const workDir = resolveDir(cwd);
|
|
360
|
+
chatSessions.set(id, { id, cwd: workDir, claudeSessionId: resumeId || null, busy: false });
|
|
361
|
+
console.log(`[${ts()}] Chat session ${id.slice(0, 8)} created (claude: ${resumeId ? resumeId.slice(0, 8) : 'new'})`);
|
|
362
|
+
send({ type: 'chat-session-ready', sessionId: id, cwd: workDir, claudeSessionId: resumeId || null });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function handleChatMessage(sessionId, text, images) {
|
|
366
|
+
const session = chatSessions.get(sessionId);
|
|
367
|
+
if (!session) { send({ type: 'chat-error', sessionId, error: 'Session not found' }); return; }
|
|
368
|
+
if (session.busy) { send({ type: 'chat-error', sessionId, error: 'Busy' }); return; }
|
|
369
|
+
|
|
370
|
+
session.busy = true;
|
|
371
|
+
send({ type: 'chat-thinking', sessionId });
|
|
372
|
+
|
|
373
|
+
const savedFiles = [];
|
|
374
|
+
if (images && images.length > 0) {
|
|
375
|
+
const tmpDir = path.join(os.tmpdir(), 'claude-remote-images');
|
|
376
|
+
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
377
|
+
for (const img of images) {
|
|
378
|
+
const ext = img.mimeType.split('/')[1] || 'png';
|
|
379
|
+
const fileName = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
380
|
+
const filePath = path.join(tmpDir, fileName);
|
|
381
|
+
fs.writeFileSync(filePath, Buffer.from(img.base64, 'base64'));
|
|
382
|
+
savedFiles.push(filePath);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let prompt = text || '';
|
|
387
|
+
if (savedFiles.length > 0) {
|
|
388
|
+
const fileRefs = savedFiles.map(f => f).join(', ');
|
|
389
|
+
if (prompt) {
|
|
390
|
+
prompt = prompt + '\n\n[Attached images: ' + fileRefs + '] Use the Read tool to view them.';
|
|
391
|
+
} else {
|
|
392
|
+
prompt = '[Attached images: ' + fileRefs + '] Use the Read tool to view and describe them.';
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
|
397
|
+
if (session.claudeSessionId) {
|
|
398
|
+
args.push('--resume', session.claudeSessionId);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const env = buildEnv(session.cwd);
|
|
402
|
+
const proc = spawn('claude', args, {
|
|
403
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
404
|
+
env,
|
|
405
|
+
cwd: session.cwd,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
let buffer = '';
|
|
409
|
+
|
|
410
|
+
proc.stdout.on('data', (chunk) => {
|
|
411
|
+
buffer += chunk.toString();
|
|
412
|
+
const lines = buffer.split('\n');
|
|
413
|
+
buffer = lines.pop();
|
|
414
|
+
for (const line of lines) {
|
|
415
|
+
if (!line.trim()) continue;
|
|
416
|
+
try {
|
|
417
|
+
const obj = JSON.parse(line);
|
|
418
|
+
if (obj.type === 'assistant' && obj.message?.content) {
|
|
419
|
+
if (obj.session_id && !session.claudeSessionId) {
|
|
420
|
+
session.claudeSessionId = obj.session_id;
|
|
421
|
+
console.log(`[${ts()}] Chat ${sessionId.slice(0, 8)} got claude session: ${obj.session_id.slice(0, 8)}`);
|
|
422
|
+
}
|
|
423
|
+
for (const block of obj.message.content) {
|
|
424
|
+
if (block.type === 'text') {
|
|
425
|
+
send({ type: 'chat-text', sessionId, text: block.text, done: false });
|
|
426
|
+
} else if (block.type === 'tool_use') {
|
|
427
|
+
send({ type: 'chat-tool', sessionId, tool: block.name, input: block.input });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
} else if (obj.type === 'result') {
|
|
431
|
+
if (obj.session_id && !session.claudeSessionId) {
|
|
432
|
+
session.claudeSessionId = obj.session_id;
|
|
433
|
+
console.log(`[${ts()}] Chat ${sessionId.slice(0, 8)} got claude session: ${obj.session_id.slice(0, 8)}`);
|
|
434
|
+
}
|
|
435
|
+
send({ type: 'chat-text', sessionId, text: obj.result || '', done: true });
|
|
436
|
+
}
|
|
437
|
+
} catch {}
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
proc.stderr.on('data', () => {});
|
|
442
|
+
|
|
443
|
+
proc.on('exit', (code) => {
|
|
444
|
+
session.busy = false;
|
|
445
|
+
send({ type: 'chat-done', sessionId, exitCode: code });
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
proc.stdin.write(prompt);
|
|
449
|
+
proc.stdin.end();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
process.on('SIGINT', () => {
|
|
453
|
+
console.log(`\n[${ts()}] Shutting down...`);
|
|
454
|
+
for (const [, session] of sessions) {
|
|
455
|
+
if (session.proc) session.proc.kill('SIGTERM');
|
|
456
|
+
}
|
|
457
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
458
|
+
if (ws) ws.close();
|
|
459
|
+
process.exit();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
process.on('SIGTERM', () => {
|
|
463
|
+
for (const [, session] of sessions) {
|
|
464
|
+
if (session.proc) session.proc.kill('SIGTERM');
|
|
465
|
+
}
|
|
466
|
+
if (ws) ws.close();
|
|
467
|
+
process.exit();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
connect();
|
package/cli.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
const opts = {};
|
|
5
|
+
|
|
6
|
+
for (let i = 0; i < args.length; i++) {
|
|
7
|
+
const arg = args[i];
|
|
8
|
+
if (arg.startsWith('--')) {
|
|
9
|
+
const eq = arg.indexOf('=');
|
|
10
|
+
if (eq !== -1) {
|
|
11
|
+
opts[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
12
|
+
} else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
|
13
|
+
opts[arg.slice(2)] = args[++i];
|
|
14
|
+
} else {
|
|
15
|
+
opts[arg.slice(2)] = true;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (opts.help) {
|
|
21
|
+
console.log(`
|
|
22
|
+
claude-remote-agent — connect this machine to Claude Remote
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
claude-remote-agent --key <ACCESS_KEY> [options]
|
|
26
|
+
npx claude-remote-agent --key <ACCESS_KEY> [options]
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--key <key> Access key for authentication (required)
|
|
30
|
+
--name <name> Agent display name (default: hostname)
|
|
31
|
+
--server <url> Relay server URL (default: wss://claude.pishchykau.eu)
|
|
32
|
+
--help Show this help
|
|
33
|
+
|
|
34
|
+
Environment variables (used as fallbacks):
|
|
35
|
+
AGENT_KEY Access key
|
|
36
|
+
AGENT_NAME Agent name
|
|
37
|
+
SERVER_URL Server URL
|
|
38
|
+
`);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (opts.key) process.env.AGENT_KEY = opts.key;
|
|
43
|
+
if (opts.name) process.env.AGENT_NAME = opts.name;
|
|
44
|
+
if (opts.server) process.env.SERVER_URL = opts.server;
|
|
45
|
+
|
|
46
|
+
if (!process.env.AGENT_KEY) {
|
|
47
|
+
console.error('Error: --key is required. Run with --help for usage.');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
require('./agent.js');
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-remote-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent for Claude Remote — connects your machine to the relay server",
|
|
5
|
+
"bin": {
|
|
6
|
+
"claude-remote-agent": "./cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"cli.js",
|
|
10
|
+
"agent.js",
|
|
11
|
+
"pty-bridge.py"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"claude",
|
|
15
|
+
"remote",
|
|
16
|
+
"agent",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"websocket"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/AlehPishchykau/claude-remote.git",
|
|
23
|
+
"directory": "agent"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/AlehPishchykau/claude-remote#readme",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"ws": "^8.16.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT"
|
|
33
|
+
}
|
package/pty-bridge.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import pty, os, sys, select, signal, struct, fcntl, termios, json, errno
|
|
3
|
+
|
|
4
|
+
def set_winsize(fd, cols, rows):
|
|
5
|
+
winsize = struct.pack('HHHH', rows, cols, 0, 0)
|
|
6
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
cols = int(sys.argv[1]) if len(sys.argv) > 1 else 120
|
|
10
|
+
rows = int(sys.argv[2]) if len(sys.argv) > 2 else 40
|
|
11
|
+
cmd = sys.argv[3] if len(sys.argv) > 3 else 'claude'
|
|
12
|
+
cmd_args = sys.argv[3:] if len(sys.argv) > 3 else ['claude']
|
|
13
|
+
cwd = os.environ.get('PTY_CWD', os.environ.get('HOME', '/'))
|
|
14
|
+
|
|
15
|
+
master, slave = pty.openpty()
|
|
16
|
+
set_winsize(master, cols, rows)
|
|
17
|
+
|
|
18
|
+
pid = os.fork()
|
|
19
|
+
if pid == 0:
|
|
20
|
+
os.setsid()
|
|
21
|
+
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
|
|
22
|
+
os.dup2(slave, 0)
|
|
23
|
+
os.dup2(slave, 1)
|
|
24
|
+
os.dup2(slave, 2)
|
|
25
|
+
os.close(master)
|
|
26
|
+
os.close(slave)
|
|
27
|
+
os.chdir(cwd)
|
|
28
|
+
os.environ['TERM'] = 'xterm-256color'
|
|
29
|
+
os.execvp(cmd, cmd_args)
|
|
30
|
+
|
|
31
|
+
os.close(slave)
|
|
32
|
+
|
|
33
|
+
def handle_sigwinch(signum, frame):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
signal.signal(signal.SIGWINCH, handle_sigwinch)
|
|
37
|
+
|
|
38
|
+
stdin_fd = sys.stdin.fileno()
|
|
39
|
+
os.set_blocking(stdin_fd, False)
|
|
40
|
+
os.set_blocking(master, False)
|
|
41
|
+
|
|
42
|
+
stdout = sys.stdout.buffer
|
|
43
|
+
try:
|
|
44
|
+
while True:
|
|
45
|
+
try:
|
|
46
|
+
rlist, _, _ = select.select([master, stdin_fd], [], [], 0.1)
|
|
47
|
+
except (select.error, OSError) as e:
|
|
48
|
+
if hasattr(e, 'errno') and e.errno == errno.EINTR:
|
|
49
|
+
continue
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
if master in rlist:
|
|
53
|
+
try:
|
|
54
|
+
data = os.read(master, 65536)
|
|
55
|
+
if not data:
|
|
56
|
+
break
|
|
57
|
+
stdout.write(data)
|
|
58
|
+
stdout.flush()
|
|
59
|
+
except OSError:
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
if stdin_fd in rlist:
|
|
63
|
+
try:
|
|
64
|
+
data = os.read(stdin_fd, 65536)
|
|
65
|
+
if not data:
|
|
66
|
+
break
|
|
67
|
+
if data.startswith(b'\x1b_RESIZE:'):
|
|
68
|
+
end = data.find(b'\x1b\\')
|
|
69
|
+
if end != -1:
|
|
70
|
+
payload = data[len(b'\x1b_RESIZE:'):end]
|
|
71
|
+
rest = data[end+2:]
|
|
72
|
+
try:
|
|
73
|
+
parts = payload.decode().split(',')
|
|
74
|
+
c, r = int(parts[0]), int(parts[1])
|
|
75
|
+
set_winsize(master, c, r)
|
|
76
|
+
os.kill(pid, signal.SIGWINCH)
|
|
77
|
+
except (ValueError, IndexError):
|
|
78
|
+
pass
|
|
79
|
+
if rest:
|
|
80
|
+
os.write(master, rest)
|
|
81
|
+
continue
|
|
82
|
+
os.write(master, data)
|
|
83
|
+
except OSError:
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
pid_result, status = os.waitpid(pid, os.WNOHANG)
|
|
87
|
+
if pid_result != 0:
|
|
88
|
+
try:
|
|
89
|
+
remaining = os.read(master, 65536)
|
|
90
|
+
if remaining:
|
|
91
|
+
stdout.write(remaining)
|
|
92
|
+
stdout.flush()
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
except KeyboardInterrupt:
|
|
98
|
+
pass
|
|
99
|
+
finally:
|
|
100
|
+
try:
|
|
101
|
+
os.close(master)
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
try:
|
|
105
|
+
os.kill(pid, signal.SIGTERM)
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
if os.WIFEXITED(status):
|
|
110
|
+
sys.exit(os.WEXITSTATUS(status))
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
if __name__ == '__main__':
|
|
114
|
+
main()
|