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