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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server.js +64 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Remote control bridge for Claude Code REPL - drive from phone/WebUI",
5
5
  "main": "server.js",
6
6
  "bin": {
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
- broadcast({ type: 'turn_complete' });
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 === 'working_started' || msg.type === 'turn_complete' || msg.type === 'status' || msg.type === 'transcript_ready') {
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 (e.g. /clear, /help, /compact) are internal CLI
691
+ // Slash commands are internal CLI control flow.
645
692
  // commands, not AI turns — the stop hook will never fire, so don't
646
- // enter the waiting state.
693
+ // They should never mutate the live turn state into running.
647
694
  if (!slashCommand) {
648
- broadcast({ type: 'working_started' });
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 we don't
1057
- // broadcast working_started for historical user messages.
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 broadcast working_started for live (new) user messages,
1173
- // not for historical events during catch-up, and not for slash
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
- broadcast({ type: 'working_started' });
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 {}