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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server.js +76 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
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
 
@@ -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 (e.g. /clear, /help, /compact) are internal CLI
703
+ // Slash commands are internal CLI control flow.
645
704
  // commands, not AI turns — the stop hook will never fire, so don't
646
- // enter the waiting state.
705
+ // They should never mutate the live turn state into running.
647
706
  if (!slashCommand) {
648
- broadcast({ type: 'working_started' });
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 we don't
1057
- // broadcast working_started for historical user messages.
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 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).
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
- broadcast({ type: 'working_started' });
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 {}