claude-remote 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server.js +76 -12
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -129,6 +129,13 @@ let tailCatchingUp = false; // true while reading historical transcript content
|
|
|
129
129
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
130
130
|
const LEGACY_REPLAY_DELAY_MS = 1500;
|
|
131
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
|
+
};
|
|
132
139
|
let ttyInputForwarderAttached = false;
|
|
133
140
|
let ttyInputHandler = null;
|
|
134
141
|
let ttyResizeHandler = null;
|
|
@@ -157,16 +164,53 @@ function wsLabel(ws) {
|
|
|
157
164
|
function sendWs(ws, msg, context = '') {
|
|
158
165
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
159
166
|
ws.send(JSON.stringify(msg));
|
|
160
|
-
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') {
|
|
161
168
|
const extra = [];
|
|
162
169
|
if (msg.sessionId !== undefined) extra.push(`session=${msg.sessionId ?? 'null'}`);
|
|
163
170
|
if (msg.lastSeq !== undefined) extra.push(`lastSeq=${msg.lastSeq}`);
|
|
164
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}`);
|
|
165
174
|
log(`Send ${msg.type}${context ? ` (${context})` : ''} -> ${wsLabel(ws)}${extra.length ? ` ${extra.join(' ')}` : ''}`);
|
|
166
175
|
}
|
|
167
176
|
return true;
|
|
168
177
|
}
|
|
169
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
|
+
|
|
170
214
|
function attachTtyForwarders() {
|
|
171
215
|
if (!isTTY || ttyInputForwarderAttached) return;
|
|
172
216
|
|
|
@@ -414,6 +458,7 @@ const server = http.createServer((req, res) => {
|
|
|
414
458
|
if (reason === 'clear') {
|
|
415
459
|
markExpectingSwitch();
|
|
416
460
|
}
|
|
461
|
+
setTurnState('idle', { reason: `session-end:${reason}` });
|
|
417
462
|
broadcast({ type: 'session_end', reason });
|
|
418
463
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
419
464
|
res.end('{}');
|
|
@@ -430,7 +475,7 @@ const server = http.createServer((req, res) => {
|
|
|
430
475
|
try {
|
|
431
476
|
maybeAttachHookSession(JSON.parse(body), 'stop');
|
|
432
477
|
} catch {}
|
|
433
|
-
|
|
478
|
+
setTurnState('idle', { reason: 'stop-hook' });
|
|
434
479
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
435
480
|
res.end('{}');
|
|
436
481
|
});
|
|
@@ -465,7 +510,7 @@ function broadcast(msg) {
|
|
|
465
510
|
recipients.push(wsLabel(ws));
|
|
466
511
|
}
|
|
467
512
|
}
|
|
468
|
-
if (msg.type === '
|
|
513
|
+
if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'turn_state') {
|
|
469
514
|
log(`Broadcast ${msg.type} -> ${recipients.length} client(s)${recipients.length ? ` [${recipients.join(', ')}]` : ''}`);
|
|
470
515
|
}
|
|
471
516
|
}
|
|
@@ -497,6 +542,7 @@ function sendReplay(ws, lastSeq = null) {
|
|
|
497
542
|
lastSeq: latestEventSeq(),
|
|
498
543
|
resumed: normalizedLastSeq != null,
|
|
499
544
|
}, 'sendReplay');
|
|
545
|
+
sendTurnState(ws, 'sendReplay');
|
|
500
546
|
}
|
|
501
547
|
|
|
502
548
|
function sendUploadStatus(ws, uploadId, status, extra = {}) {
|
|
@@ -601,6 +647,7 @@ wss.on('connection', (ws, req) => {
|
|
|
601
647
|
lastSeq: 0,
|
|
602
648
|
resumed: false,
|
|
603
649
|
}));
|
|
650
|
+
sendTurnState(ws, 'resume-empty');
|
|
604
651
|
break;
|
|
605
652
|
}
|
|
606
653
|
|
|
@@ -621,6 +668,18 @@ wss.on('connection', (ws, req) => {
|
|
|
621
668
|
sendReplay(ws, canResume ? msg.lastSeq : null);
|
|
622
669
|
break;
|
|
623
670
|
}
|
|
671
|
+
case 'foreground_probe': {
|
|
672
|
+
const probeId = typeof msg.probeId === 'string' ? msg.probeId : '';
|
|
673
|
+
sendWs(ws, {
|
|
674
|
+
type: 'foreground_probe_ack',
|
|
675
|
+
probeId,
|
|
676
|
+
sessionId: currentSessionId,
|
|
677
|
+
lastSeq: latestEventSeq(),
|
|
678
|
+
cwd: CWD,
|
|
679
|
+
}, 'foreground_probe');
|
|
680
|
+
log(`Foreground probe ack -> ${wsLabel(ws)} probeId=${probeId || 'none'} session=${currentSessionId ?? 'null'} lastSeq=${latestEventSeq()}`);
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
624
683
|
case 'input':
|
|
625
684
|
// Raw terminal keystrokes from xterm.js in WebUI
|
|
626
685
|
if (claudeProc) claudeProc.write(msg.data);
|
|
@@ -641,11 +700,11 @@ wss.on('connection', (ws, req) => {
|
|
|
641
700
|
if (slashCommand === '/clear') {
|
|
642
701
|
markExpectingSwitch();
|
|
643
702
|
}
|
|
644
|
-
// Slash commands
|
|
703
|
+
// Slash commands are internal CLI control flow.
|
|
645
704
|
// commands, not AI turns — the stop hook will never fire, so don't
|
|
646
|
-
//
|
|
705
|
+
// They should never mutate the live turn state into running.
|
|
647
706
|
if (!slashCommand) {
|
|
648
|
-
|
|
707
|
+
setTurnState('running', { reason: 'chat' });
|
|
649
708
|
}
|
|
650
709
|
claudeProc.write(text);
|
|
651
710
|
setTimeout(() => {
|
|
@@ -808,6 +867,7 @@ wss.on('connection', (ws, req) => {
|
|
|
808
867
|
logLabel: upload.name || uploadId,
|
|
809
868
|
onCleanup: () => cleanupImageUpload(uploadId),
|
|
810
869
|
});
|
|
870
|
+
setTurnState('running', { reason: 'image_submit' });
|
|
811
871
|
sendUploadStatus(ws, uploadId, 'submitted');
|
|
812
872
|
} catch (err) {
|
|
813
873
|
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
@@ -907,6 +967,7 @@ function spawnClaude() {
|
|
|
907
967
|
});
|
|
908
968
|
|
|
909
969
|
log(`Claude spawned (pid ${claudeProc.pid}) — ${cols}x${rows} cmd="${claudeCmd}"`);
|
|
970
|
+
setTurnState('idle', { sessionId: currentSessionId, reason: 'claude_spawned' });
|
|
910
971
|
broadcast({
|
|
911
972
|
type: 'status',
|
|
912
973
|
status: 'running',
|
|
@@ -932,6 +993,7 @@ function spawnClaude() {
|
|
|
932
993
|
return;
|
|
933
994
|
}
|
|
934
995
|
log(`Claude exited (code=${exitCode}, signal=${signal})`);
|
|
996
|
+
setTurnState('idle', { sessionId: currentSessionId, reason: 'pty_exit' });
|
|
935
997
|
broadcast({ type: 'pty_exit', exitCode, signal });
|
|
936
998
|
claudeProc = null;
|
|
937
999
|
|
|
@@ -1035,6 +1097,7 @@ function extractSessionPrompt(event) {
|
|
|
1035
1097
|
function attachTranscript(target, startOffset = 0) {
|
|
1036
1098
|
transcriptPath = target.full;
|
|
1037
1099
|
currentSessionId = path.basename(transcriptPath, '.jsonl');
|
|
1100
|
+
setTurnState('idle', { sessionId: currentSessionId, reason: 'transcript_attached' });
|
|
1038
1101
|
pendingInitialClearTranscript = target.ignoreInitialClearCommand
|
|
1039
1102
|
? { sessionId: currentSessionId }
|
|
1040
1103
|
: null;
|
|
@@ -1053,8 +1116,8 @@ function attachTranscript(target, startOffset = 0) {
|
|
|
1053
1116
|
}
|
|
1054
1117
|
if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
|
|
1055
1118
|
|
|
1056
|
-
// If transcript file already has content, mark as catching up so
|
|
1057
|
-
//
|
|
1119
|
+
// If transcript file already has content, mark as catching up so historical
|
|
1120
|
+
// transcript replay cannot mutate live turn state.
|
|
1058
1121
|
try {
|
|
1059
1122
|
const stat = fs.statSync(transcriptPath);
|
|
1060
1123
|
tailCatchingUp = stat.size > transcriptOffset;
|
|
@@ -1169,11 +1232,10 @@ function startTailing() {
|
|
|
1169
1232
|
pendingInitialClearTranscript &&
|
|
1170
1233
|
pendingInitialClearTranscript.sessionId === currentSessionId
|
|
1171
1234
|
);
|
|
1172
|
-
// Only
|
|
1173
|
-
//
|
|
1174
|
-
// commands (which are CLI commands, not AI turns).
|
|
1235
|
+
// Only live, AI-producing user messages can move the turn state
|
|
1236
|
+
// into running. Historical replay and slash commands are ignored.
|
|
1175
1237
|
if (!tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
|
|
1176
|
-
|
|
1238
|
+
setTurnState('running', { sessionId: currentSessionId, reason: 'transcript_user_event' });
|
|
1177
1239
|
}
|
|
1178
1240
|
if (slashCommand === '/clear') {
|
|
1179
1241
|
if (ignoreInitialClear) {
|
|
@@ -1401,6 +1463,7 @@ function restartClaude(newCwd) {
|
|
|
1401
1463
|
eventBuffer = [];
|
|
1402
1464
|
eventSeq = 0;
|
|
1403
1465
|
tailCatchingUp = false;
|
|
1466
|
+
setTurnState('idle', { sessionId: null, reason: 'restart_claude' });
|
|
1404
1467
|
|
|
1405
1468
|
// Mark the current PTY as stale before killing it so its exit handler
|
|
1406
1469
|
// does not shut down the whole bridge during a restart.
|
|
@@ -1500,6 +1563,7 @@ function handleImageUpload(msg) {
|
|
|
1500
1563
|
mediaType: msg.mediaType,
|
|
1501
1564
|
text: msg.text || '',
|
|
1502
1565
|
});
|
|
1566
|
+
setTurnState('running', { reason: 'legacy_image_upload' });
|
|
1503
1567
|
} catch (err) {
|
|
1504
1568
|
log(`Image upload error: ${err.message}`);
|
|
1505
1569
|
try { fs.unlinkSync(tmpFile); } catch {}
|