claude-remote 0.1.2 → 0.1.4
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 +26 -0
- package/package.json +1 -1
- package/server.js +910 -802
package/server.js
CHANGED
|
@@ -1,21 +1,116 @@
|
|
|
1
|
-
const http = require('http');
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const os = require('os');
|
|
5
|
-
const pty = require('node-pty');
|
|
6
|
-
const { WebSocketServer, WebSocket } = require('ws');
|
|
7
|
-
const { execSync } = require('child_process');
|
|
8
|
-
|
|
9
|
-
// ---
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const pty = require('node-pty');
|
|
6
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
7
|
+
const { execSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
// --- CLI argument parsing ---
|
|
10
|
+
// Separate bridge args (CWD positional) from claude passthrough flags.
|
|
11
|
+
// Usage: claude-remote [cwd] [--claude-flags...]
|
|
12
|
+
// Example: claude-remote --resume xxx
|
|
13
|
+
// claude-remote /path/to/project --resume xxx -c
|
|
14
|
+
const BLOCKED_FLAGS = new Set([
|
|
15
|
+
'--print', '-p', // non-interactive mode, breaks PTY bridge
|
|
16
|
+
'--output-format', // requires --print
|
|
17
|
+
'--input-format', // requires --print
|
|
18
|
+
'--include-partial-messages', // requires --print
|
|
19
|
+
'--json-schema', // requires --print
|
|
20
|
+
'--no-session-persistence', // requires --print
|
|
21
|
+
'--max-budget-usd', // requires --print
|
|
22
|
+
'--max-turns', // requires --print
|
|
23
|
+
'--fallback-model', // requires --print
|
|
24
|
+
'--permission-prompt-tool', // conflicts with bridge approval hooks
|
|
25
|
+
'--version', '-v', // exits immediately
|
|
26
|
+
'--help', '-h', // exits immediately
|
|
27
|
+
'--init-only', // exits immediately
|
|
28
|
+
'--maintenance', // exits immediately
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// Flags that consume the next argument as a value
|
|
32
|
+
const FLAGS_WITH_VALUE = new Set([
|
|
33
|
+
'--resume', '-r', '--session-id', '--from-pr', '--model',
|
|
34
|
+
'--system-prompt', '--system-prompt-file',
|
|
35
|
+
'--append-system-prompt', '--append-system-prompt-file',
|
|
36
|
+
'--permission-mode', '--add-dir', '--worktree', '-w',
|
|
37
|
+
'--mcp-config', '--settings', '--setting-sources',
|
|
38
|
+
'--agent', '--agents', '--teammate-mode',
|
|
39
|
+
'--allowedTools', '--disallowedTools', '--tools',
|
|
40
|
+
'--betas', '--debug', '--plugin-dir',
|
|
41
|
+
// blocked but still need to consume their values when filtering
|
|
42
|
+
'--output-format', '--input-format', '--json-schema',
|
|
43
|
+
'--max-budget-usd', '--max-turns', '--fallback-model',
|
|
44
|
+
'--permission-prompt-tool',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
function parseCliArgs(argv) {
|
|
48
|
+
const rawArgs = argv.slice(2);
|
|
49
|
+
let cwd = null;
|
|
50
|
+
const claudeArgs = [];
|
|
51
|
+
const blocked = [];
|
|
52
|
+
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (i < rawArgs.length) {
|
|
55
|
+
const arg = rawArgs[i];
|
|
56
|
+
|
|
57
|
+
if (arg === '--') {
|
|
58
|
+
// Everything after -- is passed to claude
|
|
59
|
+
claudeArgs.push(...rawArgs.slice(i + 1));
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!arg.startsWith('-')) {
|
|
64
|
+
// Positional arg → treat first one as CWD (backward compatible)
|
|
65
|
+
if (!cwd) {
|
|
66
|
+
cwd = arg;
|
|
67
|
+
} else {
|
|
68
|
+
claudeArgs.push(arg);
|
|
69
|
+
}
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle --flag=value syntax
|
|
75
|
+
const eqIdx = arg.indexOf('=');
|
|
76
|
+
const flagName = eqIdx > 0 ? arg.substring(0, eqIdx) : arg;
|
|
77
|
+
|
|
78
|
+
if (BLOCKED_FLAGS.has(flagName)) {
|
|
79
|
+
blocked.push(flagName);
|
|
80
|
+
if (eqIdx > 0) {
|
|
81
|
+
// --flag=value, already consumed
|
|
82
|
+
} else if (FLAGS_WITH_VALUE.has(flagName) && i + 1 < rawArgs.length) {
|
|
83
|
+
i++; // skip the value too
|
|
84
|
+
}
|
|
85
|
+
i++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Pass through to claude
|
|
90
|
+
claudeArgs.push(arg);
|
|
91
|
+
// If this flag takes a value and it's not in --flag=value form, grab next arg
|
|
92
|
+
if (eqIdx < 0 && FLAGS_WITH_VALUE.has(flagName) && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('-')) {
|
|
93
|
+
i++;
|
|
94
|
+
claudeArgs.push(rawArgs[i]);
|
|
95
|
+
}
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { cwd: cwd || process.cwd(), claudeArgs, blocked };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { cwd: _parsedCwd, claudeArgs: CLAUDE_EXTRA_ARGS, blocked: _blockedArgs } = parseCliArgs(process.argv);
|
|
103
|
+
|
|
104
|
+
// --- Config ---
|
|
105
|
+
const PORT = parseInt(process.env.PORT || '3100', 10);
|
|
106
|
+
const CWD = _parsedCwd;
|
|
107
|
+
const CLAUDE_HOME = path.join(os.homedir(), '.claude');
|
|
108
|
+
const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
|
|
109
|
+
|
|
110
|
+
// --- State ---
|
|
111
|
+
let claudeProc = null;
|
|
112
|
+
let transcriptPath = null;
|
|
113
|
+
let currentSessionId = null;
|
|
19
114
|
let transcriptOffset = 0;
|
|
20
115
|
let eventBuffer = [];
|
|
21
116
|
let eventSeq = 0;
|
|
@@ -26,21 +121,21 @@ let switchWatcher = null;
|
|
|
26
121
|
let expectingSwitch = false;
|
|
27
122
|
let expectingSwitchTimer = null;
|
|
28
123
|
let tailRemainder = Buffer.alloc(0);
|
|
29
|
-
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
30
|
-
const LEGACY_REPLAY_DELAY_MS = 1500;
|
|
31
|
-
const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
|
32
|
-
|
|
33
|
-
// --- Permission approval state ---
|
|
34
|
-
let approvalSeq = 0;
|
|
35
|
-
const pendingApprovals = new Map(); // id → { res, timer }
|
|
36
|
-
const pendingImageUploads = new Map();
|
|
37
|
-
let approvalMode = 'default'; // 'default' | 'partial' | 'all'
|
|
38
|
-
const ALWAYS_AUTO_ALLOW = new Set(['TaskCreate', 'TaskUpdate']);
|
|
39
|
-
const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
|
|
40
|
-
|
|
41
|
-
// --- Logging → file only (never pollute the terminal) ---
|
|
42
|
-
const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
|
|
43
|
-
fs.writeFileSync(LOG_FILE, `--- Bridge started ${new Date().toISOString()} ---\n`);
|
|
124
|
+
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
125
|
+
const LEGACY_REPLAY_DELAY_MS = 1500;
|
|
126
|
+
const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
|
127
|
+
|
|
128
|
+
// --- Permission approval state ---
|
|
129
|
+
let approvalSeq = 0;
|
|
130
|
+
const pendingApprovals = new Map(); // id → { res, timer }
|
|
131
|
+
const pendingImageUploads = new Map();
|
|
132
|
+
let approvalMode = 'default'; // 'default' | 'partial' | 'all'
|
|
133
|
+
const ALWAYS_AUTO_ALLOW = new Set(['TaskCreate', 'TaskUpdate']);
|
|
134
|
+
const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
|
|
135
|
+
|
|
136
|
+
// --- Logging → file only (never pollute the terminal) ---
|
|
137
|
+
const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
|
|
138
|
+
fs.writeFileSync(LOG_FILE, `--- Bridge started ${new Date().toISOString()} ---\n`);
|
|
44
139
|
function log(msg) {
|
|
45
140
|
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
46
141
|
fs.appendFileSync(LOG_FILE, line);
|
|
@@ -106,7 +201,9 @@ function maybeAttachHookSession(data, source) {
|
|
|
106
201
|
return;
|
|
107
202
|
}
|
|
108
203
|
|
|
109
|
-
|
|
204
|
+
// session-start is authoritative — always allow it to switch sessions.
|
|
205
|
+
// pre-tool-use is opportunistic — only accept if expecting a switch.
|
|
206
|
+
if (currentSessionId && currentSessionId !== target.sessionId && !expectingSwitch && source !== 'session-start') {
|
|
110
207
|
log(`Ignored hook session from ${source}: ${target.sessionId} (current=${currentSessionId})`);
|
|
111
208
|
return;
|
|
112
209
|
}
|
|
@@ -114,89 +211,89 @@ function maybeAttachHookSession(data, source) {
|
|
|
114
211
|
log(`Hook session attached from ${source}: ${target.sessionId}`);
|
|
115
212
|
attachTranscript({ full: target.full }, 0);
|
|
116
213
|
}
|
|
117
|
-
|
|
118
|
-
// ============================================================
|
|
119
|
-
// 1. Static file server
|
|
120
|
-
// ============================================================
|
|
121
|
-
const MIME = {
|
|
122
|
-
'.html': 'text/html; charset=utf-8',
|
|
123
|
-
'.js': 'text/javascript; charset=utf-8',
|
|
124
|
-
'.css': 'text/css; charset=utf-8',
|
|
125
|
-
'.json': 'application/json',
|
|
126
|
-
'.png': 'image/png',
|
|
127
|
-
'.svg': 'image/svg+xml',
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
const server = http.createServer((req, res) => {
|
|
131
|
-
const url = req.url.split('?')[0];
|
|
132
|
-
|
|
133
|
-
// --- API: Hook approval endpoint ---
|
|
134
|
-
if (req.method === 'POST' && url === '/hook/pre-tool-use') {
|
|
135
|
-
let body = '';
|
|
136
|
-
req.on('data', chunk => (body += chunk));
|
|
137
|
-
req.on('end', () => {
|
|
138
|
-
let data;
|
|
139
|
-
try { data = JSON.parse(body); } catch {
|
|
140
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
141
|
-
res.end(JSON.stringify({ decision: 'ask' }));
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
|
|
214
|
+
|
|
215
|
+
// ============================================================
|
|
216
|
+
// 1. Static file server
|
|
217
|
+
// ============================================================
|
|
218
|
+
const MIME = {
|
|
219
|
+
'.html': 'text/html; charset=utf-8',
|
|
220
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
221
|
+
'.css': 'text/css; charset=utf-8',
|
|
222
|
+
'.json': 'application/json',
|
|
223
|
+
'.png': 'image/png',
|
|
224
|
+
'.svg': 'image/svg+xml',
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const server = http.createServer((req, res) => {
|
|
228
|
+
const url = req.url.split('?')[0];
|
|
229
|
+
|
|
230
|
+
// --- API: Hook approval endpoint ---
|
|
231
|
+
if (req.method === 'POST' && url === '/hook/pre-tool-use') {
|
|
232
|
+
let body = '';
|
|
233
|
+
req.on('data', chunk => (body += chunk));
|
|
234
|
+
req.on('end', () => {
|
|
235
|
+
let data;
|
|
236
|
+
try { data = JSON.parse(body); } catch {
|
|
237
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
238
|
+
res.end(JSON.stringify({ decision: 'ask' }));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
145
242
|
maybeAttachHookSession(data, 'pre-tool-use');
|
|
146
243
|
|
|
147
244
|
if (ALWAYS_AUTO_ALLOW.has(data.tool_name)) {
|
|
148
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
149
|
-
res.end(JSON.stringify({ decision: 'allow' }));
|
|
150
|
-
log(`Permission auto-allowed (always): ${data.tool_name}`);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Auto-approve based on approvalMode setting
|
|
155
|
-
if (approvalMode === 'all') {
|
|
156
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
157
|
-
res.end(JSON.stringify({ decision: 'allow' }));
|
|
158
|
-
log(`Permission auto-allowed (mode=all): ${data.tool_name}`);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
if (approvalMode === 'partial' && PARTIAL_AUTO_ALLOW.has(data.tool_name)) {
|
|
162
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
163
|
-
res.end(JSON.stringify({ decision: 'allow' }));
|
|
164
|
-
log(`Permission auto-allowed (mode=partial): ${data.tool_name}`);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// No WebUI clients → fall back to terminal prompt
|
|
169
|
-
const clients = [...wss.clients].filter(c => c.readyState === WebSocket.OPEN);
|
|
170
|
-
if (clients.length === 0) {
|
|
171
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
172
|
-
res.end(JSON.stringify({ decision: 'ask' }));
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const id = String(++approvalSeq);
|
|
177
|
-
log(`Permission #${id}: ${data.tool_name} → ${clients.length} WebUI client(s)`);
|
|
178
|
-
|
|
179
|
-
broadcast({
|
|
180
|
-
type: 'permission_request',
|
|
181
|
-
id,
|
|
182
|
-
toolName: data.tool_name,
|
|
183
|
-
toolInput: data.tool_input,
|
|
184
|
-
permissionMode: data.permission_mode,
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// Hold HTTP response open until WebUI user decides or timeout
|
|
188
|
-
const timer = setTimeout(() => {
|
|
189
|
-
pendingApprovals.delete(id);
|
|
190
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
191
|
-
res.end(JSON.stringify({ decision: 'ask' }));
|
|
192
|
-
log(`Permission #${id}: timeout → ask`);
|
|
193
|
-
}, 90000);
|
|
194
|
-
|
|
195
|
-
pendingApprovals.set(id, { res, timer });
|
|
196
|
-
});
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
245
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
246
|
+
res.end(JSON.stringify({ decision: 'allow' }));
|
|
247
|
+
log(`Permission auto-allowed (always): ${data.tool_name}`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Auto-approve based on approvalMode setting
|
|
252
|
+
if (approvalMode === 'all') {
|
|
253
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
254
|
+
res.end(JSON.stringify({ decision: 'allow' }));
|
|
255
|
+
log(`Permission auto-allowed (mode=all): ${data.tool_name}`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (approvalMode === 'partial' && PARTIAL_AUTO_ALLOW.has(data.tool_name)) {
|
|
259
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
260
|
+
res.end(JSON.stringify({ decision: 'allow' }));
|
|
261
|
+
log(`Permission auto-allowed (mode=partial): ${data.tool_name}`);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// No WebUI clients → fall back to terminal prompt
|
|
266
|
+
const clients = [...wss.clients].filter(c => c.readyState === WebSocket.OPEN);
|
|
267
|
+
if (clients.length === 0) {
|
|
268
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
269
|
+
res.end(JSON.stringify({ decision: 'ask' }));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const id = String(++approvalSeq);
|
|
274
|
+
log(`Permission #${id}: ${data.tool_name} → ${clients.length} WebUI client(s)`);
|
|
275
|
+
|
|
276
|
+
broadcast({
|
|
277
|
+
type: 'permission_request',
|
|
278
|
+
id,
|
|
279
|
+
toolName: data.tool_name,
|
|
280
|
+
toolInput: data.tool_input,
|
|
281
|
+
permissionMode: data.permission_mode,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Hold HTTP response open until WebUI user decides or timeout
|
|
285
|
+
const timer = setTimeout(() => {
|
|
286
|
+
pendingApprovals.delete(id);
|
|
287
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
288
|
+
res.end(JSON.stringify({ decision: 'ask' }));
|
|
289
|
+
log(`Permission #${id}: timeout → ask`);
|
|
290
|
+
}, 90000);
|
|
291
|
+
|
|
292
|
+
pendingApprovals.set(id, { res, timer });
|
|
293
|
+
});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
200
297
|
// --- API: Session start hook endpoint ---
|
|
201
298
|
if (req.method === 'POST' && url === '/hook/session-start') {
|
|
202
299
|
let body = '';
|
|
@@ -212,40 +309,40 @@ const server = http.createServer((req, res) => {
|
|
|
212
309
|
}
|
|
213
310
|
|
|
214
311
|
// --- API: Stop hook endpoint ---
|
|
215
|
-
if (req.method === 'POST' && url === '/hook/stop') {
|
|
216
|
-
let body = '';
|
|
217
|
-
req.on('data', chunk => (body += chunk));
|
|
218
|
-
req.on('end', () => {
|
|
219
|
-
log('/hook/stop received — broadcasting turn_complete');
|
|
312
|
+
if (req.method === 'POST' && url === '/hook/stop') {
|
|
313
|
+
let body = '';
|
|
314
|
+
req.on('data', chunk => (body += chunk));
|
|
315
|
+
req.on('end', () => {
|
|
316
|
+
log('/hook/stop received — broadcasting turn_complete');
|
|
220
317
|
try {
|
|
221
318
|
maybeAttachHookSession(JSON.parse(body), 'stop');
|
|
222
319
|
} catch {}
|
|
223
320
|
broadcast({ type: 'turn_complete' });
|
|
224
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
225
|
-
res.end('{}');
|
|
226
|
-
});
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// --- Static files ---
|
|
231
|
-
const filePath = path.join(__dirname, 'web', url === '/' ? 'index.html' : url);
|
|
232
|
-
const ext = path.extname(filePath);
|
|
233
|
-
fs.readFile(filePath, (err, data) => {
|
|
234
|
-
if (err) {
|
|
235
|
-
res.writeHead(404);
|
|
236
|
-
res.end('Not found');
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
240
|
-
res.end(data);
|
|
241
|
-
});
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
// ============================================================
|
|
245
|
-
// 2. WebSocket server
|
|
246
|
-
// ============================================================
|
|
247
|
-
const wss = new WebSocketServer({ server });
|
|
248
|
-
|
|
321
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
322
|
+
res.end('{}');
|
|
323
|
+
});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- Static files ---
|
|
328
|
+
const filePath = path.join(__dirname, 'web', url === '/' ? 'index.html' : url);
|
|
329
|
+
const ext = path.extname(filePath);
|
|
330
|
+
fs.readFile(filePath, (err, data) => {
|
|
331
|
+
if (err) {
|
|
332
|
+
res.writeHead(404);
|
|
333
|
+
res.end('Not found');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
337
|
+
res.end(data);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ============================================================
|
|
342
|
+
// 2. WebSocket server
|
|
343
|
+
// ============================================================
|
|
344
|
+
const wss = new WebSocketServer({ server });
|
|
345
|
+
|
|
249
346
|
function broadcast(msg) {
|
|
250
347
|
const raw = JSON.stringify(msg);
|
|
251
348
|
const recipients = [];
|
|
@@ -259,11 +356,11 @@ function broadcast(msg) {
|
|
|
259
356
|
log(`Broadcast ${msg.type} -> ${recipients.length} client(s)${recipients.length ? ` [${recipients.join(', ')}]` : ''}`);
|
|
260
357
|
}
|
|
261
358
|
}
|
|
262
|
-
|
|
263
|
-
function latestEventSeq() {
|
|
264
|
-
return eventBuffer.length > 0 ? eventBuffer[eventBuffer.length - 1].seq : 0;
|
|
265
|
-
}
|
|
266
|
-
|
|
359
|
+
|
|
360
|
+
function latestEventSeq() {
|
|
361
|
+
return eventBuffer.length > 0 ? eventBuffer[eventBuffer.length - 1].seq : 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
267
364
|
function sendReplay(ws, lastSeq = null) {
|
|
268
365
|
const normalizedLastSeq = Number.isInteger(lastSeq) && lastSeq >= 0 ? lastSeq : null;
|
|
269
366
|
const replayFrom = normalizedLastSeq == null ? 0 : normalizedLastSeq;
|
|
@@ -272,15 +369,15 @@ function sendReplay(ws, lastSeq = null) {
|
|
|
272
369
|
: eventBuffer;
|
|
273
370
|
|
|
274
371
|
log(`Replay start -> ${wsLabel(ws)} from=${replayFrom} count=${records.length} currentSession=${currentSessionId ?? 'null'}`);
|
|
275
|
-
|
|
276
|
-
for (const record of records) {
|
|
277
|
-
ws.send(JSON.stringify({
|
|
278
|
-
type: 'log_event',
|
|
279
|
-
seq: record.seq,
|
|
280
|
-
event: record.event,
|
|
281
|
-
}));
|
|
282
|
-
}
|
|
283
|
-
|
|
372
|
+
|
|
373
|
+
for (const record of records) {
|
|
374
|
+
ws.send(JSON.stringify({
|
|
375
|
+
type: 'log_event',
|
|
376
|
+
seq: record.seq,
|
|
377
|
+
event: record.event,
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
|
|
284
381
|
sendWs(ws, {
|
|
285
382
|
type: 'replay_done',
|
|
286
383
|
sessionId: currentSessionId,
|
|
@@ -288,50 +385,50 @@ function sendReplay(ws, lastSeq = null) {
|
|
|
288
385
|
resumed: normalizedLastSeq != null,
|
|
289
386
|
}, 'sendReplay');
|
|
290
387
|
}
|
|
291
|
-
|
|
292
|
-
function sendUploadStatus(ws, uploadId, status, extra = {}) {
|
|
293
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
294
|
-
ws.send(JSON.stringify({
|
|
295
|
-
type: 'image_upload_status',
|
|
296
|
-
uploadId,
|
|
297
|
-
status,
|
|
298
|
-
...extra,
|
|
299
|
-
}));
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function cleanupImageUpload(uploadId) {
|
|
303
|
-
const upload = pendingImageUploads.get(uploadId);
|
|
304
|
-
if (!upload) return;
|
|
305
|
-
if (upload.tmpFile) {
|
|
306
|
-
try { fs.unlinkSync(upload.tmpFile); } catch {}
|
|
307
|
-
}
|
|
308
|
-
pendingImageUploads.delete(uploadId);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function cleanupClientUploads(ws) {
|
|
312
|
-
for (const [uploadId, upload] of pendingImageUploads) {
|
|
313
|
-
if (upload.owner === ws) cleanupImageUpload(uploadId);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function createTempImageFile(buffer, mediaType, uploadId) {
|
|
318
|
-
const tmpDir = process.env.CLAUDE_CODE_TMPDIR || os.tmpdir();
|
|
319
|
-
const type = String(mediaType || 'image/png').toLowerCase();
|
|
320
|
-
const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
|
|
321
|
-
const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
|
|
322
|
-
fs.writeFileSync(tmpFile, buffer);
|
|
323
|
-
return tmpFile;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
setInterval(() => {
|
|
327
|
-
const now = Date.now();
|
|
328
|
-
for (const [uploadId, upload] of pendingImageUploads) {
|
|
329
|
-
if ((upload.updatedAt || 0) < (now - IMAGE_UPLOAD_TTL_MS)) {
|
|
330
|
-
cleanupImageUpload(uploadId);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}, 60 * 1000).unref();
|
|
334
|
-
|
|
388
|
+
|
|
389
|
+
function sendUploadStatus(ws, uploadId, status, extra = {}) {
|
|
390
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
391
|
+
ws.send(JSON.stringify({
|
|
392
|
+
type: 'image_upload_status',
|
|
393
|
+
uploadId,
|
|
394
|
+
status,
|
|
395
|
+
...extra,
|
|
396
|
+
}));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function cleanupImageUpload(uploadId) {
|
|
400
|
+
const upload = pendingImageUploads.get(uploadId);
|
|
401
|
+
if (!upload) return;
|
|
402
|
+
if (upload.tmpFile) {
|
|
403
|
+
try { fs.unlinkSync(upload.tmpFile); } catch {}
|
|
404
|
+
}
|
|
405
|
+
pendingImageUploads.delete(uploadId);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function cleanupClientUploads(ws) {
|
|
409
|
+
for (const [uploadId, upload] of pendingImageUploads) {
|
|
410
|
+
if (upload.owner === ws) cleanupImageUpload(uploadId);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function createTempImageFile(buffer, mediaType, uploadId) {
|
|
415
|
+
const tmpDir = process.env.CLAUDE_CODE_TMPDIR || os.tmpdir();
|
|
416
|
+
const type = String(mediaType || 'image/png').toLowerCase();
|
|
417
|
+
const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
|
|
418
|
+
const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
|
|
419
|
+
fs.writeFileSync(tmpFile, buffer);
|
|
420
|
+
return tmpFile;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
setInterval(() => {
|
|
424
|
+
const now = Date.now();
|
|
425
|
+
for (const [uploadId, upload] of pendingImageUploads) {
|
|
426
|
+
if ((upload.updatedAt || 0) < (now - IMAGE_UPLOAD_TTL_MS)) {
|
|
427
|
+
cleanupImageUpload(uploadId);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}, 60 * 1000).unref();
|
|
431
|
+
|
|
335
432
|
wss.on('connection', (ws, req) => {
|
|
336
433
|
ws._bridgeId = ++nextWsId;
|
|
337
434
|
ws._clientInstanceId = '';
|
|
@@ -354,20 +451,20 @@ wss.on('connection', (ws, req) => {
|
|
|
354
451
|
lastSeq: latestEventSeq(),
|
|
355
452
|
}, 'initial');
|
|
356
453
|
}
|
|
357
|
-
|
|
358
|
-
// New clients should explicitly request a resume window. Keep a delayed
|
|
359
|
-
// full replay fallback so older clients still work.
|
|
360
|
-
ws._resumeHandled = false;
|
|
361
|
-
ws._legacyReplayTimer = setTimeout(() => {
|
|
362
|
-
if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
|
|
363
|
-
ws._resumeHandled = true;
|
|
364
|
-
sendReplay(ws, null);
|
|
365
|
-
}, LEGACY_REPLAY_DELAY_MS);
|
|
366
|
-
|
|
367
|
-
ws.on('message', (raw) => {
|
|
368
|
-
let msg;
|
|
369
|
-
try { msg = JSON.parse(raw); } catch { return; }
|
|
370
|
-
|
|
454
|
+
|
|
455
|
+
// New clients should explicitly request a resume window. Keep a delayed
|
|
456
|
+
// full replay fallback so older clients still work.
|
|
457
|
+
ws._resumeHandled = false;
|
|
458
|
+
ws._legacyReplayTimer = setTimeout(() => {
|
|
459
|
+
if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
|
|
460
|
+
ws._resumeHandled = true;
|
|
461
|
+
sendReplay(ws, null);
|
|
462
|
+
}, LEGACY_REPLAY_DELAY_MS);
|
|
463
|
+
|
|
464
|
+
ws.on('message', (raw) => {
|
|
465
|
+
let msg;
|
|
466
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
467
|
+
|
|
371
468
|
switch (msg.type) {
|
|
372
469
|
case 'hello':
|
|
373
470
|
ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
|
|
@@ -382,21 +479,21 @@ wss.on('connection', (ws, req) => {
|
|
|
382
479
|
if (ws._legacyReplayTimer) {
|
|
383
480
|
clearTimeout(ws._legacyReplayTimer);
|
|
384
481
|
ws._legacyReplayTimer = null;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (!currentSessionId) {
|
|
388
|
-
ws.send(JSON.stringify({
|
|
389
|
-
type: 'replay_done',
|
|
390
|
-
sessionId: null,
|
|
391
|
-
lastSeq: 0,
|
|
392
|
-
resumed: false,
|
|
393
|
-
}));
|
|
394
|
-
break;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const clientServerLastSeq = Number.isInteger(msg.serverLastSeq) && msg.serverLastSeq >= 0
|
|
398
|
-
? msg.serverLastSeq
|
|
399
|
-
: null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (!currentSessionId) {
|
|
485
|
+
ws.send(JSON.stringify({
|
|
486
|
+
type: 'replay_done',
|
|
487
|
+
sessionId: null,
|
|
488
|
+
lastSeq: 0,
|
|
489
|
+
resumed: false,
|
|
490
|
+
}));
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const clientServerLastSeq = Number.isInteger(msg.serverLastSeq) && msg.serverLastSeq >= 0
|
|
495
|
+
? msg.serverLastSeq
|
|
496
|
+
: null;
|
|
400
497
|
const canResume = (
|
|
401
498
|
msg.sessionId &&
|
|
402
499
|
msg.sessionId === currentSessionId &&
|
|
@@ -411,201 +508,201 @@ wss.on('connection', (ws, req) => {
|
|
|
411
508
|
sendReplay(ws, canResume ? msg.lastSeq : null);
|
|
412
509
|
break;
|
|
413
510
|
}
|
|
414
|
-
case 'input':
|
|
415
|
-
// Raw terminal keystrokes from xterm.js in WebUI
|
|
416
|
-
if (claudeProc) claudeProc.write(msg.data);
|
|
417
|
-
break;
|
|
418
|
-
case 'expect_clear':
|
|
419
|
-
// Plan mode option 1 triggers /clear inside Claude Code;
|
|
420
|
-
// client notifies us so we can detect the session switch.
|
|
421
|
-
markExpectingSwitch();
|
|
422
|
-
break;
|
|
423
|
-
case 'chat':
|
|
424
|
-
// Chat message from WebUI → write to PTY as user input
|
|
425
|
-
// Must send text first, then Enter after a delay so Claude's
|
|
426
|
-
// TUI (Ink) has time to process the typed characters
|
|
427
|
-
if (claudeProc) {
|
|
428
|
-
const text = msg.text;
|
|
429
|
-
log(`Chat input → PTY: "${text.substring(0, 80)}"`);
|
|
430
|
-
if (/^\/clear\s*$/i.test(text.trim())) {
|
|
431
|
-
markExpectingSwitch();
|
|
432
|
-
}
|
|
433
|
-
broadcast({ type: 'working_started' });
|
|
434
|
-
claudeProc.write(text);
|
|
435
|
-
setTimeout(() => {
|
|
436
|
-
if (claudeProc) claudeProc.write('\r');
|
|
437
|
-
}, 150);
|
|
438
|
-
}
|
|
439
|
-
break;
|
|
440
|
-
case 'resize':
|
|
441
|
-
// Only resize if no local TTY is controlling size
|
|
442
|
-
if (claudeProc && msg.cols && msg.rows && !isTTY) {
|
|
443
|
-
claudeProc.resize(msg.cols, msg.rows);
|
|
444
|
-
}
|
|
445
|
-
break;
|
|
446
|
-
case 'permission_response': {
|
|
447
|
-
const approval = pendingApprovals.get(msg.id);
|
|
448
|
-
if (approval) {
|
|
449
|
-
clearTimeout(approval.timer);
|
|
450
|
-
pendingApprovals.delete(msg.id);
|
|
451
|
-
approval.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
452
|
-
approval.res.end(JSON.stringify({
|
|
453
|
-
decision: msg.decision,
|
|
454
|
-
reason: msg.reason || '',
|
|
455
|
-
}));
|
|
456
|
-
log(`Permission #${msg.id}: ${msg.decision}`);
|
|
457
|
-
}
|
|
458
|
-
break;
|
|
459
|
-
}
|
|
460
|
-
case 'set_approval_mode': {
|
|
461
|
-
const valid = ['default', 'partial', 'all'];
|
|
462
|
-
if (valid.includes(msg.mode)) {
|
|
463
|
-
approvalMode = msg.mode;
|
|
464
|
-
log(`Approval mode changed to: ${approvalMode}`);
|
|
465
|
-
// If switching to 'all' or 'partial', auto-resolve queued permissions
|
|
466
|
-
if (approvalMode === 'all') {
|
|
467
|
-
for (const [id, approval] of pendingApprovals) {
|
|
468
|
-
clearTimeout(approval.timer);
|
|
469
|
-
approval.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
470
|
-
approval.res.end(JSON.stringify({ decision: 'allow' }));
|
|
471
|
-
log(`Permission #${id}: auto-allowed (mode switched to all)`);
|
|
472
|
-
}
|
|
473
|
-
pendingApprovals.clear();
|
|
474
|
-
broadcast({ type: 'clear_permissions' });
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
break;
|
|
478
|
-
}
|
|
479
|
-
case 'image_upload_init': {
|
|
480
|
-
const uploadId = String(msg.uploadId || '');
|
|
481
|
-
if (!uploadId) {
|
|
482
|
-
sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
|
|
483
|
-
break;
|
|
484
|
-
}
|
|
485
|
-
cleanupImageUpload(uploadId);
|
|
486
|
-
pendingImageUploads.set(uploadId, {
|
|
487
|
-
id: uploadId,
|
|
488
|
-
owner: ws,
|
|
489
|
-
mediaType: msg.mediaType || 'image/png',
|
|
490
|
-
name: msg.name || 'image',
|
|
491
|
-
totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
|
|
492
|
-
totalChunks: Number.isFinite(msg.totalChunks) ? msg.totalChunks : 0,
|
|
493
|
-
nextChunkIndex: 0,
|
|
494
|
-
receivedBytes: 0,
|
|
495
|
-
chunks: [],
|
|
496
|
-
tmpFile: null,
|
|
497
|
-
updatedAt: Date.now(),
|
|
498
|
-
});
|
|
499
|
-
sendUploadStatus(ws, uploadId, 'ready_for_chunks', { receivedBytes: 0, totalBytes: msg.totalBytes || 0 });
|
|
500
|
-
break;
|
|
501
|
-
}
|
|
502
|
-
case 'image_upload_chunk': {
|
|
503
|
-
const uploadId = String(msg.uploadId || '');
|
|
504
|
-
const upload = pendingImageUploads.get(uploadId);
|
|
505
|
-
if (!upload) {
|
|
506
|
-
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
|
|
507
|
-
break;
|
|
508
|
-
}
|
|
509
|
-
if (upload.owner !== ws) {
|
|
510
|
-
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
|
|
511
|
-
break;
|
|
512
|
-
}
|
|
513
|
-
if (msg.index !== upload.nextChunkIndex) {
|
|
514
|
-
sendUploadStatus(ws, uploadId, 'error', {
|
|
515
|
-
message: `Unexpected chunk index ${msg.index}, expected ${upload.nextChunkIndex}`,
|
|
516
|
-
});
|
|
517
|
-
break;
|
|
518
|
-
}
|
|
519
|
-
if (!msg.base64) {
|
|
520
|
-
sendUploadStatus(ws, uploadId, 'error', { message: 'Missing chunk payload' });
|
|
521
|
-
break;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
try {
|
|
525
|
-
const chunk = Buffer.from(msg.base64, 'base64');
|
|
526
|
-
upload.chunks.push(chunk);
|
|
527
|
-
upload.receivedBytes += chunk.length;
|
|
528
|
-
upload.nextChunkIndex += 1;
|
|
529
|
-
upload.updatedAt = Date.now();
|
|
530
|
-
sendUploadStatus(ws, uploadId, 'uploading', {
|
|
531
|
-
chunkIndex: msg.index,
|
|
532
|
-
receivedBytes: upload.receivedBytes,
|
|
533
|
-
totalBytes: upload.totalBytes,
|
|
534
|
-
});
|
|
535
|
-
} catch (err) {
|
|
536
|
-
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
537
|
-
}
|
|
538
|
-
break;
|
|
539
|
-
}
|
|
540
|
-
case 'image_upload_complete': {
|
|
541
|
-
const uploadId = String(msg.uploadId || '');
|
|
542
|
-
const upload = pendingImageUploads.get(uploadId);
|
|
543
|
-
if (!upload) {
|
|
544
|
-
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
|
|
545
|
-
break;
|
|
546
|
-
}
|
|
547
|
-
if (upload.owner !== ws) {
|
|
548
|
-
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
|
|
549
|
-
break;
|
|
550
|
-
}
|
|
551
|
-
if (upload.nextChunkIndex !== upload.totalChunks) {
|
|
552
|
-
sendUploadStatus(ws, uploadId, 'error', {
|
|
553
|
-
message: `Upload incomplete (${upload.nextChunkIndex}/${upload.totalChunks})`,
|
|
554
|
-
});
|
|
555
|
-
break;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
try {
|
|
559
|
-
const buffer = Buffer.concat(upload.chunks);
|
|
560
|
-
upload.tmpFile = createTempImageFile(buffer, upload.mediaType, uploadId);
|
|
561
|
-
upload.chunks = [];
|
|
562
|
-
upload.updatedAt = Date.now();
|
|
563
|
-
log(`Image pre-upload complete: ${upload.tmpFile} (${buffer.length} bytes)`);
|
|
564
|
-
sendUploadStatus(ws, uploadId, 'uploaded', {
|
|
565
|
-
receivedBytes: upload.receivedBytes,
|
|
566
|
-
totalBytes: upload.totalBytes,
|
|
567
|
-
});
|
|
568
|
-
} catch (err) {
|
|
569
|
-
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
570
|
-
cleanupImageUpload(uploadId);
|
|
571
|
-
}
|
|
572
|
-
break;
|
|
573
|
-
}
|
|
574
|
-
case 'image_upload_abort': {
|
|
575
|
-
const uploadId = String(msg.uploadId || '');
|
|
576
|
-
if (uploadId) cleanupImageUpload(uploadId);
|
|
577
|
-
sendUploadStatus(ws, uploadId, 'aborted');
|
|
578
|
-
break;
|
|
579
|
-
}
|
|
580
|
-
case 'image_submit': {
|
|
581
|
-
const uploadId = String(msg.uploadId || '');
|
|
582
|
-
const upload = pendingImageUploads.get(uploadId);
|
|
583
|
-
if (!upload || !upload.tmpFile) {
|
|
584
|
-
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
|
|
585
|
-
break;
|
|
586
|
-
}
|
|
587
|
-
try {
|
|
588
|
-
handlePreparedImageUpload({
|
|
589
|
-
tmpFile: upload.tmpFile,
|
|
590
|
-
mediaType: upload.mediaType,
|
|
591
|
-
text: msg.text || '',
|
|
592
|
-
logLabel: upload.name || uploadId,
|
|
593
|
-
onCleanup: () => cleanupImageUpload(uploadId),
|
|
594
|
-
});
|
|
595
|
-
sendUploadStatus(ws, uploadId, 'submitted');
|
|
596
|
-
} catch (err) {
|
|
597
|
-
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
598
|
-
cleanupImageUpload(uploadId);
|
|
599
|
-
}
|
|
600
|
-
break;
|
|
601
|
-
}
|
|
602
|
-
case 'image_upload': {
|
|
603
|
-
handleImageUpload(msg);
|
|
604
|
-
break;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
});
|
|
608
|
-
|
|
511
|
+
case 'input':
|
|
512
|
+
// Raw terminal keystrokes from xterm.js in WebUI
|
|
513
|
+
if (claudeProc) claudeProc.write(msg.data);
|
|
514
|
+
break;
|
|
515
|
+
case 'expect_clear':
|
|
516
|
+
// Plan mode option 1 triggers /clear inside Claude Code;
|
|
517
|
+
// client notifies us so we can detect the session switch.
|
|
518
|
+
markExpectingSwitch();
|
|
519
|
+
break;
|
|
520
|
+
case 'chat':
|
|
521
|
+
// Chat message from WebUI → write to PTY as user input
|
|
522
|
+
// Must send text first, then Enter after a delay so Claude's
|
|
523
|
+
// TUI (Ink) has time to process the typed characters
|
|
524
|
+
if (claudeProc) {
|
|
525
|
+
const text = msg.text;
|
|
526
|
+
log(`Chat input → PTY: "${text.substring(0, 80)}"`);
|
|
527
|
+
if (/^\/clear\s*$/i.test(text.trim())) {
|
|
528
|
+
markExpectingSwitch();
|
|
529
|
+
}
|
|
530
|
+
broadcast({ type: 'working_started' });
|
|
531
|
+
claudeProc.write(text);
|
|
532
|
+
setTimeout(() => {
|
|
533
|
+
if (claudeProc) claudeProc.write('\r');
|
|
534
|
+
}, 150);
|
|
535
|
+
}
|
|
536
|
+
break;
|
|
537
|
+
case 'resize':
|
|
538
|
+
// Only resize if no local TTY is controlling size
|
|
539
|
+
if (claudeProc && msg.cols && msg.rows && !isTTY) {
|
|
540
|
+
claudeProc.resize(msg.cols, msg.rows);
|
|
541
|
+
}
|
|
542
|
+
break;
|
|
543
|
+
case 'permission_response': {
|
|
544
|
+
const approval = pendingApprovals.get(msg.id);
|
|
545
|
+
if (approval) {
|
|
546
|
+
clearTimeout(approval.timer);
|
|
547
|
+
pendingApprovals.delete(msg.id);
|
|
548
|
+
approval.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
549
|
+
approval.res.end(JSON.stringify({
|
|
550
|
+
decision: msg.decision,
|
|
551
|
+
reason: msg.reason || '',
|
|
552
|
+
}));
|
|
553
|
+
log(`Permission #${msg.id}: ${msg.decision}`);
|
|
554
|
+
}
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
case 'set_approval_mode': {
|
|
558
|
+
const valid = ['default', 'partial', 'all'];
|
|
559
|
+
if (valid.includes(msg.mode)) {
|
|
560
|
+
approvalMode = msg.mode;
|
|
561
|
+
log(`Approval mode changed to: ${approvalMode}`);
|
|
562
|
+
// If switching to 'all' or 'partial', auto-resolve queued permissions
|
|
563
|
+
if (approvalMode === 'all') {
|
|
564
|
+
for (const [id, approval] of pendingApprovals) {
|
|
565
|
+
clearTimeout(approval.timer);
|
|
566
|
+
approval.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
567
|
+
approval.res.end(JSON.stringify({ decision: 'allow' }));
|
|
568
|
+
log(`Permission #${id}: auto-allowed (mode switched to all)`);
|
|
569
|
+
}
|
|
570
|
+
pendingApprovals.clear();
|
|
571
|
+
broadcast({ type: 'clear_permissions' });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
case 'image_upload_init': {
|
|
577
|
+
const uploadId = String(msg.uploadId || '');
|
|
578
|
+
if (!uploadId) {
|
|
579
|
+
sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
cleanupImageUpload(uploadId);
|
|
583
|
+
pendingImageUploads.set(uploadId, {
|
|
584
|
+
id: uploadId,
|
|
585
|
+
owner: ws,
|
|
586
|
+
mediaType: msg.mediaType || 'image/png',
|
|
587
|
+
name: msg.name || 'image',
|
|
588
|
+
totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
|
|
589
|
+
totalChunks: Number.isFinite(msg.totalChunks) ? msg.totalChunks : 0,
|
|
590
|
+
nextChunkIndex: 0,
|
|
591
|
+
receivedBytes: 0,
|
|
592
|
+
chunks: [],
|
|
593
|
+
tmpFile: null,
|
|
594
|
+
updatedAt: Date.now(),
|
|
595
|
+
});
|
|
596
|
+
sendUploadStatus(ws, uploadId, 'ready_for_chunks', { receivedBytes: 0, totalBytes: msg.totalBytes || 0 });
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
case 'image_upload_chunk': {
|
|
600
|
+
const uploadId = String(msg.uploadId || '');
|
|
601
|
+
const upload = pendingImageUploads.get(uploadId);
|
|
602
|
+
if (!upload) {
|
|
603
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
if (upload.owner !== ws) {
|
|
607
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
if (msg.index !== upload.nextChunkIndex) {
|
|
611
|
+
sendUploadStatus(ws, uploadId, 'error', {
|
|
612
|
+
message: `Unexpected chunk index ${msg.index}, expected ${upload.nextChunkIndex}`,
|
|
613
|
+
});
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
if (!msg.base64) {
|
|
617
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Missing chunk payload' });
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
const chunk = Buffer.from(msg.base64, 'base64');
|
|
623
|
+
upload.chunks.push(chunk);
|
|
624
|
+
upload.receivedBytes += chunk.length;
|
|
625
|
+
upload.nextChunkIndex += 1;
|
|
626
|
+
upload.updatedAt = Date.now();
|
|
627
|
+
sendUploadStatus(ws, uploadId, 'uploading', {
|
|
628
|
+
chunkIndex: msg.index,
|
|
629
|
+
receivedBytes: upload.receivedBytes,
|
|
630
|
+
totalBytes: upload.totalBytes,
|
|
631
|
+
});
|
|
632
|
+
} catch (err) {
|
|
633
|
+
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
634
|
+
}
|
|
635
|
+
break;
|
|
636
|
+
}
|
|
637
|
+
case 'image_upload_complete': {
|
|
638
|
+
const uploadId = String(msg.uploadId || '');
|
|
639
|
+
const upload = pendingImageUploads.get(uploadId);
|
|
640
|
+
if (!upload) {
|
|
641
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
if (upload.owner !== ws) {
|
|
645
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
if (upload.nextChunkIndex !== upload.totalChunks) {
|
|
649
|
+
sendUploadStatus(ws, uploadId, 'error', {
|
|
650
|
+
message: `Upload incomplete (${upload.nextChunkIndex}/${upload.totalChunks})`,
|
|
651
|
+
});
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
const buffer = Buffer.concat(upload.chunks);
|
|
657
|
+
upload.tmpFile = createTempImageFile(buffer, upload.mediaType, uploadId);
|
|
658
|
+
upload.chunks = [];
|
|
659
|
+
upload.updatedAt = Date.now();
|
|
660
|
+
log(`Image pre-upload complete: ${upload.tmpFile} (${buffer.length} bytes)`);
|
|
661
|
+
sendUploadStatus(ws, uploadId, 'uploaded', {
|
|
662
|
+
receivedBytes: upload.receivedBytes,
|
|
663
|
+
totalBytes: upload.totalBytes,
|
|
664
|
+
});
|
|
665
|
+
} catch (err) {
|
|
666
|
+
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
667
|
+
cleanupImageUpload(uploadId);
|
|
668
|
+
}
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
case 'image_upload_abort': {
|
|
672
|
+
const uploadId = String(msg.uploadId || '');
|
|
673
|
+
if (uploadId) cleanupImageUpload(uploadId);
|
|
674
|
+
sendUploadStatus(ws, uploadId, 'aborted');
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
case 'image_submit': {
|
|
678
|
+
const uploadId = String(msg.uploadId || '');
|
|
679
|
+
const upload = pendingImageUploads.get(uploadId);
|
|
680
|
+
if (!upload || !upload.tmpFile) {
|
|
681
|
+
sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
handlePreparedImageUpload({
|
|
686
|
+
tmpFile: upload.tmpFile,
|
|
687
|
+
mediaType: upload.mediaType,
|
|
688
|
+
text: msg.text || '',
|
|
689
|
+
logLabel: upload.name || uploadId,
|
|
690
|
+
onCleanup: () => cleanupImageUpload(uploadId),
|
|
691
|
+
});
|
|
692
|
+
sendUploadStatus(ws, uploadId, 'submitted');
|
|
693
|
+
} catch (err) {
|
|
694
|
+
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
695
|
+
cleanupImageUpload(uploadId);
|
|
696
|
+
}
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
case 'image_upload': {
|
|
700
|
+
handleImageUpload(msg);
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
|
|
609
706
|
ws.on('close', () => {
|
|
610
707
|
if (ws._legacyReplayTimer) {
|
|
611
708
|
clearTimeout(ws._legacyReplayTimer);
|
|
@@ -614,361 +711,364 @@ wss.on('connection', (ws, req) => {
|
|
|
614
711
|
log(`WS closed: ${wsLabel(ws)}`);
|
|
615
712
|
cleanupClientUploads(ws);
|
|
616
713
|
});
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
// ============================================================
|
|
620
|
-
// 4. PTY Manager — local terminal passthrough
|
|
621
|
-
// ============================================================
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// ============================================================
|
|
717
|
+
// 4. PTY Manager — local terminal passthrough
|
|
718
|
+
// ============================================================
|
|
622
719
|
function spawnClaude() {
|
|
623
720
|
const isWin = process.platform === 'win32';
|
|
624
|
-
const shell = isWin ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');
|
|
625
|
-
const
|
|
626
|
-
?
|
|
627
|
-
:
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
claudeProc.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
fs.
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
721
|
+
const shell = isWin ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');
|
|
722
|
+
const claudeCmd = CLAUDE_EXTRA_ARGS.length > 0
|
|
723
|
+
? `claude ${CLAUDE_EXTRA_ARGS.join(' ')}`
|
|
724
|
+
: 'claude';
|
|
725
|
+
const args = isWin
|
|
726
|
+
? ['-NoLogo', '-NoProfile', '-Command', claudeCmd]
|
|
727
|
+
: ['-c', claudeCmd];
|
|
728
|
+
|
|
729
|
+
// Use local terminal size if available, otherwise default
|
|
730
|
+
const cols = isTTY ? process.stdout.columns : 120;
|
|
731
|
+
const rows = isTTY ? process.stdout.rows : 40;
|
|
732
|
+
|
|
733
|
+
claudeProc = pty.spawn(shell, args, {
|
|
734
|
+
name: 'xterm-256color',
|
|
735
|
+
cols,
|
|
736
|
+
rows,
|
|
737
|
+
cwd: CWD,
|
|
738
|
+
env: { ...process.env, FORCE_COLOR: '1', BRIDGE_PORT: String(PORT) },
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
log(`Claude spawned (pid ${claudeProc.pid}) — ${cols}x${rows} cmd="${claudeCmd}"`);
|
|
742
|
+
broadcast({ type: 'status', status: 'running', pid: claudeProc.pid });
|
|
743
|
+
|
|
744
|
+
// === PTY output → local terminal + WebSocket + mode detection ===
|
|
745
|
+
claudeProc.onData((data) => {
|
|
746
|
+
if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
|
|
747
|
+
broadcast({ type: 'pty_output', data }); // push to WebUI
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// === Local terminal input → PTY ===
|
|
751
|
+
if (isTTY) {
|
|
752
|
+
process.stdin.setRawMode(true);
|
|
753
|
+
process.stdin.resume();
|
|
754
|
+
process.stdin.on('data', (chunk) => {
|
|
755
|
+
if (claudeProc) claudeProc.write(chunk);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// Resize PTY when local terminal resizes
|
|
759
|
+
process.stdout.on('resize', () => {
|
|
760
|
+
if (claudeProc) {
|
|
761
|
+
claudeProc.resize(process.stdout.columns, process.stdout.rows);
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// === PTY exit → cleanup ===
|
|
767
|
+
claudeProc.onExit(({ exitCode, signal }) => {
|
|
768
|
+
log(`Claude exited (code=${exitCode}, signal=${signal})`);
|
|
769
|
+
broadcast({ type: 'pty_exit', exitCode, signal });
|
|
770
|
+
claudeProc = null;
|
|
771
|
+
|
|
772
|
+
// Restore terminal and exit bridge
|
|
773
|
+
if (isTTY) {
|
|
774
|
+
process.stdin.setRawMode(false);
|
|
775
|
+
process.stdin.pause();
|
|
776
|
+
}
|
|
777
|
+
stopTailing();
|
|
778
|
+
log('Bridge shutting down.');
|
|
779
|
+
setTimeout(() => process.exit(exitCode || 0), 300);
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ============================================================
|
|
784
|
+
// 4. Transcript Discovery & Tailing
|
|
785
|
+
// ============================================================
|
|
786
|
+
function getProjectSlug(cwd) {
|
|
787
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function hasConversationEvent(evt) {
|
|
791
|
+
if (!evt || typeof evt !== 'object') return false;
|
|
792
|
+
if (evt.type === 'user' || evt.type === 'assistant') return true;
|
|
793
|
+
const role = evt.message && typeof evt.message === 'object' ? evt.message.role : null;
|
|
794
|
+
return role === 'user' || role === 'assistant';
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function fileLooksLikeTranscript(filePath) {
|
|
798
|
+
try {
|
|
799
|
+
const stat = fs.statSync(filePath);
|
|
800
|
+
if (stat.size <= 0) return false;
|
|
801
|
+
|
|
802
|
+
const readSize = Math.min(stat.size, 64 * 1024);
|
|
803
|
+
const fd = fs.openSync(filePath, 'r');
|
|
804
|
+
const buf = Buffer.alloc(readSize);
|
|
805
|
+
fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
|
|
806
|
+
fs.closeSync(fd);
|
|
807
|
+
|
|
808
|
+
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
|
809
|
+
for (const line of lines) {
|
|
810
|
+
try {
|
|
811
|
+
const evt = JSON.parse(line);
|
|
812
|
+
if (hasConversationEvent(evt)) return true;
|
|
813
|
+
} catch {
|
|
814
|
+
// ignore malformed lines at file tail
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
} catch {}
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function attachTranscript(target, startOffset = 0) {
|
|
822
|
+
transcriptPath = target.full;
|
|
823
|
+
currentSessionId = path.basename(transcriptPath, '.jsonl');
|
|
824
|
+
transcriptOffset = Math.max(0, startOffset);
|
|
825
|
+
tailRemainder = Buffer.alloc(0);
|
|
826
|
+
eventBuffer = [];
|
|
827
|
+
eventSeq = 0;
|
|
828
|
+
|
|
829
|
+
log(`Transcript attached: ${currentSessionId} (offset=${transcriptOffset})`);
|
|
830
|
+
broadcast({
|
|
831
|
+
type: 'transcript_ready',
|
|
832
|
+
transcript: transcriptPath,
|
|
833
|
+
sessionId: currentSessionId,
|
|
834
|
+
lastSeq: 0,
|
|
835
|
+
});
|
|
736
836
|
startTailing();
|
|
737
837
|
startSwitchWatcher();
|
|
738
838
|
}
|
|
739
|
-
|
|
740
|
-
function markExpectingSwitch() {
|
|
741
|
-
expectingSwitch = true;
|
|
742
|
-
if (expectingSwitchTimer) clearTimeout(expectingSwitchTimer);
|
|
743
|
-
expectingSwitchTimer = setTimeout(() => {
|
|
744
|
-
expectingSwitch = false;
|
|
745
|
-
expectingSwitchTimer = null;
|
|
746
|
-
log('Expecting-switch flag expired (no new transcript found)');
|
|
747
|
-
}, 15000);
|
|
748
|
-
log('Expecting session switch (/clear detected)');
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function startSwitchWatcher() {
|
|
752
|
-
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
753
|
-
const slug = getProjectSlug(CWD);
|
|
754
|
-
const projectDir = path.join(PROJECTS_DIR, slug);
|
|
755
|
-
|
|
756
|
-
switchWatcher = setInterval(() => {
|
|
757
|
-
if (!transcriptPath || !expectingSwitch || !fs.existsSync(projectDir)) return;
|
|
758
|
-
try {
|
|
759
|
-
const currentBasename = path.basename(transcriptPath);
|
|
760
|
-
const candidates = fs.readdirSync(projectDir)
|
|
761
|
-
.filter(f => f.endsWith('.jsonl') && f !== currentBasename)
|
|
762
|
-
.map(f => {
|
|
763
|
-
const full = path.join(projectDir, f);
|
|
764
|
-
const stat = fs.statSync(full);
|
|
765
|
-
return { name: f, full, mtime: stat.mtimeMs, size: stat.size };
|
|
766
|
-
})
|
|
767
|
-
.filter(t => t.mtime > fs.statSync(transcriptPath).mtimeMs)
|
|
768
|
-
.sort((a, b) => b.mtime - a.mtime);
|
|
769
|
-
|
|
770
|
-
const newer = candidates.find(t => fileLooksLikeTranscript(t.full));
|
|
771
|
-
if (newer) {
|
|
772
|
-
log(`Session switch detected → ${path.basename(newer.full, '.jsonl')}`);
|
|
773
|
-
expectingSwitch = false;
|
|
774
|
-
if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
|
|
775
|
-
if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
|
|
776
|
-
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
777
|
-
attachTranscript(newer, 0);
|
|
778
|
-
}
|
|
779
|
-
} catch {}
|
|
780
|
-
}, 500);
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function startTailing() {
|
|
784
|
-
tailRemainder = Buffer.alloc(0);
|
|
785
|
-
tailTimer = setInterval(() => {
|
|
786
|
-
if (!transcriptPath) return;
|
|
787
|
-
try {
|
|
788
|
-
const stat = fs.statSync(transcriptPath);
|
|
789
|
-
if (stat.size <= transcriptOffset) return;
|
|
790
|
-
|
|
791
|
-
const fd = fs.openSync(transcriptPath, 'r');
|
|
792
|
-
const buf = Buffer.alloc(stat.size - transcriptOffset);
|
|
793
|
-
fs.readSync(fd, buf, 0, buf.length, transcriptOffset);
|
|
794
|
-
fs.closeSync(fd);
|
|
795
|
-
transcriptOffset = stat.size;
|
|
796
|
-
|
|
797
|
-
const data = tailRemainder.length > 0 ? Buffer.concat([tailRemainder, buf]) : buf;
|
|
798
|
-
let start = 0;
|
|
799
|
-
for (let i = 0; i < data.length; i++) {
|
|
800
|
-
if (data[i] !== 0x0A) continue; // '\n'
|
|
801
|
-
const line = data.slice(start, i).toString('utf8').trim();
|
|
802
|
-
start = i + 1;
|
|
803
|
-
if (!line) continue;
|
|
804
|
-
try {
|
|
805
|
-
const event = JSON.parse(line);
|
|
806
|
-
// Detect /clear from JSONL events (covers terminal direct input)
|
|
807
|
-
if (event.type === 'user' || (event.message && event.message.role === 'user')) {
|
|
808
|
-
broadcast({ type: 'working_started' });
|
|
809
|
-
const content = event.message && event.message.content;
|
|
810
|
-
if (typeof content === 'string' && /^\/clear\s*$/i.test(content.trim())) {
|
|
811
|
-
markExpectingSwitch();
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
const record = { seq: ++eventSeq, event };
|
|
815
|
-
eventBuffer.push(record);
|
|
816
|
-
if (eventBuffer.length > EVENT_BUFFER_MAX) {
|
|
817
|
-
eventBuffer = eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
|
|
818
|
-
}
|
|
819
|
-
broadcast({ type: 'log_event', seq: record.seq, event });
|
|
820
|
-
} catch {
|
|
821
|
-
// skip malformed lines
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
tailRemainder = data.slice(start);
|
|
825
|
-
} catch {
|
|
826
|
-
// file might be temporarily locked
|
|
827
|
-
}
|
|
828
|
-
}, 300);
|
|
829
|
-
}
|
|
830
|
-
|
|
839
|
+
|
|
840
|
+
function markExpectingSwitch() {
|
|
841
|
+
expectingSwitch = true;
|
|
842
|
+
if (expectingSwitchTimer) clearTimeout(expectingSwitchTimer);
|
|
843
|
+
expectingSwitchTimer = setTimeout(() => {
|
|
844
|
+
expectingSwitch = false;
|
|
845
|
+
expectingSwitchTimer = null;
|
|
846
|
+
log('Expecting-switch flag expired (no new transcript found)');
|
|
847
|
+
}, 15000);
|
|
848
|
+
log('Expecting session switch (/clear detected)');
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function startSwitchWatcher() {
|
|
852
|
+
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
853
|
+
const slug = getProjectSlug(CWD);
|
|
854
|
+
const projectDir = path.join(PROJECTS_DIR, slug);
|
|
855
|
+
|
|
856
|
+
switchWatcher = setInterval(() => {
|
|
857
|
+
if (!transcriptPath || !expectingSwitch || !fs.existsSync(projectDir)) return;
|
|
858
|
+
try {
|
|
859
|
+
const currentBasename = path.basename(transcriptPath);
|
|
860
|
+
const candidates = fs.readdirSync(projectDir)
|
|
861
|
+
.filter(f => f.endsWith('.jsonl') && f !== currentBasename)
|
|
862
|
+
.map(f => {
|
|
863
|
+
const full = path.join(projectDir, f);
|
|
864
|
+
const stat = fs.statSync(full);
|
|
865
|
+
return { name: f, full, mtime: stat.mtimeMs, size: stat.size };
|
|
866
|
+
})
|
|
867
|
+
.filter(t => t.mtime > fs.statSync(transcriptPath).mtimeMs)
|
|
868
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
869
|
+
|
|
870
|
+
const newer = candidates.find(t => fileLooksLikeTranscript(t.full));
|
|
871
|
+
if (newer) {
|
|
872
|
+
log(`Session switch detected → ${path.basename(newer.full, '.jsonl')}`);
|
|
873
|
+
expectingSwitch = false;
|
|
874
|
+
if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
|
|
875
|
+
if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
|
|
876
|
+
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
877
|
+
attachTranscript(newer, 0);
|
|
878
|
+
}
|
|
879
|
+
} catch {}
|
|
880
|
+
}, 500);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function startTailing() {
|
|
884
|
+
tailRemainder = Buffer.alloc(0);
|
|
885
|
+
tailTimer = setInterval(() => {
|
|
886
|
+
if (!transcriptPath) return;
|
|
887
|
+
try {
|
|
888
|
+
const stat = fs.statSync(transcriptPath);
|
|
889
|
+
if (stat.size <= transcriptOffset) return;
|
|
890
|
+
|
|
891
|
+
const fd = fs.openSync(transcriptPath, 'r');
|
|
892
|
+
const buf = Buffer.alloc(stat.size - transcriptOffset);
|
|
893
|
+
fs.readSync(fd, buf, 0, buf.length, transcriptOffset);
|
|
894
|
+
fs.closeSync(fd);
|
|
895
|
+
transcriptOffset = stat.size;
|
|
896
|
+
|
|
897
|
+
const data = tailRemainder.length > 0 ? Buffer.concat([tailRemainder, buf]) : buf;
|
|
898
|
+
let start = 0;
|
|
899
|
+
for (let i = 0; i < data.length; i++) {
|
|
900
|
+
if (data[i] !== 0x0A) continue; // '\n'
|
|
901
|
+
const line = data.slice(start, i).toString('utf8').trim();
|
|
902
|
+
start = i + 1;
|
|
903
|
+
if (!line) continue;
|
|
904
|
+
try {
|
|
905
|
+
const event = JSON.parse(line);
|
|
906
|
+
// Detect /clear from JSONL events (covers terminal direct input)
|
|
907
|
+
if (event.type === 'user' || (event.message && event.message.role === 'user')) {
|
|
908
|
+
broadcast({ type: 'working_started' });
|
|
909
|
+
const content = event.message && event.message.content;
|
|
910
|
+
if (typeof content === 'string' && /^\/clear\s*$/i.test(content.trim())) {
|
|
911
|
+
markExpectingSwitch();
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const record = { seq: ++eventSeq, event };
|
|
915
|
+
eventBuffer.push(record);
|
|
916
|
+
if (eventBuffer.length > EVENT_BUFFER_MAX) {
|
|
917
|
+
eventBuffer = eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
|
|
918
|
+
}
|
|
919
|
+
broadcast({ type: 'log_event', seq: record.seq, event });
|
|
920
|
+
} catch {
|
|
921
|
+
// skip malformed lines
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
tailRemainder = data.slice(start);
|
|
925
|
+
} catch {
|
|
926
|
+
// file might be temporarily locked
|
|
927
|
+
}
|
|
928
|
+
}, 300);
|
|
929
|
+
}
|
|
930
|
+
|
|
831
931
|
function stopTailing() {
|
|
832
932
|
if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
|
|
833
933
|
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
834
934
|
if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
|
|
835
935
|
expectingSwitch = false;
|
|
836
|
-
tailRemainder = Buffer.alloc(0);
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
// ============================================================
|
|
840
|
-
// 5. Image Upload → Clipboard Injection
|
|
841
|
-
// ============================================================
|
|
842
|
-
function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
|
|
843
|
-
if (!claudeProc) throw new Error('Claude not running');
|
|
844
|
-
if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
|
|
845
|
-
|
|
846
|
-
const isWin = process.platform === 'win32';
|
|
847
|
-
const isMac = process.platform === 'darwin';
|
|
848
|
-
|
|
849
|
-
try {
|
|
850
|
-
const stat = fs.statSync(tmpFile);
|
|
851
|
-
log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
|
|
852
|
-
|
|
853
|
-
if (isWin) {
|
|
854
|
-
const psCmd = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $img = [System.Drawing.Image]::FromFile('${tmpFile.replace(/'/g, "''")}'); [System.Windows.Forms.Clipboard]::SetImage($img); $img.Dispose()`;
|
|
855
|
-
execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
|
|
856
|
-
} else if (isMac) {
|
|
857
|
-
execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
|
|
858
|
-
} else {
|
|
859
|
-
try {
|
|
860
|
-
execSync(`xclip -selection clipboard -t image/png -i < "${tmpFile}"`, { timeout: 10000, shell: true });
|
|
861
|
-
} catch {
|
|
862
|
-
execSync(`wl-copy --type image/png < "${tmpFile}"`, { timeout: 10000, shell: true });
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
log('Clipboard set with image');
|
|
866
|
-
|
|
867
|
-
if (isWin) claudeProc.write('\x1bv');
|
|
868
|
-
else claudeProc.write('\x16');
|
|
869
|
-
log('Sent image paste keypress to PTY');
|
|
870
|
-
|
|
871
|
-
setTimeout(() => {
|
|
872
|
-
if (!claudeProc) return;
|
|
873
|
-
const trimmedText = (text || '').trim();
|
|
874
|
-
if (trimmedText) claudeProc.write(trimmedText);
|
|
875
|
-
|
|
876
|
-
setTimeout(() => {
|
|
877
|
-
if (claudeProc) claudeProc.write('\r');
|
|
878
|
-
log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
|
|
879
|
-
|
|
880
|
-
setTimeout(() => {
|
|
881
|
-
if (onCleanup) onCleanup();
|
|
882
|
-
else {
|
|
883
|
-
try { fs.unlinkSync(tmpFile); } catch {}
|
|
884
|
-
}
|
|
885
|
-
}, 5000);
|
|
886
|
-
}, 150);
|
|
887
|
-
}, 1000);
|
|
888
|
-
} catch (err) {
|
|
889
|
-
log(`Image upload error: ${err.message}`);
|
|
890
|
-
if (onCleanup) onCleanup();
|
|
891
|
-
else {
|
|
892
|
-
try { fs.unlinkSync(tmpFile); } catch {}
|
|
893
|
-
}
|
|
894
|
-
throw err;
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
function handleImageUpload(msg) {
|
|
899
|
-
if (!claudeProc) {
|
|
900
|
-
log('Image upload ignored: Claude not running');
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
if (!msg.base64) {
|
|
904
|
-
log('Image upload ignored: no base64 data');
|
|
905
|
-
return;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
const buf = Buffer.from(msg.base64, 'base64');
|
|
909
|
-
const tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
|
|
910
|
-
|
|
911
|
-
try {
|
|
912
|
-
log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
|
|
913
|
-
handlePreparedImageUpload({
|
|
914
|
-
tmpFile,
|
|
915
|
-
mediaType: msg.mediaType,
|
|
916
|
-
text: msg.text || '',
|
|
917
|
-
});
|
|
918
|
-
} catch (err) {
|
|
919
|
-
log(`Image upload error: ${err.message}`);
|
|
920
|
-
try { fs.unlinkSync(tmpFile); } catch {}
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// ============================================================
|
|
925
|
-
// 6. Hook Auto-Setup
|
|
926
|
-
|
|
927
|
-
// ============================================================
|
|
928
|
-
function setupHooks() {
|
|
929
|
-
const claudeDir = path.join(CWD, '.claude');
|
|
930
|
-
if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
|
|
931
|
-
|
|
932
|
-
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
933
|
-
let settings = {};
|
|
934
|
-
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
|
935
|
-
|
|
936
|
-
const hookScript = path.resolve(__dirname, 'hooks', 'bridge-approval.js').replace(/\\/g, '/');
|
|
937
|
-
const hookCmd = `node "${hookScript}"`;
|
|
938
|
-
|
|
939
|
-
// Merge bridge hook into PreToolUse (preserve user's other hooks)
|
|
940
|
-
const existing = settings.hooks?.PreToolUse || [];
|
|
941
|
-
const bridgeIdx = existing.findIndex(e =>
|
|
942
|
-
e.hooks?.some(h => h.command?.includes('bridge-approval'))
|
|
943
|
-
);
|
|
944
|
-
const bridgeEntry = {
|
|
945
|
-
matcher: '',
|
|
946
|
-
hooks: [{ type: 'command', command: hookCmd, timeout: 120 }],
|
|
947
|
-
};
|
|
948
|
-
|
|
949
|
-
if (bridgeIdx >= 0) {
|
|
950
|
-
existing[bridgeIdx] = bridgeEntry;
|
|
951
|
-
} else {
|
|
952
|
-
existing.push(bridgeEntry);
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
settings.hooks = settings.hooks || {};
|
|
956
|
-
settings.hooks.PreToolUse = existing;
|
|
957
|
-
|
|
958
|
-
// Merge bridge hook into Stop (notify WebUI when Claude's turn ends)
|
|
959
|
-
const stopScript = path.resolve(__dirname, 'hooks', 'bridge-stop.js').replace(/\\/g, '/');
|
|
960
|
-
const stopCmd = `node "${stopScript}"`;
|
|
961
|
-
const existingStop = settings.hooks.Stop || [];
|
|
962
|
-
const stopBridgeIdx = existingStop.findIndex(e =>
|
|
963
|
-
e.hooks?.some(h => h.command?.includes('bridge-stop'))
|
|
964
|
-
);
|
|
965
|
-
const stopEntry = {
|
|
966
|
-
hooks: [{ type: 'command', command: stopCmd, timeout: 10 }],
|
|
967
|
-
};
|
|
968
|
-
if (stopBridgeIdx >= 0) {
|
|
969
|
-
existingStop[stopBridgeIdx] = stopEntry;
|
|
970
|
-
} else {
|
|
971
|
-
existingStop.push(stopEntry);
|
|
936
|
+
tailRemainder = Buffer.alloc(0);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// ============================================================
|
|
940
|
+
// 5. Image Upload → Clipboard Injection
|
|
941
|
+
// ============================================================
|
|
942
|
+
function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
|
|
943
|
+
if (!claudeProc) throw new Error('Claude not running');
|
|
944
|
+
if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
|
|
945
|
+
|
|
946
|
+
const isWin = process.platform === 'win32';
|
|
947
|
+
const isMac = process.platform === 'darwin';
|
|
948
|
+
|
|
949
|
+
try {
|
|
950
|
+
const stat = fs.statSync(tmpFile);
|
|
951
|
+
log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
|
|
952
|
+
|
|
953
|
+
if (isWin) {
|
|
954
|
+
const psCmd = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $img = [System.Drawing.Image]::FromFile('${tmpFile.replace(/'/g, "''")}'); [System.Windows.Forms.Clipboard]::SetImage($img); $img.Dispose()`;
|
|
955
|
+
execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
|
|
956
|
+
} else if (isMac) {
|
|
957
|
+
execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
|
|
958
|
+
} else {
|
|
959
|
+
try {
|
|
960
|
+
execSync(`xclip -selection clipboard -t image/png -i < "${tmpFile}"`, { timeout: 10000, shell: true });
|
|
961
|
+
} catch {
|
|
962
|
+
execSync(`wl-copy --type image/png < "${tmpFile}"`, { timeout: 10000, shell: true });
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
log('Clipboard set with image');
|
|
966
|
+
|
|
967
|
+
if (isWin) claudeProc.write('\x1bv');
|
|
968
|
+
else claudeProc.write('\x16');
|
|
969
|
+
log('Sent image paste keypress to PTY');
|
|
970
|
+
|
|
971
|
+
setTimeout(() => {
|
|
972
|
+
if (!claudeProc) return;
|
|
973
|
+
const trimmedText = (text || '').trim();
|
|
974
|
+
if (trimmedText) claudeProc.write(trimmedText);
|
|
975
|
+
|
|
976
|
+
setTimeout(() => {
|
|
977
|
+
if (claudeProc) claudeProc.write('\r');
|
|
978
|
+
log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
|
|
979
|
+
|
|
980
|
+
setTimeout(() => {
|
|
981
|
+
if (onCleanup) onCleanup();
|
|
982
|
+
else {
|
|
983
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
984
|
+
}
|
|
985
|
+
}, 5000);
|
|
986
|
+
}, 150);
|
|
987
|
+
}, 1000);
|
|
988
|
+
} catch (err) {
|
|
989
|
+
log(`Image upload error: ${err.message}`);
|
|
990
|
+
if (onCleanup) onCleanup();
|
|
991
|
+
else {
|
|
992
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
993
|
+
}
|
|
994
|
+
throw err;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function handleImageUpload(msg) {
|
|
999
|
+
if (!claudeProc) {
|
|
1000
|
+
log('Image upload ignored: Claude not running');
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (!msg.base64) {
|
|
1004
|
+
log('Image upload ignored: no base64 data');
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const buf = Buffer.from(msg.base64, 'base64');
|
|
1009
|
+
const tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
|
|
1010
|
+
|
|
1011
|
+
try {
|
|
1012
|
+
log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
|
|
1013
|
+
handlePreparedImageUpload({
|
|
1014
|
+
tmpFile,
|
|
1015
|
+
mediaType: msg.mediaType,
|
|
1016
|
+
text: msg.text || '',
|
|
1017
|
+
});
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
log(`Image upload error: ${err.message}`);
|
|
1020
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ============================================================
|
|
1025
|
+
// 6. Hook Auto-Setup
|
|
1026
|
+
|
|
1027
|
+
// ============================================================
|
|
1028
|
+
function setupHooks() {
|
|
1029
|
+
const claudeDir = path.join(CWD, '.claude');
|
|
1030
|
+
if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
|
|
1031
|
+
|
|
1032
|
+
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
1033
|
+
let settings = {};
|
|
1034
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
|
|
1035
|
+
|
|
1036
|
+
const hookScript = path.resolve(__dirname, 'hooks', 'bridge-approval.js').replace(/\\/g, '/');
|
|
1037
|
+
const hookCmd = `node "${hookScript}"`;
|
|
1038
|
+
|
|
1039
|
+
// Merge bridge hook into PreToolUse (preserve user's other hooks)
|
|
1040
|
+
const existing = settings.hooks?.PreToolUse || [];
|
|
1041
|
+
const bridgeIdx = existing.findIndex(e =>
|
|
1042
|
+
e.hooks?.some(h => h.command?.includes('bridge-approval'))
|
|
1043
|
+
);
|
|
1044
|
+
const bridgeEntry = {
|
|
1045
|
+
matcher: '',
|
|
1046
|
+
hooks: [{ type: 'command', command: hookCmd, timeout: 120 }],
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
if (bridgeIdx >= 0) {
|
|
1050
|
+
existing[bridgeIdx] = bridgeEntry;
|
|
1051
|
+
} else {
|
|
1052
|
+
existing.push(bridgeEntry);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
settings.hooks = settings.hooks || {};
|
|
1056
|
+
settings.hooks.PreToolUse = existing;
|
|
1057
|
+
|
|
1058
|
+
// Merge bridge hook into Stop (notify WebUI when Claude's turn ends)
|
|
1059
|
+
const stopScript = path.resolve(__dirname, 'hooks', 'bridge-stop.js').replace(/\\/g, '/');
|
|
1060
|
+
const stopCmd = `node "${stopScript}"`;
|
|
1061
|
+
const existingStop = settings.hooks.Stop || [];
|
|
1062
|
+
const stopBridgeIdx = existingStop.findIndex(e =>
|
|
1063
|
+
e.hooks?.some(h => h.command?.includes('bridge-stop'))
|
|
1064
|
+
);
|
|
1065
|
+
const stopEntry = {
|
|
1066
|
+
hooks: [{ type: 'command', command: stopCmd, timeout: 10 }],
|
|
1067
|
+
};
|
|
1068
|
+
if (stopBridgeIdx >= 0) {
|
|
1069
|
+
existingStop[stopBridgeIdx] = stopEntry;
|
|
1070
|
+
} else {
|
|
1071
|
+
existingStop.push(stopEntry);
|
|
972
1072
|
}
|
|
973
1073
|
settings.hooks.Stop = existingStop;
|
|
974
1074
|
|
|
@@ -991,37 +1091,45 @@ function setupHooks() {
|
|
|
991
1091
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
992
1092
|
log(`Hooks configured: ${settingsPath}`);
|
|
993
1093
|
}
|
|
994
|
-
|
|
995
|
-
// ============================================================
|
|
996
|
-
// 7. Startup
|
|
997
|
-
// ============================================================
|
|
998
|
-
server.listen(PORT, '0.0.0.0', () => {
|
|
999
|
-
const ifaces = os.networkInterfaces();
|
|
1000
|
-
let lanIp = 'localhost';
|
|
1001
|
-
for (const name of Object.keys(ifaces)) {
|
|
1002
|
-
for (const iface of ifaces[name]) {
|
|
1003
|
-
if (iface.family === 'IPv4' && !iface.internal) {
|
|
1004
|
-
lanIp = iface.address;
|
|
1005
|
-
break;
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
const local = `http://localhost:${PORT}`;
|
|
1010
|
-
const lan = `http://${lanIp}:${PORT}`;
|
|
1011
|
-
|
|
1012
|
-
// Print banner to stdout BEFORE PTY takes over
|
|
1013
|
-
|
|
1014
|
-
Claude Remote Control Bridge
|
|
1015
|
-
─────────────────────────────
|
|
1016
|
-
Local: ${local}
|
|
1017
|
-
LAN: ${lan}
|
|
1018
|
-
CWD: ${CWD}
|
|
1019
|
-
Log: ${LOG_FILE}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1094
|
+
|
|
1095
|
+
// ============================================================
|
|
1096
|
+
// 7. Startup
|
|
1097
|
+
// ============================================================
|
|
1098
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
1099
|
+
const ifaces = os.networkInterfaces();
|
|
1100
|
+
let lanIp = 'localhost';
|
|
1101
|
+
for (const name of Object.keys(ifaces)) {
|
|
1102
|
+
for (const iface of ifaces[name]) {
|
|
1103
|
+
if (iface.family === 'IPv4' && !iface.internal) {
|
|
1104
|
+
lanIp = iface.address;
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
const local = `http://localhost:${PORT}`;
|
|
1110
|
+
const lan = `http://${lanIp}:${PORT}`;
|
|
1111
|
+
|
|
1112
|
+
// Print banner to stdout BEFORE PTY takes over
|
|
1113
|
+
let banner = `
|
|
1114
|
+
Claude Remote Control Bridge
|
|
1115
|
+
─────────────────────────────
|
|
1116
|
+
Local: ${local}
|
|
1117
|
+
LAN: ${lan}
|
|
1118
|
+
CWD: ${CWD}
|
|
1119
|
+
Log: ${LOG_FILE}
|
|
1120
|
+
`;
|
|
1121
|
+
if (CLAUDE_EXTRA_ARGS.length > 0) {
|
|
1122
|
+
banner += ` Args: claude ${CLAUDE_EXTRA_ARGS.join(' ')}\n`;
|
|
1123
|
+
}
|
|
1124
|
+
if (_blockedArgs.length > 0) {
|
|
1125
|
+
banner += ` Blocked: ${_blockedArgs.join(', ')} (incompatible with bridge)\n`;
|
|
1126
|
+
}
|
|
1127
|
+
banner += `
|
|
1128
|
+
Phone: ${lan}
|
|
1129
|
+
─────────────────────────────
|
|
1130
|
+
|
|
1131
|
+
`;
|
|
1132
|
+
process.stdout.write(banner);
|
|
1025
1133
|
setupHooks();
|
|
1026
1134
|
spawnClaude();
|
|
1027
1135
|
});
|