@pulso/companion 0.4.0 → 0.4.2
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/dist/index.js +312 -13
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
2
8
|
|
|
3
9
|
// src/index.ts
|
|
4
10
|
import WebSocket from "ws";
|
|
@@ -62,12 +68,33 @@ function runAppleScript(script) {
|
|
|
62
68
|
}
|
|
63
69
|
function runShell(cmd, timeout = 1e4) {
|
|
64
70
|
return new Promise((resolve2, reject) => {
|
|
65
|
-
|
|
71
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
72
|
+
exec(cmd, { timeout, shell, env: { ...process.env, PATH: augmentedPath() } }, (err, stdout, stderr) => {
|
|
66
73
|
if (err) reject(new Error(stderr || err.message));
|
|
67
74
|
else resolve2(stdout.trim());
|
|
68
75
|
});
|
|
69
76
|
});
|
|
70
77
|
}
|
|
78
|
+
function augmentedPath() {
|
|
79
|
+
const base = process.env.PATH || "/usr/local/bin:/usr/bin:/bin";
|
|
80
|
+
const home = process.env.HOME || "";
|
|
81
|
+
const extras = [];
|
|
82
|
+
const nvmDir = process.env.NVM_DIR || `${home}/.nvm`;
|
|
83
|
+
try {
|
|
84
|
+
const fs = __require("fs");
|
|
85
|
+
const defaultAlias = fs.readFileSync(`${nvmDir}/alias/default`, "utf8").trim();
|
|
86
|
+
const versionsDir = `${nvmDir}/versions/node`;
|
|
87
|
+
const dirs = fs.readdirSync(versionsDir);
|
|
88
|
+
const match = dirs.filter((d) => d.includes(defaultAlias)).sort().pop() || dirs.sort().pop();
|
|
89
|
+
if (match) extras.push(`${versionsDir}/${match}/bin`);
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
if (home) extras.push(`${home}/.volta/bin`);
|
|
93
|
+
if (home) extras.push(`${home}/.fnm/aliases/default/bin`);
|
|
94
|
+
extras.push("/opt/homebrew/bin", "/usr/local/bin");
|
|
95
|
+
const allParts = [...extras, ...base.split(":")];
|
|
96
|
+
return [...new Set(allParts)].join(":");
|
|
97
|
+
}
|
|
71
98
|
function runSwift(code, timeout = 1e4) {
|
|
72
99
|
return new Promise((resolve2, reject) => {
|
|
73
100
|
const child = exec(`swift -`, { timeout }, (err, stdout, stderr) => {
|
|
@@ -530,7 +557,17 @@ async function handleCommand(command, params) {
|
|
|
530
557
|
case "sys_open_url": {
|
|
531
558
|
const url = params.url;
|
|
532
559
|
if (!url) return { success: false, error: "Missing URL" };
|
|
533
|
-
|
|
560
|
+
const sanitizedUrl = url.replace(/"/g, '\\"');
|
|
561
|
+
try {
|
|
562
|
+
await runAppleScript(`
|
|
563
|
+
tell application "Google Chrome"
|
|
564
|
+
make new window
|
|
565
|
+
set URL of active tab of front window to "${sanitizedUrl}"
|
|
566
|
+
activate
|
|
567
|
+
end tell`);
|
|
568
|
+
} catch {
|
|
569
|
+
await runShell(`open -n "${url.replace(/"/g, "")}"`);
|
|
570
|
+
}
|
|
534
571
|
return { success: true, data: { opened: url } };
|
|
535
572
|
}
|
|
536
573
|
case "sys_speak": {
|
|
@@ -1192,20 +1229,15 @@ print("\\(x),\\(y)")`;
|
|
|
1192
1229
|
if (browser === "Safari") {
|
|
1193
1230
|
await runAppleScript(`
|
|
1194
1231
|
tell application "Safari"
|
|
1232
|
+
make new document with properties {URL:"${url.replace(/"/g, '\\"')}"}
|
|
1195
1233
|
activate
|
|
1196
|
-
if (count of windows) = 0 then make new document
|
|
1197
|
-
set URL of front document to "${url.replace(/"/g, '\\"')}"
|
|
1198
1234
|
end tell`);
|
|
1199
1235
|
} else {
|
|
1200
1236
|
await runAppleScript(`
|
|
1201
1237
|
tell application "${browser.replace(/"/g, '\\"')}"
|
|
1238
|
+
make new window
|
|
1239
|
+
set URL of active tab of front window to "${url.replace(/"/g, '\\"')}"
|
|
1202
1240
|
activate
|
|
1203
|
-
if (count of windows) = 0 then
|
|
1204
|
-
make new window
|
|
1205
|
-
set URL of active tab of front window to "${url.replace(/"/g, '\\"')}"
|
|
1206
|
-
else
|
|
1207
|
-
set URL of active tab of front window to "${url.replace(/"/g, '\\"')}"
|
|
1208
|
-
end if
|
|
1209
1241
|
end tell`);
|
|
1210
1242
|
}
|
|
1211
1243
|
return { success: true, data: { navigated: url, browser } };
|
|
@@ -1224,21 +1256,22 @@ print("\\(x),\\(y)")`;
|
|
|
1224
1256
|
if (browser === "Safari") {
|
|
1225
1257
|
await runAppleScript(`
|
|
1226
1258
|
tell application "Safari"
|
|
1259
|
+
make new document with properties {URL:"${url.replace(/"/g, '\\"')}"}
|
|
1227
1260
|
activate
|
|
1228
|
-
tell front window to set current tab to (make new tab with properties {URL:"${url.replace(/"/g, '\\"')}"})
|
|
1229
1261
|
end tell`);
|
|
1230
1262
|
} else {
|
|
1231
1263
|
await runAppleScript(`
|
|
1232
1264
|
tell application "${browser.replace(/"/g, '\\"')}"
|
|
1265
|
+
make new window
|
|
1266
|
+
set URL of active tab of front window to "${url.replace(/"/g, '\\"')}"
|
|
1233
1267
|
activate
|
|
1234
|
-
tell front window to make new tab with properties {URL:"${url.replace(/"/g, '\\"')}"}
|
|
1235
1268
|
end tell`);
|
|
1236
1269
|
}
|
|
1237
1270
|
return { success: true, data: { opened: url, browser } };
|
|
1238
1271
|
} catch (err) {
|
|
1239
1272
|
return {
|
|
1240
1273
|
success: false,
|
|
1241
|
-
error: `Failed to open
|
|
1274
|
+
error: `Failed to open window: ${err.message}`
|
|
1242
1275
|
};
|
|
1243
1276
|
}
|
|
1244
1277
|
}
|
|
@@ -2945,6 +2978,170 @@ print(result.stdout[:5000])
|
|
|
2945
2978
|
}
|
|
2946
2979
|
return { success: false, error: "Use action: list, start, stop" };
|
|
2947
2980
|
}
|
|
2981
|
+
// ── Claude Code Pipe (Max Subscription) ────────────────
|
|
2982
|
+
case "sys_claude_pipe": {
|
|
2983
|
+
const prompt = params.prompt;
|
|
2984
|
+
if (!prompt) return { success: false, error: "Missing prompt" };
|
|
2985
|
+
const model = params.model;
|
|
2986
|
+
const maxTurns = params.max_turns;
|
|
2987
|
+
const systemPrompt = params.system_prompt;
|
|
2988
|
+
const outputFormat = params.output_format || "json";
|
|
2989
|
+
const timeout = Number(params.timeout) || 12e4;
|
|
2990
|
+
const flags = ["-p", `--output-format ${outputFormat}`];
|
|
2991
|
+
if (model) flags.push(`--model ${model}`);
|
|
2992
|
+
if (maxTurns) flags.push(`--max-turns ${maxTurns}`);
|
|
2993
|
+
if (systemPrompt) flags.push(`--append-system-prompt ${JSON.stringify(systemPrompt)}`);
|
|
2994
|
+
flags.push("--allowedTools ''");
|
|
2995
|
+
const cmd = `claude ${flags.join(" ")}`;
|
|
2996
|
+
return new Promise((resolve2) => {
|
|
2997
|
+
const child = exec(cmd, { timeout }, (err, stdout, stderr) => {
|
|
2998
|
+
if (err) {
|
|
2999
|
+
resolve2({
|
|
3000
|
+
success: false,
|
|
3001
|
+
error: `Claude pipe error: ${stderr || err.message}`,
|
|
3002
|
+
errorCode: "CLAUDE_PIPE_FAILED"
|
|
3003
|
+
});
|
|
3004
|
+
} else {
|
|
3005
|
+
try {
|
|
3006
|
+
if (outputFormat === "json") {
|
|
3007
|
+
const parsed = JSON.parse(stdout);
|
|
3008
|
+
resolve2({
|
|
3009
|
+
success: true,
|
|
3010
|
+
data: {
|
|
3011
|
+
response: parsed.result || stdout.trim(),
|
|
3012
|
+
session_id: parsed.session_id,
|
|
3013
|
+
cost_usd: parsed.total_cost_usd ?? 0,
|
|
3014
|
+
duration_ms: parsed.duration_ms,
|
|
3015
|
+
num_turns: parsed.num_turns,
|
|
3016
|
+
model: model || "default",
|
|
3017
|
+
via: "claude-max-subscription"
|
|
3018
|
+
}
|
|
3019
|
+
});
|
|
3020
|
+
} else {
|
|
3021
|
+
resolve2({
|
|
3022
|
+
success: true,
|
|
3023
|
+
data: {
|
|
3024
|
+
response: stdout.trim(),
|
|
3025
|
+
model: model || "default",
|
|
3026
|
+
via: "claude-max-subscription"
|
|
3027
|
+
}
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
} catch {
|
|
3031
|
+
resolve2({
|
|
3032
|
+
success: true,
|
|
3033
|
+
data: {
|
|
3034
|
+
response: stdout.trim(),
|
|
3035
|
+
model: model || "default",
|
|
3036
|
+
via: "claude-max-subscription"
|
|
3037
|
+
}
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
});
|
|
3042
|
+
child.stdin?.write(prompt);
|
|
3043
|
+
child.stdin?.end();
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
case "sys_claude_status": {
|
|
3047
|
+
try {
|
|
3048
|
+
const version = await runShell("claude --version 2>/dev/null", 5e3);
|
|
3049
|
+
let authStatus = "unknown";
|
|
3050
|
+
try {
|
|
3051
|
+
const status = await runShell("claude auth status 2>&1", 1e4);
|
|
3052
|
+
authStatus = status.includes("Authenticated") || status.includes("logged in") ? "authenticated" : "not_authenticated";
|
|
3053
|
+
} catch {
|
|
3054
|
+
authStatus = "not_authenticated";
|
|
3055
|
+
}
|
|
3056
|
+
return {
|
|
3057
|
+
success: true,
|
|
3058
|
+
data: {
|
|
3059
|
+
installed: true,
|
|
3060
|
+
version: version.trim(),
|
|
3061
|
+
authenticated: authStatus === "authenticated",
|
|
3062
|
+
status: authStatus
|
|
3063
|
+
}
|
|
3064
|
+
};
|
|
3065
|
+
} catch {
|
|
3066
|
+
return {
|
|
3067
|
+
success: true,
|
|
3068
|
+
data: {
|
|
3069
|
+
installed: false,
|
|
3070
|
+
version: null,
|
|
3071
|
+
authenticated: false,
|
|
3072
|
+
status: "not_installed"
|
|
3073
|
+
}
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
// ── OpenAI Codex CLI (ChatGPT Subscription) ────────────────
|
|
3078
|
+
case "sys_codex_status": {
|
|
3079
|
+
try {
|
|
3080
|
+
const version = await runShell("codex --version 2>/dev/null", 5e3);
|
|
3081
|
+
let authStatus = "unknown";
|
|
3082
|
+
try {
|
|
3083
|
+
const status = await runShell("codex auth whoami 2>&1 || codex --help 2>&1 | head -5", 1e4);
|
|
3084
|
+
const lc = status.toLowerCase();
|
|
3085
|
+
authStatus = lc.includes("not logged in") || lc.includes("not authenticated") || lc.includes("sign in") || lc.includes("no api key") ? "not_authenticated" : "authenticated";
|
|
3086
|
+
} catch {
|
|
3087
|
+
try {
|
|
3088
|
+
await runShell("security find-generic-password -s 'openai-codex' 2>/dev/null || security find-generic-password -s 'codex' 2>/dev/null", 5e3);
|
|
3089
|
+
authStatus = "authenticated";
|
|
3090
|
+
} catch {
|
|
3091
|
+
authStatus = "not_authenticated";
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
return {
|
|
3095
|
+
success: true,
|
|
3096
|
+
data: {
|
|
3097
|
+
installed: true,
|
|
3098
|
+
version: version.trim(),
|
|
3099
|
+
authenticated: authStatus === "authenticated",
|
|
3100
|
+
status: authStatus
|
|
3101
|
+
}
|
|
3102
|
+
};
|
|
3103
|
+
} catch {
|
|
3104
|
+
return {
|
|
3105
|
+
success: true,
|
|
3106
|
+
data: {
|
|
3107
|
+
installed: false,
|
|
3108
|
+
version: null,
|
|
3109
|
+
authenticated: false,
|
|
3110
|
+
status: "not_installed"
|
|
3111
|
+
}
|
|
3112
|
+
};
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
case "sys_codex_pipe": {
|
|
3116
|
+
const prompt = params.prompt;
|
|
3117
|
+
if (!prompt) return { success: false, error: "Missing prompt" };
|
|
3118
|
+
const model = params.model;
|
|
3119
|
+
const timeout = Number(params.timeout) || 12e4;
|
|
3120
|
+
const args = ["exec"];
|
|
3121
|
+
if (model) args.push("--model", model);
|
|
3122
|
+
args.push(JSON.stringify(prompt));
|
|
3123
|
+
const cmd = `codex ${args.join(" ")}`;
|
|
3124
|
+
return new Promise((resolve2) => {
|
|
3125
|
+
exec(cmd, { timeout }, (err, stdout, stderr) => {
|
|
3126
|
+
if (err) {
|
|
3127
|
+
resolve2({
|
|
3128
|
+
success: false,
|
|
3129
|
+
error: `Codex pipe error: ${stderr || err.message}`,
|
|
3130
|
+
errorCode: "CODEX_PIPE_FAILED"
|
|
3131
|
+
});
|
|
3132
|
+
} else {
|
|
3133
|
+
resolve2({
|
|
3134
|
+
success: true,
|
|
3135
|
+
data: {
|
|
3136
|
+
response: stdout.trim(),
|
|
3137
|
+
model: model || "default",
|
|
3138
|
+
via: "chatgpt-subscription"
|
|
3139
|
+
}
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
});
|
|
3143
|
+
});
|
|
3144
|
+
}
|
|
2948
3145
|
default:
|
|
2949
3146
|
return { success: false, error: `Unknown command: ${command}` };
|
|
2950
3147
|
}
|
|
@@ -3079,8 +3276,81 @@ async function sonosRequest(baseUrl, path) {
|
|
|
3079
3276
|
var ws = null;
|
|
3080
3277
|
var reconnectTimer = null;
|
|
3081
3278
|
var heartbeatTimer = null;
|
|
3279
|
+
var imessageTimer = null;
|
|
3082
3280
|
var HEARTBEAT_INTERVAL = 3e4;
|
|
3281
|
+
var IMESSAGE_POLL_INTERVAL = 3e3;
|
|
3083
3282
|
var reconnectAttempts = 0;
|
|
3283
|
+
var lastImessageRowId = 0;
|
|
3284
|
+
function startImessageMonitor() {
|
|
3285
|
+
const chatDbPath = join(HOME, "Library/Messages/chat.db");
|
|
3286
|
+
if (!existsSync(chatDbPath)) {
|
|
3287
|
+
console.log(" \u26A0 iMessage: chat.db not found \u2014 monitor disabled");
|
|
3288
|
+
return;
|
|
3289
|
+
}
|
|
3290
|
+
try {
|
|
3291
|
+
const initResult = execSync(
|
|
3292
|
+
`sqlite3 "${chatDbPath}" "SELECT MAX(ROWID) FROM message"`,
|
|
3293
|
+
{ encoding: "utf8", timeout: 5e3 }
|
|
3294
|
+
).trim();
|
|
3295
|
+
lastImessageRowId = parseInt(initResult, 10) || 0;
|
|
3296
|
+
console.log(` \u2713 iMessage: monitoring from ROWID ${lastImessageRowId}`);
|
|
3297
|
+
} catch (err) {
|
|
3298
|
+
console.log(` \u26A0 iMessage: failed to read chat.db \u2014 ${err.message}`);
|
|
3299
|
+
console.log(" Grant Full Disk Access to Terminal/iTerm in System Settings \u2192 Privacy & Security");
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
imessageTimer = setInterval(async () => {
|
|
3303
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
3304
|
+
try {
|
|
3305
|
+
const query = `
|
|
3306
|
+
SELECT m.ROWID, m.text, m.date,
|
|
3307
|
+
COALESCE(h.id, '') as sender_id,
|
|
3308
|
+
COALESCE(c.display_name, h.id, 'Unknown') as sender_name,
|
|
3309
|
+
c.chat_identifier
|
|
3310
|
+
FROM message m
|
|
3311
|
+
LEFT JOIN handle h ON m.handle_id = h.ROWID
|
|
3312
|
+
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
|
|
3313
|
+
LEFT JOIN chat c ON cmj.chat_id = c.ROWID
|
|
3314
|
+
WHERE m.ROWID > ${lastImessageRowId}
|
|
3315
|
+
AND m.is_from_me = 0
|
|
3316
|
+
AND m.text IS NOT NULL
|
|
3317
|
+
AND m.text != ''
|
|
3318
|
+
ORDER BY m.ROWID ASC
|
|
3319
|
+
LIMIT 10
|
|
3320
|
+
`.replace(/\n/g, " ");
|
|
3321
|
+
const result = execSync(
|
|
3322
|
+
`sqlite3 -separator '|||' "${chatDbPath}" "${query}"`,
|
|
3323
|
+
{ encoding: "utf8", timeout: 5e3 }
|
|
3324
|
+
).trim();
|
|
3325
|
+
if (!result) return;
|
|
3326
|
+
const lines = result.split("\n").filter(Boolean);
|
|
3327
|
+
for (const line of lines) {
|
|
3328
|
+
const [rowIdStr, text, , senderId, senderName, chatId] = line.split("|||");
|
|
3329
|
+
const rowId = parseInt(rowIdStr || "0", 10);
|
|
3330
|
+
if (rowId <= lastImessageRowId) continue;
|
|
3331
|
+
lastImessageRowId = rowId;
|
|
3332
|
+
if (!text || text.startsWith("\uFFFC")) continue;
|
|
3333
|
+
console.log(`
|
|
3334
|
+
\u{1F4AC} iMessage from ${senderName || senderId}: ${text.slice(0, 80)}`);
|
|
3335
|
+
ws.send(JSON.stringify({
|
|
3336
|
+
type: "imessage_incoming",
|
|
3337
|
+
from: senderId || "unknown",
|
|
3338
|
+
fromName: senderName || senderId || "Unknown",
|
|
3339
|
+
chatId: chatId || senderId || "unknown",
|
|
3340
|
+
text,
|
|
3341
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
3342
|
+
}));
|
|
3343
|
+
}
|
|
3344
|
+
} catch {
|
|
3345
|
+
}
|
|
3346
|
+
}, IMESSAGE_POLL_INTERVAL);
|
|
3347
|
+
}
|
|
3348
|
+
function stopImessageMonitor() {
|
|
3349
|
+
if (imessageTimer) {
|
|
3350
|
+
clearInterval(imessageTimer);
|
|
3351
|
+
imessageTimer = null;
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3084
3354
|
var CAPABILITY_PROBES = [
|
|
3085
3355
|
{
|
|
3086
3356
|
name: "calendar",
|
|
@@ -3190,6 +3460,30 @@ var CAPABILITY_PROBES = [
|
|
|
3190
3460
|
}
|
|
3191
3461
|
},
|
|
3192
3462
|
tools: ["sys_shortcuts_run", "sys_shortcuts_list"]
|
|
3463
|
+
},
|
|
3464
|
+
{
|
|
3465
|
+
name: "claude_cli",
|
|
3466
|
+
test: async () => {
|
|
3467
|
+
try {
|
|
3468
|
+
await runShell("which claude >/dev/null 2>&1 && claude --version >/dev/null 2>&1", 5e3);
|
|
3469
|
+
return true;
|
|
3470
|
+
} catch {
|
|
3471
|
+
return false;
|
|
3472
|
+
}
|
|
3473
|
+
},
|
|
3474
|
+
tools: ["sys_claude_pipe", "sys_claude_status"]
|
|
3475
|
+
},
|
|
3476
|
+
{
|
|
3477
|
+
name: "codex_cli",
|
|
3478
|
+
test: async () => {
|
|
3479
|
+
try {
|
|
3480
|
+
await runShell("which codex >/dev/null 2>&1 && codex --version >/dev/null 2>&1", 5e3);
|
|
3481
|
+
return true;
|
|
3482
|
+
} catch {
|
|
3483
|
+
return false;
|
|
3484
|
+
}
|
|
3485
|
+
},
|
|
3486
|
+
tools: ["sys_codex_pipe", "sys_codex_status"]
|
|
3193
3487
|
}
|
|
3194
3488
|
];
|
|
3195
3489
|
var verifiedCapabilities = {
|
|
@@ -3298,6 +3592,8 @@ function connect() {
|
|
|
3298
3592
|
platform: "macos",
|
|
3299
3593
|
version: "0.4.0",
|
|
3300
3594
|
accessLevel: ACCESS_LEVEL,
|
|
3595
|
+
homeDir: HOME,
|
|
3596
|
+
hostname: __require("os").hostname(),
|
|
3301
3597
|
capabilities: cap.available,
|
|
3302
3598
|
unavailable: cap.unavailable,
|
|
3303
3599
|
tools: cap.tools,
|
|
@@ -3310,6 +3606,8 @@ function connect() {
|
|
|
3310
3606
|
ws.send(JSON.stringify({ type: "ping" }));
|
|
3311
3607
|
}
|
|
3312
3608
|
}, HEARTBEAT_INTERVAL);
|
|
3609
|
+
stopImessageMonitor();
|
|
3610
|
+
startImessageMonitor();
|
|
3313
3611
|
});
|
|
3314
3612
|
ws.on("message", async (raw) => {
|
|
3315
3613
|
try {
|
|
@@ -3344,6 +3642,7 @@ function connect() {
|
|
|
3344
3642
|
const reasonStr = reason.toString() || "unknown";
|
|
3345
3643
|
console.log(`
|
|
3346
3644
|
\u{1F50C} Disconnected (${code}: ${reasonStr})`);
|
|
3645
|
+
stopImessageMonitor();
|
|
3347
3646
|
if (heartbeatTimer) {
|
|
3348
3647
|
clearInterval(heartbeatTimer);
|
|
3349
3648
|
heartbeatTimer = null;
|