@tongil_kim/clautunnel 1.3.6 → 1.4.1

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/README.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # ClauTunnel
2
2
 
3
- Remote monitoring and control for Claude Code CLI from your mobile device.
3
+ [![npm version](https://img.shields.io/npm/v/@tongil_kim/clautunnel.svg)](https://www.npmjs.com/package/@tongil_kim/clautunnel)
4
+ [![npm downloads](https://img.shields.io/npm/dw/@tongil_kim/clautunnel.svg)](https://www.npmjs.com/package/@tongil_kim/clautunnel)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.3-blue.svg)](https://www.typescriptlang.org/)
7
+ [![Node.js](https://img.shields.io/badge/Node.js-18%2B-green.svg)](https://nodejs.org/)
8
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/TongilKim/ClauTunnel/pulls)
9
+
10
+ > **ClauTunnel** — Remote monitoring and control tool for **Claude Code CLI** from your mobile device. Stream terminal output in real-time, send input remotely, and get push notifications for task completion via iOS & Android.
11
+
12
+ ## Why ClauTunnel?
13
+
14
+ Running Claude Code on your workstation but need to step away? ClauTunnel lets you keep the conversation going from your phone:
15
+
16
+ - **Monitor long-running tasks** — Watch Claude Code terminal output in real-time from anywhere
17
+ - **Send input remotely** — Respond to Claude's prompts and provide input from your mobile device
18
+ - **Push notifications** — Get notified instantly when tasks complete, errors occur, or input is needed
19
+ - **Background daemon mode** — Run ClauTunnel as a background service with automatic reconnection
20
+ - **Sleep prevention** — Keep your Mac awake during long-running Claude Code sessions
4
21
 
5
22
  ## Installation
6
23
 
@@ -59,22 +76,27 @@ clautunnel stop
59
76
 
60
77
  ## Features
61
78
 
62
- - Real-time terminal output streaming to mobile
63
- - Send input from mobile to CLI
64
- - Push notifications for task completion, errors, and input prompts
65
- - Automatic reconnection with exponential backoff
66
- - Sleep prevention option for long-running tasks
79
+ - **Real-time streaming** — Terminal output streamed to your mobile device instantly
80
+ - **Remote input** — Send text input and commands from mobile to CLI
81
+ - **Push notifications** — Alerts for task completion, errors, and input prompts
82
+ - **Automatic reconnection** Exponential backoff ensures reliable connections
83
+ - **Sleep prevention** Keep macOS awake during long-running tasks
84
+ - **Daemon mode** — Run as a background service
85
+ - **Multi-platform mobile** — Works on both iOS and Android via Expo
67
86
 
68
87
  ## Requirements
69
88
 
70
89
  - Node.js 18+
71
- - Claude Code CLI installed
90
+ - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed
91
+ - Supabase account (free tier works)
72
92
 
73
93
  ## Links
74
94
 
75
- - [GitHub Repository](https://github.com/TongilKim/clautunnel)
76
- - [Mobile App](https://github.com/TongilKim/clautunnel/tree/main/apps/mobile)
95
+ - [GitHub Repository](https://github.com/TongilKim/ClauTunnel)
96
+ - [Mobile App](https://github.com/TongilKim/ClauTunnel/tree/main/apps/mobile)
97
+ - [npm Package](https://www.npmjs.com/package/@tongil_kim/clautunnel)
98
+ - [Report Issues](https://github.com/TongilKim/ClauTunnel/issues)
77
99
 
78
100
  ## License
79
101
 
80
- MIT
102
+ [MIT](https://github.com/TongilKim/ClauTunnel/blob/main/LICENSE)
package/dist/index.d.ts CHANGED
@@ -83,6 +83,7 @@ declare class RealtimeClient extends EventEmitter {
83
83
  broadcastUserQuestion(questionData: UserQuestionData): Promise<void>;
84
84
  broadcastPermissionRequest(requestData: PermissionRequestData): Promise<void>;
85
85
  broadcastToolUse(toolUseData: ToolUseData): Promise<void>;
86
+ broadcastStatusResponse(isProcessing: boolean, isMessageQueued: boolean): Promise<void>;
86
87
  broadcastComplete(): Promise<void>;
87
88
  broadcastSessionTitle(title: string): Promise<void>;
88
89
  broadcastQueued(): Promise<void>;
package/dist/index.js CHANGED
@@ -715,6 +715,27 @@ var RealtimeClient = class extends EventEmitter {
715
715
  }
716
716
  this.emit("broadcast", message);
717
717
  }
718
+ async broadcastStatusResponse(isProcessing, isMessageQueued) {
719
+ if (!this.outputChannel) {
720
+ throw new Error("Not connected");
721
+ }
722
+ if (!this.realtimeEnabled) {
723
+ return;
724
+ }
725
+ const message = {
726
+ type: "status-response",
727
+ isProcessing,
728
+ isMessageQueued,
729
+ timestamp: Date.now(),
730
+ seq: ++this.seq
731
+ };
732
+ await this.outputChannel.send({
733
+ type: "broadcast",
734
+ event: "output",
735
+ payload: message
736
+ });
737
+ this.emit("broadcast", message);
738
+ }
718
739
  async broadcastComplete() {
719
740
  if (!this.outputChannel) {
720
741
  throw new Error("Not connected");
@@ -988,6 +1009,8 @@ var SdkSession = class extends EventEmitter2 {
988
1009
  currentAssistantResponse = "";
989
1010
  // Queued prompt to send after current processing completes (last-one-wins)
990
1011
  pendingPrompt = null;
1012
+ // Tracks prompt during a resume attempt — used to auto-retry with a fresh session on failure
1013
+ resumeAttemptPrompt = null;
991
1014
  constructor(options) {
992
1015
  super();
993
1016
  this.options = options;
@@ -1164,6 +1187,24 @@ var SdkSession = class extends EventEmitter2 {
1164
1187
  }
1165
1188
  return this.v2Session;
1166
1189
  }
1190
+ /**
1191
+ * Clear session state and retry the saved resume prompt with a fresh session.
1192
+ * Returns the saved prompt if a retry should happen, or null if not applicable.
1193
+ */
1194
+ consumeResumeAttempt() {
1195
+ const savedPrompt = this.resumeAttemptPrompt;
1196
+ if (!savedPrompt) return null;
1197
+ this.resumeAttemptPrompt = null;
1198
+ this.v2Session = null;
1199
+ this.sessionId = null;
1200
+ this.streamLoopRunning = false;
1201
+ this.isProcessing = false;
1202
+ if (this.conversationHistory.length > 0 && this.conversationHistory[this.conversationHistory.length - 1].role === "user") {
1203
+ this.conversationHistory.pop();
1204
+ }
1205
+ this.emit("resume-failed");
1206
+ return savedPrompt;
1207
+ }
1167
1208
  /**
1168
1209
  * Background loop that processes messages from the V2 session stream.
1169
1210
  * Runs continuously for the lifetime of the session.
@@ -1171,6 +1212,7 @@ var SdkSession = class extends EventEmitter2 {
1171
1212
  async startStreamLoop() {
1172
1213
  if (this.streamLoopRunning || !this.v2Session) return;
1173
1214
  this.streamLoopRunning = true;
1215
+ let retrying = false;
1174
1216
  try {
1175
1217
  while (this.v2Session && !this.v2Session["closed"]) {
1176
1218
  for await (const message of this.v2Session.stream()) {
@@ -1179,11 +1221,19 @@ var SdkSession = class extends EventEmitter2 {
1179
1221
  }
1180
1222
  } catch (error) {
1181
1223
  if (error.name !== "AbortError") {
1182
- this.emit("error", error);
1224
+ const savedPrompt = this.consumeResumeAttempt();
1225
+ if (savedPrompt) {
1226
+ retrying = true;
1227
+ this.sendPrompt(savedPrompt.prompt, savedPrompt.attachments).catch((retryErr) => {
1228
+ this.emit("error", retryErr);
1229
+ });
1230
+ } else {
1231
+ this.emit("error", error);
1232
+ }
1183
1233
  }
1184
1234
  } finally {
1185
1235
  this.streamLoopRunning = false;
1186
- if (this.isProcessing) {
1236
+ if (this.isProcessing && !retrying) {
1187
1237
  this.isProcessing = false;
1188
1238
  this.pendingPrompt = null;
1189
1239
  this.emit("complete");
@@ -1195,6 +1245,7 @@ var SdkSession = class extends EventEmitter2 {
1195
1245
  */
1196
1246
  processMessage(message) {
1197
1247
  if (message.type === "system" && "subtype" in message && message.subtype === "init") {
1248
+ this.resumeAttemptPrompt = null;
1198
1249
  this.sessionId = message.session_id;
1199
1250
  this.emit("session-started", this.sessionId);
1200
1251
  if ("permissionMode" in message) {
@@ -1295,6 +1346,9 @@ var SdkSession = class extends EventEmitter2 {
1295
1346
  this.conversationHistory.push({ role: "user", content: prompt2 });
1296
1347
  }
1297
1348
  try {
1349
+ if (this.sessionId && !this.v2Session) {
1350
+ this.resumeAttemptPrompt = { prompt: prompt2, attachments };
1351
+ }
1298
1352
  let finalPrompt = prompt2;
1299
1353
  if (this.pendingContextTransfer && this.conversationHistory.length > 1) {
1300
1354
  const previousHistory = this.conversationHistory.slice(0, -1);
@@ -1340,6 +1394,13 @@ ${contextLines.join("\n")}
1340
1394
  };
1341
1395
  await session.send(userMessage);
1342
1396
  } catch (error) {
1397
+ if (error.name !== "AbortError") {
1398
+ const savedPrompt = this.consumeResumeAttempt();
1399
+ if (savedPrompt) {
1400
+ await this.sendPrompt(savedPrompt.prompt, savedPrompt.attachments);
1401
+ return;
1402
+ }
1403
+ }
1343
1404
  if (error.name === "AbortError") {
1344
1405
  this.emit("output", "\n[Cancelled]\n");
1345
1406
  } else {
@@ -1414,6 +1475,9 @@ ${contextLines.join("\n")}
1414
1475
  getPendingPermissionData() {
1415
1476
  return this.pendingPermissionData;
1416
1477
  }
1478
+ hasPendingPrompt() {
1479
+ return this.pendingPrompt !== null;
1480
+ }
1417
1481
  async setModel(model) {
1418
1482
  if (model === this.currentModel) return;
1419
1483
  this.currentModel = model;
@@ -1968,6 +2032,20 @@ var Daemon = class extends EventEmitter3 {
1968
2032
  this.sdkSession.on("commands-updated", async () => {
1969
2033
  await this.broadcastCommands();
1970
2034
  });
2035
+ this.sdkSession.on("resume-failed", async () => {
2036
+ const msg = "[Could not resume previous session. Starting a new session instead.]";
2037
+ if (this.options.hybrid !== false) {
2038
+ process.stdout.write(`
2039
+ ${msg}
2040
+ `);
2041
+ }
2042
+ if (this.realtimeClient) {
2043
+ try {
2044
+ await this.realtimeClient.broadcastSystem(msg);
2045
+ } catch {
2046
+ }
2047
+ }
2048
+ });
1971
2049
  this.sdkSession.on("session-started", async (sdkSessionId) => {
1972
2050
  if (!this.session) return;
1973
2051
  try {
@@ -2114,6 +2192,10 @@ ${confirmationMsg}
2114
2192
  if (pendingPermission) {
2115
2193
  await this.realtimeClient.broadcastPermissionRequest(pendingPermission);
2116
2194
  }
2195
+ const isProcessing = this.sdkSession.isActive();
2196
+ const isActivelyWorking = isProcessing && !pendingQuestion && !pendingPermission;
2197
+ const isMessageQueued = this.sdkSession.hasPendingPrompt();
2198
+ await this.realtimeClient.broadcastStatusResponse(isActivelyWorking, isMessageQueued);
2117
2199
  } catch {
2118
2200
  }
2119
2201
  }
@@ -2481,7 +2563,7 @@ async function restoreSession(supabase, config2) {
2481
2563
  if (!sessionTokens) {
2482
2564
  return null;
2483
2565
  }
2484
- const { error: sessionError } = await supabase.auth.setSession({
2566
+ const { data: sessionData, error: sessionError } = await supabase.auth.setSession({
2485
2567
  access_token: sessionTokens.accessToken,
2486
2568
  refresh_token: sessionTokens.refreshToken
2487
2569
  });
@@ -2489,6 +2571,16 @@ async function restoreSession(supabase, config2) {
2489
2571
  config2.clearSessionTokens();
2490
2572
  return null;
2491
2573
  }
2574
+ if (sessionData?.session) {
2575
+ const newAccess = sessionData.session.access_token;
2576
+ const newRefresh = sessionData.session.refresh_token;
2577
+ if (newAccess !== sessionTokens.accessToken || newRefresh !== sessionTokens.refreshToken) {
2578
+ config2.setSessionTokens({
2579
+ accessToken: newAccess,
2580
+ refreshToken: newRefresh
2581
+ });
2582
+ }
2583
+ }
2492
2584
  const {
2493
2585
  data: { user },
2494
2586
  error: authError
@@ -3000,6 +3092,16 @@ function createStartCommand() {
3000
3092
  process.exit(1);
3001
3093
  }
3002
3094
  const { user } = session;
3095
+ const {
3096
+ data: { subscription: authSubscription }
3097
+ } = supabase.auth.onAuthStateChange((event, authSession) => {
3098
+ if (event === "TOKEN_REFRESHED" && authSession) {
3099
+ config2.setSessionTokens({
3100
+ accessToken: authSession.access_token,
3101
+ refreshToken: authSession.refresh_token
3102
+ });
3103
+ }
3104
+ });
3003
3105
  spinner.update(`Authenticated as ${user.email}...`);
3004
3106
  let fdaStatus = null;
3005
3107
  if (isMacOS()) {
@@ -3121,6 +3223,7 @@ function createStartCommand() {
3121
3223
  await machineClient.disconnect();
3122
3224
  machineClient = null;
3123
3225
  }
3226
+ authSubscription.unsubscribe();
3124
3227
  console.log("[Cleanup] All sessions ended in database");
3125
3228
  await cleanup2();
3126
3229
  } catch (error) {