@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 +32 -10
- package/dist/index.d.ts +1 -0
- package/dist/index.js +106 -3
- package/dist/index.js.map +1 -1
- package/package.json +81 -57
package/README.md
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
# ClauTunnel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@tongil_kim/clautunnel)
|
|
4
|
+
[](https://www.npmjs.com/package/@tongil_kim/clautunnel)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://nodejs.org/)
|
|
8
|
+
[](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
|
|
63
|
-
- Send input from mobile to CLI
|
|
64
|
-
- Push notifications for task completion, errors, and input prompts
|
|
65
|
-
- Automatic reconnection
|
|
66
|
-
- Sleep prevention
|
|
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/
|
|
76
|
-
- [Mobile App](https://github.com/TongilKim/
|
|
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.
|
|
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) {
|