claude-remote 0.2.2 → 0.2.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/package.json +1 -1
- package/server.js +340 -32
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -103,8 +103,9 @@ const { cwd: _parsedCwd, claudeArgs: CLAUDE_EXTRA_ARGS, blocked: _blockedArgs }
|
|
|
103
103
|
|
|
104
104
|
// --- Config ---
|
|
105
105
|
const PORT = parseInt(process.env.PORT || '3100', 10);
|
|
106
|
-
|
|
106
|
+
let CWD = _parsedCwd;
|
|
107
107
|
const CLAUDE_HOME = path.join(os.homedir(), '.claude');
|
|
108
|
+
const CLAUDE_STATE_FILE = path.join(os.homedir(), '.claude.json');
|
|
108
109
|
const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
|
|
109
110
|
|
|
110
111
|
// --- State ---
|
|
@@ -128,6 +129,16 @@ let tailCatchingUp = false; // true while reading historical transcript content
|
|
|
128
129
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
129
130
|
const LEGACY_REPLAY_DELAY_MS = 1500;
|
|
130
131
|
const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
|
132
|
+
let turnStateVersion = 0;
|
|
133
|
+
let turnState = {
|
|
134
|
+
phase: 'idle',
|
|
135
|
+
sessionId: null,
|
|
136
|
+
version: 0,
|
|
137
|
+
updatedAt: Date.now(),
|
|
138
|
+
};
|
|
139
|
+
let ttyInputForwarderAttached = false;
|
|
140
|
+
let ttyInputHandler = null;
|
|
141
|
+
let ttyResizeHandler = null;
|
|
131
142
|
|
|
132
143
|
// --- Permission approval state ---
|
|
133
144
|
let approvalSeq = 0;
|
|
@@ -153,16 +164,70 @@ function wsLabel(ws) {
|
|
|
153
164
|
function sendWs(ws, msg, context = '') {
|
|
154
165
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
155
166
|
ws.send(JSON.stringify(msg));
|
|
156
|
-
if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'replay_done') {
|
|
167
|
+
if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'replay_done' || msg.type === 'turn_state') {
|
|
157
168
|
const extra = [];
|
|
158
169
|
if (msg.sessionId !== undefined) extra.push(`session=${msg.sessionId ?? 'null'}`);
|
|
159
170
|
if (msg.lastSeq !== undefined) extra.push(`lastSeq=${msg.lastSeq}`);
|
|
160
171
|
if (msg.resumed !== undefined) extra.push(`resumed=${msg.resumed}`);
|
|
172
|
+
if (msg.phase !== undefined) extra.push(`phase=${msg.phase}`);
|
|
173
|
+
if (msg.version !== undefined) extra.push(`version=${msg.version}`);
|
|
161
174
|
log(`Send ${msg.type}${context ? ` (${context})` : ''} -> ${wsLabel(ws)}${extra.length ? ` ${extra.join(' ')}` : ''}`);
|
|
162
175
|
}
|
|
163
176
|
return true;
|
|
164
177
|
}
|
|
165
178
|
|
|
179
|
+
function getTurnStatePayload() {
|
|
180
|
+
return {
|
|
181
|
+
type: 'turn_state',
|
|
182
|
+
phase: turnState.phase,
|
|
183
|
+
sessionId: turnState.sessionId,
|
|
184
|
+
version: turnState.version,
|
|
185
|
+
updatedAt: turnState.updatedAt,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function sendTurnState(ws, context = '') {
|
|
190
|
+
return sendWs(ws, getTurnStatePayload(), context);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function setTurnState(phase, { sessionId = currentSessionId, reason = '', force = false } = {}) {
|
|
194
|
+
const normalizedPhase = phase === 'running' ? 'running' : 'idle';
|
|
195
|
+
const normalizedSessionId = sessionId || null;
|
|
196
|
+
const changed = force ||
|
|
197
|
+
turnState.phase !== normalizedPhase ||
|
|
198
|
+
turnState.sessionId !== normalizedSessionId;
|
|
199
|
+
|
|
200
|
+
if (!changed) return false;
|
|
201
|
+
|
|
202
|
+
turnState = {
|
|
203
|
+
phase: normalizedPhase,
|
|
204
|
+
sessionId: normalizedSessionId,
|
|
205
|
+
version: ++turnStateVersion,
|
|
206
|
+
updatedAt: Date.now(),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
log(`Turn state -> phase=${turnState.phase} session=${turnState.sessionId ?? 'null'} version=${turnState.version}${reason ? ` reason=${reason}` : ''}`);
|
|
210
|
+
broadcast(getTurnStatePayload());
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function attachTtyForwarders() {
|
|
215
|
+
if (!isTTY || ttyInputForwarderAttached) return;
|
|
216
|
+
|
|
217
|
+
ttyInputHandler = (chunk) => {
|
|
218
|
+
if (claudeProc) claudeProc.write(chunk);
|
|
219
|
+
};
|
|
220
|
+
ttyResizeHandler = () => {
|
|
221
|
+
if (claudeProc) claudeProc.resize(process.stdout.columns, process.stdout.rows);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
process.stdin.setRawMode(true);
|
|
225
|
+
process.stdin.resume();
|
|
226
|
+
process.stdin.on('data', ttyInputHandler);
|
|
227
|
+
process.stdout.on('resize', ttyResizeHandler);
|
|
228
|
+
ttyInputForwarderAttached = true;
|
|
229
|
+
}
|
|
230
|
+
|
|
166
231
|
function normalizeFsPath(value) {
|
|
167
232
|
const resolved = path.resolve(String(value || ''));
|
|
168
233
|
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
@@ -393,6 +458,7 @@ const server = http.createServer((req, res) => {
|
|
|
393
458
|
if (reason === 'clear') {
|
|
394
459
|
markExpectingSwitch();
|
|
395
460
|
}
|
|
461
|
+
setTurnState('idle', { reason: `session-end:${reason}` });
|
|
396
462
|
broadcast({ type: 'session_end', reason });
|
|
397
463
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
398
464
|
res.end('{}');
|
|
@@ -409,7 +475,7 @@ const server = http.createServer((req, res) => {
|
|
|
409
475
|
try {
|
|
410
476
|
maybeAttachHookSession(JSON.parse(body), 'stop');
|
|
411
477
|
} catch {}
|
|
412
|
-
|
|
478
|
+
setTurnState('idle', { reason: 'stop-hook' });
|
|
413
479
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
414
480
|
res.end('{}');
|
|
415
481
|
});
|
|
@@ -444,7 +510,7 @@ function broadcast(msg) {
|
|
|
444
510
|
recipients.push(wsLabel(ws));
|
|
445
511
|
}
|
|
446
512
|
}
|
|
447
|
-
if (msg.type === '
|
|
513
|
+
if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'turn_state') {
|
|
448
514
|
log(`Broadcast ${msg.type} -> ${recipients.length} client(s)${recipients.length ? ` [${recipients.join(', ')}]` : ''}`);
|
|
449
515
|
}
|
|
450
516
|
}
|
|
@@ -476,6 +542,7 @@ function sendReplay(ws, lastSeq = null) {
|
|
|
476
542
|
lastSeq: latestEventSeq(),
|
|
477
543
|
resumed: normalizedLastSeq != null,
|
|
478
544
|
}, 'sendReplay');
|
|
545
|
+
sendTurnState(ws, 'sendReplay');
|
|
479
546
|
}
|
|
480
547
|
|
|
481
548
|
function sendUploadStatus(ws, uploadId, status, extra = {}) {
|
|
@@ -553,7 +620,7 @@ wss.on('connection', (ws, req) => {
|
|
|
553
620
|
sendReplay(ws, null);
|
|
554
621
|
}, LEGACY_REPLAY_DELAY_MS);
|
|
555
622
|
|
|
556
|
-
ws.on('message', (raw) => {
|
|
623
|
+
ws.on('message', async (raw) => {
|
|
557
624
|
let msg;
|
|
558
625
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
559
626
|
|
|
@@ -580,6 +647,7 @@ wss.on('connection', (ws, req) => {
|
|
|
580
647
|
lastSeq: 0,
|
|
581
648
|
resumed: false,
|
|
582
649
|
}));
|
|
650
|
+
sendTurnState(ws, 'resume-empty');
|
|
583
651
|
break;
|
|
584
652
|
}
|
|
585
653
|
|
|
@@ -620,11 +688,11 @@ wss.on('connection', (ws, req) => {
|
|
|
620
688
|
if (slashCommand === '/clear') {
|
|
621
689
|
markExpectingSwitch();
|
|
622
690
|
}
|
|
623
|
-
// Slash commands
|
|
691
|
+
// Slash commands are internal CLI control flow.
|
|
624
692
|
// commands, not AI turns — the stop hook will never fire, so don't
|
|
625
|
-
//
|
|
693
|
+
// They should never mutate the live turn state into running.
|
|
626
694
|
if (!slashCommand) {
|
|
627
|
-
|
|
695
|
+
setTurnState('running', { reason: 'chat' });
|
|
628
696
|
}
|
|
629
697
|
claudeProc.write(text);
|
|
630
698
|
setTimeout(() => {
|
|
@@ -787,6 +855,7 @@ wss.on('connection', (ws, req) => {
|
|
|
787
855
|
logLabel: upload.name || uploadId,
|
|
788
856
|
onCleanup: () => cleanupImageUpload(uploadId),
|
|
789
857
|
});
|
|
858
|
+
setTurnState('running', { reason: 'image_submit' });
|
|
790
859
|
sendUploadStatus(ws, uploadId, 'submitted');
|
|
791
860
|
} catch (err) {
|
|
792
861
|
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
@@ -798,6 +867,55 @@ wss.on('connection', (ws, req) => {
|
|
|
798
867
|
handleImageUpload(msg);
|
|
799
868
|
break;
|
|
800
869
|
}
|
|
870
|
+
case 'list_sessions': {
|
|
871
|
+
try {
|
|
872
|
+
const sessions = scanSessions(CWD, 20);
|
|
873
|
+
sendWs(ws, { type: 'sessions', sessions });
|
|
874
|
+
} catch (err) {
|
|
875
|
+
log(`scanSessions error: ${err.message}`);
|
|
876
|
+
sendWs(ws, { type: 'sessions', sessions: [], error: err.message });
|
|
877
|
+
}
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
case 'list_dirs': {
|
|
881
|
+
try {
|
|
882
|
+
const browser = listDirectories(msg.cwd || CWD);
|
|
883
|
+
sendWs(ws, { type: 'dir_list', ...browser });
|
|
884
|
+
} catch (err) {
|
|
885
|
+
log(`listDirectories error: ${err.message}`);
|
|
886
|
+
sendWs(ws, {
|
|
887
|
+
type: 'dir_list',
|
|
888
|
+
cwd: path.resolve(String(msg.cwd || CWD || '')),
|
|
889
|
+
parent: null,
|
|
890
|
+
roots: getDirectoryRoots(),
|
|
891
|
+
entries: [],
|
|
892
|
+
error: err.message,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
case 'switch_session': {
|
|
898
|
+
if (claudeProc && msg.sessionId) {
|
|
899
|
+
log(`Switch session → /resume ${msg.sessionId}`);
|
|
900
|
+
markExpectingSwitch();
|
|
901
|
+
claudeProc.write(`/resume ${msg.sessionId}`);
|
|
902
|
+
setTimeout(() => {
|
|
903
|
+
if (claudeProc) claudeProc.write('\r');
|
|
904
|
+
}, 150);
|
|
905
|
+
}
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
case 'change_cwd': {
|
|
909
|
+
if (msg.cwd) {
|
|
910
|
+
try {
|
|
911
|
+
const targetCwd = assertDirectoryPath(msg.cwd);
|
|
912
|
+
restartClaude(targetCwd);
|
|
913
|
+
} catch (err) {
|
|
914
|
+
sendWs(ws, { type: 'cwd_change_error', cwd: String(msg.cwd), error: err.message });
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
801
919
|
}
|
|
802
920
|
});
|
|
803
921
|
|
|
@@ -828,7 +946,7 @@ function spawnClaude() {
|
|
|
828
946
|
const cols = isTTY ? process.stdout.columns : 120;
|
|
829
947
|
const rows = isTTY ? process.stdout.rows : 40;
|
|
830
948
|
|
|
831
|
-
claudeProc = pty.spawn(shell, args, {
|
|
949
|
+
const proc = claudeProc = pty.spawn(shell, args, {
|
|
832
950
|
name: 'xterm-256color',
|
|
833
951
|
cols,
|
|
834
952
|
rows,
|
|
@@ -837,33 +955,33 @@ function spawnClaude() {
|
|
|
837
955
|
});
|
|
838
956
|
|
|
839
957
|
log(`Claude spawned (pid ${claudeProc.pid}) — ${cols}x${rows} cmd="${claudeCmd}"`);
|
|
840
|
-
|
|
958
|
+
setTurnState('idle', { sessionId: currentSessionId, reason: 'claude_spawned' });
|
|
959
|
+
broadcast({
|
|
960
|
+
type: 'status',
|
|
961
|
+
status: 'running',
|
|
962
|
+
pid: proc.pid,
|
|
963
|
+
cwd: CWD,
|
|
964
|
+
sessionId: currentSessionId,
|
|
965
|
+
lastSeq: latestEventSeq(),
|
|
966
|
+
});
|
|
841
967
|
|
|
842
968
|
// === PTY output → local terminal + WebSocket + mode detection ===
|
|
843
|
-
|
|
969
|
+
proc.onData((data) => {
|
|
844
970
|
if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
|
|
845
971
|
broadcast({ type: 'pty_output', data }); // push to WebUI
|
|
846
972
|
});
|
|
847
973
|
|
|
848
974
|
// === Local terminal input → PTY ===
|
|
849
|
-
|
|
850
|
-
process.stdin.setRawMode(true);
|
|
851
|
-
process.stdin.resume();
|
|
852
|
-
process.stdin.on('data', (chunk) => {
|
|
853
|
-
if (claudeProc) claudeProc.write(chunk);
|
|
854
|
-
});
|
|
855
|
-
|
|
856
|
-
// Resize PTY when local terminal resizes
|
|
857
|
-
process.stdout.on('resize', () => {
|
|
858
|
-
if (claudeProc) {
|
|
859
|
-
claudeProc.resize(process.stdout.columns, process.stdout.rows);
|
|
860
|
-
}
|
|
861
|
-
});
|
|
862
|
-
}
|
|
975
|
+
attachTtyForwarders();
|
|
863
976
|
|
|
864
977
|
// === PTY exit → cleanup ===
|
|
865
|
-
|
|
978
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
979
|
+
if (claudeProc !== proc) {
|
|
980
|
+
log(`Ignoring stale Claude exit (pid ${proc.pid}, code=${exitCode}, signal=${signal})`);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
866
983
|
log(`Claude exited (code=${exitCode}, signal=${signal})`);
|
|
984
|
+
setTurnState('idle', { sessionId: currentSessionId, reason: 'pty_exit' });
|
|
867
985
|
broadcast({ type: 'pty_exit', exitCode, signal });
|
|
868
986
|
claudeProc = null;
|
|
869
987
|
|
|
@@ -949,9 +1067,25 @@ function isNonAiUserEvent(event, content) {
|
|
|
949
1067
|
return /<local-command-(?:stdout|stderr|caveat)>/i.test(text);
|
|
950
1068
|
}
|
|
951
1069
|
|
|
1070
|
+
function extractSessionPrompt(event) {
|
|
1071
|
+
if (!event || event.type !== 'user') return '';
|
|
1072
|
+
|
|
1073
|
+
const message = event.message;
|
|
1074
|
+
const content = typeof message === 'string'
|
|
1075
|
+
? message
|
|
1076
|
+
: (message && typeof message === 'object' ? message.content : '');
|
|
1077
|
+
const text = flattenUserContent(content).trim();
|
|
1078
|
+
if (!text) return '';
|
|
1079
|
+
if (isNonAiUserEvent(event, content)) return '';
|
|
1080
|
+
if (extractSlashCommand(content)) return '';
|
|
1081
|
+
|
|
1082
|
+
return text.replace(/\s+/g, ' ').trim().substring(0, 120);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
952
1085
|
function attachTranscript(target, startOffset = 0) {
|
|
953
1086
|
transcriptPath = target.full;
|
|
954
1087
|
currentSessionId = path.basename(transcriptPath, '.jsonl');
|
|
1088
|
+
setTurnState('idle', { sessionId: currentSessionId, reason: 'transcript_attached' });
|
|
955
1089
|
pendingInitialClearTranscript = target.ignoreInitialClearCommand
|
|
956
1090
|
? { sessionId: currentSessionId }
|
|
957
1091
|
: null;
|
|
@@ -970,8 +1104,8 @@ function attachTranscript(target, startOffset = 0) {
|
|
|
970
1104
|
}
|
|
971
1105
|
if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
|
|
972
1106
|
|
|
973
|
-
// If transcript file already has content, mark as catching up so
|
|
974
|
-
//
|
|
1107
|
+
// If transcript file already has content, mark as catching up so historical
|
|
1108
|
+
// transcript replay cannot mutate live turn state.
|
|
975
1109
|
try {
|
|
976
1110
|
const stat = fs.statSync(transcriptPath);
|
|
977
1111
|
tailCatchingUp = stat.size > transcriptOffset;
|
|
@@ -1086,11 +1220,10 @@ function startTailing() {
|
|
|
1086
1220
|
pendingInitialClearTranscript &&
|
|
1087
1221
|
pendingInitialClearTranscript.sessionId === currentSessionId
|
|
1088
1222
|
);
|
|
1089
|
-
// Only
|
|
1090
|
-
//
|
|
1091
|
-
// commands (which are CLI commands, not AI turns).
|
|
1223
|
+
// Only live, AI-producing user messages can move the turn state
|
|
1224
|
+
// into running. Historical replay and slash commands are ignored.
|
|
1092
1225
|
if (!tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
|
|
1093
|
-
|
|
1226
|
+
setTurnState('running', { sessionId: currentSessionId, reason: 'transcript_user_event' });
|
|
1094
1227
|
}
|
|
1095
1228
|
if (slashCommand === '/clear') {
|
|
1096
1229
|
if (ignoreInitialClear) {
|
|
@@ -1154,6 +1287,111 @@ function enrichEditStartLines(event) {
|
|
|
1154
1287
|
}
|
|
1155
1288
|
}
|
|
1156
1289
|
|
|
1290
|
+
// ============================================================
|
|
1291
|
+
// Session Scanner — list historical sessions from JSONL files
|
|
1292
|
+
// ============================================================
|
|
1293
|
+
function scanSessions(cwd, limit = 20) {
|
|
1294
|
+
const dir = path.join(PROJECTS_DIR, getProjectSlug(cwd));
|
|
1295
|
+
let files;
|
|
1296
|
+
try {
|
|
1297
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
|
|
1298
|
+
} catch {
|
|
1299
|
+
return [];
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Stat each file, sort by mtime desc
|
|
1303
|
+
const entries = [];
|
|
1304
|
+
for (const f of files) {
|
|
1305
|
+
const full = path.join(dir, f);
|
|
1306
|
+
try {
|
|
1307
|
+
const stat = fs.statSync(full);
|
|
1308
|
+
entries.push({ file: f, full, mtime: stat.mtimeMs, size: stat.size });
|
|
1309
|
+
} catch { /* skip */ }
|
|
1310
|
+
}
|
|
1311
|
+
entries.sort((a, b) => b.mtime - a.mtime);
|
|
1312
|
+
const top = entries.slice(0, limit);
|
|
1313
|
+
|
|
1314
|
+
const sessions = [];
|
|
1315
|
+
for (const entry of top) {
|
|
1316
|
+
const sessionId = path.basename(entry.file, '.jsonl');
|
|
1317
|
+
const info = {
|
|
1318
|
+
sessionId,
|
|
1319
|
+
summary: '',
|
|
1320
|
+
firstPrompt: '',
|
|
1321
|
+
lastModified: Math.round(entry.mtime),
|
|
1322
|
+
fileSize: entry.size,
|
|
1323
|
+
cwd: cwd,
|
|
1324
|
+
};
|
|
1325
|
+
|
|
1326
|
+
// Read first ~64KB to extract the first real user prompt and model.
|
|
1327
|
+
try {
|
|
1328
|
+
const fd = fs.openSync(entry.full, 'r');
|
|
1329
|
+
const buf = Buffer.alloc(Math.min(entry.size, 64 * 1024));
|
|
1330
|
+
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
1331
|
+
fs.closeSync(fd);
|
|
1332
|
+
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
|
1333
|
+
for (const line of lines) {
|
|
1334
|
+
try {
|
|
1335
|
+
const evt = JSON.parse(line);
|
|
1336
|
+
if (!info.firstPrompt) {
|
|
1337
|
+
info.firstPrompt = extractSessionPrompt(evt);
|
|
1338
|
+
}
|
|
1339
|
+
if (!info.model && evt.model) {
|
|
1340
|
+
info.model = evt.model;
|
|
1341
|
+
}
|
|
1342
|
+
} catch { /* skip malformed line */ }
|
|
1343
|
+
}
|
|
1344
|
+
} catch { /* skip unreadable file */ }
|
|
1345
|
+
|
|
1346
|
+
info.summary = info.firstPrompt || 'Untitled';
|
|
1347
|
+
sessions.push(info);
|
|
1348
|
+
}
|
|
1349
|
+
return sessions;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function getDirectoryRoots() {
|
|
1353
|
+
if (process.platform === 'win32') {
|
|
1354
|
+
const roots = [];
|
|
1355
|
+
for (let code = 65; code <= 90; code++) {
|
|
1356
|
+
const drive = String.fromCharCode(code) + ':\\';
|
|
1357
|
+
try {
|
|
1358
|
+
if (fs.existsSync(drive)) roots.push(drive);
|
|
1359
|
+
} catch {}
|
|
1360
|
+
}
|
|
1361
|
+
return roots;
|
|
1362
|
+
}
|
|
1363
|
+
return ['/'];
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function assertDirectoryPath(target) {
|
|
1367
|
+
const resolved = path.resolve(String(target || ''));
|
|
1368
|
+
let stat;
|
|
1369
|
+
try {
|
|
1370
|
+
stat = fs.statSync(resolved);
|
|
1371
|
+
} catch {
|
|
1372
|
+
throw new Error('Directory not found');
|
|
1373
|
+
}
|
|
1374
|
+
if (!stat.isDirectory()) throw new Error('Path is not a directory');
|
|
1375
|
+
return resolved;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function listDirectories(target) {
|
|
1379
|
+
const cwd = assertDirectoryPath(target);
|
|
1380
|
+
const roots = getDirectoryRoots();
|
|
1381
|
+
const parentDir = path.dirname(cwd);
|
|
1382
|
+
const parent = normalizeFsPath(parentDir) === normalizeFsPath(cwd) ? null : parentDir;
|
|
1383
|
+
|
|
1384
|
+
const entries = fs.readdirSync(cwd, { withFileTypes: true })
|
|
1385
|
+
.filter(entry => entry.isDirectory())
|
|
1386
|
+
.map(entry => ({
|
|
1387
|
+
name: entry.name,
|
|
1388
|
+
path: path.join(cwd, entry.name),
|
|
1389
|
+
}))
|
|
1390
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
|
|
1391
|
+
|
|
1392
|
+
return { cwd, parent, roots, entries };
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1157
1395
|
function stopTailing() {
|
|
1158
1396
|
if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
|
|
1159
1397
|
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
@@ -1165,6 +1403,75 @@ function stopTailing() {
|
|
|
1165
1403
|
tailRemainder = Buffer.alloc(0);
|
|
1166
1404
|
}
|
|
1167
1405
|
|
|
1406
|
+
function trustProjectCwd(cwd) {
|
|
1407
|
+
const resolved = path.resolve(String(cwd || ''));
|
|
1408
|
+
if (!resolved) return;
|
|
1409
|
+
|
|
1410
|
+
let state = {};
|
|
1411
|
+
try {
|
|
1412
|
+
state = JSON.parse(fs.readFileSync(CLAUDE_STATE_FILE, 'utf8'));
|
|
1413
|
+
} catch {}
|
|
1414
|
+
|
|
1415
|
+
state.projects = state.projects && typeof state.projects === 'object' ? state.projects : {};
|
|
1416
|
+
const keyVariants = process.platform === 'win32'
|
|
1417
|
+
? [resolved, resolved.replace(/\\/g, '/')]
|
|
1418
|
+
: [resolved];
|
|
1419
|
+
|
|
1420
|
+
for (const projectKey of keyVariants) {
|
|
1421
|
+
const existing = state.projects[projectKey];
|
|
1422
|
+
state.projects[projectKey] = {
|
|
1423
|
+
...(existing && typeof existing === 'object' ? existing : {}),
|
|
1424
|
+
hasTrustDialogAccepted: true,
|
|
1425
|
+
projectOnboardingSeenCount: Number.isInteger(existing?.projectOnboardingSeenCount)
|
|
1426
|
+
? existing.projectOnboardingSeenCount
|
|
1427
|
+
: 0,
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
fs.writeFileSync(CLAUDE_STATE_FILE, JSON.stringify(state, null, 2));
|
|
1432
|
+
log(`Trusted Claude project cwd: ${resolved}`);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function restartClaude(newCwd) {
|
|
1436
|
+
log(`Restarting Claude with new CWD: ${newCwd}`);
|
|
1437
|
+
CWD = newCwd;
|
|
1438
|
+
try {
|
|
1439
|
+
trustProjectCwd(CWD);
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
log(`Failed to trust Claude project cwd "${CWD}": ${err.message}`);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Stop transcript tailing (also clears switch/expect state)
|
|
1445
|
+
stopTailing();
|
|
1446
|
+
|
|
1447
|
+
// Reset session state
|
|
1448
|
+
currentSessionId = null;
|
|
1449
|
+
transcriptPath = null;
|
|
1450
|
+
transcriptOffset = 0;
|
|
1451
|
+
eventBuffer = [];
|
|
1452
|
+
eventSeq = 0;
|
|
1453
|
+
tailCatchingUp = false;
|
|
1454
|
+
setTurnState('idle', { sessionId: null, reason: 'restart_claude' });
|
|
1455
|
+
|
|
1456
|
+
// Mark the current PTY as stale before killing it so its exit handler
|
|
1457
|
+
// does not shut down the whole bridge during a restart.
|
|
1458
|
+
const procToRestart = claudeProc;
|
|
1459
|
+
claudeProc = null;
|
|
1460
|
+
if (procToRestart) {
|
|
1461
|
+
procToRestart.kill();
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Re-setup hooks (CWD changed → project slug may change)
|
|
1465
|
+
setupHooks();
|
|
1466
|
+
|
|
1467
|
+
// Broadcast cwd change immediately so clients drop the previous session
|
|
1468
|
+
// before the replacement Claude process attaches a new transcript.
|
|
1469
|
+
broadcast({ type: 'cwd_changed', cwd: CWD, sessionId: null, lastSeq: 0 });
|
|
1470
|
+
|
|
1471
|
+
// Respawn Claude in new directory
|
|
1472
|
+
spawnClaude();
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1168
1475
|
// ============================================================
|
|
1169
1476
|
// 5. Image Upload → Clipboard Injection
|
|
1170
1477
|
// ============================================================
|
|
@@ -1244,6 +1551,7 @@ function handleImageUpload(msg) {
|
|
|
1244
1551
|
mediaType: msg.mediaType,
|
|
1245
1552
|
text: msg.text || '',
|
|
1246
1553
|
});
|
|
1554
|
+
setTurnState('running', { reason: 'legacy_image_upload' });
|
|
1247
1555
|
} catch (err) {
|
|
1248
1556
|
log(`Image upload error: ${err.message}`);
|
|
1249
1557
|
try { fs.unlinkSync(tmpFile); } catch {}
|