claude-remote 0.2.3 → 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 +64 -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
|
|
|
@@ -641,11 +688,11 @@ wss.on('connection', (ws, req) => {
|
|
|
641
688
|
if (slashCommand === '/clear') {
|
|
642
689
|
markExpectingSwitch();
|
|
643
690
|
}
|
|
644
|
-
// Slash commands
|
|
691
|
+
// Slash commands are internal CLI control flow.
|
|
645
692
|
// commands, not AI turns — the stop hook will never fire, so don't
|
|
646
|
-
//
|
|
693
|
+
// They should never mutate the live turn state into running.
|
|
647
694
|
if (!slashCommand) {
|
|
648
|
-
|
|
695
|
+
setTurnState('running', { reason: 'chat' });
|
|
649
696
|
}
|
|
650
697
|
claudeProc.write(text);
|
|
651
698
|
setTimeout(() => {
|
|
@@ -808,6 +855,7 @@ wss.on('connection', (ws, req) => {
|
|
|
808
855
|
logLabel: upload.name || uploadId,
|
|
809
856
|
onCleanup: () => cleanupImageUpload(uploadId),
|
|
810
857
|
});
|
|
858
|
+
setTurnState('running', { reason: 'image_submit' });
|
|
811
859
|
sendUploadStatus(ws, uploadId, 'submitted');
|
|
812
860
|
} catch (err) {
|
|
813
861
|
sendUploadStatus(ws, uploadId, 'error', { message: err.message });
|
|
@@ -907,6 +955,7 @@ function spawnClaude() {
|
|
|
907
955
|
});
|
|
908
956
|
|
|
909
957
|
log(`Claude spawned (pid ${claudeProc.pid}) — ${cols}x${rows} cmd="${claudeCmd}"`);
|
|
958
|
+
setTurnState('idle', { sessionId: currentSessionId, reason: 'claude_spawned' });
|
|
910
959
|
broadcast({
|
|
911
960
|
type: 'status',
|
|
912
961
|
status: 'running',
|
|
@@ -932,6 +981,7 @@ function spawnClaude() {
|
|
|
932
981
|
return;
|
|
933
982
|
}
|
|
934
983
|
log(`Claude exited (code=${exitCode}, signal=${signal})`);
|
|
984
|
+
setTurnState('idle', { sessionId: currentSessionId, reason: 'pty_exit' });
|
|
935
985
|
broadcast({ type: 'pty_exit', exitCode, signal });
|
|
936
986
|
claudeProc = null;
|
|
937
987
|
|
|
@@ -1035,6 +1085,7 @@ function extractSessionPrompt(event) {
|
|
|
1035
1085
|
function attachTranscript(target, startOffset = 0) {
|
|
1036
1086
|
transcriptPath = target.full;
|
|
1037
1087
|
currentSessionId = path.basename(transcriptPath, '.jsonl');
|
|
1088
|
+
setTurnState('idle', { sessionId: currentSessionId, reason: 'transcript_attached' });
|
|
1038
1089
|
pendingInitialClearTranscript = target.ignoreInitialClearCommand
|
|
1039
1090
|
? { sessionId: currentSessionId }
|
|
1040
1091
|
: null;
|
|
@@ -1053,8 +1104,8 @@ function attachTranscript(target, startOffset = 0) {
|
|
|
1053
1104
|
}
|
|
1054
1105
|
if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
|
|
1055
1106
|
|
|
1056
|
-
// If transcript file already has content, mark as catching up so
|
|
1057
|
-
//
|
|
1107
|
+
// If transcript file already has content, mark as catching up so historical
|
|
1108
|
+
// transcript replay cannot mutate live turn state.
|
|
1058
1109
|
try {
|
|
1059
1110
|
const stat = fs.statSync(transcriptPath);
|
|
1060
1111
|
tailCatchingUp = stat.size > transcriptOffset;
|
|
@@ -1169,11 +1220,10 @@ function startTailing() {
|
|
|
1169
1220
|
pendingInitialClearTranscript &&
|
|
1170
1221
|
pendingInitialClearTranscript.sessionId === currentSessionId
|
|
1171
1222
|
);
|
|
1172
|
-
// Only
|
|
1173
|
-
//
|
|
1174
|
-
// 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.
|
|
1175
1225
|
if (!tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
|
|
1176
|
-
|
|
1226
|
+
setTurnState('running', { sessionId: currentSessionId, reason: 'transcript_user_event' });
|
|
1177
1227
|
}
|
|
1178
1228
|
if (slashCommand === '/clear') {
|
|
1179
1229
|
if (ignoreInitialClear) {
|
|
@@ -1401,6 +1451,7 @@ function restartClaude(newCwd) {
|
|
|
1401
1451
|
eventBuffer = [];
|
|
1402
1452
|
eventSeq = 0;
|
|
1403
1453
|
tailCatchingUp = false;
|
|
1454
|
+
setTurnState('idle', { sessionId: null, reason: 'restart_claude' });
|
|
1404
1455
|
|
|
1405
1456
|
// Mark the current PTY as stale before killing it so its exit handler
|
|
1406
1457
|
// does not shut down the whole bridge during a restart.
|
|
@@ -1500,6 +1551,7 @@ function handleImageUpload(msg) {
|
|
|
1500
1551
|
mediaType: msg.mediaType,
|
|
1501
1552
|
text: msg.text || '',
|
|
1502
1553
|
});
|
|
1554
|
+
setTurnState('running', { reason: 'legacy_image_upload' });
|
|
1503
1555
|
} catch (err) {
|
|
1504
1556
|
log(`Image upload error: ${err.message}`);
|
|
1505
1557
|
try { fs.unlinkSync(tmpFile); } catch {}
|